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..21dd6ddfc 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 isNowPlayingSource: Bool = false let action: () -> Void var body: some View { @@ -21,7 +22,7 @@ struct KasetSidebarRow: View { Label { Text(self.title) .lineLimit(1) - .foregroundStyle(.primary) + .foregroundStyle(self.isNowPlayingSource ? PackageResourceLookup.brandAccent : .primary) } icon: { Image(systemName: self.systemImage) .foregroundStyle(PackageResourceLookup.brandAccent) diff --git a/Sources/Kaset/Views/Sidebar.swift b/Sources/Kaset/Views/Sidebar.swift index eed5037a8..5324a66e8 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), + isNowPlayingSource: 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