diff --git a/.agents/skills/playback-webview-debugging/SKILL.md b/.agents/skills/playback-webview-debugging/SKILL.md index f135f4d1..5a4122e5 100644 --- a/.agents/skills/playback-webview-debugging/SKILL.md +++ b/.agents/skills/playback-webview-debugging/SKILL.md @@ -32,6 +32,10 @@ Use this skill when playback, auth recovery, queue sync, or hidden WebView state - `docs/playback.md` - `Sources/Kaset/Views/MiniPlayerWebView.swift` - `Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift` +- `Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift` +- `Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift` - `Sources/Kaset/Services/Player/PlayerService.swift` +- `Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift` - `Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift` +- `Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift` - `Sources/Kaset/Services/WebKit/WebKitManager.swift` diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f75b1696..f4af01c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -87,7 +87,7 @@ jobs: # (Xcode 26 + Sequoia) during XCTest discovery. The unit suite is # entirely Swift Testing; the only XCTest target is the manual # ParserPerformanceTests, which is invoked separately via xcodebuild. - run: swift test -q --skip KasetUITests --disable-xctest + run: swift test -q --skip KasetUITests --disable-xctest --no-parallel macos_ui_tests: name: macOS UI Tests (${{ matrix.os }}) diff --git a/Sources/APIExplorer/main.swift b/Sources/APIExplorer/main.swift index 11497b15..ff787f83 100755 --- a/Sources/APIExplorer/main.swift +++ b/Sources/APIExplorer/main.swift @@ -858,6 +858,156 @@ func analyzeResponse(_ data: [String: Any], verbose: Bool = false) -> String { return output } +// MARK: - QueueProbeSong + +private struct QueueProbeSong { + let videoId: String + let title: String + let artists: String +} + +private func playlistPanelRenderer(in data: [String: Any]) -> [String: Any]? { + guard let contents = data["contents"] as? [String: Any], + let watchNextRenderer = contents["singleColumnMusicWatchNextResultsRenderer"] as? [String: Any], + let tabbedRenderer = watchNextRenderer["tabbedRenderer"] as? [String: Any], + let watchNextTabbedResults = tabbedRenderer["watchNextTabbedResultsRenderer"] as? [String: Any], + let tabs = watchNextTabbedResults["tabs"] as? [[String: Any]], + let firstTab = tabs.first, + let tabRenderer = firstTab["tabRenderer"] as? [String: Any], + let tabContent = tabRenderer["content"] as? [String: Any], + let musicQueueRenderer = tabContent["musicQueueRenderer"] as? [String: Any], + let queueContent = musicQueueRenderer["content"] as? [String: Any] + else { + return nil + } + + return queueContent["playlistPanelRenderer"] as? [String: Any] +} + +private func playlistPanelVideoRenderer(from item: [String: Any]) -> [String: Any]? { + if let direct = item["playlistPanelVideoRenderer"] as? [String: Any] { + return direct + } + + if let wrapper = item["playlistPanelVideoWrapperRenderer"] as? [String: Any], + let primary = wrapper["primaryRenderer"] as? [String: Any], + let wrapped = primary["playlistPanelVideoRenderer"] as? [String: Any] + { + return wrapped + } + + return nil +} + +private func parseQueueProbeSongs(from data: [String: Any]) -> [QueueProbeSong] { + guard let renderer = playlistPanelRenderer(in: data), + let contents = renderer["contents"] as? [[String: Any]] + else { return [] } + + return contents.compactMap { item in + guard let videoRenderer = playlistPanelVideoRenderer(from: item), + let videoId = videoRenderer["videoId"] as? String + else { return nil } + + let title = joinedRunsText(videoRenderer["title"] as? [String: Any]) ?? "Unknown" + let artists = joinedRunsText(videoRenderer["longBylineText"] as? [String: Any]) ?? "" + return QueueProbeSong(videoId: videoId, title: title, artists: artists) + } +} + +private func queueProbeContinuationToken(in data: [String: Any]) -> String? { + guard let renderer = playlistPanelRenderer(in: data), + let continuations = renderer["continuations"] as? [[String: Any]], + let firstContinuation = continuations.first, + let nextRadioData = firstContinuation["nextRadioContinuationData"] as? [String: Any] + else { return nil } + + return nextRadioData["continuation"] as? String +} + +private func queueProbeAutoplayVideoId(in data: [String: Any]) -> String? { + guard let autoplay = data["playerOverlays"] as? [String: Any], + let playerOverlayRenderer = autoplay["playerOverlayRenderer"] as? [String: Any], + let autoplayRenderer = playerOverlayRenderer["autoplay"] as? [String: Any], + let playerOverlayAutoplayRenderer = autoplayRenderer["playerOverlayAutoplayRenderer"] as? [String: Any], + let item = playerOverlayAutoplayRenderer["item"] as? [String: Any], + let compactVideoRenderer = item["compactVideoRenderer"] as? [String: Any] + else { return nil } + + return compactVideoRenderer["videoId"] as? String +} + +func probeQueue(videoId: String, playlistId: String? = nil, verbose: Bool = false, outputFile: String? = nil) async { + let resolvedPlaylistId = playlistId ?? "RDAMVM\(videoId)" + let body: [String: Any] = [ + "videoId": videoId, + "playlistId": resolvedPlaylistId, + "enablePersistentPlaylistPanel": true, + "isAudioOnly": true, + "tunerSettingValue": "AUTOMIX_SETTING_NORMAL", + ] + + print("šŸŽ§ Probing queue for videoId: \(videoId)") + print(" playlistId: \(resolvedPlaylistId)") + print(" endpoint: next") + if loadCookiesFromAppBackup() != nil, !forceUnauthenticatedRequests { + print(" auth: cookies available") + } else { + print(" auth: guest/no cookies") + } + print() + + do { + let (data, statusCode) = try await makeRequest(endpoint: "next", body: body, authenticated: !forceUnauthenticatedRequests && loadCookiesFromAppBackup() != nil) + print("āœ… HTTP \(statusCode)") + if statusCode == 401 || statusCode == 403 { + print("āŒ Authentication required") + return + } + + let songs = parseQueueProbeSongs(from: data) + let ids = songs.map(\.videoId) + let seedPositions = ids.enumerated().compactMap { index, id in id == videoId ? index : nil } + let firstPlayable = songs.first + let nextPlayable = songs.dropFirst().first + print("Queue summary:") + print(" • Parsed songs: \(songs.count)") + print(" • Seed positions: \(seedPositions.isEmpty ? "none" : seedPositions.map(String.init).joined(separator: ", "))") + print(" • First parsed id: \(firstPlayable?.videoId ?? "none")") + print(" • Second parsed id: \(nextPlayable?.videoId ?? "none")") + print(" • Autoplay overlay id: \(queueProbeAutoplayVideoId(in: data) ?? "none")") + print(" • Has continuation: \(queueProbeContinuationToken(in: data) == nil ? "no" : "yes")") + + if !songs.isEmpty { + print("\nFirst songs:") + for (index, song) in songs.prefix(10).enumerated() { + let marker = song.videoId == videoId ? " ← seed" : "" + let artistSuffix = song.artists.isEmpty ? "" : " — \(song.artists)" + print(" [\(index)] \(song.videoId) :: \(song.title)\(artistSuffix)\(marker)") + } + } + + if verbose { + print("\nšŸ“„ Raw response (pretty-printed):") + if let prettyData = try? JSONSerialization.data(withJSONObject: data, options: .prettyPrinted), + let prettyString = String(data: prettyData, encoding: .utf8) + { + print(prettyString) + } + } + + if let outputFile { + if let prettyData = try? JSONSerialization.data(withJSONObject: data, options: .prettyPrinted) { + let url = URL(fileURLWithPath: outputFile) + try prettyData.write(to: url) + print("\nšŸ’¾ Saved to: \(outputFile)") + } + } + } catch { + print("āŒ Error: \(error.localizedDescription)") + } +} + // MARK: - Commands /// Known endpoints that require authentication @@ -2102,6 +2252,8 @@ func showHelp() { browse [params] Explore a browse endpoint action Explore an action endpoint (body as JSON) continuation [ep] Explore a continuation (ep: 'browse' or 'next') + queue-probe [playlistId] + Summarize the Music next/radio queue shape list List all known endpoints auth Check authentication status accounts Discover available accounts (via authuser) @@ -2154,6 +2306,7 @@ func showHelp() { swift run api-explorer action search '{"query":"never gonna give you up"}' swift run api-explorer action player '{"videoId":"dQw4w9WgXcQ"}' swift run api-explorer action next '{"playlistId":"RDEM...","videoId":"abc123"}' + swift run api-explorer queue-probe dQw4w9WgXcQ # Continuation (for pagination / infinite mix) swift run api-explorer continuation # browse endpoint (default) @@ -2278,6 +2431,16 @@ func runMain() async { token, endpoint: endpoint, verbose: verbose, outputFile: outputFile ) + case "queue-probe": + guard filteredArgs.count >= 2 else { + print("āŒ Usage: queue-probe [playlistId]") + print(" playlistId defaults to RDAMVM, matching YTMusicClient.getRadioQueue") + return + } + let videoId = filteredArgs[1] + let playlistId = filteredArgs.count >= 3 ? filteredArgs[2] : nil + await probeQueue(videoId: videoId, playlistId: playlistId, verbose: verbose, outputFile: outputFile) + case "list": listEndpoints() diff --git a/Sources/Kaset/Services/Audio/EqualizerService.swift b/Sources/Kaset/Services/Audio/EqualizerService.swift index e3299886..ac45cfea 100644 --- a/Sources/Kaset/Services/Audio/EqualizerService.swift +++ b/Sources/Kaset/Services/Audio/EqualizerService.swift @@ -298,6 +298,11 @@ final class EqualizerService { } } + func awaitPendingPersistence() async { + let task = self.persistTask + await task?.value + } + private func persist() { do { let data = try Self.encoder.encode(self.settings) diff --git a/Sources/Kaset/Services/Player/AlbumPlaybackActions.swift b/Sources/Kaset/Services/Player/AlbumPlaybackActions.swift index 0db49d82..77429a4e 100644 --- a/Sources/Kaset/Services/Player/AlbumPlaybackActions.swift +++ b/Sources/Kaset/Services/Player/AlbumPlaybackActions.swift @@ -45,9 +45,14 @@ enum AlbumPlaybackActions { client: any YTMusicClientProtocol, playerService: PlayerService ) { + let requestGeneration = playerService.beginPendingPlaybackSelectionRequest() Task { do { let response = try await client.getPlaylist(id: album.id) + guard playerService.isCurrentPendingPlaybackSelectionRequest(requestGeneration) else { + DiagnosticsLogger.ui.info("Discarding stale album playback request") + return + } let songs = QueueSongMetadata.albumSongs( response.detail.tracks, album: album, diff --git a/Sources/Kaset/Services/Player/PlayerQueueModels.swift b/Sources/Kaset/Services/Player/PlayerQueueModels.swift index 756d635e..7aafc7c2 100644 --- a/Sources/Kaset/Services/Player/PlayerQueueModels.swift +++ b/Sources/Kaset/Services/Player/PlayerQueueModels.swift @@ -15,6 +15,42 @@ struct QueueEntry: Identifiable, Hashable { var source: Source = .queued } +// MARK: - QueuePlaybackContext + +/// Identifies the queue occurrence and explicit playback generation that owns async navigation work. +struct QueuePlaybackContext: Equatable { + let entryID: UUID? + let index: Int + let requestGeneration: Int + let navigationGeneration: Int +} + +// MARK: - PlaybackNavigationContext + +struct PlaybackNavigationContext: Equatable { + let requestGeneration: Int + let navigationGeneration: Int +} + +// MARK: - RadioQueueFetchOutcome + +enum RadioQueueFetchOutcome: Equatable { + case applied + case unavailable + case queueMutated + case superseded +} + +// MARK: - PendingNativeQueueAdvance + +struct PendingNativeQueueAdvance: Equatable { + let sourceEntryID: UUID? + let sourceVideoId: String + let targetEntryID: UUID + let targetVideoId: String + let generation: Int +} + // MARK: - QueueState struct QueueState { diff --git a/Sources/Kaset/Services/Player/PlayerService+Episodes.swift b/Sources/Kaset/Services/Player/PlayerService+Episodes.swift index a91dc52a..f4973f93 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Episodes.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Episodes.swift @@ -18,6 +18,7 @@ extension PlayerService { /// thumbnail, then installs `currentEpisode` through the player so the UI /// can gate live behavior before metadata loading suspends. func playEpisode(_ episode: ArtistEpisode) async { + self.beginPlaybackRequest() self.logger.info("Playing artist episode: \(episode.title) (live=\(episode.isLive))") // A standalone episode is a new playback context that replaces the queue: supersede any diff --git a/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift b/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift new file mode 100644 index 00000000..4419d205 --- /dev/null +++ b/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift @@ -0,0 +1,55 @@ +import Foundation + +@MainActor +extension PlayerService { + /// Distance from `duration` at which a manual seek is treated as the end of the track. + /// `video.currentTime = duration` does not reliably fire `ended` in WebKit, and a subsequent + /// play call would restart the same song from 0 instead of advancing. + static let seekToEndThreshold: TimeInterval = 0.5 + + /// Routes a manual seek that landed at the end of the track through the track-ended path so + /// repeat / queue / autoplay-suppression rules apply consistently with a natural end. + func handleManualSeekToEnd() async { + self.logger.info("Manual seek reached end of track; routing through track-ended path") + self.clearRestoredPlaybackSessionState() + self.progress = self.duration + + if !self.queue.isEmpty, + self.repeatMode != .one, + self.canAdvanceNativeQueueAfterTrackEnd + { + SingletonPlayerWebView.shared.seekAndPause(to: self.duration) + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() + let previousNavigationContext = self.playbackNavigationContext + let previousEntryID = self.currentQueueEntryID + let didAdvance = await self.performNextNavigation() + if !didAdvance, + !Task.isCancelled, + self.playbackNavigationContext == previousNavigationContext + { + if await self.advanceToMaterializedNextQueueSongIfAvailable(after: previousEntryID) { + return + } + await self.finishPlaybackAfterFailedQueueAdvance( + reason: "manual seek continuation produced no next queue entry" + ) + } + return + } + + if self.shouldSynchronizeWebViewForTerminalManualSeekToEnd { + SingletonPlayerWebView.shared.seekAndPause(to: self.duration) + } + + await self.handleTrackEnded(observedVideoId: self.currentTrack?.videoId) + } + + private var shouldSynchronizeWebViewForTerminalManualSeekToEnd: Bool { + if self.queue.isEmpty { + return !(self.repeatMode == .one && (self.currentTrack != nil || self.pendingPlayVideoId != nil)) + } + + return !self.canAdvanceNativeQueueAfterTrackEnd + } +} diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 3269e735..7bbe94d4 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -9,7 +9,7 @@ extension PlayerService { /// Whether the persistent player should navigate to the pending video immediately. var shouldAutoloadPendingVideo: Bool { - !self.isPendingRestoredLoadDeferred + !self.isPendingRestoredLoadDeferred && self.pendingNativeQueueAdvanceVideoId == nil } /// Toggles between popup and side panel queue display modes. @@ -81,9 +81,16 @@ extension PlayerService { /// Plays a track by video ID. func play(videoId: String) async { + self.beginPlaybackRequest() + self.beginPlaybackNavigation() self.logger.debug("play() called with videoId: \(videoId)") self.logger.info("Playing video: \(videoId)") self.clearRestoredPlaybackSessionState() + self.clearQueueNavigationRecovery() + self.clearWebQueueInjectionState() + // An explicit Kaset load supersedes (rather than participates in) a + // native queue advance. The native-only path never calls `play`. + self.clearPendingNativeQueueAdvance() self.currentEpisode = nil self.state = .loading self.songNearingEnd = false @@ -117,6 +124,7 @@ extension PlayerService { /// Plays a song. func play(song: Song) async { + self.beginPlaybackRequest() await self.play(song: song, webLoadStrategy: .standard) } @@ -127,11 +135,21 @@ extension PlayerService { func play( song: Song, webLoadStrategy: SingletonPlayerWebView.VideoLoadStrategy, - episode: ArtistEpisode? = nil + episode: ArtistEpisode? = nil, + isQueueNavigationRecovery: Bool = false ) async { + self.beginPlaybackNavigation() self.logger.info("Playing song: \(song.title)") self.logger.debug("Web load strategy: \(String(describing: webLoadStrategy))") self.clearRestoredPlaybackSessionState() + if !isQueueNavigationRecovery { + self.clearQueueNavigationRecovery() + } + self.clearWebQueueInjectionState() + // An explicit Kaset load supersedes (rather than participates in) a + // native queue advance. `advanceQueueStateForNativeNavigation` does not + // call this method while YouTube Music owns the transition. + self.clearPendingNativeQueueAdvance() self.currentEpisode = episode // Brief `.loading` until the observer reports playback; in-place restarts may flash loading briefly. self.state = .loading @@ -192,6 +210,7 @@ extension PlayerService { if didStartPlayback { self.logger.info("Playback confirmed started") + self.syncWebQueue() } } @@ -301,6 +320,52 @@ extension PlayerService { func resume() async { self.logger.debug("Resuming playback") + SingletonPlayerWebView.shared.setAutoplayBlocked(false) + + if self.isPendingRestoredLoadDeferred { + if let pendingPlayVideoId = self.pendingPlayVideoId, + self.shouldLoadPendingVideoBeforePlayback + { + let strategy: SingletonPlayerWebView.VideoLoadStrategy = self.shouldForcePendingRestoredLoad ? .forceFullPageWhenSameVideoId : .standard + self.beginRestoredPlaybackLoad(autoResumeAfterSeek: true) + self.showMiniPlayer = false + self.state = .loading + self.isKasetInitiatedPlayback = true + if SingletonPlayerWebView.shared.webView != nil { + SingletonPlayerWebView.shared.loadVideo(videoId: pendingPlayVideoId, strategy: strategy) + self.shouldForcePendingRestoredLoad = false + } + return + } + + if let targetProgress = self.pendingRestoredSeek { + self.beginRestoredPlaybackLoad(autoResumeAfterSeek: true) + self.showMiniPlayer = false + self.state = .loading + self.isKasetInitiatedPlayback = true + if SingletonPlayerWebView.shared.webView != nil { + SingletonPlayerWebView.shared.seek(to: targetProgress) + SingletonPlayerWebView.shared.play() + } else { + await self.evaluatePlayerCommand("seekTo(\(targetProgress), true)") + await self.evaluatePlayerCommand("play") + } + return + } + + self.clearRestoredPlaybackSessionState() + self.showMiniPlayer = false + self.state = .loading + self.isKasetInitiatedPlayback = true + + if SingletonPlayerWebView.shared.webView != nil { + SingletonPlayerWebView.shared.play() + } else { + await self.evaluatePlayerCommand("play") + } + return + } + guard let pendingPlayVideoId = self.pendingPlayVideoId else { self.clearRestoredPlaybackSessionState() await self.evaluatePlayerCommand("play") @@ -334,59 +399,125 @@ extension PlayerService { /// Skips to next track. func next() async { + self.invalidatePendingPlaybackSelectionRequests() + _ = await self.performNextNavigation() + } + + /// Performs Next and reports whether Kaset accepted a concrete playback target. + /// Track-end callers use the result because a repeat-all restart can intentionally + /// keep the same queue entry ID and index. + func performNextNavigation() async -> Bool { // swiftlint:disable:this cyclomatic_complexity + guard !Task.isCancelled else { return false } self.logger.debug("Skipping to next track") self.clearRestoredPlaybackSessionState() + SingletonPlayerWebView.shared.setAutoplayBlocked(false) if !self.queue.isEmpty { + var targetIndex: Int? if self.currentIndex < self.queue.count - 1 { - self.pushForwardSkipStackIfLeavingIndex(for: self.currentIndex + 1) - self.currentIndex += 1 - if let nextSong = self.queue[safe: self.currentIndex] { - await self.play(song: nextSong) - } - await self.fetchMoreMixSongsIfNeeded() - await self.fillSmartShuffleWindow() - self.saveQueueForPersistence() + targetIndex = self.currentIndex + 1 } else if self.repeatMode == .all { - self.pushForwardSkipStackIfLeavingIndex(for: 0) - self.currentIndex = 0 - if let firstSong = self.queue.first { - await self.play(song: firstSong) - } - await self.fillSmartShuffleWindow() - self.saveQueueForPersistence() + targetIndex = 0 } else if self.mixContinuationToken != nil { + let sourceContext = self.queuePlaybackContext let previousCount = self.queue.count await self.fetchMoreMixSongsIfNeeded() + guard !Task.isCancelled, + self.queuePlaybackContext == sourceContext + else { + return false + } if self.queue.count > previousCount { - self.pushForwardSkipStackIfLeavingIndex(for: self.currentIndex + 1) - self.currentIndex += 1 - if let nextSong = self.queue[safe: self.currentIndex] { - await self.play(song: nextSong) - } - await self.fillSmartShuffleWindow() - self.saveQueueForPersistence() + targetIndex = self.currentIndex + 1 } } - return + + guard let targetIndex else { return false } + self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) + guard await self.loadQueueSongForNavigation(at: targetIndex) else { return false } + guard !Task.isCancelled else { return true } + await self.fetchMoreMixSongsIfNeeded() + guard !Task.isCancelled else { return true } + await self.fillSmartShuffleWindow() + guard !Task.isCancelled else { return true } + self.saveQueueForPersistence(syncWebQueue: false) + return true } // Standalone artist episodes are intentionally not in the local queue. // Do not let them fall through to YouTube Music's ambient next button. guard self.currentEpisode == nil else { self.logger.debug("Ignoring next for standalone artist episode playback") - return + return false + } + + if let currentTrack = self.currentTrack { + let sourceGeneration = self.playbackRequestGeneration + let sourceVideoId = currentTrack.videoId + let sourceEntryID = self.currentQueueEntryID + let radioOutcome = await self.fetchAndApplyRadioQueue(for: sourceVideoId) + guard !Task.isCancelled, + radioOutcome != .superseded, + sourceGeneration == self.playbackRequestGeneration, + self.currentTrack?.videoId == sourceVideoId + else { + return false + } + if await self.advanceToMaterializedNextQueueSongIfAvailable( + after: sourceEntryID, + currentEntryRepresentsSource: radioOutcome == .applied + ) { + guard !Task.isCancelled else { return true } + await self.fetchMoreMixSongsIfNeeded() + guard !Task.isCancelled else { return true } + await self.fillSmartShuffleWindow() + guard !Task.isCancelled else { return true } + self.saveQueueForPersistence(syncWebQueue: false) + return true + } else { + self.logger.debug("Ignoring next without a Kaset queue") + } + } else if self.pendingPlayVideoId != nil { + self.logger.debug("Ignoring next without a Kaset queue") } + return false + } - if self.pendingPlayVideoId != nil { - SingletonPlayerWebView.shared.next() + /// Advances using only the queue state already materialized in memory. + /// This is also used after async queue work is invalidated by a same-playback queue edit. + func advanceToMaterializedNextQueueSongIfAvailable( + after sourceEntryID: UUID?, + currentEntryRepresentsSource: Bool = false + ) async -> Bool { + guard !self.queue.isEmpty else { return false } + + let sourceIndex = sourceEntryID.flatMap { self.queueEntryIDs.firstIndex(of: $0) } + ?? (currentEntryRepresentsSource && self.queue.indices.contains(self.currentIndex) + ? self.currentIndex + : nil) + let targetIndex: Int? = if let sourceIndex, sourceIndex < self.queue.count - 1 { + sourceIndex + 1 + } else if sourceIndex != nil, self.repeatMode == .all { + 0 + } else if sourceIndex == nil { + self.queue.indices.contains(self.currentIndex) + ? self.currentIndex + : self.queue.indices.first + } else { + nil } + + guard let targetIndex else { return false } + self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) + return await self.loadQueueSongForNavigation(at: targetIndex) } /// Goes to previous track. func previous() async { + self.invalidatePendingPlaybackSelectionRequests() self.logger.debug("Going to previous track") self.clearRestoredPlaybackSessionState() + SingletonPlayerWebView.shared.setAutoplayBlocked(false) if !self.queue.isEmpty { if self.progress > 3 { @@ -395,20 +526,12 @@ extension PlayerService { } if let priorIndex = self.popForwardSkipIndex(), self.queue.indices.contains(priorIndex) { - self.currentIndex = priorIndex - if let prevSong = self.queue[safe: priorIndex] { - await self.play(song: prevSong) - } - self.saveQueueForPersistence() + await self.loadQueueSongForNavigation(at: priorIndex) return } if self.currentIndex > 0 { - self.currentIndex -= 1 - if let prevSong = self.queue[safe: self.currentIndex] { - await self.play(song: prevSong) - } - self.saveQueueForPersistence() + await self.loadQueueSongForNavigation(at: self.currentIndex - 1) } else { await self.seek(to: 0) } @@ -424,11 +547,69 @@ extension PlayerService { if self.progress > 3 { await self.seek(to: 0) - } else { - SingletonPlayerWebView.shared.previous() + } else if self.pendingPlayVideoId != nil { + self.logger.debug("Ignoring previous without a Kaset queue") } } + /// Navigates to a queue song through Kaset's deterministic load path. + @discardableResult + func loadQueueSongForNavigation( + at index: Int, + webLoadStrategy strategyOverride: SingletonPlayerWebView.VideoLoadStrategy? = nil + ) async -> Bool { + guard let song = self.queue[safe: index] else { return false } + self.currentIndex = index + self.progress = 0 + self.duration = song.duration ?? 0 + self.protectQueueNavigationTarget(song.videoId) + let strategy: SingletonPlayerWebView.VideoLoadStrategy = strategyOverride + ?? (SingletonPlayerWebView.shared.currentVideoId == song.videoId + ? .preferInPlaceWhenSameVideoId + : .standard) + await self.play(song: song, webLoadStrategy: strategy) + self.saveQueueForPersistence() + return true + } + + /// Commits a media-confirmed native WebView queue transition without forcing a page load. + func advanceQueueStateForNativeNavigation(to index: Int) { + guard let song = self.queue[safe: index] else { return } + + self.beginPlaybackNavigation() + let trackChanged = self.currentTrack?.videoId != song.videoId + self.currentIndex = index + self.currentTrack = song + self.currentEpisode = nil + self.pendingPlayVideoId = song.videoId + self.progress = 0 + self.duration = song.duration ?? 0 + self.protectQueueNavigationTarget(song.videoId) + // The confirming media observation may already be paused. Starting from + // `.paused` lets the same observation promote to `.playing` when needed, + // while a non-playing observation cannot otherwise escape `.loading`. + self.state = .paused + self.isKasetInitiatedPlayback = false + self.songNearingEnd = false + self.shouldSuppressAutoplayAfterQueueEnd = false + self.currentTrackHasVideo = song.musicVideoType?.hasVideoContent ?? song.hasVideo ?? false + + if trackChanged { + self.resetTrackStatus() + if let cachedStatus = SongLikeStatusManager.shared.status(for: song.videoId) { + self.currentTrackLikeStatus = cachedStatus + } + } + + if let details = song.feedbackTokens { + self.currentTrackFeedbackTokens = details + self.currentTrackInLibrary = song.isInLibrary ?? false + self.currentTrackLikeStatus = song.likeStatus ?? self.currentTrackLikeStatus + } + + self.saveQueueForPersistence(syncWebQueue: false) + } + /// Seeks to a specific time. func seek(to time: TimeInterval) async { let clampedTime = self.duration > 0 ? min(max(time, 0), self.duration) : max(time, 0) @@ -594,6 +775,7 @@ extension PlayerService { self.currentEpisode = nil self.currentTrack = nil self.pendingPlayVideoId = nil + self.clearPendingNativeQueueAdvance() self.progress = 0 self.setPlaybackStateVideoId(nil) self.currentLyricsLineIndex = nil @@ -631,6 +813,7 @@ extension PlayerService { self.shouldSuppressAutoplayAfterQueueEnd = false self.currentEpisode = nil self.currentTrack = nil + self.clearPendingNativeQueueAdvance() self.progress = 0 self.duration = 0 self.setPlaybackStateVideoId(nil) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift index c4e043b7..44202777 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift @@ -10,19 +10,57 @@ extension PlayerService { isPlaying: isPlaying, progress: progress, duration: duration, - observedVideoId: self.currentTrack?.videoId + observedVideoId: self.currentTrack?.videoId ?? self.pendingPlayVideoId ) } - /// Updates playback state with the video identity carried by the same WebView observation. + /// Updates playback state from an identity-bearing WebView observation. + /// State from an earlier/out-of-order video must not overwrite the current + /// queue target's progress, duration, or near-end flag. func updatePlaybackState( isPlaying: Bool, progress: Double, duration: Double, observedVideoId: String? ) { - let previousProgress = self.progress + // Preserve bridge-observation provenance for consumers such as mix + // scrobbling even when this sample is stale for PlayerService state. self.recordPlaybackStateObservation(videoId: observedVideoId) + guard self.observedPlaybackMatchesCurrentTarget(videoId: observedVideoId) else { + self.logger.debug( + "Ignoring playback state for stale video \(observedVideoId ?? "unknown")" + ) + return + } + + self.applyPlaybackStateObservation( + isPlaying: isPlaying, + progress: progress, + duration: duration + ) + } + + private func applyPlaybackStateObservation( + isPlaying: Bool, + progress: Double, + duration: Double + ) { + let previousProgress = self.progress + + if self.isPendingRestoredLoadDeferred { + if self.pendingRestoredSeek == nil { + self.progress = progress + } + if duration > 0 { + self.duration = duration + } + self.state = .paused + if isPlaying, !self.hasIssuedAutoplayPauseDuringDeferredRestore { + self.hasIssuedAutoplayPauseDuringDeferredRestore = true + SingletonPlayerWebView.shared.pause() + } + return + } guard !self.isRestoringPlaybackSession else { self.reconcileRestoredPlaybackState( @@ -53,6 +91,8 @@ extension PlayerService { guard let currentSong = queue[safe: currentIndex] else { return } self.clearRestoredPlaybackSessionState() + self.restoredPlaybackSessionGeneration &+= 1 + let restoreGeneration = self.restoredPlaybackSessionGeneration self.clearForwardSkipNavigationStack() if let entrySources, entrySources.count == queue.count { self.setQueue(entries: zip(queue, entrySources).map { song, source in @@ -68,6 +108,8 @@ extension PlayerService { self.showMiniPlayer = false self.songNearingEnd = false self.isKasetInitiatedPlayback = false + self.isAwaitingWebRestoredTrack = true + self.hasIssuedAutoplayPauseDuringDeferredRestore = false let resolvedDuration = max(duration, currentSong.duration ?? 0) let clampedProgress = self.clampedRestoredProgress(progress, duration: resolvedDuration) @@ -99,10 +141,25 @@ extension PlayerService { } // At app launch the cache may be empty and the persisted song may lack likeStatus. - // Fetch metadata from the API to get the correct like status. + // Fetch immediately; the fetch only applies if this persisted song is still current. Task { [videoId = currentSong.videoId] in await self.fetchSongMetadata(videoId: videoId) } + + // Give YT Music a chance to restore its server-synced track first. + // If no track arrives from the web page in time, fall back to the persisted one. + Task { + try? await Task.sleep(for: .seconds(8)) + guard self.restoredPlaybackSessionGeneration == restoreGeneration, + self.isPendingRestoredLoadDeferred, + self.isAwaitingWebRestoredTrack + else { return } + self.logger.info("No server-restored track observed; falling back to persisted session track") + self.pendingPlayVideoId = currentSong.videoId + self.currentTrack = currentSong + self.currentTrackHasVideo = currentSong.musicVideoType?.hasVideoContent ?? currentSong.hasVideo ?? false + self.isAwaitingWebRestoredTrack = false + } } /// Clears one-shot state used while reconciling a restored playback session. @@ -110,8 +167,10 @@ extension PlayerService { self.pendingRestoredSeek = nil self.isPendingRestoredLoadDeferred = false self.shouldForcePendingRestoredLoad = false + self.restoredPlaybackSessionGeneration &+= 1 self.isRestoringPlaybackSession = false self.shouldAutoResumeAfterRestoredLoad = false + self.isAwaitingWebRestoredTrack = false } /// Starts loading a restored session into the WebView without discarding the saved seek target. diff --git a/Sources/Kaset/Services/Player/PlayerService+Queue.swift b/Sources/Kaset/Services/Player/PlayerService+Queue.swift index fc7e6e4b..6def5fda 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Queue.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Queue.swift @@ -66,6 +66,7 @@ extension PlayerService { /// Plays a song and fetches similar songs (radio queue) in the background. /// The queue will be populated with similar songs from YouTube Music's radio feature. func playWithRadio(song: Song) async { + let requestGeneration = self.beginPlaybackRequest() self.logger.info("Playing with radio: \(song.title)") self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo() @@ -78,7 +79,11 @@ extension PlayerService { self.setQueue([song]) self.queueOrderBeforeShuffle = nil self.currentIndex = 0 - await self.play(song: song) + await self.play(song: song, webLoadStrategy: .standard) + guard self.isCurrentPlaybackRequest(requestGeneration) else { + self.logger.info("Discarding stale radio playback request") + return + } // Fetch radio queue in background await self.fetchAndApplyRadioQueue(for: song.videoId) @@ -93,7 +98,7 @@ extension PlayerService { /// - startVideoId: Optional video ID to start with. If nil, API picks a random starting point. func playWithMix(playlistId: String, startVideoId: String?) async { self.logger.info("Playing mix playlist: \(playlistId), startVideoId: \(startVideoId ?? "nil (random)")") - let requestGeneration = self.playbackRequestGeneration + let requestGeneration = self.beginPendingPlaybackSelectionRequest() let continuationRequiresAuth = self.authService?.hasPersonalAccount == true self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo() @@ -111,8 +116,8 @@ extension PlayerService { return } - guard requestGeneration == self.playbackRequestGeneration else { - self.logger.info("Discarding stale mix playback request after privacy boundary") + guard self.isCurrentPendingPlaybackSelectionRequest(requestGeneration) else { + self.logger.info("Discarding stale mix playback request") return } @@ -148,18 +153,40 @@ extension PlayerService { /// Fetches more songs for the current mix when approaching the end of the queue. /// This enables "infinite mix" behavior like YouTube Music web. - func fetchMoreMixSongsIfNeeded() async { + func fetchMoreMixSongsIfNeeded( + shouldApplyResult: @MainActor () -> Bool = { true } + ) async { let songsRemaining = self.queue.count - self.currentIndex - 1 self.logger.debug("Infinite mix check: \(songsRemaining) songs remaining, hasContinuation: \(self.mixContinuationToken != nil)") + if self.isFetchingMoreMixSongs { + let queueCountBeforeWait = self.queue.count + let continuationBeforeWait = self.mixContinuationToken + let queueMutationGenerationBeforeWait = self.queueMutationGeneration + await withCheckedContinuation { continuation in + self.mixContinuationFetchWaiters.append(continuation) + } + guard !Task.isCancelled, + shouldApplyResult(), + self.queue.count == queueCountBeforeWait, + self.mixContinuationToken == continuationBeforeWait, + self.queueMutationGeneration == queueMutationGenerationBeforeWait + else { + return + } + await self.fetchMoreMixSongsIfNeeded(shouldApplyResult: shouldApplyResult) + return + } + // Only fetch if we have a continuation token and we're near the end - guard let token = mixContinuationToken, - !isFetchingMoreMixSongs, + guard let continuation = mixContinuationToken, !(self.mixContinuationRequiresAuth && self.authService?.hasPersonalAccount != true), - let client = ytMusicClient + let client = ytMusicClient, + shouldApplyResult() else { return } + let requestQueueMutationGeneration = self.queueMutationGeneration // Fetch more when we're within 10 songs of the end guard songsRemaining <= 10 else { @@ -168,13 +195,16 @@ extension PlayerService { self.logger.info("Fetching more mix songs, \(songsRemaining) remaining in queue") self.isFetchingMoreMixSongs = true + defer { self.finishMixContinuationFetch() } let requestGeneration = self.playbackRequestGeneration do { - let result = try await client.getMixQueueContinuation(continuationToken: token) - guard requestGeneration == self.playbackRequestGeneration else { - self.logger.info("Discarding stale mix continuation after privacy boundary") - self.isFetchingMoreMixSongs = false + let result = try await client.getMixQueueContinuation(continuationToken: continuation) + guard !Task.isCancelled, + requestGeneration == self.playbackRequestGeneration, + shouldApplyResult() + else { + self.logger.info("Discarding stale or cancelled mix continuation") return } self.logger.debug("Continuation returned \(result.songs.count) songs, hasNextToken: \(result.continuationToken != nil)") @@ -183,6 +213,13 @@ extension PlayerService { let existingIds = Set(queue.map(\.videoId)) let newSongs = result.songs.filter { !existingIds.contains($0.videoId) } + guard self.mixContinuationToken == continuation, + self.queueMutationGeneration == requestQueueMutationGeneration + else { + self.logger.info("Discarding mix continuation after its queue context changed") + return + } + if !newSongs.isEmpty { let updatedEntries = self.queueEntries + newSongs.map { QueueEntry(id: UUID(), song: $0) } self.setQueue(entries: updatedEntries) @@ -195,33 +232,48 @@ extension PlayerService { } catch { self.logger.warning("Failed to fetch more mix songs: \(error.localizedDescription)") } + } + private func finishMixContinuationFetch() { self.isFetchingMoreMixSongs = false + let waiters = self.mixContinuationFetchWaiters + self.mixContinuationFetchWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } } /// Fetches radio queue and applies it, keeping the current song at the front. - func fetchAndApplyRadioQueue(for videoId: String) async { - let requestGeneration = self.playbackRequestGeneration + @discardableResult + func fetchAndApplyRadioQueue(for videoId: String) async -> RadioQueueFetchOutcome { + let sourceNavigationContext = self.playbackNavigationContext + let sourceQueueMutationGeneration = self.queueMutationGeneration guard let client = ytMusicClient else { self.logger.warning("No YTMusicClient available for fetching radio queue") - return + return .unavailable } do { let radioSongs = try await client.getRadioQueue(videoId: videoId) - guard requestGeneration == self.playbackRequestGeneration else { - self.logger.info("Discarding stale radio queue after privacy boundary") - return + guard !Task.isCancelled, + self.playbackNavigationContext == sourceNavigationContext + else { + self.logger.info("Discarding stale or cancelled radio queue") + return .superseded + } + guard self.queueMutationGeneration == sourceQueueMutationGeneration else { + self.logger.info("Discarding radio queue after a local queue edit") + return .queueMutated } guard !radioSongs.isEmpty else { self.logger.info("No radio songs returned") - return + return .unavailable } // Only update if we're still playing the same song guard let currentSong = self.currentTrack, currentSong.videoId == videoId else { self.logger.info("Track changed, discarding radio queue") - return + return .superseded } // Ensure the current song is at the front of the queue @@ -250,7 +302,10 @@ extension PlayerService { self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo() - let entries = newQueue.map { QueueEntry(id: UUID(), song: $0) } + let currentEntryID = self.currentQueueEntryID ?? UUID() + let entries = newQueue.enumerated().map { index, song in + QueueEntry(id: index == 0 ? currentEntryID : UUID(), song: song) + } if self.shuffleEnabled { self.materializeShuffleQueue( entries: entries, @@ -265,13 +320,21 @@ extension PlayerService { } self.logger.info("Radio queue updated with \(newQueue.count) songs (current song at front)") self.saveQueueForPersistence() + return .applied } catch { self.logger.warning("Failed to fetch radio queue: \(error.localizedDescription)") + if Task.isCancelled || self.playbackNavigationContext != sourceNavigationContext { + return .superseded + } + return self.queueMutationGeneration != sourceQueueMutationGeneration + ? .queueMutated + : .unavailable } } /// Clears the entire queue and current track (for "Clear" in side panel). Records state for undo. func clearQueueEntirely() { + self.queueMutationGeneration &+= 1 self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo() self.mixContinuationToken = nil @@ -285,6 +348,7 @@ extension PlayerService { /// Clears the playback queue except for the currently playing track. func clearQueue() { + self.queueMutationGeneration &+= 1 self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo() // Clear mix continuation since queue is being manually cleared @@ -751,9 +815,10 @@ extension PlayerService { } /// Saves the current queue to UserDefaults for restoration on next launch. - func saveQueueForPersistence() { + func saveQueueForPersistence(syncWebQueue shouldSyncWebQueue: Bool = true) { let queue = self.queue guard !queue.isEmpty else { + self.clearWebQueueInjectionState() if self.suppressNextEmptyQueuePersistence { self.suppressNextEmptyQueuePersistence = false self.logger.info("Skipped clearing saved playback session after guest-startup cleanup") @@ -772,9 +837,17 @@ extension PlayerService { let persistedEntries = Self.stripSuggested(from: self.queueEntries, keepingCurrentID: currentID) let persistableQueue = persistedEntries.map(\.song) guard !persistableQueue.isEmpty else { + self.clearWebQueueInjectionState() self.removeSavedPlaybackSession() return } + defer { + if shouldSyncWebQueue { + // Queue synchronization follows the live queue, including ephemeral + // Smart Shuffle entries, even when the persisted payload is unchanged. + self.syncWebQueue() + } + } do { let encoder = JSONEncoder() @@ -838,6 +911,36 @@ extension PlayerService { func invalidatePendingPlaybackRequests() { self.playbackRequestGeneration &+= 1 + self.pendingPlaybackSelectionGeneration &+= 1 + } + + @discardableResult + func beginPlaybackRequest() -> Int { + self.invalidatePendingPlaybackRequests() + return self.playbackRequestGeneration + } + + func isCurrentPlaybackRequest(_ generation: Int) -> Bool { + generation == self.playbackRequestGeneration + } + + @discardableResult + func beginPendingPlaybackSelectionRequest() -> Int { + self.playbackRequestGeneration &+= 1 + self.pendingPlaybackSelectionGeneration &+= 1 + return self.pendingPlaybackSelectionGeneration + } + + func invalidatePendingPlaybackSelectionRequests() { + self.pendingPlaybackSelectionGeneration &+= 1 + } + + func isCurrentPendingPlaybackSelectionRequest(_ generation: Int) -> Bool { + generation == self.pendingPlaybackSelectionGeneration + } + + func beginPlaybackNavigation() { + self.playbackNavigationGeneration &+= 1 } /// Re-tags a restored/persisted playback session after crossing a playback diff --git a/Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift b/Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift new file mode 100644 index 00000000..c6535223 --- /dev/null +++ b/Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift @@ -0,0 +1,72 @@ +import Foundation + +@MainActor +extension PlayerService { + private static let queueNavigationRecoveryTimeout: Duration = .seconds(8) + + func finishPlaybackAfterFailedQueueAdvance(reason: String) async { + self.mixContinuationToken = nil + self.mixContinuationRequiresAuth = false + self.shouldSuppressAutoplayAfterQueueEnd = true + self.markPlaybackEnded() + self.logger.info("Ending playback after failed queue advance: \(reason)") + await self.pause() + } + + func clearQueueNavigationRecovery() { + self.queueNavigationRecoveryGeneration &+= 1 + self.queueNavigationRecoveryLoadTask?.cancel() + self.queueNavigationRecoveryLoadTask = nil + self.queueNavigationRecoveryTask?.cancel() + self.queueNavigationRecoveryTask = nil + self.queueNavigationRecoveryVideoId = nil + } + + func scheduleQueueNavigationRecovery(for song: Song) { + guard self.queueNavigationRecoveryVideoId != song.videoId else { + self.logger.debug("Coalescing stale metadata recovery for \(song.videoId)") + return + } + + self.clearQueueNavigationRecovery() + let generation = self.queueNavigationRecoveryGeneration + self.queueNavigationRecoveryVideoId = song.videoId + self.protectQueueNavigationTarget(song.videoId) + self.queueNavigationRecoveryLoadTask = Task { @MainActor [weak self] in + guard let self, + !Task.isCancelled, + self.queueNavigationRecoveryGeneration == generation, + self.queueNavigationRecoveryVideoId == song.videoId + else { + return + } + + await self.play( + song: song, + webLoadStrategy: .forceFullPageWhenSameVideoId, + isQueueNavigationRecovery: true + ) + + guard self.queueNavigationRecoveryGeneration == generation else { return } + self.queueNavigationRecoveryLoadTask = nil + } + self.queueNavigationRecoveryTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: Self.queueNavigationRecoveryTimeout) + } catch { + return + } + guard let self, + self.queueNavigationRecoveryGeneration == generation, + self.queueNavigationRecoveryVideoId == song.videoId + else { + return + } + self.queueNavigationRecoveryGeneration &+= 1 + self.queueNavigationRecoveryLoadTask?.cancel() + self.queueNavigationRecoveryLoadTask = nil + self.queueNavigationRecoveryTask = nil + self.queueNavigationRecoveryVideoId = nil + } + } +} diff --git a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift new file mode 100644 index 00000000..16500cbc --- /dev/null +++ b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift @@ -0,0 +1,316 @@ +import Foundation + +// MARK: - Web Playback Identity + +@MainActor +extension PlayerService { + private static let nativeQueueAdvanceTimeout: Duration = .seconds(3) + + /// Reconciles WebView metadata before playback state is applied. + /// + /// The page's `trackChanged` flag is advisory: YouTube can update its internal + /// video ID before the observer's title/artist state catches up, then report the + /// mismatched video with `trackChanged == false` on later ticks. Video identity + /// remains authoritative, so any mismatch must still pass through queue-drift + /// reconciliation. + /// + /// - Returns: Whether progress/play state from this observation belongs to the + /// current Kaset queue target after reconciliation. + func reconcileWebPlaybackMetadata( + title: String, + artist: String, + thumbnailUrl: String, + observedVideoId: String?, + mediaVideoId: String? = nil, + bridgeTrackChanged: Bool + ) -> Bool { + let normalizedLogicalVideoId = self.normalizedWebPlaybackVideoId(observedVideoId) + let normalizedMediaVideoId = self.normalizedWebPlaybackVideoId(mediaVideoId) + let identitiesCoherent = normalizedLogicalVideoId != nil + && normalizedLogicalVideoId == normalizedMediaVideoId + let authoritativeVideoId = normalizedMediaVideoId + let expectedVideoId = self.expectedPlaybackVideoId + let videoIdMismatch = authoritativeVideoId.map { $0 != expectedVideoId } ?? false + let hasObservedMetadata = authoritativeVideoId != nil || !title.isEmpty + let thumbnailMetadataChanged = !thumbnailUrl.isEmpty + && thumbnailUrl != self.currentTrack?.thumbnailURL?.absoluteString + let textualMetadataChanged = !title.isEmpty + && (title != self.currentTrack?.title + || !artist.isEmpty && artist != self.currentTrack?.artistsDisplay + || thumbnailMetadataChanged) + + // Media identity is authoritative for queue alignment. Textual metadata is + // applied only when the player-bar identity agrees with that media. + let shouldReconcileMetadata = identitiesCoherent + && (bridgeTrackChanged + || videoIdMismatch + || textualMetadataChanged) + if hasObservedMetadata, videoIdMismatch || shouldReconcileMetadata { + self.updateTrackMetadata( + title: identitiesCoherent ? title : "", + artist: identitiesCoherent ? artist : "", + thumbnailUrl: identitiesCoherent ? thumbnailUrl : "", + videoId: authoritativeVideoId + ) + } + + guard let authoritativeVideoId else { return true } + return self.observedPlaybackMatchesCurrentTarget(videoId: authoritativeVideoId) + } + + /// Whether an identity-bearing WebView playback observation belongs to + /// Kaset's current queue target. Once a target identity is known, transient + /// identityless ticks are rejected because they may belong to the outgoing video. + func observedPlaybackMatchesCurrentTarget(videoId observedVideoId: String?) -> Bool { + guard let expectedVideoId = self.expectedPlaybackVideoId else { return true } + guard let observedVideoId = self.normalizedWebPlaybackVideoId(observedVideoId) else { return false } + return observedVideoId == expectedVideoId + } + + var expectedPlaybackVideoId: String? { + self.pendingNativeQueueAdvanceVideoId + ?? self.queue[safe: self.currentIndex]?.videoId + ?? self.currentTrack?.videoId + ?? self.pendingPlayVideoId + } + + var isPendingNativeQueueAdvanceValid: Bool { + guard let pending = self.pendingNativeQueueAdvance, + let sourceEntryID = pending.sourceEntryID, + let sourceIndex = self.queueEntryIDs.firstIndex(of: sourceEntryID), + sourceIndex == self.currentIndex, + self.queue[safe: sourceIndex]?.videoId == pending.sourceVideoId, + let expectedTargetIndex = self.expectedQueueIndexAfterCurrentTrack(), + self.queueEntryIDs[safe: expectedTargetIndex] == pending.targetEntryID, + self.queue[safe: expectedTargetIndex]?.videoId == pending.targetVideoId + else { + return false + } + return true + } + + /// Starts a bounded native handoff without changing the visible queue pointer. + /// The pointer moves only after the media-bound observer reports the expected target. + func beginPendingNativeQueueAdvance(to index: Int) { + guard let targetEntry = self.queueEntries[safe: index], + let sourceVideoId = self.queue[safe: self.currentIndex]?.videoId + else { + return + } + + self.clearPendingNativeQueueAdvance() + let generation = self.pendingNativeQueueAdvanceGeneration + self.pendingNativeQueueAdvance = PendingNativeQueueAdvance( + sourceEntryID: self.currentQueueEntryID, + sourceVideoId: sourceVideoId, + targetEntryID: targetEntry.id, + targetVideoId: targetEntry.song.videoId, + generation: generation + ) + self.state = .loading + self.songNearingEnd = false + self.isKasetInitiatedPlayback = false + + Task { + try? await Task.sleep(for: Self.nativeQueueAdvanceTimeout) + await self.handleNativeQueueAdvanceTimeout(generation: generation) + } + } + + /// Reconciles an authoritative media-bound observation during a native handoff. + /// - Returns: Whether the caller should continue applying this observation. + func reconcilePendingNativeQueueAdvanceObservation(videoId: String?) async -> Bool { + guard let pending = self.pendingNativeQueueAdvance else { return true } + guard let videoId = self.normalizedWebPlaybackVideoId(videoId) else { return false } + + if videoId == pending.sourceVideoId { + // The outgoing element can emit a final pause/time update after `ended`. + return false + } + + if videoId == pending.targetVideoId, + await self.confirmPendingNativeQueueAdvance(videoId: videoId) + { + return true + } + + await self.fallbackPendingNativeQueueAdvance( + generation: pending.generation, + reason: "observed unexpected native video \(videoId)" + ) + return false + } + + func handleNativeQueueAdvanceTimeout(generation: Int) async { + await self.fallbackPendingNativeQueueAdvance( + generation: generation, + reason: "timed out waiting for expected native media" + ) + } + + func clearPendingNativeQueueAdvance() { + self.pendingNativeQueueAdvanceGeneration &+= 1 + self.pendingNativeQueueAdvance = nil + self.clearNativeQueueMaintenance() + } + + @discardableResult + private func confirmPendingNativeQueueAdvance(videoId: String) async -> Bool { + guard let pending = self.pendingNativeQueueAdvance, + pending.targetVideoId == videoId, + self.isPendingNativeQueueAdvanceValid, + let targetIndex = self.expectedQueueIndexAfterCurrentTrack() + else { + return false + } + + self.clearPendingNativeQueueAdvance() + self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) + self.advanceQueueStateForNativeNavigation(to: targetIndex) + SingletonPlayerWebView.shared.currentVideoId = videoId + self.logger.info("Confirmed native queue advance to \(videoId)") + + self.scheduleNativeQueueMaintenance() + return true + } + + func fallbackInvalidatedNativeQueueAdvance( + generation: Int, + reason: String + ) async { + guard let pending = self.pendingNativeQueueAdvance, + pending.generation == generation, + !self.isPendingNativeQueueAdvanceValid + else { + return + } + + await self.fallbackPendingNativeQueueAdvance( + generation: generation, + reason: reason + ) + } + + private func fallbackPendingNativeQueueAdvance( + generation: Int, + reason: String + ) async { + guard let pending = self.pendingNativeQueueAdvance, + pending.generation == generation + else { + return + } + + let sourceIndex = pending.sourceEntryID.flatMap { self.queueEntryIDs.firstIndex(of: $0) } + let targetIndex: Int? + if let sourceIndex { + // When the source still exists, a nil expected successor means the + // queue now ends here. Do not turn that into a replay of the source. + self.currentIndex = sourceIndex + targetIndex = self.expectedQueueIndexAfterCurrentTrack() + } else { + // Queue mutation helpers realign `currentIndex` after removing the source; + // that post-edit position is now authoritative. + targetIndex = self.queue.indices.contains(self.currentIndex) + ? self.currentIndex + : self.queue.indices.first + } + self.clearPendingNativeQueueAdvance() + + guard let targetIndex, + let targetSong = self.queue[safe: targetIndex] + else { + self.logger.warning("Native queue advance fallback has no remaining target: \(reason)") + self.shouldSuppressAutoplayAfterQueueEnd = true + self.markPlaybackEnded() + await self.pause() + return + } + + self.logger.warning( + "Native queue advance to \(pending.targetVideoId) failed; loading current expected target \(targetSong.videoId): \(reason)" + ) + self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) + // The tracked WebView ID still describes the pre-handoff source, while + // the actual media may already be an unexpected target. Force navigation + // when IDs happen to match instead of restarting the wrong media in place. + await self.loadQueueSongForNavigation( + at: targetIndex, + webLoadStrategy: .forceFullPageWhenSameVideoId + ) + } + + func awaitNativeQueueMaintenanceIfNeeded(generation: Int) async { + guard generation == self.nativeQueueMaintenanceGeneration, + self.nativeQueueMaintenanceTask != nil + else { + return + } + await withCheckedContinuation { continuation in + guard generation == self.nativeQueueMaintenanceGeneration, + self.nativeQueueMaintenanceTask != nil + else { + continuation.resume() + return + } + self.nativeQueueMaintenanceWaiters[generation, default: []].append(continuation) + } + } + + private func scheduleNativeQueueMaintenance() { + let previousGeneration = self.nativeQueueMaintenanceGeneration + self.nativeQueueMaintenanceGeneration &+= 1 + let generation = self.nativeQueueMaintenanceGeneration + self.nativeQueueMaintenanceTask?.cancel() + self.resumeNativeQueueMaintenanceWaiters(generation: previousGeneration) + self.nativeQueueMaintenanceTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { self.finishNativeQueueMaintenance(generation: generation) } + await NativeQueueMaintenanceContext.$isApplyingQueueMutation.withValue(true) { + await self.fetchMoreMixSongsIfNeeded { + !Task.isCancelled && self.nativeQueueMaintenanceGeneration == generation + } + guard !Task.isCancelled, + self.nativeQueueMaintenanceGeneration == generation + else { return } + await self.fillSmartShuffleWindow() + guard !Task.isCancelled, + self.nativeQueueMaintenanceGeneration == generation + else { return } + self.saveQueueForPersistence(syncWebQueue: false) + } + } + } + + func clearNativeQueueMaintenance() { + let previousGeneration = self.nativeQueueMaintenanceGeneration + self.nativeQueueMaintenanceGeneration &+= 1 + self.nativeQueueMaintenanceTask?.cancel() + self.nativeQueueMaintenanceTask = nil + self.resumeNativeQueueMaintenanceWaiters(generation: previousGeneration) + } + + private func finishNativeQueueMaintenance(generation: Int) { + if self.nativeQueueMaintenanceGeneration == generation { + self.nativeQueueMaintenanceTask = nil + } + self.resumeNativeQueueMaintenanceWaiters(generation: generation) + } + + private func resumeNativeQueueMaintenanceWaiters(generation: Int) { + let waiters = self.nativeQueueMaintenanceWaiters.removeValue(forKey: generation) ?? [] + for waiter in waiters { + waiter.resume() + } + } + + func resumeNativeQueueMaintenanceWaitersIfSuccessorMaterialized() { + guard self.expectedQueueIndexAfterCurrentTrack() != nil else { return } + self.resumeNativeQueueMaintenanceWaiters(generation: self.nativeQueueMaintenanceGeneration) + } + + private func normalizedWebPlaybackVideoId(_ videoId: String?) -> String? { + guard let videoId, !videoId.isEmpty else { return nil } + return videoId + } +} diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index a9420c50..95e78c46 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -4,31 +4,82 @@ import Foundation @MainActor extension PlayerService { - /// Distance from `duration` at which a manual seek is treated as the end of the track. - /// `video.currentTime = duration` does not reliably fire `ended` in WebKit, and a subsequent - /// play call would restart the same song from 0 instead of advancing. - static let seekToEndThreshold: TimeInterval = 0.5 + private static let queueNavigationInFlightProtectionGrace: Duration = .seconds(20) + private static let queueNavigationConfirmedProtectionGrace: Duration = .seconds(8) + private static let webQueueInjectionTimeout: Duration = .seconds(20) - /// Routes a manual seek that landed at the end of the track through the track-ended path so - /// repeat / queue / autoplay-suppression rules apply consistently with a natural end. - func handleManualSeekToEnd() async { - self.logger.info("Manual seek reached end of track; routing through track-ended path") - self.clearRestoredPlaybackSessionState() - self.progress = self.duration + private func applyDeferredRestoredMetadata( + title: String, + artist: String, + thumbnailUrl: String, + videoId observedVideoId: String? + ) { + guard let observedVideoId = self.normalizedObservedVideoId(observedVideoId) else { return } - if self.shouldSynchronizeWebViewForTerminalManualSeekToEnd { - SingletonPlayerWebView.shared.seekAndPause(to: self.duration) + let thumbnailURL = URL(string: thumbnailUrl) + let artistObj = Artist(id: "unknown", name: artist) + let matchedQueueSong = self.queue.first(where: { $0.videoId == observedVideoId }) + let seedSong: Song + + let previousVideoId = self.currentTrack?.videoId + self.pendingPlayVideoId = observedVideoId + self.isKasetInitiatedPlayback = false + self.isAwaitingWebRestoredTrack = false + if previousVideoId != observedVideoId { + // The saved seek belongs to the persisted track, not a different server-restored track. + self.pendingRestoredSeek = nil + self.progress = 0 + self.duration = matchedQueueSong?.duration ?? 0 } - await self.handleTrackEnded(observedVideoId: self.currentTrack?.videoId) - } + // Sync the web view's current video ID so Kaset knows the player is already on this track + SingletonPlayerWebView.shared.currentVideoId = observedVideoId + if observedVideoId == previousVideoId, !self.queue.isEmpty { + Task { + await self.fetchSongMetadata(videoId: observedVideoId) + } + return + } + self.mixContinuationToken = nil - private var shouldSynchronizeWebViewForTerminalManualSeekToEnd: Bool { - if self.queue.isEmpty { - return !(self.repeatMode == .one && (self.currentTrack != nil || self.pendingPlayVideoId != nil)) + if let matchedQueueSong, + self.shouldKeepQueueMetadata(title: title, artist: artist, song: matchedQueueSong) + { + seedSong = matchedQueueSong + } else { + seedSong = Song( + id: observedVideoId, + title: title, + artists: [artistObj], + album: nil, + duration: self.duration > 0 ? self.duration : nil, + thumbnailURL: thumbnailURL, + videoId: observedVideoId + ) } - return !self.canAdvanceNativeQueueAfterTrackEnd + self.clearForwardSkipNavigationStack() + self.setQueue([seedSong]) + self.currentIndex = 0 + self.currentTrack = seedSong + self.currentTrackHasVideo = seedSong.musicVideoType?.hasVideoContent + ?? seedSong.hasVideo + ?? false + self.saveQueueForPersistence() + + Task { + await self.fetchAndApplyRadioQueue(for: observedVideoId) + } + + if previousVideoId != observedVideoId { + self.resetTrackStatus() + if let cachedStatus = SongLikeStatusManager.shared.status(for: observedVideoId) { + self.currentTrackLikeStatus = cachedStatus + } + Task { + await self.fetchSongMetadata(videoId: observedVideoId) + } + } } private func normalizedObservedVideoId(_ videoId: String?) -> String? { @@ -60,20 +111,127 @@ extension PlayerService { title.isEmpty || artist.isEmpty || !self.metadataMatchesSong(title: title, artist: artist, song: song) } - private var canAdvanceNativeQueueAfterTrackEnd: Bool { - self.shuffleEnabled - || self.repeatMode == .one + /// Synchronizes Kaset's expected next track with YouTube Music's native "Up Next" queue. + /// Injecting the next track ahead of time enables best-effort gapless playback when the current track ends. + /// + /// **Important:** Only runs when the player is in a stable state (`.playing` or `.paused`). + /// During `.loading` or a main-document navigation, the player bar DOM is in flux. + /// Injection is deferred until playback is stable or the navigation delegate reports + /// that the replacement document has finished loading. + func syncWebQueue() { + // Never manipulate the player-bar menu while its document is being replaced. + guard self.state == .playing || self.state == .paused else { return } + guard !SingletonPlayerWebView.shared.isDocumentNavigationInProgress else { return } + + guard let nextIndex = self.expectedQueueIndexAfterCurrentTrack(), + let nextSong = self.queue[safe: nextIndex], + let sourceVideoId = self.expectedPlaybackVideoId + else { + self.clearWebQueueInjectionState() + return + } + + // Duplicate video IDs need an explicit in-place restart so the media + // generation advances with the queue entry. Clear any marker consumed by + // the preceding occurrence before checking cached target IDs. + guard nextSong.videoId != sourceVideoId else { + self.clearWebQueueInjectionState() + return + } + + let confirmedTargetChanged = self.injectedWebQueueVideoId.map { $0 != nextSong.videoId } ?? false + let pendingTargetChanged = self.pendingWebQueueInjectionVideoId.map { $0 != nextSong.videoId } ?? false + if confirmedTargetChanged || pendingTargetChanged { + self.clearWebQueueInjectionState() + } + + guard self.injectedWebQueueVideoId != nextSong.videoId, + self.pendingWebQueueInjectionVideoId != nextSong.videoId + else { return } + + self.pendingWebQueueInjectionVideoId = nextSong.videoId + self.webQueueInjectionGeneration &+= 1 + let injectionGeneration = self.webQueueInjectionGeneration + if SingletonPlayerWebView.shared.injectNextSong( + videoId: nextSong.videoId, + afterVideoId: sourceVideoId, + attemptGeneration: injectionGeneration + ) { + self.logger.info("Syncing web queue: requested injection of \(nextSong.videoId) to play next natively") + self.scheduleWebQueueInjectionTimeout( + videoId: nextSong.videoId, + generation: injectionGeneration + ) + } else { + self.pendingWebQueueInjectionVideoId = nil + } + } + + private func scheduleWebQueueInjectionTimeout(videoId: String, generation: Int) { + Task { + try? await Task.sleep(for: Self.webQueueInjectionTimeout) + guard self.webQueueInjectionGeneration == generation, + self.pendingWebQueueInjectionVideoId == videoId + else { + return + } + self.webQueueInjectionGeneration &+= 1 + self.pendingWebQueueInjectionVideoId = nil + SingletonPlayerWebView.shared.cancelQueueInjection() + self.logger.warning("Web queue injection timed out for \(videoId)") + } + } + + /// Records the WebView result for an attempted native queue injection. + func handleWebQueueInjectionResult( + videoId: String, + attemptGeneration: Int, + success: Bool, + reason: String? + ) { + guard self.webQueueInjectionGeneration == attemptGeneration, + self.pendingWebQueueInjectionVideoId == videoId + else { + self.logger.debug("Ignoring web queue injection result for non-pending video \(videoId)") + return + } + self.webQueueInjectionGeneration &+= 1 + self.pendingWebQueueInjectionVideoId = nil + + guard success, reason == "queue-readback-confirmed" else { + if self.injectedWebQueueVideoId == videoId { + self.injectedWebQueueVideoId = nil + } + self.logger.warning("Web queue injection failed for \(videoId): \(reason ?? "unknown")") + return + } + + guard let nextIndex = self.expectedQueueIndexAfterCurrentTrack(), + self.queue[safe: nextIndex]?.videoId == videoId + else { + if self.injectedWebQueueVideoId == videoId { + self.injectedWebQueueVideoId = nil + } + self.logger.debug("Ignoring stale web queue injection confirmation for \(videoId)") + return + } + + self.injectedWebQueueVideoId = videoId + self.logger.info("Synced web queue: confirmed \(videoId) to play next natively") + } + + var canAdvanceNativeQueueAfterTrackEnd: Bool { + self.repeatMode == .one || self.currentIndex < self.queue.count - 1 || self.repeatMode == .all || self.mixContinuationToken != nil } - private func expectedQueueIndexAfterCurrentTrack() -> Int? { + func expectedQueueIndexAfterCurrentTrack() -> Int? { guard !self.queue.isEmpty else { return nil } if self.repeatMode == .one { return self.currentIndex } - guard !self.shuffleEnabled else { return nil } if self.currentIndex < self.queue.count - 1 { return self.currentIndex + 1 } @@ -83,12 +241,62 @@ extension PlayerService { return nil } + func protectQueueNavigationTarget(_ videoId: String) { + self.protectedQueueNavigationVideoId = videoId + self.protectedQueueNavigationStartedAt = ContinuousClock.now + self.protectedQueueNavigationConfirmedAt = nil + } + + private func confirmQueueNavigationTarget(_ videoId: String) { + guard self.protectedQueueNavigationVideoId == videoId else { return } + if self.protectedQueueNavigationConfirmedAt == nil { + self.protectedQueueNavigationConfirmedAt = ContinuousClock.now + } + } + + @discardableResult + private func clearExpiredQueueNavigationProtectionIfNeeded() -> Bool { + let now = ContinuousClock.now + if let confirmedAt = self.protectedQueueNavigationConfirmedAt { + guard now - confirmedAt >= Self.queueNavigationConfirmedProtectionGrace else { return false } + } else if let startedAt = self.protectedQueueNavigationStartedAt { + guard now - startedAt >= Self.queueNavigationInFlightProtectionGrace else { return false } + } else { + return false + } + + self.protectedQueueNavigationVideoId = nil + self.protectedQueueNavigationStartedAt = nil + self.protectedQueueNavigationConfirmedAt = nil + return true + } + + private func rejectProtectedQueueNavigationDriftIfNeeded( + observedVideoId: String, + currentQueueSong: Song, + thumbnailUrl: String + ) -> Bool { + self.clearExpiredQueueNavigationProtectionIfNeeded() + + guard let normalizedObservedVideoId = self.normalizedObservedVideoId(observedVideoId), + let protectedVideoId = self.protectedQueueNavigationVideoId, + currentQueueSong.videoId == protectedVideoId, + normalizedObservedVideoId != protectedVideoId + else { return false } + + self.logger.info( + "Ignoring stale in-queue metadata for \(normalizedObservedVideoId); keeping protected queue target \(protectedVideoId)" + ) + self.keepQueueSongVisible(currentQueueSong, thumbnailUrl: thumbnailUrl) + self.scheduleQueueNavigationRecovery(for: currentQueueSong) + return true + } + private func isRepeatAllWraparoundTrackEnd( observedVideoId: String, expectedCurrentVideoId: String ) -> Bool { guard self.repeatMode == .all, - !self.shuffleEnabled, self.expectedQueueIndexAfterCurrentTrack() == 0, let currentQueueSong = self.queue[safe: self.currentIndex], let firstQueueSong = self.queue.first @@ -156,18 +364,26 @@ extension PlayerService { thumbnailUrl: String, trackChanged: Bool ) -> Bool { + if self.clearExpiredQueueNavigationProtectionIfNeeded() { + self.isKasetInitiatedPlayback = false + self.clearQueueNavigationRecovery() + } + guard self.isKasetInitiatedPlayback, !self.queue.isEmpty else { return false } guard let intendedSong = self.queue[safe: self.currentIndex] else { self.isKasetInitiatedPlayback = false + self.clearQueueNavigationRecovery() return false } let matchesObservedVideo = self.normalizedObservedVideoId(observedVideoId) == intendedSong.videoId if matchesObservedVideo, self.shouldKeepQueueMetadata(title: title, artist: artist, song: intendedSong) { + self.confirmQueueNavigationTarget(intendedSong.videoId) self.isKasetInitiatedPlayback = false + self.clearQueueNavigationRecovery() self.logger.debug( "Confirmed intended videoId \(intendedSong.videoId) with incomplete metadata '\(title)'; keeping queue metadata" ) @@ -181,7 +397,9 @@ extension PlayerService { artist: artist, song: intendedSong ) { + self.confirmQueueNavigationTarget(intendedSong.videoId) self.isKasetInitiatedPlayback = false + self.clearQueueNavigationRecovery() self.logger.debug("Confirmed Kaset-initiated playback for '\(intendedSong.title)'") return false } @@ -194,13 +412,24 @@ extension PlayerService { self.logger.info( "YouTube loaded different track '\(title)' (\(resolvedVideoId)), re-playing intended track '\(intendedSong.title)'" ) - self.isKasetInitiatedPlayback = false - Task { - await self.play(song: intendedSong, webLoadStrategy: .forceFullPageWhenSameVideoId) - } + // Keep the Kaset-initiated guard active until the intended video is + // actually confirmed. WebView metadata can emit multiple stale frames + // for the previous/native queue item while our manual navigation load is + // still in flight; dropping the guard here lets a later stale frame + // realign `currentIndex` backward through `handleUnexpectedQueueDriftIfNeeded`. + self.scheduleQueueNavigationRecovery(for: intendedSong) return true } + private func commitObservedQueueTrack(to index: Int, videoId: String) { + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() + self.pushForwardSkipStackIfLeavingIndex(for: index) + self.advanceQueueStateForNativeNavigation(to: index) + SingletonPlayerWebView.shared.currentVideoId = videoId + self.saveQueueForPersistence(syncWebQueue: false) + } + private func handleNearEndTrackChangeIfNeeded( observedVideoId: String?, title: String, @@ -234,14 +463,16 @@ extension PlayerService { } self.logger.info("YouTube autoplay detected, overriding with queue track") Task { - await self.next() + _ = await self.performNextNavigation() } return true } - self.currentIndex = expectedNextIndex + self.commitObservedQueueTrack( + to: expectedNextIndex, + videoId: expectedNextTrack.videoId + ) self.logger.info("Track advanced to queue index \(expectedNextIndex)") - self.saveQueueForPersistence() if self.shouldKeepQueueMetadata(title: title, artist: artist, song: expectedNextTrack) { self.logger.debug( @@ -263,7 +494,7 @@ extension PlayerService { } self.logger.info("Near-end track change detected, advancing native queue to enforce playback order") Task { - await self.next() + _ = await self.performNextNavigation() } return true } @@ -337,6 +568,14 @@ extension PlayerService { return false } + if self.rejectProtectedQueueNavigationDriftIfNeeded( + observedVideoId: observedVideoId, + currentQueueSong: currentQueueSong, + thumbnailUrl: thumbnailUrl + ) { + return true + } + // Repeat one: autoplay can swap the video before title/artist update, so `trackChanged` may still be false. // Without this branch we fall through and assign `currentTrack` from YouTube, breaking UI sync. guard trackChanged || self.repeatMode == .one else { @@ -360,9 +599,8 @@ extension PlayerService { { let queueIndexChanged = matchingIndex != self.currentIndex if queueIndexChanged { - self.currentIndex = matchingIndex + self.commitObservedQueueTrack(to: matchingIndex, videoId: observedVideoId) self.logger.info("Observed playback moved to queue index \(matchingIndex), realigning native queue") - self.saveQueueForPersistence() } if queueIndexChanged || self.shouldKeepQueueMetadata(title: title, artist: artist, song: matchingSong) { @@ -420,9 +658,24 @@ extension PlayerService { } } + // swiftlint:disable cyclomatic_complexity function_body_length /// Handles a natural track completion reported directly by the WebView. - func handleTrackEnded(observedVideoId: String?) async { + func handleTrackEnded( + observedVideoId: String?, + shouldContinue: @MainActor () -> Bool = { true } + ) async { + guard shouldContinue() else { return } + var endedNavigationContext = self.playbackNavigationContext + var endedEntryID = self.currentQueueEntryID self.logger.debug("Track ended reported by WebView: \(observedVideoId ?? "unknown")") + while !self.queue.isEmpty, + self.expectedQueueIndexAfterCurrentTrack() == nil, + self.nativeQueueMaintenanceTask != nil + { + let maintenanceGeneration = self.nativeQueueMaintenanceGeneration + await self.awaitNativeQueueMaintenanceIfNeeded(generation: maintenanceGeneration) + guard shouldContinue(), self.playbackNavigationContext == endedNavigationContext else { return } + } self.songNearingEnd = false guard !self.queue.isEmpty else { if self.repeatMode == .one, self.currentTrack != nil || self.pendingPlayVideoId != nil { @@ -432,6 +685,27 @@ extension PlayerService { self.markPlaybackEnded() return } + if let pending = self.pendingNativeQueueAdvance { + let normalizedEndedVideoId = self.normalizedObservedVideoId(observedVideoId) + if normalizedEndedVideoId == pending.sourceVideoId { + self.logger.debug("Ignoring duplicate ended event from native handoff source") + return + } + guard normalizedEndedVideoId == pending.targetVideoId, + await self.reconcilePendingNativeQueueAdvanceObservation( + videoId: normalizedEndedVideoId + ) + else { + if normalizedEndedVideoId != nil { + _ = await self.reconcilePendingNativeQueueAdvanceObservation( + videoId: normalizedEndedVideoId + ) + } + return + } + endedNavigationContext = self.playbackNavigationContext + endedEntryID = self.currentQueueEntryID + } if let observedVideoId = self.normalizedObservedVideoId(observedVideoId) { let currentQueueVideoId = self.queue[safe: self.currentIndex]?.videoId let expectedCurrentVideoId = currentQueueVideoId ?? self.currentTrack?.videoId ?? self.pendingPlayVideoId @@ -471,19 +745,76 @@ extension PlayerService { await self.replayCurrentQueueSongForRepeatOneAfterTrackEnd() return } + + // A readback-confirmed YouTube Music queue entry may advance without + // navigation. Keep the outgoing song visible until the media-bound observer + // confirms the exact expected target; a wrong or missing transition falls + // back to deterministic loading after a short bounded wait. + if let expectedIndex = self.expectedQueueIndexAfterCurrentTrack(), + let expectedSong = self.queue[safe: expectedIndex], + self.injectedWebQueueVideoId == expectedSong.videoId + { + self.logger.info( + "Track ended with verified native next \(expectedSong.videoId); awaiting media confirmation" + ) + self.injectedWebQueueVideoId = nil + self.beginPendingNativeQueueAdvance(to: expectedIndex) + return + } + self.logger.info("Track ended in WebView, advancing native queue immediately") - await self.next() + guard shouldContinue() else { return } + let didAdvance = await self.performNextNavigation() + guard shouldContinue() else { return } + if !didAdvance { + guard self.playbackNavigationContext == endedNavigationContext else { return } + if await self.advanceToMaterializedNextQueueSongIfAvailable(after: endedEntryID) { + return + } + await self.finishPlaybackAfterFailedQueueAdvance( + reason: "continuation produced no next queue entry" + ) + } } + // swiftlint:enable cyclomatic_complexity function_body_length + /// Updates track metadata and enforces Kaset's queue when YouTube tries to diverge. func updateTrackMetadata(title: String, artist: String, thumbnailUrl: String, videoId observedVideoId: String?) { self.logger.debug("Track metadata updated: \(title) - \(artist)") + + let isRestoringFromCloud = self.isAwaitingWebRestoredTrack + && !self.isKasetInitiatedPlayback + && observedVideoId != nil + + if self.isPendingRestoredLoadDeferred { + guard self.isAwaitingWebRestoredTrack else { return } + self.applyDeferredRestoredMetadata( + title: title, + artist: artist, + thumbnailUrl: thumbnailUrl, + videoId: observedVideoId + ) + return + } + + if isRestoringFromCloud { + self.applyDeferredRestoredMetadata( + title: title, + artist: artist, + thumbnailUrl: thumbnailUrl, + videoId: observedVideoId + ) + return + } + let thumbnailURL = URL(string: thumbnailUrl) let artistObj = Artist(id: "unknown", name: artist) let resolvedVideoId = self.resolvedObservedVideoId(observedVideoId) + let videoIdChanged = self.currentTrack?.videoId != resolvedVideoId let trackChanged = self.currentTrack?.title != title || self.currentTrack?.artistsDisplay != artist - || self.currentTrack?.videoId != resolvedVideoId + || videoIdChanged if self.suppressUnexpectedAutoplayAfterQueueEndIfNeeded( trackChanged: trackChanged, @@ -557,6 +888,12 @@ extension PlayerService { if let cachedStatus = SongLikeStatusManager.shared.status(for: resolvedVideoId) { self.currentTrackLikeStatus = cachedStatus } + + if videoIdChanged { + self.clearWebQueueInjectionState() + // Re-sync the web queue since the playing video changed natively. + self.syncWebQueue() + } } } } diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 6d6d5ced..c10e6403 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -2,6 +2,12 @@ import Foundation import Observation import os +// MARK: - NativeQueueMaintenanceContext + +enum NativeQueueMaintenanceContext { + @TaskLocal static var isApplyingQueueMutation = false +} + // MARK: - PlayerService /// Controls music playback via a hidden WKWebView. @@ -176,6 +182,22 @@ final class PlayerService: NSObject, PlayerServiceProtocol { private(set) var currentQueueEntryID: UUID? + var queuePlaybackContext: QueuePlaybackContext { + QueuePlaybackContext( + entryID: self.currentQueueEntryID, + index: self.currentIndex, + requestGeneration: self.playbackRequestGeneration, + navigationGeneration: self.playbackNavigationGeneration + ) + } + + var playbackNavigationContext: PlaybackNavigationContext { + PlaybackNavigationContext( + requestGeneration: self.playbackRequestGeneration, + navigationGeneration: self.playbackNavigationGeneration + ) + } + /// Whether the mini player should be shown (user needs to interact to start playback). var showMiniPlayer: Bool = false @@ -197,6 +219,21 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// The video ID that needs to be played in the mini player. var pendingPlayVideoId: String? + /// Native YouTube Music queue advance waiting for media-bound confirmation. + /// The visible queue pointer remains on the outgoing entry until the expected + /// target media is observed. While pending, the persistent player must not + /// autoload the target through Kaset's deterministic navigation path. + var pendingNativeQueueAdvance: PendingNativeQueueAdvance? + + var pendingNativeQueueAdvanceGeneration: Int = 0 + var nativeQueueMaintenanceGeneration: Int = 0 + var nativeQueueMaintenanceTask: Task? + @ObservationIgnored var nativeQueueMaintenanceWaiters: [Int: [CheckedContinuation]] = [:] + + var pendingNativeQueueAdvanceVideoId: String? { + self.pendingNativeQueueAdvance?.targetVideoId + } + /// Whether the user has successfully interacted at least once this session. /// After first successful playback, we can auto-play without showing the popup. private(set) var hasUserInteractedThisSession: Bool = false @@ -217,6 +254,15 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// Whether a restored load should automatically resume after seeking to the saved position. var shouldAutoResumeAfterRestoredLoad: Bool = false + /// Monotonic generation for deferred restored-session fallback tasks. + var restoredPlaybackSessionGeneration: Int = 0 + + /// Whether startup is waiting for YT Music to report its own server-restored track. + var isAwaitingWebRestoredTrack: Bool = false + + /// Whether Kaset has already issued the one-shot defensive pause while a deferred restored session is awaiting web metadata. + var hasIssuedAutoplayPauseDuringDeferredRestore: Bool = false + /// Like status of the current track. var currentTrackLikeStatus: LikeStatus = .indifferent @@ -273,10 +319,16 @@ final class PlayerService: NSObject, PlayerServiceProtocol { var mixContinuationToken: String? var mixContinuationRequiresAuth = false var playbackRequestGeneration = 0 + var pendingPlaybackSelectionGeneration = 0 + var playbackNavigationGeneration = 0 + var queueMutationGeneration = 0 /// Whether we're currently fetching more mix songs. var isFetchingMoreMixSongs: Bool = false + /// Callers waiting for the current mix continuation request to release its single-flight slot. + @ObservationIgnored var mixContinuationFetchWaiters: [CheckedContinuation] = [] + /// Smart Shuffle: videoIds suggested this session, for dedup across fills. var smartShuffleSeenSuggestionIds: Set = [] @@ -536,15 +588,51 @@ final class PlayerService: NSObject, PlayerServiceProtocol { } func setQueue(entries: [QueueEntry]) { + let queueStructureChanged = self.queueStorage.count != entries.count + || zip(self.queueStorage, entries).contains { existing, replacement in + existing.id != replacement.id + || existing.song.videoId != replacement.song.videoId + || existing.source != replacement.source + } + if queueStructureChanged { + self.queueMutationGeneration &+= 1 + } + if !NativeQueueMaintenanceContext.isApplyingQueueMutation, + !self.isApplyingQueueEnrichmentResult + { + self.clearNativeQueueMaintenance() + } + let pendingGeneration = self.pendingNativeQueueAdvance?.generation self.queueStorage = entries self.synchronizeCurrentQueueEntryID() + if NativeQueueMaintenanceContext.isApplyingQueueMutation { + self.resumeNativeQueueMaintenanceWaitersIfSuccessorMaterialized() + } self.queueDidChangeForEnrichment() + + if let pendingGeneration, + !self.isPendingNativeQueueAdvanceValid + { + Task { + await self.fallbackInvalidatedNativeQueueAdvance( + generation: pendingGeneration, + reason: "queue adjacency changed" + ) + } + } } func synchronizeCurrentQueueEntryID() { self.currentQueueEntryID = self.queueStorage[safe: self.currentIndex]?.id } + func clearWebQueueInjectionState() { + self.webQueueInjectionGeneration &+= 1 + SingletonPlayerWebView.shared.cancelQueueInjection() + self.injectedWebQueueVideoId = nil + self.pendingWebQueueInjectionVideoId = nil + } + /// Records the current index before `next()` moves to `newIndex` (no-op if unchanged). func pushForwardSkipStackIfLeavingIndex(for newIndex: Int) { let from = self.currentIndex @@ -630,6 +718,40 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// Flag to suppress YouTube autoplay after the native queue has finished. var shouldSuppressAutoplayAfterQueueEnd: Bool = false + /// Video ID of the song last confirmed as injected into YouTube Music's native "Up Next" queue. + /// Used to avoid duplicate injections and to detect when YouTube has auto-advanced + /// to the injected track (enabling gapless transition without calling `loadVideo`). + var injectedWebQueueVideoId: String? + + /// Video ID currently being injected into YouTube Music's native "Up Next" queue. + /// This is intentionally separate from ``injectedWebQueueVideoId`` so track-end logic + /// only trusts injections after the WebView script accepts the source-bound command. + var pendingWebQueueInjectionVideoId: String? + + /// Invalidates native queue-injection timeout tasks when an attempt completes + /// or a new playback context supersedes it. + var webQueueInjectionGeneration: Int = 0 + + /// Video ID that Kaset just selected through deterministic queue navigation. + /// WebView metadata can arrive out of order around manual/media-key skips; keep + /// this target protected briefly so stale in-queue rows cannot realign `currentIndex` backward. + var protectedQueueNavigationVideoId: String? + + /// Instant when the protected queue target was first confirmed by WebView metadata. + /// `nil` means the target is still in flight and should remain protected. + var protectedQueueNavigationConfirmedAt: ContinuousClock.Instant? + + /// Instant when the protected queue target was requested. Used to expire + /// in-flight protection if WebView never confirms the requested video. + var protectedQueueNavigationStartedAt: ContinuousClock.Instant? + + /// Coalesces stale-metadata correction loads while the intended queue target + /// is still awaiting WebView confirmation. + var queueNavigationRecoveryGeneration: Int = 0 + var queueNavigationRecoveryVideoId: String? + var queueNavigationRecoveryLoadTask: Task? + var queueNavigationRecoveryTask: Task? + /// Grace period instant - don't auto-close video window shortly after opening (uses monotonic clock) var videoWindowOpenedAt: ContinuousClock.Instant? diff --git a/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift b/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift index 94ba1def..65ea286f 100644 --- a/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift +++ b/Sources/Kaset/Services/Player/PlaylistPlaybackActions.swift @@ -1,6 +1,7 @@ import Foundation /// Playback actions for playlist-backed queues. +@MainActor enum PlaylistPlaybackActions { struct ContinuationContext { let continuationToken: String? @@ -12,17 +13,18 @@ enum PlaylistPlaybackActions { } /// Plays a playlist immediately, replacing the current queue. + @discardableResult static func playPlaylist( _ playlist: Playlist, client: any YTMusicClientProtocol, playerService: PlayerService - ) { - Task { @MainActor in - let requestGeneration = playerService.playbackRequestGeneration + ) -> Task { + let requestGeneration = playerService.beginPendingPlaybackSelectionRequest() + return Task { @MainActor in do { let response = try await client.getPlaylist(id: playlist.id) - guard requestGeneration == playerService.playbackRequestGeneration else { - DiagnosticsLogger.ui.info("Discarding stale playlist playback request after privacy boundary") + guard playerService.isCurrentPendingPlaybackSelectionRequest(requestGeneration) else { + DiagnosticsLogger.ui.info("Discarding stale playlist playback request") return } var songs = response.detail.tracks @@ -30,8 +32,8 @@ enum PlaylistPlaybackActions { if self.isRadioPlaylist(playlist.id) { do { let allTracks = try await client.getPlaylistAllTracks(playlistId: playlist.id) - guard requestGeneration == playerService.playbackRequestGeneration else { - DiagnosticsLogger.ui.info("Discarding stale playlist all-tracks request after privacy boundary") + guard playerService.isCurrentPendingPlaybackSelectionRequest(requestGeneration) else { + DiagnosticsLogger.ui.info("Discarding stale playlist all-tracks request") return } if allTracks.count >= songs.count, !allTracks.isEmpty { @@ -43,6 +45,10 @@ enum PlaylistPlaybackActions { } catch { DiagnosticsLogger.ui.debug("Falling back to browse playlist tracks: \(error.localizedDescription)") } + guard playerService.isCurrentPendingPlaybackSelectionRequest(requestGeneration) else { + DiagnosticsLogger.ui.info("Discarding stale radio playlist fallback") + return + } } else { let playableSongs = self.playableSongsWithPlaylistArtwork(songs, playlist: playlist) guard !playableSongs.isEmpty else { return } diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index a318d531..03a884e5 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -19,6 +19,10 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length static let commandBarTopPadding: CGFloat = 72 } + nonisolated static func shouldMountPersistentPlayer(isLoggedIn: Bool, pendingVideoId: String?) -> Bool { + isLoggedIn || pendingVideoId != nil + } + @Environment(AuthService.self) private var authService @Environment(PlayerService.self) private var playerService @Environment(YouTubePlayerService.self) private var youtubePlayerService @@ -155,11 +159,14 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length DiagnosticsLogger.app.info("MainWindow: Login check complete") } - // Persistent WebView - always present once a video has been requested. + // Persistent WebView - eager once logged in, and mounted on demand for guest playback. // Uses a SINGLETON WebView instance that persists for the app lifetime. // Keep it as a hidden 1Ɨ1 anchor for audio playback; do not reveal a mini overlay. - if let videoId = playerService.pendingPlayVideoId { - PersistentPlayerView(videoId: videoId, isExpanded: false) + if Self.shouldMountPersistentPlayer( + isLoggedIn: self.authService.state.isLoggedIn, + pendingVideoId: self.playerService.pendingPlayVideoId + ) { + PersistentPlayerView(videoId: self.playerService.pendingPlayVideoId, isExpanded: false) .frame(width: 1, height: 1) .opacity(0) .allowsHitTesting(false) diff --git a/Sources/Kaset/Views/MiniPlayerViews.swift b/Sources/Kaset/Views/MiniPlayerViews.swift index 39544036..b3d2b38e 100644 --- a/Sources/Kaset/Views/MiniPlayerViews.swift +++ b/Sources/Kaset/Views/MiniPlayerViews.swift @@ -11,13 +11,13 @@ struct PersistentPlayerView: NSViewRepresentable { @Environment(PlayerService.self) private var playerService @Environment(AuthService.self) private var authService - let videoId: String + let videoId: String? let isExpanded: Bool // Retained for compatibility; audio playback keeps this hidden. private let logger = DiagnosticsLogger.player func makeNSView(context _: Context) -> NSView { - self.logger.info("PersistentPlayerView.makeNSView for videoId: \(self.videoId)") + self.logger.info("PersistentPlayerView.makeNSView for videoId: \(self.videoId ?? "nil")") let container = NSView(frame: .zero) container.wantsLayer = true @@ -36,11 +36,12 @@ struct PersistentPlayerView: NSViewRepresentable { container.addSubview(webView) // Restored sessions keep the hidden WebView inert until the user explicitly resumes. - if self.playerService.shouldAutoloadPendingVideo, - SingletonPlayerWebView.shared.currentVideoId != self.videoId + if let videoId = self.videoId, + self.playerService.shouldAutoloadPendingVideo, + SingletonPlayerWebView.shared.currentVideoId != videoId { - self.logger.info("Initial hidden load for videoId: \(self.videoId)") - SingletonPlayerWebView.shared.loadVideo(videoId: self.videoId) + self.logger.info("Initial hidden load for videoId: \(videoId)") + SingletonPlayerWebView.shared.loadVideo(videoId: videoId) } return container @@ -64,10 +65,11 @@ struct PersistentPlayerView: NSViewRepresentable { webView.frame = container.bounds - if self.playerService.shouldAutoloadPendingVideo, - SingletonPlayerWebView.shared.currentVideoId != self.videoId + if let videoId = self.videoId, + self.playerService.shouldAutoloadPendingVideo, + SingletonPlayerWebView.shared.currentVideoId != videoId { - SingletonPlayerWebView.shared.loadVideo(videoId: self.videoId) + SingletonPlayerWebView.shared.loadVideo(videoId: videoId) } } } diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index f2e1acb2..2901e9a7 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -3,6 +3,99 @@ import os import SwiftUI import WebKit +// MARK: - WebPlaybackIdentityTransition + +enum WebPlaybackIdentityTransition { + static func isConfirmed( + observedVideoId: String?, + lastAcceptedObservedVideoId: String?, + expectedVideoIdBeforeReconciliation: String? + ) -> Bool { + guard let observedVideoId else { return false } + if let lastAcceptedObservedVideoId { + return observedVideoId != lastAcceptedObservedVideoId + } + guard let expectedVideoIdBeforeReconciliation else { return false } + return observedVideoId != expectedVideoIdBeforeReconciliation + } + + static func shouldAcceptMediaState( + queueEntryChanged: Bool, + observerEpoch: Double, + lastAcceptedObserverEpoch: Double?, + mediaGeneration: Int, + lastAcceptedMediaGeneration: Int? + ) -> Bool { + guard self.isObservationOrdered( + observerEpoch: observerEpoch, + lastAcceptedObserverEpoch: lastAcceptedObserverEpoch, + mediaGeneration: mediaGeneration, + lastAcceptedMediaGeneration: lastAcceptedMediaGeneration + ) else { + return false + } + guard let lastAcceptedObserverEpoch else { return true } + if observerEpoch > lastAcceptedObserverEpoch { + return true + } + guard let lastAcceptedMediaGeneration else { return true } + if mediaGeneration < lastAcceptedMediaGeneration { + return false + } + return !queueEntryChanged || mediaGeneration > lastAcceptedMediaGeneration + } + + static func isObservationOrdered( + observerEpoch: Double, + lastAcceptedObserverEpoch: Double?, + mediaGeneration: Int, + lastAcceptedMediaGeneration: Int? + ) -> Bool { + guard let lastAcceptedObserverEpoch else { return true } + if observerEpoch < lastAcceptedObserverEpoch { + return false + } + if observerEpoch > lastAcceptedObserverEpoch { + return true + } + guard let lastAcceptedMediaGeneration else { return true } + return mediaGeneration >= lastAcceptedMediaGeneration + } + + static func shouldAcceptEndedOccurrence( + observerEpoch: Double, + lastHandledObserverEpoch: Double?, + mediaGeneration: Int, + lastHandledMediaGeneration: Int? + ) -> Bool { + guard let lastHandledObserverEpoch else { return true } + if observerEpoch < lastHandledObserverEpoch { + return false + } + if observerEpoch > lastHandledObserverEpoch { + return true + } + guard let lastHandledMediaGeneration else { return true } + return mediaGeneration > lastHandledMediaGeneration + } + + static func shouldHandleDeferredIdentitylessObservation( + isDeferred: Bool, + observedVideoId: String?, + mediaVideoId: String? + ) -> Bool { + isDeferred && observedVideoId == nil && mediaVideoId == nil + } + + static func didQueueEntryChange( + hasBaseline: Bool, + lastAcceptedQueueEntryID: UUID?, + currentQueueEntryID: UUID? + ) -> Bool { + hasBaseline && lastAcceptedQueueEntryID != currentQueueEntryID + } +} + // MARK: - MiniPlayerWebView /// A visible WebView that displays the YouTube Music player. @@ -221,7 +314,13 @@ struct MiniPlayerWebView: NSViewRepresentable { /// - Video mode CSS injection (SingletonPlayerWebView+VideoMode.swift) /// - Observer script (SingletonPlayerWebView+ObserverScript.swift) @MainActor -final class SingletonPlayerWebView { +final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length + private struct PendingRouterNavigation { + let videoId: String + let fallbackURL: URL + let generation: Int + } + static let shared = SingletonPlayerWebView() private(set) var webView: WKWebView? @@ -232,6 +331,12 @@ final class SingletonPlayerWebView { var coordinator: Coordinator? let logger = DiagnosticsLogger.player private var loadGeneration = 0 + private var pendingRouterNavigation: PendingRouterNavigation? + private var documentIDGeneration = 0 + var pendingDocumentID: Int? + var activeDocumentNavigation: WKNavigation? + var activeDocumentNavigationID: Int? + var isDocumentNavigationInProgress = false /// Current display mode for the WebView. enum DisplayMode { @@ -253,6 +358,7 @@ final class SingletonPlayerWebView { var displayMode: DisplayMode = .hidden var mediaControlUsesNextPrev: Bool var playbackAudioQuality: SettingsManager.PlaybackAudioQuality + private var hasStartedHomePreload = false /// Native timer that re-asserts the media-key override while backgrounded. /// See `beginBackgroundMediaControlReassertion()`. @@ -302,9 +408,13 @@ final class SingletonPlayerWebView { // Dynamic startup state is refreshed before each full page load so the // next document gets current volume/autoplay flags at document start. + let shouldBlockAutoplay = playerService.isRestoringPlaybackSession + || playerService.isPendingRestoredLoadDeferred + || playerService.pendingPlayVideoId == nil + self.installUserScripts( on: configuration.userContentController, - isRestoringPlaybackSession: playerService.isRestoringPlaybackSession, + shouldBlockAutoplay: shouldBlockAutoplay, targetVolume: playerService.volume ) @@ -322,9 +432,25 @@ final class SingletonPlayerWebView { if let previousContainer { self.ensureInHierarchy(container: previousContainer) } + self.preloadHomePageIfNeeded() return newWebView } + private func preloadHomePageIfNeeded() { + guard !UITestConfig.isRunningUnitTests else { return } + guard !self.hasStartedHomePreload else { return } + guard self.currentVideoId == nil else { return } + guard let webView else { return } + guard let homeURL = URL(string: "https://music.youtube.com/") else { + self.logger.error("Unable to construct YT Music home URL") + return + } + + self.hasStartedHomePreload = true + self.logger.info("Preloading YT Music home page") + webView.load(URLRequest(url: homeURL)) + } + /// Ensures the WebView is in the given container's view hierarchy. func ensureInHierarchy(container: NSView) { guard let webView else { return } @@ -370,15 +496,22 @@ final class SingletonPlayerWebView { guard let webView else { return } self.logger.info("Tearing down singleton music WebView") self.loadGeneration += 1 + self.pendingRouterNavigation = nil + self.pendingDocumentID = nil + self.activeDocumentNavigation = nil + self.activeDocumentNavigationID = nil + self.isDocumentNavigationInProgress = false self.currentVideoId = nil webView.evaluateJavaScript("document.querySelector('video')?.pause()", completionHandler: nil) webView.loadHTMLString("", baseURL: nil) webView.removeFromSuperview() self.webKitManager?.extensionHostWebViewDidDeactivate(role: .musicPlayer) self.webView = nil + self.coordinator?.cancelPlaybackBridgeTasks() self.coordinator = nil self.currentContainer = nil self.usesCookieFreeDataStore = nil + self.hasStartedHomePreload = false } /// Recreates the playback WebView when crossing a cookie-store boundary while preserving the tracked video id. @@ -419,7 +552,8 @@ final class SingletonPlayerWebView { switch strategy { case .standard: if videoId == previousVideoId { - self.logger.debug("Video \(videoId) already loaded, skipping") + self.logger.debug("Video \(videoId) already loaded, skipping routing and playing") + self.play() return } case .preferInPlaceWhenSameVideoId: @@ -434,6 +568,11 @@ final class SingletonPlayerWebView { } } + guard let urlToLoad = Self.youtubeMusicWatchURL(videoId: videoId) else { + self.logger.error("Unable to construct YouTube Music watch URL") + return + } + if videoId != previousVideoId { self.logger.info("Loading video: \(videoId) (was: \(previousVideoId ?? "none"))") } @@ -442,6 +581,7 @@ final class SingletonPlayerWebView { self.currentVideoId = videoId self.loadGeneration &+= 1 let generation = self.loadGeneration + self.pendingRouterNavigation = nil // Get current volume from PlayerService via coordinator let currentVolume = self.coordinator?.playerService.volume ?? 1.0 @@ -450,16 +590,15 @@ final class SingletonPlayerWebView { self.installUserScripts( on: webView.configuration.userContentController, - isRestoringPlaybackSession: isRestoringPlaybackSession, + shouldBlockAutoplay: isRestoringPlaybackSession, targetVolume: currentVolume ) - // Stop current playback first, then load new video. For a forced - // full-page navigation (e.g. an identity-switch reload) skip pausing the - // OLD