Skip to content

Commit b886bd8

Browse files
Fix PlayerBar layout at narrow widths (#270)
Co-authored-by: Sertac Ozercan <sozercan@gmail.com>
1 parent f46f838 commit b886bd8

2 files changed

Lines changed: 228 additions & 53 deletions

File tree

Sources/Kaset/Views/MainWindow.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ struct MainWindow: View {
356356
}
357357
.animation(.easeInOut(duration: 0.25), value: self.playerService.showLyrics)
358358
.animation(.easeInOut(duration: 0.25), value: self.playerService.showQueue)
359-
.frame(minWidth: 900, minHeight: 600)
359+
.frame(minWidth: 980, minHeight: 600)
360360
.toolbar {
361361
ToolbarItem(placement: .primaryAction) {
362362
Button {
@@ -499,7 +499,7 @@ struct MainWindow: View {
499499
.controlSize(.regular)
500500
.frame(width: 20, height: 20)
501501
}
502-
.frame(minWidth: 900, minHeight: 600)
502+
.frame(minWidth: 980, minHeight: 600)
503503
}
504504

505505
private func handleAuthStateChange(oldState: AuthService.State, newState: AuthService.State) {

Sources/Kaset/Views/PlayerBar.swift

Lines changed: 226 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import SwiftUI
66
@available(macOS 26.0, *)
77
struct PlayerBar: View {
88
private static let brandAccent = PackageResourceLookup.brandAccent
9+
private static let compactLayoutThreshold: CGFloat = 980
910

1011
@Environment(PlayerService.self) private var playerService
1112
@Environment(WebKitManager.self) private var webKitManager
@@ -25,6 +26,8 @@ struct PlayerBar: View {
2526
@State private var volumeValue: Double = 1.0
2627
@State private var isAdjustingVolume = false
2728

29+
@State private var playerBarWidth: CGFloat = 0
30+
2831
/// Cached formatted progress string to avoid repeated formatting.
2932
@State private var formattedProgress: String = "0:00"
3033
@State private var formattedRemaining: String = "-0:00"
@@ -58,6 +61,12 @@ struct PlayerBar: View {
5861
.background(alignment: .bottom) {
5962
self.playerAreaFade
6063
}
64+
.background {
65+
GeometryReader { proxy in
66+
Color.clear
67+
.preference(key: PlayerBarWidthPreferenceKey.self, value: proxy.size.width)
68+
}
69+
}
6170
.background {
6271
// Keyboard shortcuts for media controls
6372
Group {
@@ -99,6 +108,11 @@ struct PlayerBar: View {
99108
.opacity(0)
100109
}
101110
}
111+
.onPreferenceChange(PlayerBarWidthPreferenceKey.self) { width in
112+
if abs(width - self.playerBarWidth) > 0.5 {
113+
self.playerBarWidth = width
114+
}
115+
}
102116
.onChange(of: self.playerService.progress) { _, newValue in
103117
// Sync local seek value when not actively seeking
104118
if !self.isSeeking, self.playerService.duration > 0 {
@@ -176,18 +190,23 @@ struct PlayerBar: View {
176190
self.isHoveringSeekBar && self.playerService.currentTrack != nil
177191
}
178192

193+
private var isCompactLayout: Bool {
194+
self.playerBarWidth > 0 && self.playerBarWidth < Self.compactLayoutThreshold
195+
}
196+
179197
private var seekInteractionLayer: some View {
180-
Group {
181-
if self.showsSeekControls {
182-
if self.playerService.isCurrentItemLive {
183-
self.liveIndicatorView
184-
.transition(.opacity)
185-
} else {
186-
self.seekBarView
187-
.transition(.opacity)
188-
}
189-
} else if !self.playerService.isCurrentItemLive {
198+
ZStack {
199+
if self.playerService.isCurrentItemLive {
200+
self.liveIndicatorView
201+
.opacity(self.showsSeekControls ? 1 : 0)
202+
.transition(.opacity)
203+
} else {
204+
self.seekBarView
205+
.opacity(self.showsSeekControls ? 1 : 0)
206+
.allowsHitTesting(self.showsSeekControls)
207+
190208
self.compactProgressView
209+
.opacity(self.showsSeekControls ? 0 : 1)
191210
}
192211
}
193212
.frame(height: self.showsSeekControls ? 28 : 10, alignment: .bottom)
@@ -516,23 +535,14 @@ struct PlayerBar: View {
516535

517536
private var volumeControl: some View {
518537
HStack(spacing: 8) {
519-
// Like/Dislike/Library actions
520-
self.actionButtons
521-
522-
// AirPlay button
523-
Button {
524-
HapticService.toggle()
525-
self.playerService.showAirPlayPicker()
526-
} label: {
527-
Image(systemName: "airplayaudio")
528-
.font(.system(size: 15, weight: .medium))
529-
.foregroundStyle(self.playerService.isAirPlayConnected ? .red : .primary.opacity(0.85))
530-
.contentTransition(.symbolEffect(.replace))
538+
if self.isCompactLayout {
539+
CompactVisibleActionButtons(playerNamespace: self.playerNamespace)
540+
self.compactActionsMenu
541+
} else {
542+
// Like/Dislike/Library actions
543+
self.actionButtons
544+
self.airPlayButton
531545
}
532-
.buttonStyle(.pressable)
533-
.accessibilityIdentifier(AccessibilityID.PlayerBar.airplayButton)
534-
.accessibilityLabel(self.playerService.isAirPlayConnected ? String(localized: "AirPlay Connected") : String(localized: "AirPlay"))
535-
.disabled(self.playerService.currentTrack == nil)
536546

537547
Divider()
538548
.frame(height: 20)
@@ -543,38 +553,105 @@ struct PlayerBar: View {
543553
.foregroundStyle(.primary.opacity(0.85))
544554
.frame(width: 18)
545555

546-
// Volume slider with immediate updates
547-
Slider(value: self.$volumeValue, in: 0 ... 1) { editing in
548-
if editing {
549-
// User started dragging
550-
self.isAdjustingVolume = true
551-
} else {
552-
// User finished dragging/clicking - apply volume change
553-
self.isAdjustingVolume = false
554-
// Always apply volume when interaction ends to ensure WebView is synced
555-
Task {
556-
await self.playerService.setVolume(self.volumeValue)
557-
}
556+
self.volumeSlider
557+
}
558+
}
559+
560+
private var airPlayButton: some View {
561+
Button {
562+
HapticService.toggle()
563+
self.playerService.showAirPlayPicker()
564+
} label: {
565+
Image(systemName: "airplayaudio")
566+
.font(.system(size: 15, weight: .medium))
567+
.foregroundStyle(self.playerService.isAirPlayConnected ? .red : .primary.opacity(0.85))
568+
.contentTransition(.symbolEffect(.replace))
569+
}
570+
.buttonStyle(.pressable)
571+
.accessibilityIdentifier(AccessibilityID.PlayerBar.airplayButton)
572+
.accessibilityLabel(self.playerService.isAirPlayConnected ? String(localized: "AirPlay Connected") : String(localized: "AirPlay"))
573+
.disabled(self.playerService.currentTrack == nil)
574+
}
575+
576+
private var volumeSlider: some View {
577+
Slider(value: self.$volumeValue, in: 0 ... 1) { editing in
578+
if editing {
579+
// User started dragging
580+
self.isAdjustingVolume = true
581+
} else {
582+
// User finished dragging/clicking - apply volume change
583+
self.isAdjustingVolume = false
584+
// Always apply volume when interaction ends to ensure WebView is synced
585+
Task {
586+
await self.playerService.setVolume(self.volumeValue)
558587
}
559588
}
560-
.frame(width: 80)
561-
.controlSize(.small)
562-
.tint(Self.brandAccent)
563-
.onChange(of: self.volumeValue) { oldValue, newValue in
564-
// Apply volume changes in real-time during dragging for immediate feedback
565-
if self.isAdjustingVolume {
566-
// Haptic feedback at slider boundaries
567-
if (oldValue > 0 && newValue == 0) || (oldValue < 1 && newValue == 1) {
568-
HapticService.sliderBoundary()
569-
}
570-
Task {
571-
await self.playerService.setVolume(newValue)
572-
}
589+
}
590+
.frame(width: 80)
591+
.controlSize(.small)
592+
.tint(Self.brandAccent)
593+
.onChange(of: self.volumeValue) { oldValue, newValue in
594+
// Apply volume changes in real-time during dragging for immediate feedback
595+
if self.isAdjustingVolume {
596+
// Haptic feedback at slider boundaries
597+
if (oldValue > 0 && newValue == 0) || (oldValue < 1 && newValue == 1) {
598+
HapticService.sliderBoundary()
599+
}
600+
Task {
601+
await self.playerService.setVolume(newValue)
573602
}
574603
}
575604
}
576605
}
577606

607+
private var compactActionsMenu: some View {
608+
@Bindable var player = self.playerService
609+
610+
return Menu {
611+
Button {
612+
HapticService.toggle()
613+
self.playerService.showAirPlayPicker()
614+
} label: {
615+
Label(
616+
self.playerService.isAirPlayConnected ? String(localized: "AirPlay Connected") : String(localized: "AirPlay"),
617+
systemImage: "airplayaudio"
618+
)
619+
}
620+
.disabled(self.playerService.currentTrack == nil)
621+
622+
Divider()
623+
624+
Button {
625+
HapticService.toggle()
626+
withAnimation(AppAnimation.standard) {
627+
player.showQueue.toggle()
628+
}
629+
} label: {
630+
Label(String(localized: "Queue"), systemImage: "list.bullet")
631+
}
632+
633+
Button {
634+
HapticService.toggle()
635+
_ = player.toggleMiniPlayer(mode: .switchFromMainWindow)
636+
} label: {
637+
Label(
638+
self.playerService.isMiniPlayerVisible
639+
? String(localized: "Return to Kaset")
640+
: String(localized: "Switch to Mini Player"),
641+
systemImage: self.playerService.isMiniPlayerVisible
642+
? "macwindow"
643+
: "rectangle.inset.bottomright.filled"
644+
)
645+
}
646+
} label: {
647+
Image(systemName: "ellipsis.circle")
648+
.font(.system(size: 15, weight: .medium))
649+
.foregroundStyle(.primary.opacity(0.85))
650+
}
651+
.menuStyle(.borderlessButton)
652+
.accessibilityLabel(String(localized: "More"))
653+
}
654+
578655
// MARK: - Action Buttons (Like/Dislike/Lyrics/Queue)
579656

580657
private var actionButtons: some View {
@@ -702,6 +779,94 @@ struct PlayerBar: View {
702779
}
703780
}
704781

782+
// MARK: - CompactVisibleActionButtons
783+
784+
@available(macOS 26.0, *)
785+
private struct CompactVisibleActionButtons: View {
786+
let playerNamespace: Namespace.ID
787+
788+
@Environment(PlayerService.self) private var playerService
789+
790+
var body: some View {
791+
@Bindable var player = self.playerService
792+
793+
HStack(spacing: 12) {
794+
Button {
795+
HapticService.toggle()
796+
self.playerService.dislikeCurrentTrack()
797+
} label: {
798+
Image(systemName: self.playerService.currentTrackLikeStatus == .dislike
799+
? "hand.thumbsdown.fill"
800+
: "hand.thumbsdown")
801+
.font(.system(size: 15, weight: .medium))
802+
.foregroundStyle(self.playerService.currentTrackLikeStatus == .dislike ? .red : .primary.opacity(0.85))
803+
.contentTransition(.symbolEffect(.replace))
804+
}
805+
.buttonStyle(.pressable)
806+
.symbolEffect(.bounce, value: self.playerService.currentTrackLikeStatus == .dislike)
807+
.accessibilityLabel(String(localized: "Dislike"))
808+
.accessibilityValue(self.playerService.currentTrackLikeStatus == .dislike ? String(localized: "Disliked") : String(localized: "Not disliked"))
809+
.disabled(self.playerService.currentTrack == nil)
810+
811+
Button {
812+
HapticService.toggle()
813+
self.playerService.likeCurrentTrack()
814+
} label: {
815+
Image(systemName: self.playerService.currentTrackLikeStatus == .like
816+
? "hand.thumbsup.fill"
817+
: "hand.thumbsup")
818+
.font(.system(size: 15, weight: .medium))
819+
.foregroundStyle(self.playerService.currentTrackLikeStatus == .like ? .red : .primary.opacity(0.85))
820+
.contentTransition(.symbolEffect(.replace))
821+
}
822+
.buttonStyle(.pressable)
823+
.symbolEffect(.bounce, value: self.playerService.currentTrackLikeStatus == .like)
824+
.accessibilityLabel(String(localized: "Like"))
825+
.accessibilityValue(self.playerService.currentTrackLikeStatus == .like ? String(localized: "Liked") : String(localized: "Not liked"))
826+
.disabled(self.playerService.currentTrack == nil)
827+
828+
Button {
829+
HapticService.toggle()
830+
withAnimation(AppAnimation.standard) {
831+
player.showLyrics.toggle()
832+
}
833+
} label: {
834+
Image(systemName: "quote.bubble")
835+
.font(.system(size: 15, weight: .medium))
836+
.foregroundStyle(self.playerService.showLyrics ? .red : .primary.opacity(0.85))
837+
}
838+
.buttonStyle(.pressable)
839+
.glassEffectID("compactLyrics", in: self.playerNamespace)
840+
.accessibilityIdentifier(AccessibilityID.PlayerBar.lyricsButton)
841+
.accessibilityLabel(String(localized: "Lyrics"))
842+
.accessibilityValue(self.playerService.showLyrics ? String(localized: "Showing") : String(localized: "Hidden"))
843+
844+
Button {
845+
guard self.playerService.currentTrackHasVideo else { return }
846+
HapticService.toggle()
847+
DiagnosticsLogger.player.debug(
848+
"Video button clicked, toggling showVideo from \(self.playerService.showVideo)"
849+
)
850+
withAnimation(AppAnimation.standard) {
851+
player.showVideo.toggle()
852+
}
853+
} label: {
854+
Image(systemName: self.playerService.showVideo ? "tv.fill" : "tv")
855+
.font(.system(size: 15, weight: .medium))
856+
.foregroundStyle(self.playerService.showVideo ? .red : .primary.opacity(0.85))
857+
.contentTransition(.symbolEffect(.replace))
858+
}
859+
.buttonStyle(.pressable)
860+
.glassEffectID("compactVideo", in: self.playerNamespace)
861+
.keyboardShortcut("v", modifiers: [.command, .shift])
862+
.accessibilityIdentifier(AccessibilityID.PlayerBar.videoButton)
863+
.accessibilityLabel(String(localized: "Video"))
864+
.accessibilityValue(self.playerService.showVideo ? String(localized: "Playing") : String(localized: "Off"))
865+
.disabled(self.playerService.currentTrack == nil || !self.playerService.currentTrackHasVideo)
866+
}
867+
}
868+
}
869+
705870
@available(macOS 26.0, *)
706871
#Preview {
707872
PlayerBar()
@@ -713,3 +878,13 @@ struct PlayerBar: View {
713878
.padding()
714879
.background(Color(nsColor: .windowBackgroundColor))
715880
}
881+
882+
// MARK: - PlayerBarWidthPreferenceKey
883+
884+
private struct PlayerBarWidthPreferenceKey: PreferenceKey {
885+
static let defaultValue: CGFloat = 0
886+
887+
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
888+
value = nextValue()
889+
}
890+
}

0 commit comments

Comments
 (0)