@@ -6,6 +6,7 @@ import SwiftUI
66@available ( macOS 26 . 0 , * )
77struct 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