diff --git a/.agents/skills/playback-webview-debugging/SKILL.md b/.agents/skills/playback-webview-debugging/SKILL.md index f135f4d19..5a4122e59 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 d3597c81f..3b8a2ee6f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -87,15 +87,10 @@ 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. - # Swift Testing's cross-suite parallelism is also unstable on the - # macOS 15 runner because suites share process-wide app singletons. - # Keep Tahoe parallel, but serialize Sequoia to avoid unrelated flakes. - run: | - if [[ "${{ matrix.os }}" == "macos-15" ]]; then - swift test -q --skip KasetUITests --disable-xctest --no-parallel - else - swift test -q --skip KasetUITests --disable-xctest - fi + # Suites share process-wide app singletons, including the playback + # WebView. Serialize both runners so another suite cannot invalidate + # a test's document generation while its bridge events are queued. + 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 c60e257c5..9a61d3b6b 100755 --- a/Sources/APIExplorer/main.swift +++ b/Sources/APIExplorer/main.swift @@ -27,8 +27,8 @@ // help - Show this help message // // Options: -// -v, --verbose - Show raw JSON, or redacted audit schemas/samples -// -o, --output - Save a response; discover saves only a redacted report +// -v, --verbose - Show raw JSON or expand audits; discover/queue-probe stay redacted +// -o, --output - Save mode-0600 output; discover/queue-probe stay redacted // --client-version - Override the resolved InnerTube client version // --confirm-live-ai - Required acknowledgement for live AI requests // --prompt-file - Read a private free-text prompt from a mode-0600 file or stdin @@ -1713,6 +1713,192 @@ func analyzeResponse( 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 +} + +private let queueProbeRedactedDiagnosticValue = "[REDACTED]" +private let queueProbeDiagnosticKeys: Set = [ + "autoplay", "browseId", "compactVideoRenderer", "content", "contents", "continuation", + "continuations", "item", "longBylineText", "musicQueueRenderer", "nextRadioContinuationData", + "playerOverlayAutoplayRenderer", "playerOverlayRenderer", "playerOverlays", "playlistId", + "playlistPanelRenderer", "playlistPanelVideoRenderer", "playlistPanelVideoWrapperRenderer", + "primaryRenderer", "responseContext", "runs", "simpleText", "singleColumnMusicWatchNextResultsRenderer", + "tabRenderer", "tabbedRenderer", "tabs", "text", "title", "videoId", "watchNextTabbedResultsRenderer", +] + +/// Preserve response structure without exporting personalized identifiers, text, or opaque values. +private func sanitizedQueueProbeDiagnosticValue(_ value: Any) -> Any { + if let dictionary = value as? [String: Any] { + return sanitizedQueueProbeDiagnosticResponse(dictionary) + } + + if let array = value as? [Any] { + return array.map(sanitizedQueueProbeDiagnosticValue) + } + + if value is NSNull { + return value + } + + return queueProbeRedactedDiagnosticValue +} + +private func sanitizedQueueProbeDiagnosticResponse(_ data: [String: Any]) -> [String: Any] { + var sanitized: [String: Any] = [:] + for (index, key) in data.keys.sorted().enumerated() { + // Preserve known queue fields; dynamic keys may themselves contain private values. + // Numbered placeholders retain every member without collisions between unknown keys. + let safeKey = queueProbeDiagnosticKeys.contains(key) ? key : "[REDACTED_KEY_\(index)]" + sanitized[safeKey] = data[key].map(sanitizedQueueProbeDiagnosticValue) + } + return sanitized +} + +private func prettyPrintedSanitizedQueueProbeResponse(_ data: [String: Any]) throws -> Data { + try JSONSerialization.data( + withJSONObject: sanitizedQueueProbeDiagnosticResponse(data), + options: [.prettyPrinted, .sortedKeys] + ) +} + +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 (identifiers and text stay hidden)") + print(" playlist: \(playlistId == nil ? "derived from seed" : "supplied")") + 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(" • Has first parsed song: \(firstPlayable == nil ? "no" : "yes")") + print(" • Has second parsed song: \(nextPlayable == nil ? "no" : "yes")") + print(" • Has autoplay overlay: \(queueProbeAutoplayVideoId(in: data) == nil ? "no" : "yes")") + print(" • Has continuation: \(queueProbeContinuationToken(in: data) == nil ? "no" : "yes")") + + if verbose || outputFile != nil { + let sanitizedData = try prettyPrintedSanitizedQueueProbeResponse(data) + + if verbose, + let sanitizedString = String(data: sanitizedData, encoding: .utf8) + { + print("\nšŸ“„ Sanitized response (scalar values and unknown keys redacted):") + print(sanitizedString) + } + + if let outputFile { + let url = URL(fileURLWithPath: outputFile) + try writePrivateOutput(sanitizedData, to: url.path) + print("\nšŸ’¾ Saved sanitized response to: \(outputFile)") + } + } + } catch { + print("āŒ Queue probe failed (error code: \((error as NSError).code))") + } +} + // MARK: - Commands /// Known endpoints that require authentication @@ -3628,6 +3814,8 @@ func showHelp() { Validate one server-commanded free-text request search-audit Audit live Music search shapes, filters, and continuations continuation [ep] Explore a continuation (ep: 'browse', 'search', or 'next') + queue-probe [playlistId] + Summarize the Music next/radio queue shape analyze-file Safely summarize a saved JSON response list List all known endpoints auth Check authentication status @@ -3642,8 +3830,8 @@ func showHelp() { help Show this help message Options: - -v, --verbose Show raw JSON for browse/action/continuation; expand audits - -o, --output Save raw output, or discover's redacted report (mode 0600) + -v, --verbose Show raw JSON or expand audits; discover/queue-probe stay redacted + -o, --output Save mode-0600 output; discover/queue-probe stay redacted --body-file Read a sensitive JSON body from a chmod-600 file or stdin --prompt-file Read a private prompt from a mode-0600 file or stdin --confirm-live-ai Required acknowledgement for live Ask commands @@ -3703,6 +3891,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 # Deeply audit YouTube Music search response coverage swift run api-explorer --guest search-audit "ambient electronic mix" @@ -4162,6 +4351,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 "analyze-file": guard filteredArgs.count >= 2 else { print("āŒ Usage: analyze-file ") diff --git a/Sources/Kaset/Services/Audio/EqualizerService.swift b/Sources/Kaset/Services/Audio/EqualizerService.swift index e32998861..ac45cfea8 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 a16124627..008155f6c 100644 --- a/Sources/Kaset/Services/Player/AlbumPlaybackActions.swift +++ b/Sources/Kaset/Services/Player/AlbumPlaybackActions.swift @@ -59,7 +59,10 @@ enum AlbumPlaybackActions { return Task { @MainActor in do { let response = try await client.getPlaylist(id: album.id) - guard playerService.acceptsMusicPlaybackIntent(intent) else { return } + guard playerService.acceptsMusicPlaybackIntent(intent) else { + DiagnosticsLogger.ui.info("Discarding stale album playback request after newer playback intent") + return + } let songs = QueueSongMetadata.albumSongs( response.detail.tracks, album: album, diff --git a/Sources/Kaset/Services/Player/MusicPlaybackIntent.swift b/Sources/Kaset/Services/Player/MusicPlaybackIntent.swift index 67c2eea81..a823c397d 100644 --- a/Sources/Kaset/Services/Player/MusicPlaybackIntent.swift +++ b/Sources/Kaset/Services/Player/MusicPlaybackIntent.swift @@ -62,6 +62,20 @@ struct MusicLibraryConfirmedState: Equatable { struct MusicPlaybackRestoreClock: Equatable { let progress: TimeInterval let duration: TimeInterval + let allowsSongDurationFallback: Bool + let isExplicitTransportSeek: Bool + + init( + progress: TimeInterval, + duration: TimeInterval, + allowsSongDurationFallback: Bool = true, + isExplicitTransportSeek: Bool = false + ) { + self.progress = progress + self.duration = duration + self.allowsSongDurationFallback = allowsSongDurationFallback + self.isExplicitTransportSeek = isExplicitTransportSeek + } } @MainActor @@ -347,25 +361,78 @@ extension PlayerService { self.remoteMusicTransportBatchGeneration == batchGeneration, self.acceptsMusicPlaybackIntent(intent) else { return } + guard !self.isShowingAd else { + self.clearRemoteMusicSkipCoalescingTarget() + return + } let currentVideoID = self.currentTrack?.videoId ?? self.pendingPlayVideoId let currentQueueEntryID = self.queueEntryIDOwningCurrentPlayback - let adPlaybackGeneration = self.adPlaybackGeneration + if self.isRestoringPlaybackSession || self.isPendingRestoredLoadDeferred, + let pendingRestoredSeek = self.pendingRestoredSeek, + self.pendingPlayVideoId == currentVideoID + { + let canCoalesce = self.remoteMusicSkipVideoID == currentVideoID + && self.remoteMusicSkipQueueEntryID == currentQueueEntryID + && self.remoteMusicSkipAdmittedAt.map { + admittedAt >= $0 && admittedAt - $0 <= .seconds(1) + } == true + let baseProgress = canCoalesce ? self.remoteMusicSkipTarget ?? pendingRestoredSeek : pendingRestoredSeek + let rawTarget = baseProgress + delta + let target = self.duration > 0 + ? min(max(0, rawTarget), self.duration) + : max(0, rawTarget) + self.remoteMusicSkipTarget = target + self.remoteMusicSkipVideoID = currentVideoID + self.remoteMusicSkipQueueEntryID = currentQueueEntryID + self.remoteMusicSkipAdmittedAt = admittedAt + self.progress = target + await self.seek(to: target, intent: intent) + return + } + if self.pendingNativeQueueAdvance != nil { + let canCoalesce = self.remoteMusicSkipVideoID == currentVideoID + && self.remoteMusicSkipQueueEntryID == currentQueueEntryID + && self.remoteMusicSkipAdmittedAt.map { + admittedAt >= $0 && admittedAt - $0 <= .seconds(1) + } == true + let baseProgress = canCoalesce ? self.remoteMusicSkipTarget ?? self.progress : self.progress + let rawTarget = baseProgress + delta + let target = self.duration > 0 + ? min(max(0, rawTarget), self.duration) + : max(0, rawTarget) + self.remoteMusicSkipTarget = target + self.remoteMusicSkipVideoID = currentVideoID + self.remoteMusicSkipQueueEntryID = currentQueueEntryID + self.remoteMusicSkipAdmittedAt = admittedAt + self.progress = target + await self.seek(to: target, intent: intent) + return + } + let adPlaybackStateGeneration = self.adPlaybackStateGeneration + let playbackStateObservationSequence = self.playbackStateObservationSequence let playbackSnapshot = await self.currentMusicPlaybackSnapshot() guard self.remoteMusicTransportBatchGeneration == batchGeneration, self.acceptsMusicPlaybackIntent(intent) else { return } guard !self.isShowingAd, - self.adPlaybackGeneration == adPlaybackGeneration, + self.adPlaybackStateGeneration == adPlaybackStateGeneration, self.queueEntryIDOwningCurrentPlayback == currentQueueEntryID, - (self.currentTrack?.videoId ?? self.pendingPlayVideoId) == currentVideoID, - let playbackSnapshot, - self.remoteMusicPlaybackSnapshot(playbackSnapshot, matches: currentVideoID) + (self.currentTrack?.videoId ?? self.pendingPlayVideoId) == currentVideoID else { self.clearRemoteMusicSkipCoalescingTarget() return } + guard let authoritativeClock = self.authoritativeRemoteMusicClock( + playbackSnapshot: playbackSnapshot, + currentVideoID: currentVideoID, + playbackStateObservationSequence: playbackStateObservationSequence + ) else { + self.clearRemoteMusicSkipCoalescingTarget() + return + } + let canCoalesce = self.remoteMusicSkipVideoID == currentVideoID && self.remoteMusicSkipQueueEntryID == currentQueueEntryID && self.remoteMusicSkipAdmittedAt.map { @@ -374,11 +441,11 @@ extension PlayerService { let baseProgress = if canCoalesce, let remoteMusicSkipTarget = self.remoteMusicSkipTarget { remoteMusicSkipTarget } else { - playbackSnapshot.progress + authoritativeClock.progress } let rawTarget = baseProgress + delta - let target = if playbackSnapshot.duration > 0 { - min(max(0, rawTarget), playbackSnapshot.duration) + let target = if authoritativeClock.duration > 0 { + min(max(0, rawTarget), authoritativeClock.duration) } else { max(0, rawTarget) } @@ -391,6 +458,23 @@ extension PlayerService { await self.seek(to: target, intent: intent) } + private func authoritativeRemoteMusicClock( + playbackSnapshot: SingletonPlayerWebView.PlaybackSnapshot?, + currentVideoID: String?, + playbackStateObservationSequence: Int + ) -> (progress: TimeInterval, duration: TimeInterval)? { + // Rejected stale observations also advance the sequence, without updating the clock. + if self.playbackStateObservationSequence != playbackStateObservationSequence, + self.playbackStateVideoId == currentVideoID + { + return (self.progress, self.duration) + } + guard let playbackSnapshot, + self.remoteMusicPlaybackSnapshot(playbackSnapshot, matches: currentVideoID) + else { return nil } + return (playbackSnapshot.progress, playbackSnapshot.duration) + } + private func remoteMusicPlaybackSnapshot( _ snapshot: SingletonPlayerWebView.PlaybackSnapshot, matches currentVideoID: String? @@ -480,11 +564,8 @@ extension PlayerService { func invalidateMixContinuationRequest() { self.activeMixContinuationRequestID = nil self.isFetchingMoreMixSongs = false - let waiters = self.mixContinuationWaiters - self.mixContinuationWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } + self.resumeMixContinuationRequestWaiters() + self.resumeMixContinuationFetchWaiters() } func waitForActiveMixContinuationRequest() async { @@ -494,10 +575,24 @@ extension PlayerService { } } - func finishMixContinuationRequest(_ requestID: UUID) { - guard self.activeMixContinuationRequestID == requestID else { return } + @discardableResult + func finishMixContinuationRequest(_ requestID: UUID) -> Bool { + guard self.activeMixContinuationRequestID == requestID else { return false } self.activeMixContinuationRequestID = nil self.isFetchingMoreMixSongs = false + self.resumeMixContinuationRequestWaiters() + return true + } + + func resumeMixContinuationFetchWaiters() { + let waiters = self.mixContinuationFetchWaiters + self.mixContinuationFetchWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + private func resumeMixContinuationRequestWaiters() { let waiters = self.mixContinuationWaiters self.mixContinuationWaiters.removeAll() for waiter in waiters { diff --git a/Sources/Kaset/Services/Player/PlayerQueueModels.swift b/Sources/Kaset/Services/Player/PlayerQueueModels.swift index 6c7d29b9d..25d2d63f0 100644 --- a/Sources/Kaset/Services/Player/PlayerQueueModels.swift +++ b/Sources/Kaset/Services/Player/PlayerQueueModels.swift @@ -15,6 +15,43 @@ 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 + let fallbackDeadline: ContinuousClock.Instant +} + // MARK: - QueueState struct QueueState { diff --git a/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift b/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift new file mode 100644 index 000000000..b13208e30 --- /dev/null +++ b/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift @@ -0,0 +1,76 @@ +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( + intent: MusicPlaybackIntent, + startsPaused suppliedStartsPaused: Bool? = nil + ) async { + guard self.acceptsMusicPlaybackIntent(intent) else { return } + let startsPaused = suppliedStartsPaused ?? self.isExplicitPauseIntentActive + 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 + { + guard self.claimTerminalMusicPlaybackOccurrence(self.currentMusicPlaybackOccurrence) else { return } + SingletonPlayerWebView.shared.seekAndPause(to: self.duration) + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() + let previousNavigationContext = self.playbackNavigationContext + let previousEntryID = self.currentQueueEntryID + let didAdvance = await self.performNextNavigation( + intent: intent, + startsPaused: startsPaused + ) + if !didAdvance, + !Task.isCancelled, + self.acceptsMusicPlaybackIntent(intent), + self.playbackNavigationContext == previousNavigationContext + { + if await self.advanceToMaterializedNextQueueSongIfAvailable( + after: previousEntryID, + intent: intent, + startsPaused: startsPaused + ) { + return + } + guard !Task.isCancelled, + self.acceptsMusicPlaybackIntent(intent), + self.playbackNavigationContext == previousNavigationContext + else { 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, + startsPaused: startsPaused + ) + } + + 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+PlaybackBoundaries.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackBoundaries.swift index 232c5948a..1af918f95 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackBoundaries.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackBoundaries.swift @@ -40,6 +40,9 @@ extension PlayerService { self.invalidatePendingPlaybackRequests() self.cancelDeferredQueueWork() self.clearRestoredPlaybackSessionState() + self.clearQueueNavigationRecovery() + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() SingletonPlayerWebView.shared.tearDown() self.state = .idle self.shouldResumeAfterInterruption = false @@ -55,6 +58,9 @@ extension PlayerService { self.pendingPlayVideoId = nil self.progress = 0 self.currentTimeMs = 0 + self.setPlaybackStateVideoId(nil) + self.currentLyricsLineIndex = nil + self.currentLyricsDisplayTimeMs = nil self.duration = 0 self.showMiniPlayer = false self.isMiniPlayerVisible = false @@ -97,6 +103,9 @@ extension PlayerService { self.isAwaitingPlaybackConfirmation = false self.isExplicitPauseIntentActive = true self.clearRestoredPlaybackSessionState() + self.clearQueueNavigationRecovery() + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() self.state = .idle self.songNearingEnd = false self.isKasetInitiatedPlayback = false @@ -108,6 +117,9 @@ extension PlayerService { self.pendingPlayVideoId = nil self.progress = 0 self.currentTimeMs = 0 + self.setPlaybackStateVideoId(nil) + self.currentLyricsLineIndex = nil + self.currentLyricsDisplayTimeMs = nil self.duration = 0 self.resetAdPlaybackState() await SingletonPlayerWebView.shared.cancelPendingPlayback() diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 55a574c7b..f4e5dbe0b 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -11,7 +11,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. @@ -83,12 +83,18 @@ extension PlayerService { /// Plays a track by video ID. func play(videoId: String) async { + self.playbackContextGeneration &+= 1 + self.invalidatePendingPlaybackSelectionRequests() let intent = self.beginMusicPlaybackIntent() await self.play(videoId: videoId, intent: intent) } func play(videoId: String, intent: MusicPlaybackIntent) async { guard self.acceptsMusicPlaybackIntent(intent) else { return } + self.beginPlaybackNavigation() + self.clearQueueNavigationRecovery() + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() self.logger.debug("play() called with videoId: \(videoId)") let acceptsPlaybackRequest = SingletonPlayerWebView.shared.acceptsPlaybackRequest( videoId: videoId, @@ -163,6 +169,8 @@ extension PlayerService { /// Plays a song. func play(song: Song) async { + self.playbackContextGeneration &+= 1 + self.invalidatePendingPlaybackSelectionRequests() let intent = self.beginMusicPlaybackIntent() await self.play( song: song, @@ -179,7 +187,8 @@ extension PlayerService { func play( song: Song, webLoadStrategy: SingletonPlayerWebView.VideoLoadStrategy, - episode: ArtistEpisode? = nil + episode: ArtistEpisode? = nil, + isQueueNavigationRecovery: Bool = false ) async { let intent = self.beginMusicPlaybackIntent() await self.play( @@ -187,6 +196,7 @@ extension PlayerService { webLoadStrategy: webLoadStrategy, episode: episode, queueEntryID: self.currentQueueEntryID(matching: song), + isQueueNavigationRecovery: isQueueNavigationRecovery, intent: intent ) } @@ -200,9 +210,17 @@ extension PlayerService { startsPaused: Bool = false, restoreClock: MusicPlaybackRestoreClock? = nil, fetchesMetadata: Bool = true, + isQueueNavigationRecovery: Bool = false, + bypassesSamePlaybackFastPath: Bool = false, intent: MusicPlaybackIntent ) async { guard self.acceptsMusicPlaybackIntent(intent) else { return } + self.beginPlaybackNavigation() + if !isQueueNavigationRecovery { + self.clearQueueNavigationRecovery() + } + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() self.logger.info("Playing song: \(song.title)") let acceptsPlaybackRequest = SingletonPlayerWebView.shared.acceptsPlaybackRequest( videoId: song.videoId, @@ -218,7 +236,10 @@ extension PlayerService { && self.currentEpisode?.id == episode?.id let hasPendingSameLogicalLoad = self.pendingPlayVideoId == song.videoId && (self.state == .loading || self.isAwaitingPlaybackConfirmation) - guard !isSameLogicalPlayback + let shouldBypassSamePlaybackFastPath = bypassesSamePlaybackFastPath + || webLoadStrategy == .forceFullPageWhenSameVideoId + guard shouldBypassSamePlaybackFastPath + || !isSameLogicalPlayback || (acceptsPlaybackRequest && !hasPendingSameLogicalLoad) else { if let restoreClock { @@ -269,7 +290,13 @@ extension PlayerService { self.resetAdPlaybackState() self.progress = restoreClock?.progress ?? 0 self.currentTimeMs = Int((restoreClock?.progress ?? 0) * 1000) - self.duration = max(restoreClock?.duration ?? 0, song.duration ?? 0) + self.duration = if let restoreClock { + restoreClock.duration > 0 + ? restoreClock.duration + : (restoreClock.allowsSongDurationFallback ? song.duration ?? 0 : 0) + } else { + song.duration ?? 0 + } self.songNearingEnd = false self.shouldSuppressAutoplayAfterQueueEnd = false self.currentTrack = song @@ -376,6 +403,7 @@ extension PlayerService { if didStartPlayback { self.logger.info("Playback confirmed started") + self.syncWebQueue() } } @@ -486,6 +514,7 @@ extension PlayerService { func pause(intent: MusicPlaybackIntent) async { guard self.acceptsMusicPlaybackIntent(intent) else { return } self.logger.debug("Pausing playback") + self.clearQueueNavigationRecovery() self.shouldResumeAfterInterruption = false self.isAwaitingPlaybackConfirmation = false self.isExplicitPauseIntentActive = true @@ -525,8 +554,12 @@ extension PlayerService { await self.resume(intent: intent) } + // swiftlint:disable:next cyclomatic_complexity func resume(intent: MusicPlaybackIntent) async { guard self.acceptsMusicPlaybackIntent(intent) else { return } + if await self.resolvePendingNativeQueueAdvanceForResume(intent: intent) { + return + } self.logger.debug("Resuming playback") self.isStoppingPlayback = false self.shouldResumeAfterInterruption = true @@ -543,6 +576,52 @@ extension PlayerService { self.resetAdPlaybackState() } + 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") @@ -583,112 +662,172 @@ extension PlayerService { /// Skips to next track. func next() async { + self.invalidatePendingPlaybackSelectionRequests() let intent = self.beginMusicPlaybackIntent() - await self.next(intent: intent) + _ = await self.performNextNavigation(intent: intent) } func next( intent: MusicPlaybackIntent, defersNetworkFollowUp: Bool = false ) async { - guard self.acceptsMusicPlaybackIntent(intent) else { return } + _ = await self.performNextNavigation( + intent: intent, + defersNetworkFollowUp: defersNetworkFollowUp + ) + } + + /// 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( // swiftlint:disable:this cyclomatic_complexity + intent: MusicPlaybackIntent? = nil, + defersNetworkFollowUp: Bool = false, + startsPaused: Bool = false + ) async -> Bool { + let intent = intent ?? self.currentMusicPlaybackIntent + guard !Task.isCancelled, self.acceptsMusicPlaybackIntent(intent) else { return false } self.logger.debug("Skipping to next track") self.clearRestoredPlaybackSessionState() + SingletonPlayerWebView.shared.setAutoplayBlocked(startsPaused) if self.shouldUseNativeQueueForTrackNavigation, !self.queueEntries.isEmpty { - await self.advanceNativeQueue( + var targetIndex: Int? + if self.currentIndex < self.queue.count - 1 { + targetIndex = self.currentIndex + 1 + } else if self.repeatMode == .all { + targetIndex = 0 + } else if self.mixContinuationToken != nil { + let sourceContext = self.queuePlaybackContext + let previousCount = self.queue.count + await self.fetchMoreMixSongsIfNeeded() + guard !Task.isCancelled, + self.acceptsMusicPlaybackIntent(intent), + self.queuePlaybackContext == sourceContext + else { + return false + } + if self.queue.count > previousCount { + targetIndex = self.currentIndex + 1 + } + } + + guard let targetIndex else { return false } + self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) + guard await self.loadQueueSongForNavigation( + at: targetIndex, + startsPaused: startsPaused, intent: intent, - defersNetworkFollowUp: defersNetworkFollowUp - ) - return + fetchesMetadata: !defersNetworkFollowUp + ) else { return false } + guard !Task.isCancelled, self.acceptsMusicPlaybackIntent(intent) else { return true } + if defersNetworkFollowUp { + self.saveQueueForPersistence(syncWebQueue: false) + return true + } + await self.fetchMoreMixSongsIfNeeded() + guard !Task.isCancelled, self.acceptsMusicPlaybackIntent(intent) else { return true } + await self.fillSmartShuffleWindow() + guard !Task.isCancelled, self.acceptsMusicPlaybackIntent(intent) 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 self.pendingPlayVideoId != nil { - SingletonPlayerWebView.shared.next() + guard self.queueEntries.isEmpty || self.queueEntryIDOwningCurrentPlayback != nil else { + self.logger.debug("Ignoring next for detached playback with a non-owning queue") + return false } - } - private func advanceNativeQueue( - intent: MusicPlaybackIntent, - defersNetworkFollowUp: Bool - ) async { - if self.currentIndex < self.queueEntries.count - 1 { - await self.advanceToQueueIndex( - self.currentIndex + 1, - intent: intent, - fetchesMixContinuation: true, - defersNetworkFollowUp: defersNetworkFollowUp - ) - return - } - if self.repeatMode == .all { - await self.advanceToQueueIndex( - 0, + if let currentTrack = self.currentTrack { + let sourceGeneration = self.playbackContextGeneration + let sourceVideoId = currentTrack.videoId + let sourceEntryID = self.currentQueueEntryID + let radioOutcome = await self.fetchAndApplyRadioQueue(for: sourceVideoId) + guard !Task.isCancelled, + radioOutcome != .superseded, + sourceGeneration == self.playbackContextGeneration, + self.currentTrack?.videoId == sourceVideoId + else { + return false + } + let resolvedSourceEntryID = self.queueEntryIDOwningCurrentPlayback ?? sourceEntryID + if await self.advanceToMaterializedNextQueueSongIfAvailable( + after: resolvedSourceEntryID, + currentEntryRepresentsSource: radioOutcome == .applied, intent: intent, - fetchesMixContinuation: false, - defersNetworkFollowUp: defersNetworkFollowUp - ) - return + defersNetworkFollowUp: defersNetworkFollowUp, + startsPaused: startsPaused + ) { + guard !Task.isCancelled, self.acceptsMusicPlaybackIntent(intent) else { return true } + if defersNetworkFollowUp { + self.saveQueueForPersistence(syncWebQueue: false) + return true + } + await self.fetchMoreMixSongsIfNeeded() + guard !Task.isCancelled, self.acceptsMusicPlaybackIntent(intent) else { return true } + await self.fillSmartShuffleWindow() + guard !Task.isCancelled, self.acceptsMusicPlaybackIntent(intent) 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") } - guard self.mixContinuationToken != nil else { return } - let previousCount = self.queueEntries.count - let queueGeneration = self.queueLoadGeneration - await self.fetchMoreMixSongsIfNeeded(queueGeneration: queueGeneration) - guard self.acceptsMusicPlaybackIntent(intent), - self.queueEntries.count > previousCount - else { return } - await self.advanceToQueueIndex( - self.currentIndex + 1, - intent: intent, - fetchesMixContinuation: false, - defersNetworkFollowUp: defersNetworkFollowUp - ) + return false } - private func advanceToQueueIndex( - _ index: Int, - intent: MusicPlaybackIntent, - fetchesMixContinuation: Bool, - defersNetworkFollowUp: Bool - ) async { - guard self.acceptsMusicPlaybackIntent(intent), - let entry = self.queueEntries[safe: index] - else { return } - let queueGeneration = self.queueLoadGeneration - self.pushForwardSkipStackIfLeavingIndex(for: index) - self.currentIndex = index - await self.play( - song: entry.song, - webLoadStrategy: .standard, - queueEntryID: entry.id, - fetchesMetadata: !defersNetworkFollowUp, - intent: intent - ) - guard self.isCurrentQueueLoad(queueGeneration) else { return } - if defersNetworkFollowUp { - self.saveQueueForPersistence() - return - } - if fetchesMixContinuation { - await self.fetchMoreMixSongsIfNeeded(queueGeneration: queueGeneration) - guard self.isCurrentQueueLoad(queueGeneration) else { return } + /// 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, + intent suppliedIntent: MusicPlaybackIntent? = nil, + defersNetworkFollowUp: Bool = false, + startsPaused: Bool = false + ) async -> Bool { + let intent = suppliedIntent ?? self.currentMusicPlaybackIntent + guard self.acceptsMusicPlaybackIntent(intent), !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 } - await self.fillSmartShuffleWindow() - guard self.isCurrentQueueLoad(queueGeneration) else { return } - self.saveQueueForPersistence() + + guard let targetIndex else { return false } + self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) + return await self.loadQueueSongForNavigation( + at: targetIndex, + startsPaused: startsPaused, + intent: intent, + fetchesMetadata: !defersNetworkFollowUp + ) } /// Goes to previous track. func previous() async { + self.invalidatePendingPlaybackSelectionRequests() let intent = self.beginMusicPlaybackIntent() await self.previous(intent: intent) } @@ -698,8 +837,43 @@ extension PlayerService { defersNetworkFollowUp: Bool = false ) async { guard self.acceptsMusicPlaybackIntent(intent) else { return } + if self.pendingNativeQueueAdvance != nil { + let sourceIsAvailable = self.hasAvailablePendingNativeQueueAdvanceSource + let startsPaused = self.isExplicitPauseIntentActive + if !sourceIsAvailable { + self.cancelPendingNativeQueueAdvanceForExplicitPriorNavigation(reason: "previous") + self.progress = 0 + self.currentTimeMs = 0 + let priorIndex = self.popForwardSkipIndex().flatMap { index in + self.queueEntries.indices.contains(index) ? index : nil + } ?? (self.currentIndex > 0 ? self.currentIndex - 1 : self.currentIndex) + if self.queueEntries.indices.contains(priorIndex) { + _ = await self.loadQueueSongForNavigation( + at: priorIndex, + startsPaused: startsPaused, + intent: intent, + fetchesMetadata: !defersNetworkFollowUp + ) + return + } + } else { + let restartsSource = self.progress > 3 + || (self.currentIndex == 0 && self.peekForwardSkipIndex() == nil) + if restartsSource, + await self.reanchorPendingNativeQueueAdvanceSource( + intent: intent, + startsPaused: startsPaused, + reason: "previous" + ) + { + return + } + self.cancelPendingNativeQueueAdvanceForExplicitPriorNavigation(reason: "previous") + } + } self.logger.debug("Going to previous track") self.clearRestoredPlaybackSessionState() + SingletonPlayerWebView.shared.setAutoplayBlocked(false) if self.shouldUseNativeQueueForTrackNavigation, !self.queueEntries.isEmpty @@ -711,34 +885,22 @@ extension PlayerService { } if let priorIndex = self.popForwardSkipIndex(), self.queueEntries.indices.contains(priorIndex) { - self.currentIndex = priorIndex - if let previousEntry = self.queueEntries[safe: priorIndex] { - await self.play( - song: previousEntry.song, - webLoadStrategy: .standard, - queueEntryID: previousEntry.id, - fetchesMetadata: !defersNetworkFollowUp, - intent: intent - ) - } + _ = await self.loadQueueSongForNavigation( + at: priorIndex, + intent: intent, + fetchesMetadata: !defersNetworkFollowUp + ) guard self.isCurrentQueueLoad(queueGeneration) else { return } - self.saveQueueForPersistence() return } if self.currentIndex > 0 { - self.currentIndex -= 1 - if let previousEntry = self.queueEntries[safe: self.currentIndex] { - await self.play( - song: previousEntry.song, - webLoadStrategy: .standard, - queueEntryID: previousEntry.id, - fetchesMetadata: !defersNetworkFollowUp, - intent: intent - ) - } + _ = await self.loadQueueSongForNavigation( + at: self.currentIndex - 1, + intent: intent, + fetchesMetadata: !defersNetworkFollowUp + ) guard self.isCurrentQueueLoad(queueGeneration) else { return } - self.saveQueueForPersistence() } else { await self.seek(to: 0, intent: intent) } @@ -755,8 +917,94 @@ extension PlayerService { if self.progress > 3 { await self.seek(to: 0, intent: intent) } else { - SingletonPlayerWebView.shared.previous() + self.logger.debug("Ignoring previous without a Kaset-owned queue") + } + } + + /// Navigates to a queue song through Kaset's deterministic load path. + @discardableResult + func loadQueueSongForNavigation( + at index: Int, + webLoadStrategy strategyOverride: SingletonPlayerWebView.VideoLoadStrategy? = nil, + startsPaused: Bool = false, + restoreClock: MusicPlaybackRestoreClock? = nil, + intent suppliedIntent: MusicPlaybackIntent? = nil, + fetchesMetadata: Bool = true + ) async -> Bool { + let intent = suppliedIntent ?? self.currentMusicPlaybackIntent + guard self.acceptsMusicPlaybackIntent(intent), + let entry = self.queueEntries[safe: index] + else { return false } + let song = entry.song + self.currentIndex = index + self.progress = restoreClock?.progress ?? 0 + self.currentTimeMs = Int((restoreClock?.progress ?? 0) * 1000) + self.duration = if let restoreClock { + restoreClock.duration > 0 + ? restoreClock.duration + : (restoreClock.allowsSongDurationFallback ? song.duration ?? 0 : 0) + } else { + song.duration ?? 0 + } + self.protectQueueNavigationTarget(song.videoId) + let strategy = strategyOverride ?? SingletonPlayerWebView.queueNavigationStrategy( + currentVideoId: SingletonPlayerWebView.shared.currentVideoId, + targetVideoId: song.videoId, + startsPaused: startsPaused, + allowsInPlaceRestart: SingletonPlayerWebView.shared.canRestartInPlace + ) + await self.play( + song: song, + webLoadStrategy: strategy, + queueEntryID: entry.id, + startsPaused: startsPaused, + restoreClock: restoreClock, + fetchesMetadata: fetchesMetadata, + bypassesSamePlaybackFastPath: strategy == .forceFullPageWhenSameVideoId, + intent: intent + ) + guard self.acceptsMusicPlaybackIntent(intent) else { return false } + 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.activePlaybackQueueEntryID = self.currentQueueEntryID + 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 = self.songLikeStatusManager.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. @@ -769,16 +1017,46 @@ extension PlayerService { func seek(to time: TimeInterval, intent: MusicPlaybackIntent) async { guard !self.isShowingAd, self.acceptsMusicPlaybackIntent(intent) else { return } let clampedTime = self.duration > 0 ? min(max(time, 0), self.duration) : max(time, 0) + if self.pendingNativeQueueAdvance != nil, + self.duration > 0, + clampedTime >= self.duration - Self.seekToEndThreshold + { + await self.resolvePendingNativeQueueAdvanceForExplicitTerminal( + intent: intent, + reason: "manual seek reached end during native handoff" + ) + return + } + + if self.pendingNativeQueueAdvance != nil { + let startsPaused = self.isExplicitPauseIntentActive + let restoreClock = MusicPlaybackRestoreClock( + progress: clampedTime, + duration: self.duration, + isExplicitTransportSeek: true + ) + if await self.reanchorPendingNativeQueueAdvanceSource( + intent: intent, + startsPaused: startsPaused, + restoreClock: restoreClock, + reason: "backward seek" + ) { + return + } + } self.logger.debug("Seeking to \(clampedTime)") - if self.isPendingRestoredLoadDeferred { - self.progress = clampedTime - self.pendingRestoredSeek = clampedTime + if await self.retargetRestoredPlaybackSeekIfNeeded( + to: clampedTime, + intent: intent + ) { return } + guard !self.isShowingAd else { return } + if self.duration > 0, clampedTime >= self.duration - Self.seekToEndThreshold { - await self.handleManualSeekToEnd() + await self.handleManualSeekToEnd(intent: intent) return } @@ -906,6 +1184,9 @@ extension PlayerService { /// Cycles through repeat modes: off -> all -> one -> off. func cycleRepeatMode() { self.advanceRepeatMode() + self.clearWebQueueInjectionState() + self.revalidatePendingNativeQueueAdvanceAfterRepeatModeChange() + self.syncWebQueue() self.logger.info("Repeat mode: \(String(describing: self.repeatMode))") } diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift index fee27c6d1..6ceeb1577 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift @@ -8,19 +8,32 @@ extension PlayerService { isShowingAd: Bool, observedProgress: TimeInterval, observedVideoId: String?, - isAuthoritativeContent: Bool + isAuthoritativeContent: Bool, + now: ContinuousClock.Instant = .now ) { if isShowingAd { self.isShowingAd = true + if observedProgress.isFinite, observedProgress >= 0 { + if let previousProgress = self.lastAdPlaybackProgress, + observedProgress > previousProgress + { + self.lastAdPlaybackProgressAt = now + } + self.lastAdPlaybackProgress = observedProgress + } return } guard isAuthoritativeContent else { return } - self.isShowingAd = false + self.clearAdPlaybackBoundary() guard let observedVideoId = self.normalizedPlaybackVideoId(observedVideoId) else { return } self.lastNonAdContentProgress = observedProgress self.lastNonAdContentVideoId = observedVideoId } + func clearAdPlaybackBoundary() { + self.isShowingAd = false + } + func resetAdPlaybackState() { self.isShowingAd = false self.lastNonAdContentProgress = 0 @@ -58,19 +71,71 @@ 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? + ) { + // 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 + ) + self.recordDurationObservation( + videoId: self.playbackStateVideoId, + duration: duration + ) + } + + private func applyPlaybackStateObservation( + isPlaying: Bool, + progress: Double, + duration: Double ) { let previousProgress = self.progress self.isApplyingPlaybackStateObservation = true + defer { + self.isApplyingPlaybackStateObservation = false + } + + 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() + } + if let targetProgress = self.pendingRestoredSeek { + _ = self.scheduleTerminalRestoredSeekIfNeeded( + targetProgress: targetProgress, + resolvedDuration: self.duration + ) + } + return + } if self.isRestoringPlaybackSession { self.reconcileRestoredPlaybackState( @@ -87,9 +152,6 @@ extension PlayerService { previousProgress: previousProgress ) } - - self.isApplyingPlaybackStateObservation = false - self.recordPlaybackStateObservation(videoId: observedVideoId, duration: duration) } /// Updates only play/pause transport while deliberately preserving the @@ -126,10 +188,16 @@ extension PlayerService { ) { guard let currentSong = queue[safe: currentIndex] else { return } self.beginMusicPlaybackIntent() + self.playbackContextGeneration &+= 1 + self.clearQueueNavigationRecovery() + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() self.cancelDeferredQueueWork() self.clearQueueUndoRedoHistory() 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 @@ -146,6 +214,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) @@ -178,7 +248,7 @@ 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; queue ownership ensures the result only applies to this persisted occurrence. let queueEntryID = self.currentQueueEntryID Task { [videoId = currentSong.videoId] in await self.fetchSongMetadata( @@ -186,6 +256,21 @@ extension PlayerService { queueOwner: queueEntryID.map(MusicQueueMetadataOwner.entry) ?? .none ) } + + // 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. @@ -193,8 +278,12 @@ extension PlayerService { self.pendingRestoredSeek = nil self.isPendingRestoredLoadDeferred = false self.shouldForcePendingRestoredLoad = false + self.isRestoringExplicitTransportSeek = false + self.shouldFinishRestoredSeekAtEnd = false + self.restoredPlaybackSessionGeneration &+= 1 self.isRestoringPlaybackSession = false self.shouldAutoResumeAfterRestoredLoad = false + self.isAwaitingWebRestoredTrack = false } /// Reconciles an undo/redo clock against physical media before resuming playback. @@ -207,7 +296,15 @@ extension PlayerService { startsPaused: Bool ) -> TimeInterval { self.clearRestoredPlaybackSessionState() - let resolvedDuration = max(restoreClock.duration, songDuration ?? 0) + self.isRestoringExplicitTransportSeek = restoreClock.isExplicitTransportSeek + self.shouldFinishRestoredSeekAtEnd = restoreClock.isExplicitTransportSeek + let resolvedDuration: TimeInterval = if restoreClock.duration > 0 { + restoreClock.duration + } else if restoreClock.allowsSongDurationFallback { + songDuration ?? 0 + } else { + 0 + } let targetProgress = self.clampedRestoredProgress( restoreClock.progress, duration: resolvedDuration @@ -226,6 +323,35 @@ extension PlayerService { return targetProgress } + func retargetRestoredPlaybackSeekIfNeeded( + to targetProgress: TimeInterval, + intent: MusicPlaybackIntent + ) async -> Bool { + let isDeferredRestore = self.isPendingRestoredLoadDeferred + let isActiveRestore = self.isRestoringPlaybackSession && self.pendingRestoredSeek != nil + guard isDeferredRestore || isActiveRestore else { return false } + + let startsPaused = isDeferredRestore + || self.isExplicitPauseIntentActive + || !self.shouldAutoResumeAfterRestoredLoad + if self.duration > 0, + targetProgress >= self.duration - Self.seekToEndThreshold + { + await self.handleManualSeekToEnd( + intent: intent, + startsPaused: startsPaused + ) + return true + } + + self.progress = targetProgress + self.currentTimeMs = Int(targetProgress * 1000) + self.pendingRestoredSeek = targetProgress + self.isRestoringExplicitTransportSeek = true + self.shouldFinishRestoredSeekAtEnd = true + return true + } + /// Starts loading a restored session into the WebView without discarding the saved seek target. func beginRestoredPlaybackLoad(autoResumeAfterSeek: Bool) { self.isPendingRestoredLoadDeferred = false @@ -326,6 +452,10 @@ extension PlayerService { // account scoping + the reactive now-playing resolver keep it correct. self.songLikeStatusManager.invalidateSession(clearsActiveCache: false) self.beginMusicPlaybackIntent() + self.playbackContextGeneration &+= 1 + self.clearQueueNavigationRecovery() + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() self.cancelDeferredQueueWork() self.clearQueueUndoRedoHistory() self.libraryMutationGeneration &+= 1 @@ -457,12 +587,64 @@ private extension PlayerService { return resolvedDuration } + @discardableResult + func scheduleTerminalRestoredSeekIfNeeded( + targetProgress: TimeInterval, + resolvedDuration: TimeInterval + ) -> Bool { + let clampedTargetProgress = self.clampedRestoredProgress( + targetProgress, + duration: resolvedDuration + ) + guard self.shouldFinishRestoredSeekAtEnd, + resolvedDuration > 0, + clampedTargetProgress >= resolvedDuration - Self.seekToEndThreshold + else { return false } + + let intent = self.currentMusicPlaybackIntent + let restorationGeneration = self.restoredPlaybackSessionGeneration + let playbackVideoId = self.currentTrack?.videoId ?? self.pendingPlayVideoId + let queueEntryID = self.queueEntryIDOwningCurrentPlayback + let startsPaused = self.isPendingRestoredLoadDeferred + || self.isExplicitPauseIntentActive + || !self.shouldAutoResumeAfterRestoredLoad + Task { @MainActor [weak self] in + guard let self, + self.acceptsMusicPlaybackIntent(intent), + self.restoredPlaybackSessionGeneration == restorationGeneration, + self.isRestoringPlaybackSession || self.isPendingRestoredLoadDeferred, + self.isRestoringExplicitTransportSeek, + self.shouldFinishRestoredSeekAtEnd, + self.pendingRestoredSeek == targetProgress, + (self.currentTrack?.videoId ?? self.pendingPlayVideoId) == playbackVideoId, + self.queueEntryIDOwningCurrentPlayback == queueEntryID, + self.duration > 0, + self.clampedRestoredProgress( + targetProgress, + duration: self.duration + ) >= self.duration - Self.seekToEndThreshold + else { return } + self.shouldFinishRestoredSeekAtEnd = false + await self.handleManualSeekToEnd( + intent: intent, + startsPaused: startsPaused + ) + } + return true + } + func reconcilePendingRestoredSeek( isPlaying: Bool, progress: Double, targetProgress: TimeInterval, resolvedDuration: TimeInterval ) { + if self.scheduleTerminalRestoredSeekIfNeeded( + targetProgress: targetProgress, + resolvedDuration: resolvedDuration + ) { + return + } let clampedTargetProgress = self.clampedRestoredProgress(targetProgress, duration: resolvedDuration) if self.progress != clampedTargetProgress { self.progress = clampedTargetProgress diff --git a/Sources/Kaset/Services/Player/PlayerService+Queue.swift b/Sources/Kaset/Services/Player/PlayerService+Queue.swift index 1a4cd8757..97769423a 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Queue.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Queue.swift @@ -37,6 +37,7 @@ extension PlayerService { intent: MusicPlaybackIntent ) async -> Int? { guard self.acceptsMusicPlaybackIntent(intent), !songs.isEmpty else { return nil } + let playbackContextGeneration = self.beginPlaybackRequest() self.invalidateMixContinuationRequest() self.resetSmartShuffleForNewQueue() let loadGeneration: Int? @@ -73,11 +74,15 @@ extension PlayerService { intent: intent ) } - guard self.isCurrentQueueLoad(queueGeneration) else { return loadGeneration } + guard self.isCurrentPlaybackRequest(playbackContextGeneration), + self.isCurrentQueueLoad(queueGeneration) + else { return loadGeneration } self.saveQueueForPersistence() if self.shuffleMode == .smart, loadGeneration == nil { await self.fillSmartShuffleWindow() - guard self.isCurrentQueueLoad(queueGeneration) else { return nil } + guard self.isCurrentPlaybackRequest(playbackContextGeneration), + self.isCurrentQueueLoad(queueGeneration) + else { return nil } } return loadGeneration } @@ -91,6 +96,7 @@ extension PlayerService { func playWithRadio(song: Song, intent: MusicPlaybackIntent) async { guard self.acceptsMusicPlaybackIntent(intent) else { return } + let requestGeneration = self.beginPlaybackRequest() self.logger.info("Playing with radio: \(song.title)") self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo() @@ -109,16 +115,24 @@ extension PlayerService { queueEntryID: seedEntry.id, intent: intent ) - guard self.isCurrentQueueLoad(queueGeneration), - self.activePlaybackQueueEntryID == seedEntry.id - else { return } + guard self.isCurrentPlaybackRequest(requestGeneration), + self.isCurrentQueueLoad(queueGeneration), + self.queueEntryIDOwningCurrentPlayback == seedEntry.id + else { + self.logger.info("Discarding stale radio playback request") + return + } - await self.fetchAndApplyRadioQueue( + let outcome = await self.fetchAndApplyRadioQueue( for: song.videoId, seedEntryID: seedEntry.id, queueGeneration: queueGeneration ) - guard self.isCurrentQueueLoad(queueGeneration) else { return } + guard outcome == .applied || outcome == .unavailable, + self.isCurrentPlaybackRequest(requestGeneration), + self.isCurrentQueueLoad(queueGeneration), + self.queueEntryIDOwningCurrentPlayback == seedEntry.id + else { return } self.saveQueueForPersistence() } @@ -136,6 +150,7 @@ extension PlayerService { let intent = suppliedIntent ?? self.beginMusicPlaybackIntent() guard self.acceptsMusicPlaybackIntent(intent) else { return } self.logger.info("Playing mix playlist: \(playlistId), startVideoId: \(startVideoId ?? "nil (random)")") + let requestGeneration = self.beginPendingPlaybackSelectionRequest() let continuationRequiresAuth = self.authService?.hasPersonalAccount == true self.clearForwardSkipNavigationStack() @@ -152,12 +167,15 @@ extension PlayerService { return } - guard self.acceptsMusicPlaybackIntent(intent) else { + guard self.acceptsMusicPlaybackIntent(intent), + self.isCurrentPendingPlaybackSelectionRequest(requestGeneration) + else { self.logger.info("Discarding stale mix playback request after playback boundary") return } let priorQueueState = self.makeQueueStateSnapshot() + let playbackContextGeneration = self.beginPlaybackRequest() // Store continuation token for infinite mix self.mixContinuationToken = result.continuationToken @@ -189,13 +207,17 @@ extension PlayerService { intent: intent ) } - guard self.isCurrentQueueLoad(queueGeneration) else { return } + guard self.isCurrentPlaybackRequest(playbackContextGeneration), + self.isCurrentQueueLoad(queueGeneration) + else { return } self.logger.info( "Mix queue loaded with \(shuffledEntries.count) songs, hasContinuation: \(result.continuationToken != nil)" ) self.saveQueueForPersistence() } catch { - guard self.acceptsMusicPlaybackIntent(intent) else { return } + guard self.acceptsMusicPlaybackIntent(intent), + self.isCurrentPendingPlaybackSelectionRequest(requestGeneration) + else { return } self.logger.warning("Failed to fetch mix queue: \(error.localizedDescription)") } } @@ -203,127 +225,215 @@ 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( - queueGeneration suppliedQueueGeneration: Int? = nil + queueGeneration suppliedQueueGeneration: Int? = nil, + shouldApplyResult: @MainActor () -> Bool = { true } ) async { + let queueGeneration = suppliedQueueGeneration ?? self.queueLoadGeneration + if self.isFetchingMoreMixSongs { - await self.waitForActiveMixContinuationRequest() + let queueCountBeforeWait = self.queue.count + let continuationBeforeWait = self.mixContinuationToken + let queueMutationGenerationBeforeWait = self.queueMutationGeneration + let completionGenerationBeforeWait = self.mixContinuationCompletionGeneration + await withCheckedContinuation { continuation in + self.mixContinuationFetchWaiters.append(continuation) + } + guard !Task.isCancelled, + shouldApplyResult(), + self.isCurrentQueueLoad(queueGeneration), + self.queue.count == queueCountBeforeWait, + self.mixContinuationToken == continuationBeforeWait, + self.queueMutationGeneration == queueMutationGenerationBeforeWait, + self.mixContinuationCompletionGeneration == completionGenerationBeforeWait + else { + return + } + await self.fetchMoreMixSongsIfNeeded( + queueGeneration: queueGeneration, + shouldApplyResult: shouldApplyResult + ) return } let songsRemaining = self.queue.count - self.currentIndex - 1 - self.logger.debug("Infinite mix check: \(songsRemaining) songs remaining, hasContinuation: \(self.mixContinuationToken != nil)") + self.logger.debug( + "Infinite mix check: \(songsRemaining) songs remaining, hasContinuation: \(self.mixContinuationToken != nil)" + ) - // Only fetch if we have a continuation token and we're near the end - guard let token = mixContinuationToken, - !isFetchingMoreMixSongs, + guard let continuation = self.mixContinuationToken, !(self.mixContinuationRequiresAuth && self.authService?.hasPersonalAccount != true), - let client = ytMusicClient + let client = self.ytMusicClient, + shouldApplyResult() else { return } + let requestQueueMutationGeneration = self.queueMutationGeneration - // Fetch more when we're within 10 songs of the end - guard songsRemaining <= 10 else { + // Fetch more when we're within 10 songs of the end. + guard songsRemaining <= 10, self.isCurrentQueueLoad(queueGeneration) else { return } - let queueGeneration = suppliedQueueGeneration ?? self.queueLoadGeneration - guard self.isCurrentQueueLoad(queueGeneration) else { return } self.logger.info("Fetching more mix songs, \(songsRemaining) remaining in queue") self.isFetchingMoreMixSongs = true let requestID = UUID() self.activeMixContinuationRequestID = requestID - defer { - self.finishMixContinuationRequest(requestID) - } + var didHandleResult = false + defer { self.finishMixContinuationFetch(requestID: requestID, didHandleResult: didHandleResult) } do { - let result = try await client.getMixQueueContinuation(continuationToken: token) - guard self.isCurrentQueueLoad(queueGeneration) else { - self.logger.info("Discarding stale mix continuation after queue replacement") + let result = try await client.getMixQueueContinuation(continuationToken: continuation) + guard !Task.isCancelled, + shouldApplyResult(), + self.isCurrentQueueLoad(queueGeneration), + self.activeMixContinuationRequestID == requestID, + self.mixContinuationToken == continuation, + self.queueMutationGeneration == requestQueueMutationGeneration + else { + self.logger.info("Discarding stale or cancelled mix continuation") return } - guard self.activeMixContinuationRequestID == requestID else { return } - self.logger.debug("Continuation returned \(result.songs.count) songs, hasNextToken: \(result.continuationToken != nil)") - - // Filter out songs already in queue to avoid duplicates - let existingIds = Set(queue.map(\.videoId)) - let newSongs = result.songs.filter { !existingIds.contains($0.videoId) } + didHandleResult = true + self.logger.debug( + "Continuation returned \(result.songs.count) songs, hasNextToken: \(result.continuationToken != nil)" + ) + let existingIDs = Set(self.queue.map(\.videoId)) + let newSongs = result.songs.filter { !existingIDs.contains($0.videoId) } if !newSongs.isEmpty { let updatedEntries = self.queueEntries + newSongs.map { QueueEntry(id: UUID(), song: $0) } self.setQueue(entries: updatedEntries) self.logger.info("Added \(newSongs.count) new songs to queue, total: \(self.queue.count)") - self.saveQueueForPersistence() } - // Update continuation token for next batch self.mixContinuationToken = result.continuationToken + self.saveQueueForPersistence( + syncWebQueue: !NativeQueueMaintenanceContext.isApplyingQueueMutation + ) } catch { + guard !Task.isCancelled, + shouldApplyResult(), + self.isCurrentQueueLoad(queueGeneration), + self.activeMixContinuationRequestID == requestID + else { return } + didHandleResult = true self.logger.warning("Failed to fetch more mix songs: \(error.localizedDescription)") } } - /// Fetches radio queue and applies it to the queue context that owns the seed. - func fetchAndApplyRadioQueue(for videoId: String) async { + private func finishMixContinuationFetch(requestID: UUID, didHandleResult: Bool) { + guard self.finishMixContinuationRequest(requestID) else { return } + if didHandleResult { + // Coalesced callers share success or failure; only a discarded owner needs replacement. + self.mixContinuationCompletionGeneration &+= 1 + } + self.resumeMixContinuationFetchWaiters() + } + + /// Fetches radio queue and applies it while preserving the logical playback owner. + @discardableResult + func fetchAndApplyRadioQueue(for videoId: String) async -> RadioQueueFetchOutcome { await self.fetchAndApplyRadioQueue( for: videoId, - seedEntryID: self.activePlaybackQueueEntryID, + seedEntryID: self.queueEntryIDOwningCurrentPlayback, queueGeneration: self.queueLoadGeneration ) } + // swiftlint:disable cyclomatic_complexity function_body_length + @discardableResult func fetchAndApplyRadioQueue( for videoId: String, seedEntryID: UUID?, queueGeneration: Int - ) async { - guard self.isCurrentQueueLoad(queueGeneration), - let client = self.ytMusicClient - else { return } + ) async -> RadioQueueFetchOutcome { + guard self.isCurrentQueueLoad(queueGeneration) else { return .superseded } + guard let client = self.ytMusicClient else { + self.logger.warning("No YTMusicClient available for fetching radio queue") + return .unavailable + } + + let sourceNavigationContext = self.playbackNavigationContext + let sourceQueueMutationGeneration = self.queueMutationGeneration do { let radioSongs = try await client.getRadioQueue(videoId: videoId) + guard !Task.isCancelled, + self.playbackNavigationContext == sourceNavigationContext + else { + self.logger.info("Discarding stale or cancelled radio queue") + return .superseded + } guard self.isCurrentQueueLoad(queueGeneration) else { + if self.queueMutationGeneration != sourceQueueMutationGeneration { + self.logger.info("Discarding radio queue after a local queue replacement") + return .queueMutated + } self.logger.info("Discarding stale radio queue after queue replacement") - return + return .superseded + } + if seedEntryID == nil, + self.queueMutationGeneration != sourceQueueMutationGeneration + { + self.logger.info("Using queue entries added while detached radio navigation was pending") + return .queueMutated } guard !radioSongs.isEmpty else { self.logger.info("No radio songs returned") - return + return .unavailable } - guard let seedEntryID, - let seedEntry = self.queueEntries.first(where: { $0.id == seedEntryID }), - seedEntry.song.videoId == videoId, - self.currentTrack?.videoId == videoId, - self.activePlaybackQueueEntryID == seedEntryID - else { - self.logger.info("Logical seed changed, discarding radio queue") - return + guard let currentSong = self.currentTrack, currentSong.videoId == videoId else { + self.logger.info("Track changed, discarding radio queue") + return .superseded } - let existingVideoIDs = Set(self.queue.map(\.videoId)) + let seedEntry: QueueEntry + let existingEntries: [QueueEntry] + if let seedEntryID { + guard self.queueEntryIDOwningCurrentPlayback == seedEntryID, + let ownedEntry = self.queueEntries.first(where: { $0.id == seedEntryID }), + ownedEntry.song.videoId == videoId + else { + self.logger.info("Logical seed changed, discarding radio queue") + return .superseded + } + seedEntry = ownedEntry + existingEntries = self.queueEntries + } else { + guard self.queueEntryIDOwningCurrentPlayback == nil, + self.activePlaybackQueueEntryID == nil + else { + self.logger.info("Logical seed changed, discarding detached radio queue") + return .superseded + } + seedEntry = QueueEntry(id: UUID(), song: currentSong) + existingEntries = [seedEntry] + } + + let existingVideoIDs = Set(existingEntries.map(\.song.videoId)) let relatedEntries = radioSongs.compactMap { song -> QueueEntry? in guard song.videoId != videoId, !existingVideoIDs.contains(song.videoId) else { return nil } return QueueEntry(id: UUID(), song: song) } - guard !relatedEntries.isEmpty else { return } + guard !relatedEntries.isEmpty else { return .unavailable } self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo() + self.activePlaybackQueueEntryID = seedEntry.id + let mergedEntries = existingEntries + relatedEntries if self.shuffleEnabled { - let liveEntryIDs = Set(self.queueEntries.map(\.id)) + let liveEntryIDs = Set(existingEntries.map(\.id)) var originalEntries = (self.queueOrderBeforeShuffle ?? []).filter { liveEntryIDs.contains($0.id) } let originalIDs = Set(originalEntries.map(\.id)) - originalEntries.append(contentsOf: self.queueEntries.filter { + originalEntries.append(contentsOf: existingEntries.filter { !originalIDs.contains($0.id) }) originalEntries.append(contentsOf: relatedEntries) - let seedOriginalIndex = originalEntries.firstIndex(where: { $0.id == seedEntryID }) ?? 0 + let seedOriginalIndex = originalEntries.firstIndex(where: { $0.id == seedEntry.id }) ?? 0 self.materializeShuffleQueue( entries: originalEntries, startingAt: seedOriginalIndex, @@ -331,21 +441,31 @@ extension PlayerService { storesOriginalOrder: true ) } else { - self.setQueue(entries: self.queueEntries + relatedEntries) + self.setQueue(entries: mergedEntries) self.queueOrderBeforeShuffle = nil } - if let seedIndex = self.queueEntries.firstIndex(where: { $0.id == seedEntryID }) { + if let seedIndex = self.queueEntries.firstIndex(where: { $0.id == seedEntry.id }) { self.currentIndex = seedIndex } - self.activePlaybackQueueEntryID = seedEntryID self.logger.info("Radio queue merged with \(relatedEntries.count) related songs") self.saveQueueForPersistence() + return .applied } catch { - guard self.isCurrentQueueLoad(queueGeneration) else { return } + if Task.isCancelled || self.playbackNavigationContext != sourceNavigationContext { + return .superseded + } + guard self.isCurrentQueueLoad(queueGeneration) else { + return self.queueMutationGeneration != sourceQueueMutationGeneration + ? .queueMutated + : .superseded + } self.logger.warning("Failed to fetch radio queue: \(error.localizedDescription)") + return .unavailable } } + // swiftlint:enable cyclomatic_complexity function_body_length + /// Stops playback and clears the entire queue. Records state for undo. func clearQueueEntirely() async { await self.stopAndClearQueue() @@ -356,6 +476,8 @@ extension PlayerService { undoState: QueueState? = nil ) { guard self.acceptsMusicPlaybackIntent(intent) else { return } + self.beginPlaybackRequest() + self.queueMutationGeneration &+= 1 self.cancelDeferredQueueWork() self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo(undoState ?? self.makeQueueStateSnapshot()) @@ -371,6 +493,8 @@ extension PlayerService { /// Clears the playback queue except for the currently playing track. func clearQueue() { self.beginMusicPlaybackIntent(allowsPriorTerminalEvent: true) + self.invalidatePendingPlaybackSelectionRequests() + self.queueMutationGeneration &+= 1 self.cancelDeferredQueueWork() self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo() @@ -922,10 +1046,17 @@ extension PlayerService { self.queuePersistenceDefaults.data(forKey: self.savedPlaybackSessionKey) != nil } + // swiftlint:disable function_body_length /// Saves the current queue to UserDefaults for restoration on next launch. - func saveQueueForPersistence(ownerScopeOverride: String? = nil) { + func saveQueueForPersistence( + ownerScopeOverride: String? = nil, + syncWebQueue shouldSyncWebQueue: Bool = true + ) { let queue = self.queue guard !queue.isEmpty || self.currentTrack != nil else { + if shouldSyncWebQueue { + self.clearWebQueueInjectionState() + } if self.suppressNextEmptyQueuePersistence { self.suppressNextEmptyQueuePersistence = false self.logger.info("Skipped clearing saved playback session after guest-startup cleanup") @@ -966,9 +1097,19 @@ extension PlayerService { persistedEntryIDs.contains($0.id) } guard !persistableQueue.isEmpty else { + if shouldSyncWebQueue { + 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 = Self.persistenceEncoder() @@ -1033,6 +1174,8 @@ extension PlayerService { } } + // swiftlint:enable function_body_length + private static func persistenceEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] @@ -1075,9 +1218,39 @@ extension PlayerService { func invalidatePendingPlaybackRequests() { self.beginMusicPlaybackIntent() + self.beginPlaybackRequest() self.invalidateMixContinuationRequest() } + @discardableResult + func beginPlaybackRequest() -> Int { + self.playbackContextGeneration &+= 1 + self.pendingPlaybackSelectionGeneration &+= 1 + return self.playbackContextGeneration + } + + func isCurrentPlaybackRequest(_ generation: Int) -> Bool { + generation == self.playbackContextGeneration + } + + @discardableResult + func beginPendingPlaybackSelectionRequest() -> Int { + 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 /// privacy boundary without otherwise changing the queue payload. func updateRestoredPlaybackSessionOwnerScope(_ ownerScope: String?) { diff --git a/Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift b/Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift new file mode 100644 index 000000000..d4ef93c89 --- /dev/null +++ b/Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift @@ -0,0 +1,86 @@ +import Foundation + +@MainActor +extension PlayerService { + private static let queueNavigationRecoveryTimeout: Duration = .seconds(8) + + func finishPlaybackAfterFailedQueueAdvance(reason: String) async { + // A transient failure leaves the current token intact, while a duplicate-only page can + // advance to another token. Only a nil token proves that the continuation is exhausted. + let hasRetryableMixContinuation = self.mixContinuationToken != nil + if !hasRetryableMixContinuation { + self.mixContinuationRequiresAuth = false + } + self.shouldSuppressAutoplayAfterQueueEnd = true + self.markPlaybackEnded() + self.logger.info( + "Ending playback after failed queue advance: \(reason), retryable continuation: \(hasRetryableMixContinuation)" + ) + 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 + let intent = self.currentMusicPlaybackIntent + let startsPaused = self.isExplicitPauseIntentActive + let queueEntryID = self.currentQueueEntryID(matching: song) + 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, + self.acceptsMusicPlaybackIntent(intent), + self.isExplicitPauseIntentActive == startsPaused + else { + return + } + + await self.play( + song: song, + webLoadStrategy: .forceFullPageWhenSameVideoId, + queueEntryID: queueEntryID, + startsPaused: startsPaused, + isQueueNavigationRecovery: true, + intent: intent + ) + + 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 000000000..f7b3d8f1a --- /dev/null +++ b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift @@ -0,0 +1,549 @@ +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, + playbackOccurrence: MusicPlaybackOccurrence? = nil + ) -> 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, + playbackOccurrence: playbackOccurrence, + allowsObservedArtistForMatchingVideo: identitiesCoherent + ) + } + + guard let authoritativeVideoId else { return true } + return self.observedPlaybackMatchesCurrentTarget(videoId: authoritativeVideoId) + } + + // swiftlint:disable:next function_parameter_count + func reconcileWebPlaybackMetadata( + title: String, + artist: String, + thumbnailUrl: String, + observedVideoId: String?, + playbackVideoId: String?, + bridgeTrackChanged: Bool, + playbackOccurrence: MusicPlaybackOccurrence? = nil + ) -> Bool { + self.reconcileWebPlaybackMetadata( + title: title, + artist: artist, + thumbnailUrl: thumbnailUrl, + observedVideoId: observedVideoId, + mediaVideoId: playbackVideoId, + bridgeTrackChanged: bridgeTrackChanged, + playbackOccurrence: playbackOccurrence + ) + } + + func observedPlaybackWouldChangeQueueEntry(videoId observedVideoId: String?) -> Bool { + guard self.activePlaybackOwnsCurrentQueueEntry, + let observedVideoId = self.normalizedWebPlaybackVideoId(observedVideoId), + self.queue[safe: self.currentIndex]?.videoId != observedVideoId + else { return false } + if self.songNearingEnd { + return true + } + let matchingIndices = self.queueEntries.indices.filter { index in + self.queueEntries[index].song.videoId == observedVideoId + } + return matchingIndices.count == 1 && matchingIndices[0] != self.currentIndex + } + + /// 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.activePlaybackOwnsCurrentQueueEntry ? self.queue[safe: self.currentIndex]?.videoId : nil) + ?? self.currentTrack?.videoId + ?? self.pendingPlayVideoId + } + + var hasAvailablePendingNativeQueueAdvanceSource: Bool { + guard let pending = self.pendingNativeQueueAdvance, + let sourceEntryID = pending.sourceEntryID, + let sourceIndex = self.queueEntryIDs.firstIndex(of: sourceEntryID) + else { return false } + return self.queue[safe: sourceIndex]?.videoId == pending.sourceVideoId + } + + 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 + let fallbackStartedAt = ContinuousClock.now + self.pendingNativeQueueAdvance = PendingNativeQueueAdvance( + sourceEntryID: self.currentQueueEntryID, + sourceVideoId: sourceVideoId, + targetEntryID: targetEntry.id, + targetVideoId: targetEntry.song.videoId, + generation: generation, + fallbackDeadline: SingletonPlayerWebView.transitionFallbackDeadline( + now: fallbackStartedAt, + initialFallbackDelay: Self.nativeQueueAdvanceTimeout + ) + ) + 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?, + shouldContinue: @escaping @MainActor () -> Bool = { true } + ) async -> Bool { + guard shouldContinue() else { return false } + 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 { + guard shouldContinue(), self.confirmPendingNativeQueueAdvance(videoId: videoId) else { + return false + } + return true + } + + guard shouldContinue() else { return false } + await self.fallbackPendingNativeQueueAdvance( + generation: pending.generation, + reason: "observed unexpected native video \(videoId)" + ) + return false + } + + func handleNativeQueueAdvanceTimeout(generation: Int) async { + guard let pending = self.pendingNativeQueueAdvance, + pending.generation == generation + else { return } + guard self.isPendingNativeQueueAdvanceValid else { + await self.fallbackPendingNativeQueueAdvance( + generation: generation, + reason: "pending native queue relationship became invalid" + ) + return + } + let now = ContinuousClock.now + if let retryDelay = SingletonPlayerWebView.transitionFallbackRetryDelay( + isShowingAd: self.isShowingAd, + now: now, + deadline: pending.fallbackDeadline, + lastAdvertisementProgressAt: self.lastAdPlaybackProgressAt + ) { + Task { + try? await Task.sleep(for: retryDelay) + await self.handleNativeQueueAdvanceTimeout(generation: generation) + } + return + } + await self.fallbackPendingNativeQueueAdvance( + generation: generation, + reason: "timed out waiting for expected native media" + ) + } + + @discardableResult + func reanchorPendingNativeQueueAdvanceSource( + intent: MusicPlaybackIntent, + startsPaused: Bool, + restoreClock: MusicPlaybackRestoreClock? = nil, + reason: String + ) async -> Bool { + guard self.acceptsMusicPlaybackIntent(intent), + let pending = self.pendingNativeQueueAdvance + else { return false } + let sourceIndex = pending.sourceEntryID.flatMap { self.queueEntryIDs.firstIndex(of: $0) } + guard let sourceIndex, + self.queue[safe: sourceIndex]?.videoId == pending.sourceVideoId + else { + await self.fallbackPendingNativeQueueAdvance( + generation: pending.generation, + reason: "source unavailable during explicit \(reason)", + intent: intent, + startsPaused: startsPaused, + restoreClock: restoreClock + ) + return true + } + self.logger.info("Re-anchoring pending native queue advance source for explicit transport: \(reason)") + self.clearPendingNativeQueueAdvance() + self.clearWebQueueInjectionState() + await self.loadQueueSongForNavigation( + at: sourceIndex, + webLoadStrategy: .forceFullPageWhenSameVideoId, + startsPaused: startsPaused, + restoreClock: restoreClock, + intent: intent, + fetchesMetadata: false + ) + // Once this operation clears the pending handoff, callers must not fall + // through and apply a superseded transport action to newer playback state. + return true + } + + func resolvePendingNativeQueueAdvanceForResume(intent: MusicPlaybackIntent) async -> Bool { + guard self.pendingNativeQueueAdvance != nil else { return false } + if self.isExplicitPauseIntentActive { + return await self.reanchorPendingNativeQueueAdvanceSource( + intent: intent, + startsPaused: false, + reason: "resume" + ) + } + + switch self.state { + case .loading, .playing, .buffering: + self.logger.debug("Ignoring idempotent resume while native queue handoff awaits target media") + return true + case .idle, .paused, .ended, .error: + self.logger.debug("Resuming paused media while native queue handoff remains pending") + } + + self.isStoppingPlayback = false + self.shouldResumeAfterInterruption = true + self.isAwaitingPlaybackConfirmation = true + self.isExplicitPauseIntentActive = false + self.state = .loading + SingletonPlayerWebView.shared.setAutoplayBlocked(false) + SingletonPlayerWebView.shared.play() + return true + } + + func resolvePendingNativeQueueAdvanceForExplicitTerminal( + intent: MusicPlaybackIntent, + reason: String + ) async { + guard self.acceptsMusicPlaybackIntent(intent), + let pending = self.pendingNativeQueueAdvance + else { return } + await self.fallbackPendingNativeQueueAdvance( + generation: pending.generation, + reason: reason, + intent: intent, + startsPaused: self.isExplicitPauseIntentActive + ) + } + + func cancelPendingNativeQueueAdvanceForExplicitPriorNavigation(reason: String) { + guard self.pendingNativeQueueAdvance != nil else { return } + self.logger.info("Cancelling pending native queue advance for explicit prior navigation: \(reason)") + self.clearPendingNativeQueueAdvance() + self.clearWebQueueInjectionState() + } + + func clearPendingNativeQueueAdvance() { + self.pendingNativeQueueAdvanceGeneration &+= 1 + self.pendingNativeQueueAdvance = nil + self.clearNativeQueueMaintenance() + } + + @discardableResult + private func confirmPendingNativeQueueAdvance(videoId: String) -> Bool { + guard !Task.isCancelled, + 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 revalidatePendingNativeQueueAdvanceAfterRepeatModeChange() { + guard let pending = self.pendingNativeQueueAdvance, + !self.isPendingNativeQueueAdvanceValid + else { return } + Task { @MainActor [weak self] in + await self?.fallbackInvalidatedNativeQueueAdvance( + generation: pending.generation, + reason: "repeat mode changed" + ) + } + } + + func recoverPendingNativeQueueAdvanceAfterContentProcessTermination( + intent: MusicPlaybackIntent + ) async -> Bool { + guard self.acceptsMusicPlaybackIntent(intent), + let pending = self.pendingNativeQueueAdvance + else { return false } + await self.fallbackPendingNativeQueueAdvance( + generation: pending.generation, + reason: "WebContent process terminated", + intent: intent, + startsPaused: self.isExplicitPauseIntentActive + ) + return true + } + + func fallbackInvalidatedNativeQueueAdvance( + generation: Int, + reason: String + ) async { + guard !Task.isCancelled, + 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, + intent suppliedIntent: MusicPlaybackIntent? = nil, + startsPaused suppliedStartsPaused: Bool? = nil, + restoreClock: MusicPlaybackRestoreClock? = nil + ) async { + guard let pending = self.pendingNativeQueueAdvance, + pending.generation == generation + else { + return + } + + let fallbackIntent = suppliedIntent ?? self.currentMusicPlaybackIntent + let startsPaused = suppliedStartsPaused ?? self.isExplicitPauseIntentActive + 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 + } + + let normalizedRestoreClock = restoreClock.map { clock in + MusicPlaybackRestoreClock( + progress: max(clock.progress, 0), + duration: 0, + allowsSongDurationFallback: false, + isExplicitTransportSeek: clock.isExplicitTransportSeek + ) + } + + 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, + startsPaused: startsPaused, + restoreClock: normalizedRestoreClock, + intent: fallbackIntent + ) + } + + 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) + } + guard !Task.isCancelled, + self.nativeQueueMaintenanceGeneration == generation + else { return } + self.syncWebQueue() + } + } + + 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 + } +} + +extension WebPlaybackIdentityTransition { + static func shouldHandleDeferredIdentitylessObservation( + isDeferred: Bool, + observedVideoId: String?, + playbackVideoId: String? + ) -> Bool { + self.shouldHandleDeferredIdentitylessObservation( + isDeferred: isDeferred, + observedVideoId: observedVideoId, + mediaVideoId: playbackVideoId + ) + } +} diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index e9b262a81..5adee3186 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -1,41 +1,113 @@ +// swiftlint:disable file_length + import Foundation // MARK: - Web Queue Sync @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") - guard self.claimTerminalMusicPlaybackOccurrence(self.currentMusicPlaybackOccurrence) else { - self.logger.debug("Ignoring duplicate manual track-end transition for consumed playback occurrence") - return + private static let queueNavigationInFlightProtectionGrace: Duration = .seconds(20) + private static let queueNavigationConfirmedProtectionGrace: Duration = .seconds(8) + private static let webQueueInjectionTimeout: Duration = .seconds(20) + + private func applyDeferredRestoredMetadata( + title: String, + artist: String, + thumbnailUrl: String, + videoId observedVideoId: String? + ) { + guard let observedVideoId = self.normalizedPlaybackVideoId(observedVideoId) else { return } + + let thumbnailURL = URL(string: thumbnailUrl) + let normalizedArtistName = Self.normalizedWebArtistName(artist) + let artistObj = Artist(id: "unknown", name: normalizedArtistName) + let matchedQueueEntry = self.queueEntries.first(where: { $0.song.videoId == observedVideoId }) + let matchedQueueSong = matchedQueueEntry?.song + 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 } - self.clearRestoredPlaybackSessionState() - self.progress = self.duration - if self.shouldSynchronizeWebViewForTerminalManualSeekToEnd { - SingletonPlayerWebView.shared.seekAndPause(to: self.duration) + // 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 { + let queueEntryID = self.currentQueueEntryID + Task { [queueEntryID] in + await self.fetchSongMetadata( + videoId: observedVideoId, + queueOwner: queueEntryID.map(MusicQueueMetadataOwner.entry) ?? .none + ) + } + return } + self.mixContinuationToken = nil - await self.finishTrackEnded( - observedVideoId: self.currentTrack?.videoId, - intent: self.currentMusicPlaybackIntent - ) - } + if let matchedQueueSong, + self.shouldKeepQueueMetadata( + title: title, + artist: normalizedArtistName, + 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 + ) + } - private var shouldSynchronizeWebViewForTerminalManualSeekToEnd: Bool { - if self.queue.isEmpty { - return !(self.repeatMode == .one && (self.currentTrack != nil || self.pendingPlayVideoId != nil)) + self.clearForwardSkipNavigationStack() + if let matchedQueueEntry { + self.setQueue(entries: [ + QueueEntry( + id: matchedQueueEntry.id, + song: seedSong, + source: matchedQueueEntry.source + ), + ]) + } else { + self.setQueue([seedSong]) + } + self.currentIndex = 0 + self.synchronizeCurrentQueueEntryID() + self.activePlaybackQueueEntryID = self.currentQueueEntryID + self.currentTrack = seedSong + self.currentTrackHasVideo = seedSong.musicVideoType?.hasVideoContent + ?? seedSong.hasVideo + ?? false + self.saveQueueForPersistence() + + Task { + await self.fetchAndApplyRadioQueue(for: observedVideoId) } - return !self.canAdvanceNativeQueueAfterTrackEnd + if previousVideoId != observedVideoId { + self.resetTrackStatus() + if let cachedStatus = self.songLikeStatusManager.status(for: observedVideoId) { + self.currentTrackLikeStatus = cachedStatus + } + let queueEntryID = self.currentQueueEntryID + Task { [queueEntryID] in + await self.fetchSongMetadata( + videoId: observedVideoId, + queueOwner: queueEntryID.map(MusicQueueMetadataOwner.entry) ?? .none + ) + } + } } private func resolvedObservedVideoId(_ videoId: String?) -> String { @@ -62,20 +134,135 @@ extension PlayerService { title.isEmpty || artist.isEmpty || !self.metadataMatchesSong(title: title, artist: artist, song: song) } + /// 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 self.activePlaybackOwnsCurrentQueueEntry else { + self.clearWebQueueInjectionState() + 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, + self.activePlaybackOwnsCurrentQueueEntry + 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.shuffleEnabled - || self.repeatMode == .one - || self.currentIndex < self.queue.count - 1 - || self.repeatMode == .all - || self.mixContinuationToken != nil + self.activePlaybackOwnsCurrentQueueEntry + && ( + 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 } @@ -85,12 +272,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.normalizedPlaybackVideoId(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 @@ -106,21 +343,10 @@ extension PlayerService { private func keepQueueSongVisible(_ song: Song, thumbnailUrl: String) { let intendedThumbnailURL = URL(string: thumbnailUrl) ?? song.thumbnailURL - self.currentTrack = Song( - id: song.id, + self.currentTrack = song.replacingDisplayMetadata( title: song.title, artists: song.artists, - album: song.album, - duration: song.duration, - thumbnailURL: intendedThumbnailURL, - videoId: song.videoId, - isPlayable: song.isPlayable, - hasVideo: song.hasVideo, - musicVideoType: song.musicVideoType, - likeStatus: song.likeStatus, - isInLibrary: song.isInLibrary, - feedbackTokens: song.feedbackTokens, - isExplicit: song.isExplicit + thumbnailURL: intendedThumbnailURL ) } @@ -161,6 +387,11 @@ extension PlayerService { thumbnailUrl: String, trackChanged: Bool ) -> Bool { + if self.clearExpiredQueueNavigationProtectionIfNeeded() { + self.isKasetInitiatedPlayback = false + self.clearQueueNavigationRecovery() + } + guard self.isKasetInitiatedPlayback, !self.queue.isEmpty, self.activePlaybackOwnsCurrentQueueEntry @@ -170,13 +401,16 @@ extension PlayerService { guard let intendedEntry = self.queueEntries[safe: self.currentIndex] else { self.isKasetInitiatedPlayback = false + self.clearQueueNavigationRecovery() return false } let intendedSong = intendedEntry.song let matchesObservedVideo = self.normalizedPlaybackVideoId(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" ) @@ -190,7 +424,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 } @@ -203,16 +439,56 @@ extension PlayerService { self.logger.info( "YouTube loaded different track '\(title)' (\(resolvedVideoId)), re-playing intended track '\(intendedSong.title)'" ) - self.isKasetInitiatedPlayback = false - self.scheduleMusicPlaybackIntentTask(expectedQueueEntryID: intendedEntry.id) { service, intent in - await service.play( - song: intendedSong, - webLoadStrategy: .forceFullPageWhenSameVideoId, - queueEntryID: intendedEntry.id, - intent: intent + // 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 + } + + func handleRejectedQueueNavigationObservationIfNeeded( + observedVideoId: String?, + title: String, + artist: String, + thumbnailUrl: String, + trackChanged: Bool + ) { + guard self.isKasetInitiatedPlayback, + let intendedSong = self.queue[safe: self.currentIndex], + self.protectedQueueNavigationVideoId == intendedSong.videoId, + let observedVideoId = self.normalizedPlaybackVideoId(observedVideoId), + observedVideoId != intendedSong.videoId + else { return } + _ = self.handleKasetInitiatedPlaybackMetadata( + observedVideoId: observedVideoId, + title: title, + artist: artist, + thumbnailUrl: thumbnailUrl, + trackChanged: trackChanged + || self.normalizedPlaybackVideoId(observedVideoId) != intendedSong.videoId + ) + } + + private func commitObservedQueueTrack( + to index: Int, + videoId: String, + beginsPlaybackOccurrence: Bool = false + ) { + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() + self.pushForwardSkipStackIfLeavingIndex(for: index) + self.advanceQueueStateForNativeNavigation(to: index) + if beginsPlaybackOccurrence { + self.beginNativeMusicPlaybackOccurrence( + videoId: videoId, + synchronizeCurrentDocument: true ) + self.activePlaybackQueueEntryID = self.currentQueueEntryID } - return true + SingletonPlayerWebView.shared.currentVideoId = videoId + self.saveQueueForPersistence(syncWebQueue: false) } // The occurrence token joins the existing observed metadata so the near-end @@ -282,19 +558,17 @@ extension PlayerService { } self.logger.info("YouTube autoplay detected, overriding with queue track") self.scheduleMusicPlaybackIntentTask(expectedQueueEntryID: self.currentQueueEntryID) { service, intent in - await service.next(intent: intent) + _ = await service.performNextNavigation(intent: intent) } return true } - self.currentIndex = expectedNextIndex - self.beginNativeMusicPlaybackOccurrence( + self.commitObservedQueueTrack( + to: expectedNextIndex, videoId: expectedNextTrack.videoId, - synchronizeCurrentDocument: true + beginsPlaybackOccurrence: true ) - self.activePlaybackQueueEntryID = self.currentQueueEntryID self.logger.info("Track advanced to queue index \(expectedNextIndex)") - self.saveQueueForPersistence() if self.shouldKeepQueueMetadata(title: title, artist: artist, song: expectedNextTrack) { self.logger.debug( @@ -316,7 +590,7 @@ extension PlayerService { } self.logger.info("Near-end track change detected, advancing native queue to enforce playback order") self.scheduleMusicPlaybackIntentTask(expectedQueueEntryID: self.currentQueueEntryID) { service, intent in - await service.next(intent: intent) + _ = await service.performNextNavigation(intent: intent) } return true } @@ -399,6 +673,14 @@ extension PlayerService { } let currentQueueSong = currentQueueEntry.song + 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 { @@ -428,8 +710,7 @@ extension PlayerService { let matchingSong = match.element.song let queueIndexChanged = matchingIndex != self.currentIndex if queueIndexChanged { - self.currentIndex = matchingIndex - self.activePlaybackQueueEntryID = self.currentQueueEntryID + self.commitObservedQueueTrack(to: matchingIndex, videoId: observedVideoId) self.logger.info("Observed playback moved to queue index \(matchingIndex), realigning native queue") } @@ -442,9 +723,6 @@ extension PlayerService { } } self.keepQueueSongVisible(matchingSong, thumbnailUrl: thumbnailUrl) - if queueIndexChanged { - self.saveQueueForPersistence() - } return true } return false @@ -465,12 +743,25 @@ extension PlayerService { } /// Replays the current queue song after a natural `ended` event. User-initiated **Next** uses ``PlayerService/next()`` instead. - private func replayCurrentQueueSongForRepeatOneAfterTrackEnd(intent: MusicPlaybackIntent) async { + private func replayCurrentQueueSongForRepeatOneAfterTrackEnd( + intent: MusicPlaybackIntent, + startsPaused: Bool = false + ) async { guard self.acceptsMusicPlaybackIntent(intent), let currentEntry = self.queueEntries[safe: self.currentIndex] else { return } let currentSong = currentEntry.song self.songNearingEnd = false + if startsPaused { + await self.play( + song: currentSong, + webLoadStrategy: .forceFullPageWhenSameVideoId, + queueEntryID: currentEntry.id, + startsPaused: true, + intent: intent + ) + return + } let kasetAlignedWithQueue = self.pendingPlayVideoId == currentSong.videoId && self.currentWebPlaybackVideoId() == currentSong.videoId if self.hasUserInteractedThisSession, kasetAlignedWithQueue { @@ -501,7 +792,8 @@ extension PlayerService { /// Replays the currently playing song for repeat-one when no native queue is active. private func replayCurrentSongForRepeatOneWithoutQueueAfterTrackEnd( - intent: MusicPlaybackIntent + intent: MusicPlaybackIntent, + startsPaused: Bool = false ) async { guard self.acceptsMusicPlaybackIntent(intent) else { return } self.songNearingEnd = false @@ -509,8 +801,9 @@ extension PlayerService { self.logger.info("Track ended with repeat one and no queue; replaying current track") await self.play( song: currentTrack, - webLoadStrategy: .preferInPlaceWhenSameVideoId, + webLoadStrategy: startsPaused ? .forceFullPageWhenSameVideoId : .preferInPlaceWhenSameVideoId, queueEntryID: nil, + startsPaused: startsPaused, intent: intent ) return @@ -518,48 +811,233 @@ extension PlayerService { if let pendingVideoId = self.pendingPlayVideoId { self.logger.info("Track ended with repeat one and no queue metadata; replaying pending video") - await self.play(videoId: pendingVideoId, intent: intent) + if startsPaused { + self.beginNativeMusicPlaybackOccurrence( + videoId: pendingVideoId, + synchronizeCurrentDocument: true + ) + self.progress = 0 + self.currentTimeMs = 0 + self.shouldResumeAfterInterruption = false + self.isAwaitingPlaybackConfirmation = false + self.isExplicitPauseIntentActive = true + self.state = .paused + SingletonPlayerWebView.shared.setAutoplayBlocked(true) + SingletonPlayerWebView.shared.seekAndPause(to: 0) + } else { + await self.play(videoId: pendingVideoId, intent: intent) + } return } } + private func acceptedTrackEndContinuationIntent( + originalIntent: MusicPlaybackIntent, + occurrence: MusicPlaybackOccurrence? + ) -> MusicPlaybackIntent? { + if self.acceptsMusicPlaybackIntent(originalIntent) { + return originalIntent + } + + guard self.musicPlaybackIntentAcceptsPriorTerminalEvent, + originalIntent.generation >= self.musicPlaybackMinimumAcceptedTerminalIntentGeneration, + originalIntent.generation <= self.musicPlaybackIntentGeneration, + let occurrence, + occurrence == self.currentMusicPlaybackOccurrence + else { + return nil + } + return self.currentMusicPlaybackIntent + } + + private func restoreTrackEndQueueOwnershipAfterMaintenanceIfNeeded( + sourceEntryID: UUID?, + observedVideoId: String?, + maintenanceGeneration: Int + ) -> UUID? { + guard maintenanceGeneration == self.nativeQueueMaintenanceGeneration, + self.expectedQueueIndexAfterCurrentTrack() != nil, + let sourceEntryID, + !self.queueEntryIDs.contains(sourceEntryID), + self.activePlaybackQueueEntryID == nil || self.activePlaybackQueueEntryID == sourceEntryID, + let observedVideoId = self.normalizedPlaybackVideoId(observedVideoId), + self.currentTrack?.videoId == observedVideoId, + let currentEntry = self.queueEntries[safe: self.currentIndex], + currentEntry.song.videoId == observedVideoId, + self.queueEntries.count(where: { $0.song.videoId == observedVideoId }) == 1 + else { + return nil + } + + self.activePlaybackQueueEntryID = currentEntry.id + return currentEntry.id + } + + // swiftlint:disable cyclomatic_complexity function_body_length /// Handles a natural track completion reported directly by the WebView. func handleTrackEnded( observedVideoId: String?, playbackOccurrence: MusicPlaybackOccurrence? = nil, - intent suppliedIntent: MusicPlaybackIntent? = nil + intent suppliedIntent: MusicPlaybackIntent? = nil, + startsPaused suppliedStartsPaused: Bool? = nil, + identityResolutionTimedOut: Bool = false, + shouldContinue: @MainActor () -> Bool = { true } ) async { let intent = suppliedIntent ?? self.currentMusicPlaybackIntent - guard self.acceptsMusicPlaybackIntent(intent) else { return } + guard shouldContinue(), self.acceptsMusicPlaybackIntent(intent) else { return } + if identityResolutionTimedOut { + guard observedVideoId == nil, + playbackOccurrence?.documentGeneration != nil, + playbackOccurrence?.videoId == nil + else { return } + } self.logger.debug("Track ended reported by WebView: \(observedVideoId ?? "unknown")") - guard self.claimTerminalMusicPlaybackOccurrence(playbackOccurrence) else { - self.logger.debug("Ignoring duplicate track-ended transition for consumed playback occurrence") - return + + let normalizedEndedVideoId = self.normalizedPlaybackVideoId(observedVideoId) + let defersOccurrenceClaimForPendingTarget = playbackOccurrence == nil + && self.pendingNativeQueueAdvance.map { $0.targetVideoId == normalizedEndedVideoId } == true + var terminalOccurrence = playbackOccurrence ?? self.currentMusicPlaybackOccurrence + if !defersOccurrenceClaimForPendingTarget { + guard self.claimTerminalMusicPlaybackOccurrence(playbackOccurrence) else { + self.logger.debug("Ignoring duplicate track-ended transition for consumed playback occurrence") + return + } + terminalOccurrence = playbackOccurrence ?? self.currentMusicPlaybackOccurrence } - guard !self.isExplicitPauseIntentActive else { + + guard suppliedStartsPaused != nil || !self.isExplicitPauseIntentActive else { self.songNearingEnd = false self.logger.debug("Consumed track-ended transition without advancing because pause intent is active") return } - await self.finishTrackEnded(observedVideoId: observedVideoId, intent: intent) + let startsPaused = suppliedStartsPaused ?? self.isExplicitPauseIntentActive + if identityResolutionTimedOut { + self.clearWebQueueInjectionState() + if self.pendingNativeQueueAdvance != nil { + guard shouldContinue(), + let continuationIntent = self.acceptedTrackEndContinuationIntent( + originalIntent: intent, + occurrence: terminalOccurrence + ) + else { return } + await self.resolvePendingNativeQueueAdvanceForExplicitTerminal( + intent: continuationIntent, + reason: "track ended before media identity resolved" + ) + return + } + } + await self.finishTrackEnded( + observedVideoId: observedVideoId, + intent: intent, + terminalOccurrence: terminalOccurrence, + claimsPendingTargetOccurrence: defersOccurrenceClaimForPendingTarget, + startsPaused: startsPaused, + allowsNativeQueueHandoff: !identityResolutionTimedOut, + shouldContinue: shouldContinue + ) } + // swiftlint:disable:next function_parameter_count private func finishTrackEnded( observedVideoId: String?, - intent: MusicPlaybackIntent + intent: MusicPlaybackIntent, + terminalOccurrence suppliedTerminalOccurrence: MusicPlaybackOccurrence?, + claimsPendingTargetOccurrence: Bool, + startsPaused: Bool, + allowsNativeQueueHandoff: Bool, + shouldContinue: @MainActor () -> Bool ) async { - guard self.acceptsMusicPlaybackIntent(intent) else { return } + guard shouldContinue(), + let acceptedIntent = self.acceptedTrackEndContinuationIntent( + originalIntent: intent, + occurrence: suppliedTerminalOccurrence + ) + else { return } + var continuationIntent = acceptedIntent + var terminalOccurrence = suppliedTerminalOccurrence + var endedNavigationContext = self.playbackNavigationContext + var endedEntryID = self.queueEntryIDOwningCurrentPlayback + while !self.queue.isEmpty, + self.expectedQueueIndexAfterCurrentTrack() == nil, + self.nativeQueueMaintenanceTask != nil + { + let maintenanceGeneration = self.nativeQueueMaintenanceGeneration + await self.awaitNativeQueueMaintenanceIfNeeded(generation: maintenanceGeneration) + guard shouldContinue(), + let refreshedIntent = self.acceptedTrackEndContinuationIntent( + originalIntent: intent, + occurrence: terminalOccurrence + ), + self.playbackNavigationContext == endedNavigationContext + else { return } + continuationIntent = refreshedIntent + if let reboundEntryID = self.restoreTrackEndQueueOwnershipAfterMaintenanceIfNeeded( + sourceEntryID: endedEntryID, + observedVideoId: observedVideoId, + maintenanceGeneration: maintenanceGeneration + ) { + endedEntryID = reboundEntryID + } + } + self.songNearingEnd = false + if !allowsNativeQueueHandoff { + self.clearWebQueueInjectionState() + } guard self.activePlaybackOwnsCurrentQueueEntry, !self.queue.isEmpty else { if self.repeatMode == .one, self.currentTrack != nil || self.pendingPlayVideoId != nil { - await self.replayCurrentSongForRepeatOneWithoutQueueAfterTrackEnd(intent: intent) + await self.replayCurrentSongForRepeatOneWithoutQueueAfterTrackEnd( + intent: continuationIntent, + startsPaused: startsPaused + ) return } self.markPlaybackEnded() return } + + if let pending = self.pendingNativeQueueAdvance { + let normalizedEndedVideoId = self.normalizedPlaybackVideoId(observedVideoId) + if normalizedEndedVideoId == pending.sourceVideoId { + self.logger.debug("Ignoring duplicate ended event from native handoff source") + return + } + + let didReconcileTarget: Bool + if normalizedEndedVideoId == pending.targetVideoId { + didReconcileTarget = await self.reconcilePendingNativeQueueAdvanceObservation( + videoId: normalizedEndedVideoId + ) + } else { + if normalizedEndedVideoId != nil { + _ = await self.reconcilePendingNativeQueueAdvanceObservation( + videoId: normalizedEndedVideoId + ) + } + didReconcileTarget = false + } + guard shouldContinue(), didReconcileTarget else { return } + + if claimsPendingTargetOccurrence { + let targetOccurrence = self.beginNativeMusicPlaybackOccurrence( + videoId: normalizedEndedVideoId, + synchronizeCurrentDocument: true + ) + guard self.claimTerminalMusicPlaybackOccurrence(targetOccurrence) else { return } + terminalOccurrence = targetOccurrence + } + guard let refreshedIntent = self.acceptedTrackEndContinuationIntent( + originalIntent: intent, + occurrence: terminalOccurrence + ) else { return } + continuationIntent = refreshedIntent + endedNavigationContext = self.playbackNavigationContext + endedEntryID = self.queueEntryIDOwningCurrentPlayback + } + if let observedVideoId = self.normalizedPlaybackVideoId(observedVideoId) { let currentQueueVideoId = self.queue[safe: self.currentIndex]?.videoId let expectedCurrentVideoId = currentQueueVideoId ?? self.currentTrack?.videoId ?? self.pendingPlayVideoId @@ -590,19 +1068,78 @@ extension PlayerService { self.shouldSuppressAutoplayAfterQueueEnd = true self.markPlaybackEnded() self.logger.info("Reached end of native queue; not yielding to YouTube autoplay") - await self.pause(intent: intent) + await self.pause(intent: continuationIntent) return } self.shouldSuppressAutoplayAfterQueueEnd = false if self.repeatMode == .one { self.logger.info("Track ended with repeat one; replaying current queue song") - await self.replayCurrentQueueSongForRepeatOneAfterTrackEnd(intent: intent) + await self.replayCurrentQueueSongForRepeatOneAfterTrackEnd( + intent: continuationIntent, + startsPaused: startsPaused + ) + 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 allowsNativeQueueHandoff, + 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(intent: intent) + guard shouldContinue(), + let refreshedIntent = self.acceptedTrackEndContinuationIntent( + originalIntent: intent, + occurrence: terminalOccurrence + ) + else { return } + continuationIntent = refreshedIntent + let didAdvance = await self.performNextNavigation( + intent: continuationIntent, + startsPaused: startsPaused + ) + guard shouldContinue(), + let postNavigationIntent = self.acceptedTrackEndContinuationIntent( + originalIntent: intent, + occurrence: terminalOccurrence + ) + else { return } + continuationIntent = postNavigationIntent + if !didAdvance { + guard self.playbackNavigationContext == endedNavigationContext else { return } + if await self.advanceToMaterializedNextQueueSongIfAvailable( + after: endedEntryID, + intent: continuationIntent, + startsPaused: startsPaused + ) { + return + } + guard shouldContinue(), + self.acceptedTrackEndContinuationIntent( + originalIntent: intent, + occurrence: terminalOccurrence + ) != nil + else { 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.updateTrackMetadata( @@ -614,14 +1151,42 @@ extension PlayerService { ) } + // swiftlint:disable:next function_body_length func updateTrackMetadata( title: String, artist: String, thumbnailUrl: String, videoId observedVideoId: String?, - playbackOccurrence: MusicPlaybackOccurrence? + playbackOccurrence: MusicPlaybackOccurrence?, + allowsObservedArtistForMatchingVideo: Bool = false ) { 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 normalizedArtistName = Self.normalizedWebArtistName(artist) // The WebView byline can carry a view-count tail (e.g. "Artist • 1.3M views"); @@ -631,6 +1196,7 @@ extension PlayerService { let artistObj = Artist(id: "unknown", name: normalizedArtistName) let normalizedObservedVideoId = self.normalizedPlaybackVideoId(observedVideoId) let resolvedVideoId = self.resolvedObservedVideoId(observedVideoId) + let videoIdChanged = normalizedObservedVideoId.map { self.currentTrack?.videoId != $0 } ?? false let trackChanged = if let normalizedObservedVideoId { self.currentTrack?.videoId != normalizedObservedVideoId } else { @@ -699,7 +1265,8 @@ extension PlayerService { normalizedObservedVideoId: normalizedObservedVideoId, title: title, artist: artistObj, - thumbnailURL: thumbnailURL + thumbnailURL: thumbnailURL, + allowsObservedArtist: allowsObservedArtistForMatchingVideo ) { return } @@ -735,13 +1302,20 @@ extension PlayerService { if let cachedStatus = self.songLikeStatusManager.status(for: resolvedVideoId) { self.currentTrackLikeStatus = cachedStatus } + + if videoIdChanged { + self.clearWebQueueInjectionState() + // Re-sync the web queue since the playing video changed natively. + self.syncWebQueue() + } } private func updateCurrentTrackForMatchingVideoIfNeeded( normalizedObservedVideoId: String?, title: String, artist: Artist, - thumbnailURL: URL? + thumbnailURL: URL?, + allowsObservedArtist: Bool ) -> Bool { guard let currentTrack = self.currentTrack, let normalizedObservedVideoId, @@ -754,9 +1328,14 @@ extension PlayerService { // identity resolves independently. Preserve navigable artists and ignore // transient empty observations without pinning generated parser placeholders. let resolvedTitle = Self.resolvedObservedTitle(current: currentTrack.title, observed: title) + let shouldApplyObservedArtist = allowsObservedArtist + && !observedArtistIsEmpty + && artist.name != currentTrack.artistsDisplay self.currentTrack = currentTrack.replacingDisplayMetadata( title: resolvedTitle, - artists: hasResolvedArtists || observedArtistIsEmpty ? currentTrack.artists : [artist], + artists: shouldApplyObservedArtist || (!hasResolvedArtists && !observedArtistIsEmpty) + ? [artist] + : currentTrack.artists, thumbnailURL: thumbnailURL ?? currentTrack.thumbnailURL ) return true diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 30f798b71..6de358a91 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -4,11 +4,18 @@ import Foundation import Observation import os +// MARK: - NativeQueueMaintenanceContext + +enum NativeQueueMaintenanceContext { + @TaskLocal static var isApplyingQueueMutation = false +} + // MARK: - PlayerService /// Controls music playback via a hidden WKWebView. @MainActor @Observable +// swiftlint:disable:next type_body_length final class PlayerService: NSObject, PlayerServiceProtocol { @ObservationIgnored var currentWebPlaybackVideoId: @MainActor () -> String? = { SingletonPlayerWebView.shared.currentVideoId @@ -164,13 +171,21 @@ final class PlayerService: NSObject, PlayerServiceProtocol { var isShowingAd = false { didSet { if self.isShowingAd != oldValue { - self.adPlaybackGeneration &+= 1 + self.adPlaybackStateGeneration &+= 1 + } + if !self.isShowingAd { + self.lastAdPlaybackProgress = nil + self.lastAdPlaybackProgressAt = nil } } } + /// Only advancing, authoritative ad media refreshes the transition watchdog. + var lastAdPlaybackProgress: TimeInterval? + var lastAdPlaybackProgressAt: ContinuousClock.Instant? + /// Invalidates asynchronous snapshots that span an ad transition. - @ObservationIgnored private(set) var adPlaybackGeneration: UInt64 = 0 + @ObservationIgnored private(set) var adPlaybackStateGeneration = 0 /// Last clock sample known to belong to the requested music content. var lastNonAdContentProgress: TimeInterval = 0 @@ -191,10 +206,15 @@ final class PlayerService: NSObject, PlayerServiceProtocol { self.playbackStateVideoId = self.normalizedPlaybackVideoId(videoId) } - /// Records duration provenance only when both values came from the same bridge sample. - func recordPlaybackStateObservation(videoId: String?, duration: TimeInterval) { + /// Records playback identity provenance without attributing an unrelated duration. + func recordPlaybackStateObservation(videoId: String?) { self.playbackStateObservationSequence &+= 1 self.playbackStateVideoId = self.normalizedPlaybackVideoId(videoId) + } + + /// Records duration provenance only when both values came from the same bridge sample. + func recordPlaybackStateObservation(videoId: String?, duration: TimeInterval) { + self.recordPlaybackStateObservation(videoId: videoId) self.recordDurationObservation(videoId: self.playbackStateVideoId, duration: duration) } @@ -316,6 +336,22 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// Queue occurrence currently represented by active Web media. var activePlaybackQueueEntryID: UUID? + var queuePlaybackContext: QueuePlaybackContext { + QueuePlaybackContext( + entryID: self.currentQueueEntryID, + index: self.currentIndex, + requestGeneration: self.playbackContextGeneration, + navigationGeneration: self.playbackNavigationGeneration + ) + } + + var playbackNavigationContext: PlaybackNavigationContext { + PlaybackNavigationContext( + requestGeneration: self.playbackContextGeneration, + navigationGeneration: self.playbackNavigationGeneration + ) + } + /// Whether the mini player should be shown (user needs to interact to start playback). var showMiniPlayer: Bool = false @@ -337,6 +373,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 @@ -357,6 +408,21 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// Whether a restored load should automatically resume after seeking to the saved position. var shouldAutoResumeAfterRestoredLoad: Bool = false + /// Whether the active restore clock belongs to an explicit source re-anchor during native handoff. + var isRestoringExplicitTransportSeek = false + + /// Whether an explicit transport restore must route an eventual authoritative end position through track-ended handling. + var shouldFinishRestoredSeekAtEnd = 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 @@ -417,6 +483,10 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// Continuation token for loading more songs in infinite mix/radio. var mixContinuationToken: String? var mixContinuationRequiresAuth = false + var playbackContextGeneration = 0 + var pendingPlaybackSelectionGeneration = 0 + var playbackNavigationGeneration = 0 + var queueMutationGeneration = 0 var musicPlaybackIntentGeneration: UInt64 = 0 var musicPlaybackReservationGeneration: UInt64 = 0 var musicPlaybackIntentIssuedAtMilliseconds: Double = 0 @@ -449,6 +519,10 @@ final class PlayerService: NSObject, PlayerServiceProtocol { var activeMixContinuationRequestID: UUID? var mixContinuationWaiters: [CheckedContinuation] = [] + /// Callers waiting for the current mix continuation request to release its single-flight slot. + @ObservationIgnored var mixContinuationFetchWaiters: [CheckedContinuation] = [] + @ObservationIgnored var mixContinuationCompletionGeneration: UInt64 = 0 + /// Smart Shuffle: videoIds suggested this session, for dedup across fills. var smartShuffleSeenSuggestionIds: Set = [] @@ -654,6 +728,22 @@ 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 queueStructureChanged, + !NativeQueueMaintenanceContext.isApplyingQueueMutation, + !self.isApplyingQueueEnrichmentResult + { + self.clearNativeQueueMaintenance() + } + let pendingGeneration = self.pendingNativeQueueAdvance?.generation self.queueStorage = entries if let activePlaybackQueueEntryID, !entries.contains(where: { $0.id == activePlaybackQueueEntryID }) @@ -661,13 +751,34 @@ final class PlayerService: NSObject, PlayerServiceProtocol { self.activePlaybackQueueEntryID = nil } 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 @@ -680,6 +791,10 @@ final class PlayerService: NSObject, PlayerServiceProtocol { self.forwardSkipIndexStack.popLast() } + func peekForwardSkipIndex() -> Int? { + self.forwardSkipIndexStack.last + } + /// Loads mock player state from environment variables for UI testing. private func loadMockStateIfNeeded() { guard UITestConfig.isUITestMode else { return } @@ -765,6 +880,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 6b98c99aa..046a0cfb5 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? @@ -13,7 +14,6 @@ enum PlaylistPlaybackActions { /// Plays a playlist immediately, replacing the current queue. @discardableResult - @MainActor static func playPlaylist( _ playlist: Playlist, client: any YTMusicClientProtocol, @@ -45,6 +45,10 @@ enum PlaylistPlaybackActions { } catch { DiagnosticsLogger.ui.debug("Falling back to browse playlist tracks: \(error.localizedDescription)") } + guard playerService.acceptsMusicPlaybackIntent(intent) else { + DiagnosticsLogger.ui.info("Discarding stale radio playlist fallback after newer playback intent") + return + } } else { let playableSongs = self.playableSongsWithPlaylistArtwork(songs, playlist: playlist) guard !playableSongs.isEmpty else { return } diff --git a/Sources/Kaset/Services/Player/WebPlaybackAudioOutput.swift b/Sources/Kaset/Services/Player/WebPlaybackAudioOutput.swift new file mode 100644 index 000000000..b1e290b82 --- /dev/null +++ b/Sources/Kaset/Services/Player/WebPlaybackAudioOutput.swift @@ -0,0 +1,119 @@ +/// Keeps WebKit's audio output open across short music playback transitions. +enum WebPlaybackAudioOutput { + nonisolated static let script = """ + (() => { + if (window.__kasetAudioOutput) return; + let output = null; + let releaseTimer = null; + + function clearReleaseTimer() { + if (releaseTimer !== null) clearTimeout(releaseTimer); + releaseTimer = null; + } + + function stop() { + clearReleaseTimer(); + const previous = output; + output = null; + if (!previous) return; + try { previous.source.stop(); } catch (_) {} + try { previous.source.disconnect(); } catch (_) {} + try { previous.gain.disconnect(); } catch (_) {} + try { previous.context.close().catch(() => {}); } catch (_) {} + } + + function start() { + const video = document.querySelector('video'); + if (window.__kasetPlaybackSuppressed || window.__kasetBlockAutoplay + || (video && video.webkitCurrentPlaybackTargetIsWireless)) { + stop(); + return; + } + clearReleaseTimer(); + try { + if (!output) { + const context = new AudioContext(); + output = { context, source: null, gain: null }; + const source = context.createOscillator(); + output.source = source; + const gain = context.createGain(); + output.gain = gain; + // No media is routed through this graph. Zero gain keeps the + // output active without altering DRM playback or its volume. + gain.gain.value = 0; + source.connect(gain); + gain.connect(context.destination); + source.start(); + } + const pending = output; + pending.context.resume().catch(() => { + if (output === pending) stop(); + }); + } catch (_) { + stop(); + } + } + + function releaseAfterTransition() { + if (!output || releaseTimer !== null) return; + // A failed load or the end of the queue must not leave silent output + // running indefinitely. Normal transitions finish well within this. + releaseTimer = setTimeout(stop, 5000); + } + + function prepare() { + start(); + releaseAfterTransition(); + } + + function observe(event, handler) { + document.addEventListener(event, event => { + const video = event.target; + if (video && video.tagName === 'VIDEO' + && video === document.querySelector('video')) handler(video); + }, true); + } + + observe('play', prepare); + observe('playing', start); + observe('loadstart', () => { + if (window.__kasetAutoplayPending) prepare(); + }); + observe('pause', video => { + if (!window.__kasetPlaybackSuppressed && !window.__kasetBlockAutoplay + && (video.ended || window.__kasetAutoplayPending)) { + releaseAfterTransition(); + } else { + stop(); + } + }); + observe('ended', releaseAfterTransition); + observe('emptied', releaseAfterTransition); + observe('error', stop); + observe('webkitcurrentplaybacktargetiswirelesschanged', video => { + if (video.webkitCurrentPlaybackTargetIsWireless) stop(); + else if (!video.paused) start(); + }); + let currentVideo = document.querySelector('video'); + const mediaObserver = new MutationObserver(() => { + const video = document.querySelector('video'); + if (video === currentVideo) return; + currentVideo = video; + // Detached media no longer sends events through this document. + // Bound the wait for a replacement that has not started playing. + if (video && !video.paused && !video.ended && video.readyState >= 3) start(); + else releaseAfterTransition(); + }); + mediaObserver.observe(document, { childList: true, subtree: true }); + window.addEventListener('pagehide', () => { + mediaObserver.disconnect(); + stop(); + }); + window.__kasetAudioOutput = { prepare, stop }; + if (window.__kasetAutoplayPending) prepare(); + })(); + """ + + nonisolated static let stopScript = "window.__kasetAudioOutput?.stop();" + nonisolated static let prepareScript = "window.__kasetAudioOutput?.prepare();" +} diff --git a/Sources/Kaset/Services/Player/WebPlaybackDocumentGeneration.swift b/Sources/Kaset/Services/Player/WebPlaybackDocumentGeneration.swift index 6b821309e..6af5995d1 100644 --- a/Sources/Kaset/Services/Player/WebPlaybackDocumentGeneration.swift +++ b/Sources/Kaset/Services/Player/WebPlaybackDocumentGeneration.swift @@ -58,6 +58,7 @@ struct WebPlaybackDocumentGeneration: Equatable { static let mediaSuppressionScript = """ (function() { window.__kasetPlaybackSuppressed = true; + \(WebPlaybackAudioOutput.stopScript) if (!window.__kasetPlaybackSuppressionInstalled) { window.__kasetPlaybackSuppressionInstalled = true; document.addEventListener('play', function(event) { diff --git a/Sources/Kaset/Services/Player/WebPlaybackNavigationMap.swift b/Sources/Kaset/Services/Player/WebPlaybackNavigationMap.swift new file mode 100644 index 000000000..149052494 --- /dev/null +++ b/Sources/Kaset/Services/Player/WebPlaybackNavigationMap.swift @@ -0,0 +1,65 @@ +/// Associates playback state with a navigation only while that object exists. +/// +/// WebKit does not always deliver a terminal callback after `stopLoading()`. +/// An `ObjectIdentifier` alone can then outlive its navigation and alias a later +/// load. Weak keys preserve late-callback ownership without retaining cancelled +/// navigations indefinitely. +struct WebPlaybackNavigationMap: Sequence { + typealias Element = (key: Navigation, value: Value) + + private struct Entry { + weak var navigation: Navigation? + let value: Value + } + + private var entries: [ObjectIdentifier: Entry] = [:] + + subscript(navigation: Navigation) -> Value? { + get { + guard let entry = self.entries[ObjectIdentifier(navigation)], + entry.navigation === navigation + else { return nil } + return entry.value + } + set { + self.entries = self.entries.filter { $0.value.navigation != nil } + self.entries[ObjectIdentifier(navigation)] = newValue.map { + Entry(navigation: navigation, value: $0) + } + } + } + + var values: [Value] { + self.map(\.value) + } + + var count: Int { + self.values.count + } + + @discardableResult + mutating func removeValue(forKey navigation: Navigation) -> Value? { + guard let entry = self.entries.removeValue(forKey: ObjectIdentifier(navigation)), + entry.navigation === navigation + else { return nil } + return entry.value + } + + mutating func removeAll() { + self.entries.removeAll() + } + + func filter(_ isIncluded: (Element) -> Bool) -> Self { + var result = Self() + for entry in self where isIncluded(entry) { + result[entry.key] = entry.value + } + return result + } + + func makeIterator() -> IndexingIterator<[Element]> { + self.entries.values.compactMap { entry in + entry.navigation.map { (key: $0, value: entry.value) } + }.makeIterator() + } +} diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index eaffc1d8a..83d492404 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -22,6 +22,15 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length let accountID: String } + nonisolated static func shouldMountPersistentPlayer( + isLoggedIn: Bool, + pendingVideoId: String?, + isPendingRestoredLoadDeferred: Bool, + showVideo: Bool + ) -> Bool { + !showVideo && (isLoggedIn || (!isPendingRestoredLoadDeferred && pendingVideoId != nil)) + } + @Environment(AuthService.self) private var authService @Environment(PlayerService.self) private var playerService @Environment(YouTubePlayerService.self) private var youtubePlayerService @@ -217,14 +226,18 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length DiagnosticsLogger.app.info("MainWindow: UI appeared") } - // Persistent WebView - present once a video is ready to load. + // 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, - !self.playerService.isPendingRestoredLoadDeferred, - !self.playerService.showVideo - { - PersistentPlayerView(videoId: videoId, isExpanded: false) + // Keep authenticated Home available while restoration defers watch loading. + // Let the video window own the visible WebView. + if Self.shouldMountPersistentPlayer( + isLoggedIn: self.authService.state.isLoggedIn, + pendingVideoId: self.playerService.pendingPlayVideoId, + isPendingRestoredLoadDeferred: self.playerService.isPendingRestoredLoadDeferred, + showVideo: self.playerService.showVideo + ) { + 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 e0ea77e62..f42e221f4 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+Coordinator.swift b/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift index 7ed7da593..eb4ba2156 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift @@ -1,7 +1,71 @@ +// swiftlint:disable file_length import Foundation import os import WebKit +// MARK: - PlaybackBridgeEventQueue + +@MainActor +final class PlaybackBridgeEventQueue { + private typealias Operation = @MainActor () async -> Void + + private var activeDocumentGeneration: UInt64? + private var pendingOperations: [Operation] = [] + private var workerTask: Task? + private var workerID: UInt64 = 0 + + func enqueue( + documentGeneration: UInt64, + operation: @escaping @MainActor () async -> Void + ) { + if self.activeDocumentGeneration != documentGeneration { + self.cancelAll() + self.activeDocumentGeneration = documentGeneration + } + + self.pendingOperations.append(operation) + guard self.workerTask == nil else { return } + self.startWorker() + } + + func cancelAll() { + self.workerID &+= 1 + self.workerTask?.cancel() + self.workerTask = nil + self.pendingOperations.removeAll() + self.activeDocumentGeneration = nil + } + + func waitUntilIdle() async { + await self.workerTask?.value + } + + private func startWorker() { + self.workerID &+= 1 + let workerID = self.workerID + self.workerTask = Task { @MainActor [weak self] in + await self?.drainOperations(workerID: workerID) + } + } + + private func drainOperations(workerID: UInt64) async { + defer { + if self.workerID == workerID { + self.pendingOperations.removeAll() + self.workerTask = nil + } + } + + while self.workerID == workerID, + !Task.isCancelled, + !self.pendingOperations.isEmpty + { + let operation = self.pendingOperations.removeFirst() + await operation() + } + } +} + // MARK: - SingletonPlayerWebView.Coordinator extension SingletonPlayerWebView { @@ -24,6 +88,10 @@ extension SingletonPlayerWebView { return decoded } + nonisolated static func playbackBridgeInt(from value: Any?) -> Int? { + WebPlaybackDocumentGeneration.decode(value).flatMap { Int(exactly: $0) } + } + /// Sanitizes the thumbnail URL reported by the playback bridge. /// /// `img.src` resolves relative to the document, so a player-bar image that has not @@ -47,7 +115,17 @@ extension SingletonPlayerWebView { // MARK: - Coordinator final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + private static let queueNavigationObservationGrace: Duration = .seconds(1) + let playerService: PlayerService + private var lastAcceptedObserverEpoch: Double? + private var lastAcceptedMediaGeneration: Int? + private var lastAcceptedQueueEntryID: UUID? + private var hasAcceptedQueueEntryBaseline = false + private var lastAcceptedObservedVideoId: String? + private var lastHandledEndedObserverEpoch: Double? + private var lastHandledEndedMediaGeneration: Int? + private let playbackBridgeEvents = PlaybackBridgeEventQueue() init(playerService: PlayerService) { self.playerService = playerService @@ -84,63 +162,64 @@ extension SingletonPlayerWebView { : singleton.documentGeneration.accepts(generation: messageDocumentGeneration) guard acceptsGeneration else { return } - let observedVideoId = Self.observedVideoId(from: body) - let playbackOccurrence = Self.musicPlaybackOccurrence( - from: body, - documentGeneration: messageDocumentGeneration + self.dispatchAcceptedBridgeMessage( + body: body, + type: type, + documentGeneration: messageDocumentGeneration, + commandIssuedAtMilliseconds: commandIssuedAtMilliseconds ) + } + + private func dispatchAcceptedBridgeMessage( + body: [String: Any], + type: String, + documentGeneration: UInt64, + commandIssuedAtMilliseconds: Double? + ) { + let observedVideoId = Self.observedVideoId(from: body) let musicPlaybackIntent = self.playerService.currentMusicPlaybackIntent let eventIssuedAtMilliseconds = SingletonPlayerWebView.finitePlaybackBridgeDouble( from: body["eventIssuedAtMilliseconds"] ) switch type { - case "TRACK_ENDED": - let endedDuringAd = body["isAd"] as? Bool ?? false - Task { @MainActor in - guard SingletonPlayerWebView.shared.documentGeneration.accepts( - generation: messageDocumentGeneration - ), self.playerService.acceptsMusicTerminalBridgeEvent( - intent: musicPlaybackIntent, - eventIssuedAtMilliseconds: eventIssuedAtMilliseconds - ), - !endedDuringAd - else { return } - await self.playerService.handleTrackEnded( - observedVideoId: observedVideoId, - playbackOccurrence: playbackOccurrence, - intent: musicPlaybackIntent - ) - } + case "TRACK_ENDED", "TRACK_ENDED_IDENTITY_DEADLINE": + self.enqueueTrackEnded( + body: body, + documentGeneration: documentGeneration, + musicPlaybackIntent: musicPlaybackIntent, + eventIssuedAtMilliseconds: eventIssuedAtMilliseconds, + identityResolutionTimedOut: type == "TRACK_ENDED_IDENTITY_DEADLINE" + ) case "REMOTE_NEXT", "REMOTE_PREVIOUS": self.handleRemoteCommand( type: type, - documentGeneration: messageDocumentGeneration, + documentGeneration: documentGeneration, commandIssuedAtMilliseconds: commandIssuedAtMilliseconds, musicPlaybackIntent: musicPlaybackIntent ) case "AIRPLAY_STATUS": self.handleAirPlayStatusUpdate( body: body, - documentGeneration: messageDocumentGeneration + documentGeneration: documentGeneration ) case "LYRICS_TIME": self.handleLyricsTimeUpdate( body: body, - documentGeneration: messageDocumentGeneration + documentGeneration: documentGeneration ) case "LYRICS_LINE": self.handleLyricsLineUpdate( body: body, - documentGeneration: messageDocumentGeneration + documentGeneration: documentGeneration ) case "PLAYBACK_AUDIO_QUALITY_STATS": Self.logAudioQualityStats(body: body, observedVideoId: observedVideoId) case "STATE_UPDATE": - self.handleStateUpdate( + self.enqueueStateUpdate( body: body, observedVideoId: observedVideoId, - documentGeneration: messageDocumentGeneration, + documentGeneration: documentGeneration, musicPlaybackIntent: musicPlaybackIntent, eventIssuedAtMilliseconds: eventIssuedAtMilliseconds ) @@ -149,6 +228,146 @@ extension SingletonPlayerWebView { } } + private func enqueueTrackEnded( + body: [String: Any], + documentGeneration: UInt64, + musicPlaybackIntent: MusicPlaybackIntent, + eventIssuedAtMilliseconds: Double?, + identityResolutionTimedOut: Bool, + beforeHandling: (@MainActor () async -> Void)? = nil + ) { + let observedVideoId = Self.observedVideoId(from: body) + let playbackOccurrence = Self.musicPlaybackOccurrence( + from: body, + documentGeneration: documentGeneration + ) + let endedDuringAd = body["isAd"] as? Bool ?? false + let endedIdentityUncertain = body["mediaIdentityUncertain"] as? Bool ?? false + let observerEpoch = SingletonPlayerWebView.finitePlaybackBridgeDouble( + from: body["observerEpoch"] + ) ?? 0 + let mediaGeneration = SingletonPlayerWebView.playbackBridgeInt(from: body["mediaGeneration"]) ?? 0 + self.enqueuePlaybackBridgeMessage(generation: documentGeneration) { coordinator in + if let beforeHandling { + await beforeHandling() + } + guard !Task.isCancelled, + coordinator.isCurrentPlaybackBridgeDocument(documentGeneration), + coordinator.playerService.acceptsMusicTerminalBridgeEvent( + intent: musicPlaybackIntent, + eventIssuedAtMilliseconds: eventIssuedAtMilliseconds + ), + !endedDuringAd, + // Identity retries retain this occurrence. Neither deduplication layer + // may claim it before identity resolves or the observer's deadline arrives. + !endedIdentityUncertain || identityResolutionTimedOut, + !identityResolutionTimedOut || playbackOccurrence != nil, + coordinator.consumeTrackEndedOccurrence( + observerEpoch: observerEpoch, + mediaGeneration: mediaGeneration + ) + else { return } + await coordinator.playerService.handleTrackEnded( + observedVideoId: observedVideoId, + playbackOccurrence: playbackOccurrence, + intent: musicPlaybackIntent, + identityResolutionTimedOut: identityResolutionTimedOut, + shouldContinue: { + !Task.isCancelled + && coordinator.isCurrentPlaybackBridgeDocument(documentGeneration) + } + ) + } + } + + private func enqueuePlaybackBridgeMessage( + generation: UInt64, + operation: @escaping @MainActor (Coordinator) async -> Void + ) { + self.playbackBridgeEvents.enqueue(documentGeneration: generation) { [weak self] in + guard let self, + !Task.isCancelled, + self.isCurrentPlaybackBridgeDocument(generation) + else { return } + await operation(self) + } + } + + func enqueueWebQueueInjectionResult( + videoId: String, + attemptGeneration: Int, + success: Bool, + reason: String?, + documentGeneration: UInt64 + ) { + guard self.isCurrentPlaybackBridgeDocument(documentGeneration) else { return } + self.enqueuePlaybackBridgeMessage(generation: documentGeneration) { coordinator in + coordinator.playerService.handleWebQueueInjectionResult( + videoId: videoId, + attemptGeneration: attemptGeneration, + success: success, + reason: reason + ) + } + } + + func cancelPlaybackBridgeTasks() { + self.playbackBridgeEvents.cancelAll() + } + + func enqueueTrackEndedForTesting( + body: [String: Any], + documentGeneration: UInt64, + beforeHandling: @escaping @MainActor () async -> Void = {} + ) { + self.enqueueTrackEnded( + body: body, + documentGeneration: documentGeneration, + musicPlaybackIntent: self.playerService.currentMusicPlaybackIntent, + eventIssuedAtMilliseconds: self.playerService.musicPlaybackIntentIssuedAtMilliseconds + 1, + identityResolutionTimedOut: body["type"] as? String == "TRACK_ENDED_IDENTITY_DEADLINE", + beforeHandling: beforeHandling + ) + } + + func enqueueStateUpdateForTesting( + body: [String: Any], + observedVideoId: String?, + documentGeneration: UInt64 + ) { + self.enqueueStateUpdate( + body: body, + observedVideoId: observedVideoId, + documentGeneration: documentGeneration, + musicPlaybackIntent: self.playerService.currentMusicPlaybackIntent, + eventIssuedAtMilliseconds: self.playerService.musicPlaybackIntentIssuedAtMilliseconds + 1 + ) + } + + func awaitPlaybackBridgeDrainForTesting() async { + await self.playbackBridgeEvents.waitUntilIdle() + } + + private func isCurrentPlaybackBridgeDocument(_ generation: UInt64) -> Bool { + SingletonPlayerWebView.shared.coordinator === self + && SingletonPlayerWebView.shared.documentGeneration.accepts(generation: generation) + } + + private func consumeTrackEndedOccurrence( + observerEpoch: Double, + mediaGeneration: Int + ) -> Bool { + guard WebPlaybackIdentityTransition.shouldAcceptEndedOccurrence( + observerEpoch: observerEpoch, + lastHandledObserverEpoch: self.lastHandledEndedObserverEpoch, + mediaGeneration: mediaGeneration, + lastHandledMediaGeneration: self.lastHandledEndedMediaGeneration + ) else { return false } + self.lastHandledEndedObserverEpoch = observerEpoch + self.lastHandledEndedMediaGeneration = mediaGeneration + return true + } + private func handleRemoteCommand( type: String, documentGeneration: UInt64, @@ -507,7 +726,12 @@ extension SingletonPlayerWebView { } func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - SingletonPlayerWebView.shared.commitDocumentNavigation(navigation, webView: webView) + let singleton = SingletonPlayerWebView.shared + let previousGeneration = singleton.documentGeneration.currentGeneration + singleton.commitDocumentNavigation(navigation, webView: webView) + if singleton.documentGeneration.currentGeneration != previousGeneration { + self.cancelPlaybackBridgeTasks() + } } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -531,6 +755,7 @@ extension SingletonPlayerWebView { host: "music.youtube.com" ) else { return } SingletonPlayerWebView.shared.syncAutoplayIntent(on: webView) + self.playerService.syncWebQueue() DiagnosticsLogger.player.info( "Singleton WebView finished loading: \(webView.url?.absoluteString ?? "nil")" ) @@ -606,123 +831,338 @@ extension SingletonPlayerWebView { } } -private extension SingletonPlayerWebView.Coordinator { - private func handleStateUpdate( +extension SingletonPlayerWebView.Coordinator { + /// Test-facing bridge hook for exercising observation ordering without a live WKWebView document. + func handleStateUpdate( + body: [String: Any], + observedVideoId: String?, + mediaVideoId playbackVideoId: String?, + observationReceivedAt: ContinuousClock.Instant, + messageGeneration _: Int + ) async { + await self.processStateUpdate( + body: body, + observedVideoId: observedVideoId, + mediaVideoId: playbackVideoId, + observationReceivedAt: observationReceivedAt, + documentGeneration: nil, + musicPlaybackIntent: nil, + eventIssuedAtMilliseconds: nil, + validatesBridgeContext: false + ) + } + + private func enqueueStateUpdate( body: [String: Any], observedVideoId: String?, documentGeneration: UInt64, musicPlaybackIntent: MusicPlaybackIntent, eventIssuedAtMilliseconds: Double? ) { + let playbackVideoId = Self.playbackVideoId(from: body) + let observationReceivedAt = ContinuousClock.now + self.enqueuePlaybackBridgeMessage(generation: documentGeneration) { coordinator in + await coordinator.processStateUpdate( + body: body, + observedVideoId: observedVideoId, + mediaVideoId: playbackVideoId, + observationReceivedAt: observationReceivedAt, + documentGeneration: documentGeneration, + musicPlaybackIntent: musicPlaybackIntent, + eventIssuedAtMilliseconds: eventIssuedAtMilliseconds, + validatesBridgeContext: true, + shouldContinue: { + !Task.isCancelled + && coordinator.isCurrentPlaybackBridgeDocument(documentGeneration) + } + ) + } + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length function_parameter_count + private func processStateUpdate( + body: [String: Any], + observedVideoId: String?, + mediaVideoId playbackVideoId: String?, + observationReceivedAt: ContinuousClock.Instant, + documentGeneration: UInt64?, + musicPlaybackIntent: MusicPlaybackIntent?, + eventIssuedAtMilliseconds: Double?, + validatesBridgeContext: Bool, + shouldContinue: @escaping @MainActor () -> Bool = { true } + ) async { + guard shouldContinue() else { return } + let isPlaying = body["isPlaying"] as? Bool ?? false let progress = SingletonPlayerWebView.finitePlaybackBridgeDouble(from: body["progress"]) ?? 0 let duration = SingletonPlayerWebView.finitePlaybackBridgeDouble(from: body["duration"]) ?? 0 let isAd = body["isAd"] as? Bool ?? false - let hasReadyMedia = body["hasReadyMedia"] as? Bool ?? false + let hasReadyMedia = body["hasReadyMedia"] as? Bool ?? true let title = body["title"] as? String ?? "" let artist = body["artist"] as? String ?? "" let thumbnailUrl = SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: body["thumbnailUrl"]) let trackChanged = body["trackChanged"] as? Bool ?? false let likeStatus = Self.likeStatus(from: body["likeStatus"] as? String) let hasVideo = body["hasVideo"] as? Bool ?? false - let playbackVideoId = Self.playbackVideoId(from: body) - let playbackOccurrence = Self.musicPlaybackOccurrence( - from: body, - documentGeneration: documentGeneration - ) + let mediaGeneration = SingletonPlayerWebView.playbackBridgeInt(from: body["mediaGeneration"]) ?? 0 + let observerEpoch = SingletonPlayerWebView.finitePlaybackBridgeDouble(from: body["observerEpoch"]) ?? 0 + let playbackOccurrence = documentGeneration.flatMap { + Self.musicPlaybackOccurrence(from: body, documentGeneration: $0) + } - Task { @MainActor in - guard SingletonPlayerWebView.shared.documentGeneration.accepts( - generation: documentGeneration - ), self.playerService.acceptsMusicBridgeEvent( - intent: musicPlaybackIntent, - eventIssuedAtMilliseconds: eventIssuedAtMilliseconds - ), self.playerService.currentTrack != nil || self.playerService.pendingPlayVideoId != nil + if validatesBridgeContext { + guard let documentGeneration, + let musicPlaybackIntent, + SingletonPlayerWebView.shared.documentGeneration.accepts( + generation: documentGeneration + ), + self.playerService.acceptsMusicBridgeEvent( + intent: musicPlaybackIntent, + eventIssuedAtMilliseconds: eventIssuedAtMilliseconds + ), + self.playerService.currentTrack != nil || self.playerService.pendingPlayVideoId != nil else { return } - if let playbackOccurrence, - !self.playerService.acceptsWebMusicPlaybackOccurrence(playbackOccurrence) - { - return - } - let currentPlaybackOccurrence = self.playerService.currentMusicPlaybackOccurrence - let terminalPlaybackOccurrence = currentPlaybackOccurrence ?? playbackOccurrence - defer { - if let playbackOccurrence { - self.playerService.bindWebMusicPlaybackOccurrence( - documentGeneration: documentGeneration, - mediaGeneration: playbackOccurrence.mediaGeneration, - nativeGeneration: playbackOccurrence.nativeGeneration, - videoId: playbackVideoId - ) - } + } + if let playbackOccurrence, + !self.playerService.acceptsWebMusicPlaybackOccurrence(playbackOccurrence) + { + return + } + let terminalPlaybackOccurrence = self.playerService.currentMusicPlaybackOccurrence + ?? playbackOccurrence + defer { + if shouldContinue(), let playbackOccurrence, let documentGeneration { + self.playerService.bindWebMusicPlaybackOccurrence( + documentGeneration: documentGeneration, + mediaGeneration: playbackOccurrence.mediaGeneration, + nativeGeneration: playbackOccurrence.nativeGeneration, + videoId: playbackVideoId + ) } - let isAuthoritativeContent = SingletonPlayerWebView.isAuthoritativePlaybackSample( + } + + let isAuthoritativeContent = SingletonPlayerWebView.isAuthoritativePlaybackSample( + hasReadyMedia: hasReadyMedia, + isShowingAd: isAd + ) + guard isAuthoritativeContent else { + if WebPlaybackIdentityTransition.shouldAcceptAdvertisementState( hasReadyMedia: hasReadyMedia, - isShowingAd: isAd - ) - self.playerService.updateAdPlaybackState( isShowingAd: isAd, - observedProgress: Double(progress), observedVideoId: playbackVideoId, - isAuthoritativeContent: isAuthoritativeContent - ) - if isAuthoritativeContent { - self.playerService.updatePlaybackState( - isPlaying: isPlaying, - progress: Double(progress), - duration: Double(duration), - observedVideoId: playbackVideoId + pendingSourceVideoId: self.playerService.pendingNativeQueueAdvance?.sourceVideoId, + order: WebPlaybackIdentityTransition.ObservationOrder( + observerEpoch: observerEpoch, + lastAcceptedObserverEpoch: self.lastAcceptedObserverEpoch, + mediaGeneration: mediaGeneration, + lastAcceptedMediaGeneration: self.lastAcceptedMediaGeneration + ) + ) { + self.playerService.updateAdPlaybackState( + isShowingAd: true, + observedProgress: progress, + observedVideoId: playbackVideoId, + isAuthoritativeContent: false ) - } else if hasReadyMedia, isAd { self.playerService.updatePlaybackTransportState(isPlaying: isPlaying) if !isPlaying, self.playerService.shouldResumeReadyAdDuringRestoration { SingletonPlayerWebView.shared.resumeReadyAdvertisementIfPresent() } } + return + } - // Ad creatives can expose their own metadata and video identity. - // Only content may update the track, queue, or track-specific controls. - guard !isAd else { return } + guard WebPlaybackIdentityTransition.isObservationOrdered( + observerEpoch: observerEpoch, + lastAcceptedObserverEpoch: self.lastAcceptedObserverEpoch, + mediaGeneration: mediaGeneration, + lastAcceptedMediaGeneration: self.lastAcceptedMediaGeneration + ) else { return } + self.playerService.clearAdPlaybackBoundary() + if let pendingAdvance = self.playerService.pendingNativeQueueAdvance, + let playbackVideoId, + playbackVideoId != pendingAdvance.sourceVideoId + { + guard WebPlaybackIdentityTransition.shouldAcceptMediaState( + queueEntryChanged: true, + observerEpoch: observerEpoch, + lastAcceptedObserverEpoch: self.lastAcceptedObserverEpoch, + mediaGeneration: mediaGeneration, + lastAcceptedMediaGeneration: self.lastAcceptedMediaGeneration + ) else { return } + } + guard shouldContinue() else { return } + let shouldContinuePendingAdvance = await self.playerService + .reconcilePendingNativeQueueAdvanceObservation( + videoId: playbackVideoId, + shouldContinue: shouldContinue + ) + if validatesBridgeContext { + guard shouldContinue(), + let documentGeneration, + let musicPlaybackIntent, + SingletonPlayerWebView.shared.documentGeneration.accepts( + generation: documentGeneration + ), + self.playerService.acceptsMusicBridgeEvent( + intent: musicPlaybackIntent, + eventIssuedAtMilliseconds: eventIssuedAtMilliseconds + ), + shouldContinuePendingAdvance + else { return } + } else if !shouldContinuePendingAdvance { + return + } - // Update video availability - self.playerService.updateVideoAvailability(hasVideo: hasVideo) + if WebPlaybackIdentityTransition.shouldHandleDeferredIdentitylessObservation( + isDeferred: self.playerService.isPendingRestoredLoadDeferred, + observedVideoId: observedVideoId, + playbackVideoId: playbackVideoId + ) { + self.playerService.updateAdPlaybackState( + isShowingAd: false, + observedProgress: progress, + observedVideoId: playbackVideoId, + isAuthoritativeContent: true + ) + self.playerService.updatePlaybackState( + isPlaying: isPlaying, + progress: self.playerService.progress, + duration: self.playerService.duration + ) + return + } - // Update like status only when track changes (initial state) - if trackChanged { - self.playerService.updateLikeStatus(likeStatus) - } + let isWithinQueueNavigationObservationGrace = if let navigationStartedAt = self.playerService + .protectedQueueNavigationStartedAt + { + observationReceivedAt - navigationStartedAt < Self.queueNavigationObservationGrace + } else { + false + } - let hasObservedMetadata = observedVideoId != nil || !title.isEmpty - // Repeat-one still needs drift recovery, but the normal same-song polling path - // should not rewrite `currentTrack` on every observer tick. - let repeatOneNeedsReconcile = self.playerService.repeatMode == .one - && hasObservedMetadata - && (trackChanged - || (observedVideoId != nil && observedVideoId != self.playerService.currentTrack?.videoId) - || (observedVideoId == nil && !title.isEmpty && title != self.playerService.currentTrack?.title)) - let shouldReconcileMetadata = hasObservedMetadata && (trackChanged || repeatOneNeedsReconcile) + if !self.hasAcceptedQueueEntryBaseline, + self.playerService.isKasetInitiatedPlayback, + playbackVideoId != nil, + !self.playerService.observedPlaybackMatchesCurrentTarget(videoId: playbackVideoId), + isWithinQueueNavigationObservationGrace + { + return + } - if shouldReconcileMetadata { - self.playerService.updateTrackMetadata( + let queueEntryIDBeforeReconciliation = self.playerService.currentQueueEntryID + let queueEntryChangedBeforeReconciliation = WebPlaybackIdentityTransition.didQueueEntryChange( + hasBaseline: self.hasAcceptedQueueEntryBaseline, + lastAcceptedQueueEntryID: self.lastAcceptedQueueEntryID, + currentQueueEntryID: queueEntryIDBeforeReconciliation + ) || self.playerService.observedPlaybackWouldChangeQueueEntry(videoId: playbackVideoId) + let shouldAcceptBeforeReconciliation = WebPlaybackIdentityTransition.shouldAcceptMediaState( + queueEntryChanged: queueEntryChangedBeforeReconciliation, + observerEpoch: observerEpoch, + lastAcceptedObserverEpoch: self.lastAcceptedObserverEpoch, + mediaGeneration: mediaGeneration, + lastAcceptedMediaGeneration: self.lastAcceptedMediaGeneration + ) + if !shouldAcceptBeforeReconciliation { + if !isWithinQueueNavigationObservationGrace { + self.playerService.handleRejectedQueueNavigationObservationIfNeeded( + observedVideoId: playbackVideoId, title: title, artist: artist, thumbnailUrl: thumbnailUrl, - videoId: observedVideoId, - playbackOccurrence: terminalPlaybackOccurrence + trackChanged: trackChanged ) + } + return + } - // Close video window on track change, but skip during grace period. - // We only close if the videoId actually changed to prevent closing - // due to spurious metadata (title/artist) glitches during resize. - let videoIdChanged = observedVideoId != nil && observedVideoId != self.playerService.currentTrack?.videoId + let expectedVideoIdBeforeReconciliation = self.playerService.currentTrack?.videoId + ?? self.playerService.pendingPlayVideoId + let shouldApplyPlaybackState = self.playerService.reconcileWebPlaybackMetadata( + title: title, + artist: artist, + thumbnailUrl: thumbnailUrl, + observedVideoId: observedVideoId, + playbackVideoId: playbackVideoId, + bridgeTrackChanged: trackChanged, + playbackOccurrence: terminalPlaybackOccurrence + ) - if self.playerService.showVideo, videoIdChanged, !self.playerService.isVideoGracePeriodActive { - DiagnosticsLogger.player.info( - "trackChanged to videoId '\(observedVideoId ?? "unknown")' while video shown - closing video window" - ) - self.playerService.showVideo = false - } - } + let currentQueueEntryID = self.playerService.currentQueueEntryID + let queueEntryChanged = WebPlaybackIdentityTransition.didQueueEntryChange( + hasBaseline: self.hasAcceptedQueueEntryBaseline, + lastAcceptedQueueEntryID: self.lastAcceptedQueueEntryID, + currentQueueEntryID: currentQueueEntryID + ) + let shouldAcceptMediaState = WebPlaybackIdentityTransition.shouldAcceptMediaState( + queueEntryChanged: queueEntryChanged, + observerEpoch: observerEpoch, + lastAcceptedObserverEpoch: self.lastAcceptedObserverEpoch, + mediaGeneration: mediaGeneration, + lastAcceptedMediaGeneration: self.lastAcceptedMediaGeneration + ) + let mediaMatches = self.playerService.observedPlaybackMatchesCurrentTarget( + videoId: playbackVideoId + ) + guard shouldApplyPlaybackState, mediaMatches, shouldAcceptMediaState else { return } + self.playerService.updateAdPlaybackState( + isShowingAd: false, + observedProgress: progress, + observedVideoId: playbackVideoId, + isAuthoritativeContent: true + ) + if validatesBridgeContext { + SingletonPlayerWebView.shared.confirmRouterNavigationIfNeeded(videoId: playbackVideoId) + } + + let acceptedVideoId = playbackVideoId + let previousAcceptedVideoId = self.lastAcceptedObservedVideoId + let acceptedObservedVideoIdChanged = acceptedVideoId != nil + && acceptedVideoId != previousAcceptedVideoId + let confirmedTrackTransition = WebPlaybackIdentityTransition.isConfirmed( + observedVideoId: acceptedVideoId, + lastAcceptedObservedVideoId: previousAcceptedVideoId, + expectedVideoIdBeforeReconciliation: expectedVideoIdBeforeReconciliation + ) + if let acceptedVideoId { + self.lastAcceptedObservedVideoId = acceptedVideoId } + + self.playerService.updatePlaybackState( + isPlaying: isPlaying, + progress: progress, + duration: Double(duration), + observedVideoId: playbackVideoId + ) + self.lastAcceptedObserverEpoch = observerEpoch + self.lastAcceptedMediaGeneration = mediaGeneration + self.lastAcceptedQueueEntryID = currentQueueEntryID + self.hasAcceptedQueueEntryBaseline = true + + self.playerService.updateVideoAvailability(hasVideo: hasVideo) + let logicalMatchesMedia = observedVideoId != nil && observedVideoId == playbackVideoId + if logicalMatchesMedia, acceptedObservedVideoIdChanged || trackChanged { + self.playerService.updateLikeStatus(likeStatus) + } + + self.closeVideoWindowAfterConfirmedTransitionIfNeeded( + confirmedTrackTransition: confirmedTrackTransition, + observedVideoId: acceptedVideoId + ) + } + + private func closeVideoWindowAfterConfirmedTransitionIfNeeded( + confirmedTrackTransition: Bool, + observedVideoId: String? + ) { + guard self.playerService.showVideo, + confirmedTrackTransition, + !self.playerService.isVideoGracePeriodActive + else { return } + + DiagnosticsLogger.player.info( + "trackChanged to videoId '\(observedVideoId ?? "unknown")' while video shown - closing video window" + ) + self.playerService.showVideo = false } } diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index d3c43d6c4..60c4f0482 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -3,6 +3,228 @@ import os import SwiftUI import WebKit +// MARK: - WebPlaybackIdentityTransition + +enum WebPlaybackIdentityTransition { + struct ObservationOrder { + let observerEpoch: Double + let lastAcceptedObserverEpoch: Double? + let mediaGeneration: Int + let lastAcceptedMediaGeneration: Int? + } + + struct TrackEndedIdentityDeadlinePayload { + let identityDisposition: String? + let mediaIdentityUncertain: Bool? + let videoId: String? + let mediaVideoId: String? + let observerEpoch: Double? + let eventIssuedAtMilliseconds: Double? + let documentGeneration: UInt64? + let nativePlaybackGeneration: UInt64? + let mediaGeneration: UInt64? + let isAd: Bool? + } + + 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 shouldAcceptAdvertisementState( + hasReadyMedia: Bool, + isShowingAd: Bool, + observedVideoId: String?, + pendingSourceVideoId: String?, + order: ObservationOrder + ) -> Bool { + guard hasReadyMedia, + isShowingAd, + self.isObservationOrdered( + observerEpoch: order.observerEpoch, + lastAcceptedObserverEpoch: order.lastAcceptedObserverEpoch, + mediaGeneration: order.mediaGeneration, + lastAcceptedMediaGeneration: order.lastAcceptedMediaGeneration + ) + else { return false } + guard let pendingSourceVideoId, + let observedVideoId, + observedVideoId == pendingSourceVideoId + else { return true } + guard let lastAcceptedObserverEpoch = order.lastAcceptedObserverEpoch else { return false } + if order.observerEpoch > lastAcceptedObserverEpoch { + return true + } + guard order.observerEpoch == lastAcceptedObserverEpoch, + let lastAcceptedMediaGeneration = order.lastAcceptedMediaGeneration + else { return false } + return order.mediaGeneration > lastAcceptedMediaGeneration + } + + static func isValidTrackEndedIdentityDeadlinePayload( + _ payload: TrackEndedIdentityDeadlinePayload + ) -> Bool { + guard payload.identityDisposition == "deadlineFallback", + payload.mediaIdentityUncertain == true, + let videoId = payload.videoId, + videoId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let mediaVideoId = payload.mediaVideoId, + mediaVideoId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let observerEpoch = payload.observerEpoch, + observerEpoch.isFinite, + let eventIssuedAtMilliseconds = payload.eventIssuedAtMilliseconds, + eventIssuedAtMilliseconds.isFinite, + payload.documentGeneration != nil, + payload.nativePlaybackGeneration != nil, + let mediaGeneration = payload.mediaGeneration, + mediaGeneration > 0, + payload.isAd == false + else { return false } + return true + } + + 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: - MusicHomePreloadPolicy + +enum MusicHomePreloadPolicy { + nonisolated static func shouldPreload( + isRunningUnitTests: Bool, + isSuppressedForDeferredRestore: Bool, + hasStartedHomePreload: Bool, + currentVideoId: String? + ) -> Bool { + !isRunningUnitTests + && !isSuppressedForDeferredRestore + && !hasStartedHomePreload + && currentVideoId == nil + } +} + +// MARK: - WebPlaybackTransitionFallbackPolicy + +enum WebPlaybackTransitionFallbackPolicy { + static let advertisementStallGrace: Duration = .seconds(15) + static let advertisementRetryInterval: Duration = .seconds(1) + + nonisolated static func deadline( + now: ContinuousClock.Instant, + initialFallbackDelay: Duration + ) -> ContinuousClock.Instant { + now.advanced(by: initialFallbackDelay + self.advertisementStallGrace) + } + + nonisolated static func retryDelay( + isShowingAd: Bool, + now: ContinuousClock.Instant, + deadline: ContinuousClock.Instant, + lastAdvertisementProgressAt: ContinuousClock.Instant? = nil + ) -> Duration? { + guard isShowingAd else { return nil } + // A healthy ad can outlast the initial grace. Recover after its media + // clock stops advancing, rather than reloading in the middle of the ad. + let progressDeadline = lastAdvertisementProgressAt?.advanced(by: self.advertisementStallGrace) + let effectiveDeadline = max(deadline, progressDeadline ?? deadline) + guard now < effectiveDeadline else { return nil } + return min(self.advertisementRetryInterval, effectiveDeadline - now) + } + + nonisolated static func shouldDefer( + isShowingAd: Bool, + now: ContinuousClock.Instant, + deadline: ContinuousClock.Instant + ) -> Bool { + self.retryDelay( + isShowingAd: isShowingAd, + now: now, + deadline: deadline + ) != nil + } +} + // MARK: - MiniPlayerWebView /// A visible WebView that displays the YouTube Music player. @@ -221,7 +443,122 @@ struct MiniPlayerWebView: NSViewRepresentable { /// - Video mode CSS injection (SingletonPlayerWebView+VideoMode.swift) /// - Observer script (SingletonPlayerWebView+ObserverScript.swift) @MainActor +// swiftlint:disable:next type_body_length final class SingletonPlayerWebView { + private static let routerNavigationFallbackDelay: Duration = .seconds(3) + + private struct PendingRouterNavigation { + let videoId: String + let fallbackURL: URL + let generation: Int + let fallbackDeadline: ContinuousClock.Instant + } + + private final class PlaybackBridgeMultiplexer: NSObject, WKScriptMessageHandler { + private weak var coordinator: Coordinator? + + init(coordinator: Coordinator) { + self.coordinator = coordinator + } + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + let singleton = SingletonPlayerWebView.shared + guard let coordinator = self.coordinator, + singleton.coordinator === coordinator, + message.webView === singleton.webView, + let body = message.body as? [String: Any], + let type = body["type"] as? String, + SingletonPlayerWebView.acceptsBridgeSource( + isMainFrame: message.frameInfo.isMainFrame, + sourceScheme: message.frameInfo.securityOrigin.protocol, + sourceHost: message.frameInfo.securityOrigin.host + ) + else { return } + + guard SingletonPlayerWebView.acceptsBridgeDocumentID( + body["documentID"] as? Int, + expectedDocumentID: singleton.expectedBridgeDocumentID, + messageType: type + ) else { return } + + switch type { + case "QUEUE_INJECTION_RESULT": + guard let documentGeneration = WebPlaybackDocumentGeneration.decode( + body["documentGeneration"] + ) else { return } + self.handleQueueInjectionResult( + body: body, + coordinator: coordinator, + documentGeneration: documentGeneration + ) + return + case "TRACK_ENDED": + // Keep an uncertain occurrence unclaimed so a resolved retry can consume it. + guard body["mediaIdentityUncertain"] as? Bool != true else { return } + case "TRACK_ENDED_IDENTITY_DEADLINE": + guard let expectedDocumentID = singleton.expectedBridgeDocumentID, + body["documentID"] as? Int == expectedDocumentID, + WebPlaybackIdentityTransition.isValidTrackEndedIdentityDeadlinePayload( + .init( + identityDisposition: body["identityDisposition"] as? String, + mediaIdentityUncertain: body["mediaIdentityUncertain"] as? Bool, + videoId: body["videoId"] as? String, + mediaVideoId: body["mediaVideoId"] as? String, + observerEpoch: SingletonPlayerWebView.finitePlaybackBridgeDouble( + from: body["observerEpoch"] + ), + eventIssuedAtMilliseconds: SingletonPlayerWebView.finitePlaybackBridgeDouble( + from: body["eventIssuedAtMilliseconds"] + ), + documentGeneration: WebPlaybackDocumentGeneration.decode( + body["documentGeneration"] + ), + nativePlaybackGeneration: WebPlaybackDocumentGeneration.decode( + body["nativePlaybackGeneration"] + ), + mediaGeneration: WebPlaybackDocumentGeneration.decode( + body["mediaGeneration"] + ), + isAd: body["isAd"] as? Bool + ) + ) + else { return } + case "STATE_UPDATE": + break + default: + break + } + + coordinator.userContentController(userContentController, didReceive: message) + } + + private func handleQueueInjectionResult( + body: [String: Any], + coordinator: Coordinator, + documentGeneration: UInt64 + ) { + guard let videoID = Self.normalizedVideoID(body["videoId"]), + let attemptGeneration = body["attemptGeneration"] as? Int + else { return } + coordinator.enqueueWebQueueInjectionResult( + videoId: videoID, + attemptGeneration: attemptGeneration, + success: body["success"] as? Bool ?? false, + reason: body["reason"] as? String, + documentGeneration: documentGeneration + ) + } + + private static func normalizedVideoID(_ value: Any?) -> String? { + guard let value = value as? String else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + } + static let shared = SingletonPlayerWebView() /// Creates an isolated wrapper for tests that exercise WebView lifecycle state. @@ -237,12 +574,29 @@ final class SingletonPlayerWebView { var coordinator: Coordinator? let logger = DiagnosticsLogger.player private var loadGeneration = 0 + private var pendingRouterNavigation: PendingRouterNavigation? + private var playbackBridgeMultiplexer: PlaybackBridgeMultiplexer? + private var documentIDGeneration = 0 + var pendingDocumentID: Int? + var activeDocumentNavigation: WKNavigation? + var activeDocumentNavigationID: Int? + private var committedDocumentID: Int? + var isDocumentNavigationInProgress = false private(set) var documentGeneration = WebPlaybackDocumentGeneration() private(set) var documentNavigationStartedAtMilliseconds: Double? - private var documentNavigations: [ObjectIdentifier: WebPlaybackTrackedNavigation] = [:] - private var cancelledDocumentNavigations: [ObjectIdentifier: WebPlaybackCancelledNavigation] = [:] + private var documentNavigations = WebPlaybackNavigationMap() + private var cancelledDocumentNavigations = WebPlaybackNavigationMap() private var continuationGenerationsAwaitingStart: Set = [] + private var expectedBridgeDocumentID: Int? { + if self.documentGeneration.pendingGeneration != nil + || self.documentGeneration.inFlightGeneration != nil + { + return self.pendingDocumentID + } + return self.committedDocumentID ?? self.activeDocumentNavigationID ?? self.pendingDocumentID + } + /// Current display mode for the WebView. enum DisplayMode { case hidden // 1x1 for audio-only @@ -282,6 +636,22 @@ final class SingletonPlayerWebView { ) } + nonisolated static func queueNavigationStrategy( + currentVideoId: String?, + targetVideoId: String, + startsPaused: Bool, + allowsInPlaceRestart: Bool = true + ) -> VideoLoadStrategy { + guard currentVideoId == targetVideoId else { return .standard } + return startsPaused || !allowsInPlaceRestart + ? .forceFullPageWhenSameVideoId + : .preferInPlaceWhenSameVideoId + } + + var canRestartInPlace: Bool { + self.documentGeneration.accepts(generation: self.documentGeneration.currentGeneration) + } + nonisolated static func freshSameIDPlaybackStrategy( isShowingAd: Bool ) -> VideoLoadStrategy { @@ -291,6 +661,8 @@ final class SingletonPlayerWebView { var displayMode: DisplayMode = .hidden var mediaControlUsesNextPrev: Bool var playbackAudioQuality: SettingsManager.PlaybackAudioQuality + private var hasStartedHomePreload = false + private(set) var isHomePreloadSuppressedForDeferredRestore = false /// Native timer that re-asserts the media-key override while backgrounded. /// See `beginBackgroundMediaControlReassertion()`. @@ -315,7 +687,9 @@ final class SingletonPlayerWebView { playerService: PlayerService, usesCookieFreeDataStore: Bool = false ) -> WKWebView { + self.releaseDeferredHomePreloadSuppressionIfNeeded(playerService: playerService) if let existing = webView, self.usesCookieFreeDataStore == usesCookieFreeDataStore { + self.preloadHomePageIfNeeded() return existing } let previousContainer = self.currentContainer @@ -328,14 +702,21 @@ final class SingletonPlayerWebView { self.usesCookieFreeDataStore = usesCookieFreeDataStore // Create coordinator - self.coordinator = Coordinator(playerService: playerService) + let coordinator = Coordinator(playerService: playerService) + self.coordinator = coordinator let configuration = webKitManager.createWebViewConfiguration( websiteDataStore: usesCookieFreeDataStore ? .nonPersistent() : nil ) - // Add script message handler - configuration.userContentController.add(self.coordinator!, name: "singletonPlayer") + // Preserve feature-specific queue/SPA ingress checks while the main + // coordinator owns generation-scoped bridge decoding. + let playbackBridgeMultiplexer = PlaybackBridgeMultiplexer(coordinator: coordinator) + self.playbackBridgeMultiplexer = playbackBridgeMultiplexer + configuration.userContentController.add( + playbackBridgeMultiplexer, + name: "singletonPlayer" + ) // Dynamic startup state is refreshed before each full page load so the // next document gets current volume/autoplay flags at document start. @@ -362,9 +743,55 @@ final class SingletonPlayerWebView { if let previousContainer { self.ensureInHierarchy(container: previousContainer) } + self.preloadHomePageIfNeeded() return newWebView } + private func releaseDeferredHomePreloadSuppressionIfNeeded(playerService: PlayerService) { + guard self.isHomePreloadSuppressedForDeferredRestore, + !playerService.isPendingRestoredLoadDeferred, + !playerService.isRestoringPlaybackSession + else { return } + self.isHomePreloadSuppressedForDeferredRestore = false + } + + private func preloadHomePageIfNeeded() { + guard MusicHomePreloadPolicy.shouldPreload( + isRunningUnitTests: UITestConfig.isRunningUnitTests, + isSuppressedForDeferredRestore: self.isHomePreloadSuppressedForDeferredRestore, + hasStartedHomePreload: self.hasStartedHomePreload, + currentVideoId: self.currentVideoId + ) else { return } + guard let webView, let playerService = self.coordinator?.playerService else { return } + + self.cancelActiveDocumentNavigation(on: webView) + if self.documentGeneration.pendingGeneration != nil { + self.documentGeneration.cancelPendingNavigation() + } + self.documentNavigationStartedAtMilliseconds = Date().timeIntervalSince1970 * 1000 + let generation = self.documentGeneration.beginNavigation() + self.installUserScripts( + on: webView.configuration.userContentController, + shouldAutoplay: playerService.shouldAutoplayPlaybackDocument, + targetVolume: playerService.volume, + documentGeneration: generation, + nativePlaybackGeneration: playerService.currentNativeMusicPlaybackGeneration + ) + guard let homeURL = Self.homePreloadURL(documentGeneration: generation) else { + self.documentGeneration.cancelPendingNavigation() + self.logger.error("Unable to construct YT Music home URL") + return + } + + self.hasStartedHomePreload = true + self.logger.info("Preloading YT Music home page") + self.startDocumentNavigation( + on: webView, + request: URLRequest(url: homeURL), + generation: generation + ) + } + /// Ensures the WebView is in the given container's view hierarchy. func ensureInHierarchy(container: NSView) { guard let webView else { return } @@ -411,6 +838,12 @@ 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.committedDocumentID = nil + self.isDocumentNavigationInProgress = false self.currentVideoId = nil webView.evaluateJavaScript("document.querySelector('video')?.pause()", completionHandler: nil) if let blankURL { @@ -423,13 +856,17 @@ final class SingletonPlayerWebView { webView.removeFromSuperview() self.webKitManager?.unregisterExtensionHostWebView(role: .musicPlayer) self.webView = nil + self.playbackBridgeMultiplexer = nil + self.coordinator?.cancelPlaybackBridgeTasks() self.coordinator = nil self.cancelledDocumentNavigations.removeAll() self.currentContainer = nil self.usesCookieFreeDataStore = nil + self.hasStartedHomePreload = false + self.isHomePreloadSuppressedForDeferredRestore = false } - /// Recreates the playback WebView when crossing a cookie-store boundary while preserving the tracked video id. + /// Recreates the playback WebView across a cookie-store boundary while preserving only active document identity. func rebuildForAuthDataStoreChange(usesCookieFreeDataStore: Bool) { guard self.usesCookieFreeDataStore != usesCookieFreeDataStore else { return } guard let webKitManager = self.webKitManager, @@ -438,10 +875,19 @@ final class SingletonPlayerWebView { self.usesCookieFreeDataStore = usesCookieFreeDataStore return } - let videoId = self.currentVideoId + // A deferred restored session has not committed its pending watch + // document. Rebuilding must therefore leave the replacement WebView + // unlabeled and inert so explicit Resume routes to the persisted video. + let isDeferredRestoredLoad = playerService.isPendingRestoredLoadDeferred + let videoId = isDeferredRestoredLoad ? nil : self.currentVideoId let previousContainer = self.currentContainer self.logger.info("Rebuilding singleton music WebView for auth data-store boundary") self.tearDown() + self.isHomePreloadSuppressedForDeferredRestore = isDeferredRestoredLoad + + // Restore a real active document identity before WebView creation so the + // ordinary home preload cannot race an immediate identity re-point. + self.currentVideoId = videoId _ = self.getWebView( webKitManager: webKitManager, playerService: playerService, @@ -450,7 +896,6 @@ final class SingletonPlayerWebView { if let previousContainer { self.ensureInHierarchy(container: previousContainer) } - self.currentVideoId = videoId } /// Load a video, stopping any currently playing audio first. @@ -462,12 +907,14 @@ final class SingletonPlayerWebView { return } + self.isHomePreloadSuppressedForDeferredRestore = false let previousVideoId = self.currentVideoId 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: @@ -482,37 +929,145 @@ final class SingletonPlayerWebView { } } + guard let fallbackURL = 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"))") } - self.cancelActiveDocumentNavigation(on: webView) - - // Update currentVideoId immediately to prevent duplicate loads self.currentVideoId = videoId self.loadGeneration &+= 1 - self.documentNavigationStartedAtMilliseconds = Date().timeIntervalSince1970 * 1000 - let reservedDocumentGeneration = self.documentGeneration.beginNavigation() + let generation = self.loadGeneration + self.pendingRouterNavigation = nil - // Get current volume from PlayerService via coordinator - let currentVolume = self.coordinator?.playerService.volume ?? 1.0 - let shouldAutoplay = self.coordinator?.playerService.shouldAutoplayPlaybackDocument ?? false + let playerService = self.coordinator?.playerService + let currentVolume = playerService?.volume ?? 1.0 + let shouldAutoplay = playerService?.shouldAutoplayPlaybackDocument ?? false + let nativePlaybackGeneration = playerService?.currentNativeMusicPlaybackGeneration ?? 0 self.logger.info("Will apply volume \(currentVolume) after page load") + let canUseRouter = strategy != .forceFullPageWhenSameVideoId + && self.documentGeneration.accepts(generation: self.documentGeneration.currentGeneration) + && WebPlaybackDocumentGeneration.isExpectedPlaybackURL( + webView.url, + host: "music.youtube.com" + ) + guard canUseRouter else { + self.startFullPageNavigation( + videoId: videoId, + on: webView, + currentVolume: currentVolume, + shouldAutoplay: shouldAutoplay, + nativePlaybackGeneration: nativePlaybackGeneration + ) + return + } + + // Preserve the committed document generation while YouTube Music's SPA + // router swaps media in place. The observer's media generation and the + // native queue occurrence fence the handoff inside that document. + let prepareScript = """ + (function() { + const video = document.querySelector('video'); + if (video && !video.paused) video.pause(); + window.__kasetTargetVolume = \(currentVolume); + window.__kasetNativePlaybackGeneration = \(nativePlaybackGeneration); + window.__kasetAutoplayPending = \(shouldAutoplay ? "true" : "false"); + window.__kasetBlockAutoplay = \(shouldAutoplay ? "false" : "true"); + window.__kasetPlaybackSuppressed = \(shouldAutoplay ? "false" : "true"); + window.__kasetResumeAdOnly = false; + window.__kasetAutoplayAttempts = 0; + window.__kasetAutoplayRetryScheduled = false; + \(WebPlaybackAudioOutput.prepareScript) + })(); + """ + webView.evaluateJavaScript(prepareScript, completionHandler: nil) + self.navigateViaRouter( + videoId: videoId, + fallbackURL: fallbackURL, + generation: generation + ) + } + + nonisolated static func transitionFallbackDeadline( + now: ContinuousClock.Instant, + initialFallbackDelay: Duration + ) -> ContinuousClock.Instant { + WebPlaybackTransitionFallbackPolicy.deadline( + now: now, + initialFallbackDelay: initialFallbackDelay + ) + } + + nonisolated static func transitionFallbackRetryDelay( + isShowingAd: Bool, + now: ContinuousClock.Instant, + deadline: ContinuousClock.Instant, + lastAdvertisementProgressAt: ContinuousClock.Instant? = nil + ) -> Duration? { + WebPlaybackTransitionFallbackPolicy.retryDelay( + isShowingAd: isShowingAd, + now: now, + deadline: deadline, + lastAdvertisementProgressAt: lastAdvertisementProgressAt + ) + } + + nonisolated static func shouldDeferTransitionFallback( + isShowingAd: Bool, + now: ContinuousClock.Instant, + deadline: ContinuousClock.Instant + ) -> Bool { + self.transitionFallbackRetryDelay( + isShowingAd: isShowingAd, + now: now, + deadline: deadline + ) != nil + } + + nonisolated static func youtubeMusicWatchURL(videoId: String) -> URL? { + var components = URLComponents() + components.scheme = "https" + components.host = "music.youtube.com" + components.path = "/watch" + components.queryItems = [URLQueryItem(name: "v", value: videoId)] + return components.url + } + + nonisolated static func javaScriptStringLiteral(_ value: String) -> String { + guard let data = try? JSONEncoder().encode(value), + let literal = String(data: data, encoding: .utf8) + else { + return "\"\"" + } + return literal + } + + private func startFullPageNavigation( + videoId: String, + on webView: WKWebView, + currentVolume: Double, + shouldAutoplay: Bool, + nativePlaybackGeneration: UInt64 + ) { + self.cancelActiveDocumentNavigation(on: webView) + if self.documentGeneration.pendingGeneration != nil { + self.documentGeneration.cancelPendingNavigation() + } + self.documentNavigationStartedAtMilliseconds = Date().timeIntervalSince1970 * 1000 + let reservedDocumentGeneration = self.documentGeneration.beginNavigation() + self.installUserScripts( on: webView.configuration.userContentController, shouldAutoplay: shouldAutoplay, targetVolume: currentVolume, documentGeneration: reservedDocumentGeneration, - nativePlaybackGeneration: self.coordinator?.playerService - .currentNativeMusicPlaybackGeneration ?? 0 + nativePlaybackGeneration: nativePlaybackGeneration ) - // Stop current playback first, then load new video. For a forced - // full-page navigation (e.g. an identity-switch reload) skip pausing the - // OLD