From 770deaf9f95dc557ac66d4b2c450946c0fd62a0e Mon Sep 17 00:00:00 2001 From: tsibog Date: Thu, 13 Aug 2026 14:55:50 +0000 Subject: [PATCH 1/2] feat(sidebar): highlight currently-playing playlist Track which playlist the current track was played from via sourcePlaylistId on PlayerService. Show NowPlayingIndicator (equalizer bars) on the matching sidebar row instead of the static system image. Closes #433 --- .../Player/PlayerService+PlaybackBoundaries.swift | 1 + .../Services/Player/PlayerService+PlaybackControls.swift | 3 +++ Sources/Kaset/Services/Player/PlayerService+Queue.swift | 2 ++ Sources/Kaset/Services/Player/PlayerService.swift | 6 ++++++ .../Kaset/Services/Player/PlaylistPlaybackActions.swift | 1 + Sources/Kaset/Services/Protocols.swift | 3 +++ Sources/Kaset/Views/PlaylistDetailView.swift | 1 + Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift | 9 +++++++-- Sources/Kaset/Views/Sidebar.swift | 3 ++- Tests/KasetTests/Helpers/MockPlayerService.swift | 1 + 10 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackBoundaries.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackBoundaries.swift index 232c5948a..94bfa3679 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackBoundaries.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackBoundaries.swift @@ -91,6 +91,7 @@ extension PlayerService { if !preservesQueueContext { self.cancelDeferredQueueWork() } + self.sourcePlaylistId = nil self.logger.debug("Stopping playback") self.isStoppingPlayback = true self.shouldResumeAfterInterruption = false diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 0798e853e..1fbbcc6cc 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -90,6 +90,7 @@ extension PlayerService { func play(videoId: String, intent: MusicPlaybackIntent) async { guard self.acceptsMusicPlaybackIntent(intent) else { return } self.logger.debug("play() called with videoId: \(videoId)") + self.sourcePlaylistId = nil let acceptsPlaybackRequest = SingletonPlayerWebView.shared.acceptsPlaybackRequest( videoId: videoId, strategy: .standard @@ -164,6 +165,7 @@ extension PlayerService { /// Plays a song. func play(song: Song) async { let intent = self.beginMusicPlaybackIntent() + self.sourcePlaylistId = nil await self.play( song: song, webLoadStrategy: .standard, @@ -182,6 +184,7 @@ extension PlayerService { episode: ArtistEpisode? = nil ) async { let intent = self.beginMusicPlaybackIntent() + self.sourcePlaylistId = nil await self.play( song: song, webLoadStrategy: webLoadStrategy, diff --git a/Sources/Kaset/Services/Player/PlayerService+Queue.swift b/Sources/Kaset/Services/Player/PlayerService+Queue.swift index 1a4cd8757..8c0eb0137 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Queue.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Queue.swift @@ -92,6 +92,7 @@ extension PlayerService { func playWithRadio(song: Song, intent: MusicPlaybackIntent) async { guard self.acceptsMusicPlaybackIntent(intent) else { return } self.logger.info("Playing with radio: \(song.title)") + self.sourcePlaylistId = nil self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo() self.prepareForNewPlaybackContext() @@ -137,6 +138,7 @@ extension PlayerService { guard self.acceptsMusicPlaybackIntent(intent) else { return } self.logger.info("Playing mix playlist: \(playlistId), startVideoId: \(startVideoId ?? "nil (random)")") let continuationRequiresAuth = self.authService?.hasPersonalAccount == true + self.sourcePlaylistId = nil self.clearForwardSkipNavigationStack() guard let client = self.ytMusicClient else { diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 8a0a499bc..c30d45258 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -123,6 +123,12 @@ final class PlayerService: NSObject, PlayerServiceProtocol { } } + /// The playlist ID the current track was played from, if any. + /// Set when playback starts from a playlist/album and cleared when a non-playlist + /// source (radio, mix, standalone track, or stop) takes over. The sidebar reads + /// this to highlight the originating pinned playlist row. + var sourcePlaylistId: String? + @ObservationIgnored private var durationObservation: (videoId: String, duration: TimeInterval)? @ObservationIgnored var isApplyingPlaybackStateObservation = false diff --git a/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift b/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift index 6b98c99aa..cfd0d74a3 100644 --- a/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift +++ b/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift @@ -20,6 +20,7 @@ enum PlaylistPlaybackActions { playerService: PlayerService ) -> Task { let intent = playerService.beginMusicPlaybackIntent() + playerService.sourcePlaylistId = playlist.id return Task { @MainActor in do { let response = try await client.getPlaylist(id: playlist.id) diff --git a/Sources/Kaset/Services/Protocols.swift b/Sources/Kaset/Services/Protocols.swift index 2e8bedb6d..4ea691323 100644 --- a/Sources/Kaset/Services/Protocols.swift +++ b/Sources/Kaset/Services/Protocols.swift @@ -442,6 +442,9 @@ protocol PlayerServiceProtocol: AnyObject, Sendable { /// Currently playing track. var currentTrack: Song? { get } + /// The playlist ID the current track was played from, if any. + var sourcePlaylistId: String? { get } + /// Whether playback is active. var isPlaying: Bool { get } diff --git a/Sources/Kaset/Views/PlaylistDetailView.swift b/Sources/Kaset/Views/PlaylistDetailView.swift index 2b0f64e91..571c8cc02 100644 --- a/Sources/Kaset/Views/PlaylistDetailView.swift +++ b/Sources/Kaset/Views/PlaylistDetailView.swift @@ -530,6 +530,7 @@ struct PlaylistDetailView: View { fallbackArtist: String?, fallbackAlbum: Album? ) { let intent = self.playerService.beginMusicPlaybackIntent() + self.playerService.sourcePlaylistId = self.viewModel.playlistDetail?.id Task { @MainActor in let willDeferLoad = self.viewModel.hasMore let loadGeneration = await self.playerService.playQueue( diff --git a/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift b/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift index 152e9f15d..a115270c7 100644 --- a/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift +++ b/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift @@ -14,6 +14,7 @@ struct KasetSidebarRow: View { let title: String let systemImage: String let isSelected: Bool + var isPlaying: Bool = false let action: () -> Void var body: some View { @@ -23,8 +24,12 @@ struct KasetSidebarRow: View { .lineLimit(1) .foregroundStyle(.primary) } icon: { - Image(systemName: self.systemImage) - .foregroundStyle(PackageResourceLookup.brandAccent) + if self.isPlaying { + NowPlayingIndicator(isPlaying: true, size: 18) + } else { + Image(systemName: self.systemImage) + .foregroundStyle(PackageResourceLookup.brandAccent) + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 10) diff --git a/Sources/Kaset/Views/Sidebar.swift b/Sources/Kaset/Views/Sidebar.swift index eed5037a8..e0ffe01bd 100644 --- a/Sources/Kaset/Views/Sidebar.swift +++ b/Sources/Kaset/Views/Sidebar.swift @@ -204,7 +204,8 @@ struct Sidebar: View { KasetSidebarRow( title: item.title, systemImage: item.systemImage, - isSelected: self.currentSidebarSelection == .pinned(item) + isSelected: self.currentSidebarSelection == .pinned(item), + isPlaying: self.playerService.sourcePlaylistId == item.contentId ) { self.selectPinnedItem(item) } diff --git a/Tests/KasetTests/Helpers/MockPlayerService.swift b/Tests/KasetTests/Helpers/MockPlayerService.swift index 8a2c964ba..1c9374941 100644 --- a/Tests/KasetTests/Helpers/MockPlayerService.swift +++ b/Tests/KasetTests/Helpers/MockPlayerService.swift @@ -5,6 +5,7 @@ import Foundation final class MockPlayerService: PlayerServiceProtocol { var state: PlayerService.PlaybackState = .idle var currentTrack: Song? + var sourcePlaylistId: String? var progress: TimeInterval = 0 var duration: TimeInterval = 0 var volume: Double = 1 From 8a85205e6951ab9dd1d14b2bc981df21b6c091fa Mon Sep 17 00:00:00 2001 From: tsibog Date: Sun, 16 Aug 2026 00:15:39 +0200 Subject: [PATCH 2/2] refactor(sidebar): tint now-playing title instead of swapping the icon Keeps each row's own icon and drops the animated equalizer, so the sidebar indicates the playing playlist with colour alone and no motion. Renames isPlaying to isNowPlayingSource: the flag means "this is the playing source", not "audio is currently playing". The old name is what made NowPlayingIndicator(isPlaying: true) look correct at the call site, which kept the bars animating while paused. --- .../Kaset/Views/SharedViews/KasetSidebarRow.swift | 12 ++++-------- Sources/Kaset/Views/Sidebar.swift | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift b/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift index a115270c7..21dd6ddfc 100644 --- a/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift +++ b/Sources/Kaset/Views/SharedViews/KasetSidebarRow.swift @@ -14,7 +14,7 @@ struct KasetSidebarRow: View { let title: String let systemImage: String let isSelected: Bool - var isPlaying: Bool = false + var isNowPlayingSource: Bool = false let action: () -> Void var body: some View { @@ -22,14 +22,10 @@ struct KasetSidebarRow: View { Label { Text(self.title) .lineLimit(1) - .foregroundStyle(.primary) + .foregroundStyle(self.isNowPlayingSource ? PackageResourceLookup.brandAccent : .primary) } icon: { - if self.isPlaying { - NowPlayingIndicator(isPlaying: true, size: 18) - } else { - Image(systemName: self.systemImage) - .foregroundStyle(PackageResourceLookup.brandAccent) - } + Image(systemName: self.systemImage) + .foregroundStyle(PackageResourceLookup.brandAccent) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 10) diff --git a/Sources/Kaset/Views/Sidebar.swift b/Sources/Kaset/Views/Sidebar.swift index e0ffe01bd..5324a66e8 100644 --- a/Sources/Kaset/Views/Sidebar.swift +++ b/Sources/Kaset/Views/Sidebar.swift @@ -205,7 +205,7 @@ struct Sidebar: View { title: item.title, systemImage: item.systemImage, isSelected: self.currentSidebarSelection == .pinned(item), - isPlaying: self.playerService.sourcePlaylistId == item.contentId + isNowPlayingSource: self.playerService.sourcePlaylistId == item.contentId ) { self.selectPinnedItem(item) }