From 06f9250fe34e61e2f379949bb9afa719d3907f50 Mon Sep 17 00:00:00 2001 From: tui <61665468+tui2019@users.noreply.github.com> Date: Thu, 7 May 2026 23:07:05 +0300 Subject: [PATCH 01/73] Port gapless playback and SPA navigation from fork --- .../PlayerService+PlaybackControls.swift | 17 +++ .../PlayerService+PlaybackRestoration.swift | 38 +++++- .../Services/Player/PlayerService+Queue.swift | 3 + .../Player/PlayerService+WebQueueSync.swift | 124 ++++++++++++++++++ .../Kaset/Services/Player/PlayerService.swift | 8 ++ Sources/Kaset/Views/MiniPlayerWebView.swift | 61 ++++++++- ...ingletonPlayerWebView+ObserverScript.swift | 8 ++ ...gletonPlayerWebView+PlaybackControls.swift | 23 ++++ ...ingletonPlayerWebView+QueueInjection.swift | 86 ++++++++++++ 9 files changed, 357 insertions(+), 11 deletions(-) create mode 100644 Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 31212ba78..030d9d796 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -136,6 +136,7 @@ extension PlayerService { if didStartPlayback { self.logger.info("Playback confirmed started") + self.syncWebQueue() } } @@ -224,6 +225,22 @@ extension PlayerService { func resume() async { self.logger.debug("Resuming playback") + if self.isPendingRestoredLoadDeferred { + self.clearRestoredPlaybackSessionState() + SingletonPlayerWebView.shared.setAutoplayBlocked(false) + + 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") diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift index 3ea69b4bd..1346ea0b8 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift @@ -8,6 +8,22 @@ extension PlayerService { func updatePlaybackState(isPlaying: Bool, progress: Double, duration: Double) { let previousProgress = self.progress + if self.isPendingRestoredLoadDeferred { + self.progress = progress + self.duration = duration + self.state = .paused + if isPlaying { + if self.isAwaitingWebRestoredTrack { + SingletonPlayerWebView.shared.pause() + } else { + Task { @MainActor in + await self.resume() + } + } + } + return + } + guard !self.isRestoringPlaybackSession else { self.reconcileRestoredPlaybackState( isPlaying: isPlaying, @@ -40,11 +56,12 @@ extension PlayerService { self.setQueue(queue) self.currentIndex = currentIndex self.currentTrack = currentSong - self.pendingPlayVideoId = currentSong.videoId + self.pendingPlayVideoId = nil self.currentTrackHasVideo = currentSong.musicVideoType?.hasVideoContent ?? currentSong.hasVideo ?? false self.showMiniPlayer = false self.songNearingEnd = false self.isKasetInitiatedPlayback = false + self.isAwaitingWebRestoredTrack = true let resolvedDuration = max(duration, currentSong.duration ?? 0) let clampedProgress = self.clampedRestoredProgress(progress, duration: resolvedDuration) @@ -75,10 +92,20 @@ extension PlayerService { self.currentTrackLikeStatus = cachedStatus } - // 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. - Task { [videoId = currentSong.videoId] in - await self.fetchSongMetadata(videoId: videoId) + // Give YT Music a chance to restore its server-synced track first. + // If no track arrives from the web page in time, fall back to the persisted one. + Task { + try? await Task.sleep(for: .seconds(8)) + guard self.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 + + // 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. + await self.fetchSongMetadata(videoId: currentSong.videoId) } } @@ -88,6 +115,7 @@ extension PlayerService { self.isPendingRestoredLoadDeferred = false self.isRestoringPlaybackSession = false self.shouldAutoResumeAfterRestoredLoad = false + self.isAwaitingWebRestoredTrack = false } /// Starts loading a restored session into the WebView without discarding the saved seek target. diff --git a/Sources/Kaset/Services/Player/PlayerService+Queue.swift b/Sources/Kaset/Services/Player/PlayerService+Queue.swift index c1bf73ff8..0767ddf21 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Queue.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Queue.swift @@ -535,6 +535,9 @@ extension PlayerService { UserDefaults.standard.set(safeIndex, forKey: Self.savedQueueIndexKey) UserDefaults.standard.set(sessionData, forKey: Self.savedPlaybackSessionKey) self.logger.info("Saved playback session with \(self.queue.count) songs at index \(safeIndex)") + + // Re-sync the web queue in case the queue order or next song changed + self.syncWebQueue() } catch { self.logger.error("Failed to save playback session: \(error.localizedDescription)") } diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index a9420c50e..26c40e576 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -4,6 +4,71 @@ import Foundation @MainActor extension PlayerService { + private func applyDeferredRestoredMetadata( + title: String, + artist: String, + thumbnailUrl: String, + videoId observedVideoId: String? + ) { + guard let observedVideoId = self.normalizedObservedVideoId(observedVideoId) else { return } + + let thumbnailURL = URL(string: thumbnailUrl) + let artistObj = Artist(id: "unknown", name: artist) + let matchedQueueSong = self.queue.first(where: { $0.videoId == observedVideoId }) + let seedSong: Song + + let previousVideoId = self.currentTrack?.videoId + self.pendingPlayVideoId = observedVideoId + self.isKasetInitiatedPlayback = false + self.isAwaitingWebRestoredTrack = false + + // 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 { + return + } + self.mixContinuationToken = nil + + if let matchedQueueSong, + self.shouldKeepQueueMetadata(title: title, artist: artist, song: matchedQueueSong) + { + seedSong = matchedQueueSong + } else { + seedSong = Song( + id: observedVideoId, + title: title, + artists: [artistObj], + album: nil, + duration: self.duration > 0 ? self.duration : nil, + thumbnailURL: thumbnailURL, + videoId: observedVideoId + ) + } + + self.clearForwardSkipNavigationStack() + self.setQueue([seedSong]) + self.currentIndex = 0 + self.currentTrack = seedSong + self.currentTrackHasVideo = seedSong.musicVideoType?.hasVideoContent + ?? seedSong.hasVideo + ?? false + self.saveQueueForPersistence() + + Task { + await self.fetchAndApplyRadioQueue(for: observedVideoId) + } + + if previousVideoId != observedVideoId { + self.resetTrackStatus() + if let cachedStatus = SongLikeStatusManager.shared.status(for: observedVideoId) { + self.currentTrackLikeStatus = cachedStatus + } + Task { + await self.fetchSongMetadata(videoId: observedVideoId) + } + } + } + /// 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. @@ -60,6 +125,28 @@ 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. + /// By injecting the next track ahead of time, we achieve true gapless playback when the current track ends. + /// + /// **Important:** Only runs when the player is in a stable state (`.playing` or `.paused`). + /// During `.loading` (SPA navigation), the player bar DOM is in flux and clicking the 3-dot + /// menu would interfere with `resolveCommand`. The injection is safely deferred to + /// `confirmPlaybackStarted()` which fires once the song is actually playing. + func syncWebQueue() { + // Never manipulate the DOM during navigation — the MutationObserver in + // injectNextSong conflicts with resolveCommand's DOM mutations. + guard self.state == .playing || self.state == .paused else { return } + + guard let nextIndex = self.expectedQueueIndexAfterCurrentTrack(), + let nextSong = self.queue[safe: nextIndex] else { return } + + if self.injectedWebQueueVideoId != nextSong.videoId { + self.injectedWebQueueVideoId = nextSong.videoId + SingletonPlayerWebView.shared.injectNextSong(videoId: nextSong.videoId) + self.logger.info("Synced web queue: injected \(nextSong.videoId) to play next natively") + } + } + private var canAdvanceNativeQueueAfterTrackEnd: Bool { self.shuffleEnabled || self.repeatMode == .one @@ -471,6 +558,29 @@ extension PlayerService { await self.replayCurrentQueueSongForRepeatOneAfterTrackEnd() return } + + // Check if the next expected track was already injected into the web queue. + // If so, YouTube Music will auto-advance to it natively — we just need to + // update our internal state without triggering another loadVideo navigation. + if let expectedIndex = self.expectedQueueIndexAfterCurrentTrack(), + let expectedSong = self.queue[safe: expectedIndex], + self.injectedWebQueueVideoId == expectedSong.videoId { + self.logger.info("Track ended natively. Injected track \(expectedSong.videoId) will auto-play; advancing queue index only.") + self.injectedWebQueueVideoId = nil + self.pushForwardSkipStackIfLeavingIndex(for: expectedIndex) + self.currentIndex = expectedIndex + self.currentTrack = expectedSong + self.isKasetInitiatedPlayback = true + self.resetTrackStatus() + if let cachedStatus = SongLikeStatusManager.shared.status(for: expectedSong.videoId) { + self.currentTrackLikeStatus = cachedStatus + } + self.saveQueueForPersistence() + // Pre-inject the *next* next track for the following transition + self.syncWebQueue() + return + } + self.logger.info("Track ended in WebView, advancing native queue immediately") await self.next() } @@ -478,6 +588,17 @@ extension PlayerService { /// Updates track metadata and enforces Kaset's queue when YouTube tries to diverge. func updateTrackMetadata(title: String, artist: String, thumbnailUrl: String, videoId observedVideoId: String?) { self.logger.debug("Track metadata updated: \(title) - \(artist)") + + if self.isPendingRestoredLoadDeferred { + self.applyDeferredRestoredMetadata( + title: title, + artist: artist, + thumbnailUrl: thumbnailUrl, + videoId: observedVideoId + ) + return + } + let thumbnailURL = URL(string: thumbnailUrl) let artistObj = Artist(id: "unknown", name: artist) let resolvedVideoId = self.resolvedObservedVideoId(observedVideoId) @@ -557,6 +678,9 @@ extension PlayerService { if let cachedStatus = SongLikeStatusManager.shared.status(for: resolvedVideoId) { self.currentTrackLikeStatus = cachedStatus } + + // Re-sync the web queue since the track changed natively + self.syncWebQueue() } } } diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index ba4f741ef..58054cae4 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -127,6 +127,9 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// Whether a restored load should automatically resume after seeking to the saved position. var shouldAutoResumeAfterRestoredLoad: Bool = false + /// Whether startup is waiting for YT Music to report its own server-restored track. + var isAwaitingWebRestoredTrack: Bool = false + /// Like status of the current track. var currentTrackLikeStatus: LikeStatus = .indifferent @@ -439,6 +442,11 @@ 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 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? + /// Grace period instant - don't auto-close video window shortly after opening (uses monotonic clock) var videoWindowOpenedAt: ContinuousClock.Instant? diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index d8ed54b7a..329bde0f9 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -336,7 +336,8 @@ final class SingletonPlayerWebView { switch strategy { case .standard: if videoId == previousVideoId { - self.logger.debug("Video \(videoId) already loaded, skipping") + self.logger.debug("Video \(videoId) already loaded, skipping routing and playing") + self.play() return } case .preferInPlaceWhenSameVideoId: @@ -347,7 +348,9 @@ final class SingletonPlayerWebView { } case .forceFullPageWhenSameVideoId: if videoId == previousVideoId { - self.logger.info("Force full navigation for \(videoId) (DOM/WebView resync)") + self.logger.debug("Bypassing forceFullPageWhenSameVideoId for router command execution") + self.play() + return } } @@ -369,17 +372,58 @@ final class SingletonPlayerWebView { targetVolume: currentVolume ) - // Stop current playback first, then load new video + // Prefer in-page router navigation when the shell is already loaded. let urlToLoad = URL(string: "https://music.youtube.com/watch?v=\(videoId)")! webView.evaluateJavaScript("document.querySelector('video')?.pause()") { [weak self] _, _ in guard let self, let webView = self.webView else { return } // Keep the current page's target volume fresh until the new document // finishes loading and gets the same value from didFinish. - let prepareScript = "window.__kasetTargetVolume = \(currentVolume);" + let prepareScript = """ + window.__kasetTargetVolume = \(currentVolume); + window.__kasetAutoplayPending = \(isRestoringPlaybackSession ? "false" : "true"); + window.__kasetBlockAutoplay = \(isRestoringPlaybackSession ? "true" : "false"); + """ webView.evaluateJavaScript(prepareScript, completionHandler: nil) + self.navigateViaRouter(videoId: videoId, fallbackURL: urlToLoad) + } + } + + private func navigateViaRouter(videoId: String, fallbackURL: URL) { + guard let webView else { return } - webView.load(URLRequest(url: urlToLoad)) + let host = webView.url?.host ?? "" + guard host == "music.youtube.com" || host == "www.music.youtube.com" else { + self.logger.debug("Router unavailable (host: \(host, privacy: .public)); falling back to full load") + webView.load(URLRequest(url: fallbackURL)) + return + } + + let escapedVideoId = videoId + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + let routerScript = """ + (function() { + const app = document.querySelector('ytmusic-app'); + if (!app || typeof app.resolveCommand !== 'function') return false; + try { + app.resolveCommand({ watchEndpoint: { videoId: '\(escapedVideoId)' } }); + return true; + } catch (_) { + return false; + } + })(); + """ + + webView.evaluateJavaScript(routerScript) { [weak self] result, _ in + guard let self, let webView = self.webView else { return } + let didNavigate = result as? Bool ?? false + if didNavigate { + self.logger.info("Router navigation started for video: \(videoId)") + } else { + self.logger.info("Router navigation failed for video: \(videoId), using full load") + webView.load(URLRequest(url: fallbackURL)) + } } } @@ -387,7 +431,10 @@ final class SingletonPlayerWebView { /// page's window. Restored sessions suppress autoplay so the reconcile path /// resumes at the saved seek rather than at 0s. nonisolated static func autoplayIntentScript(isRestoringPlaybackSession: Bool) -> String { - "window.__kasetAutoplayPending = \(isRestoringPlaybackSession ? "false" : "true");" + """ + window.__kasetAutoplayPending = \(isRestoringPlaybackSession ? "false" : "true"); + window.__kasetBlockAutoplay = \(isRestoringPlaybackSession ? "true" : "false"); + """ } nonisolated static func pageBootstrapScript( @@ -618,6 +665,8 @@ final class SingletonPlayerWebView { } })(); """ + SingletonPlayerWebView.shared.setAutoplayBlocked(self.playerService.isPendingRestoredLoadDeferred) + webView.evaluateJavaScript(applyVolumeScript) { result, error in if let error { DiagnosticsLogger.player.error( diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift index 12eccbd07..6c4ed99dd 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift @@ -78,6 +78,10 @@ extension SingletonPlayerWebView { // Enforce volume on playing event to catch all track changes // (auto-advance, SPA navigation, button clicks) video.addEventListener('playing', () => { + if (window.__kasetBlockAutoplay) { + try { video.pause(); } catch (_) {} + return; + } window.__kasetAutoplayPending = false; enforceVolumeNow(); }); @@ -139,6 +143,10 @@ extension SingletonPlayerWebView { video.addEventListener('loadeddata', () => enforceVolumeNow()); function recoverAutoplayIfNeeded() { enforceVolumeNow(); + if (window.__kasetBlockAutoplay) { + try { video.pause(); } catch (_) {} + return; + } // Autoplay recovery: YTM sometimes leaves the video paused // after navigation even with the WebKit autoplay allowance. const btn = document.querySelector('.play-pause-button.ytmusic-player-bar'); diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift index 2dcb456fa..22001ccef 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift @@ -3,6 +3,29 @@ import WebKit // MARK: - SingletonPlayerWebView Playback Controls Extension extension SingletonPlayerWebView { + /// Enables/disables startup autoplay blocking inside the observer script. + func setAutoplayBlocked(_ blocked: Bool) { + guard let webView else { return } + let script = """ + (function() { + window.__kasetBlockAutoplay = \(blocked ? "true" : "false"); + if (!window.__kasetBlockAutoplay) return 'autoplay-allowed'; + window.__kasetAutoplayPending = false; + let ticks = 0; + const timer = setInterval(function() { + const video = document.querySelector('video'); + if (video && !video.paused) { + try { video.pause(); } catch (_) {} + } + ticks += 1; + if (ticks >= 20) clearInterval(timer); + }, 150); + return 'autoplay-blocked'; + })(); + """ + webView.evaluateJavaScript(script, completionHandler: nil) + } + /// Toggle play/pause. func playPause() { guard let webView else { return } diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift b/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift new file mode 100644 index 000000000..a99808061 --- /dev/null +++ b/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift @@ -0,0 +1,86 @@ +import Foundation +import WebKit + +@MainActor +extension SingletonPlayerWebView { + /// Injects a song into YouTube Music's "Up Next" queue by intercepting JSON.stringify + /// and simulating clicks on the player bar menu. This allows YouTube Music to seamlessly + /// transition to the target song natively, achieving gapless playback. + func injectNextSong(videoId: String) { + guard let webView = self.webView else { return } + + let escapedVideoId = videoId + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + + // This script: + // 1. Intercepts `JSON.stringify` to swap the video ID payload when "Play next" is clicked + // 2. Clicks the player bar 3-dot menu to open the context menu + // 3. Finds and clicks the "Play next" button (by SVG path data or text fallback) + // 4. Dismisses the menu by clicking document.body + let injectionScript = """ + (function(targetVideoId) { + // Step 1: Arm the payload interceptor (once per page) + if (!window.__stringifyIntercepted) { + const originalStringify = JSON.stringify; + JSON.stringify = function(value, replacer, space) { + if (value && typeof value === 'object' && value.videoIds && Array.isArray(value.videoIds)) { + if (window.__targetVideoIdToInject) { + console.log('[INJECTOR] Swapping ' + value.videoIds[0] + ' -> ' + window.__targetVideoIdToInject); + value.videoIds = [window.__targetVideoIdToInject]; + window.__targetVideoIdToInject = null; + } + } + return originalStringify(value, replacer, space); + }; + window.__stringifyIntercepted = true; + } + + window.__targetVideoIdToInject = targetVideoId; + + // Step 2: Find "Play next" by its SVG icon path + function fireQueueClick(menuItems) { + const playNextPathData = "M6 2.86V5H3a1 1 0 00-1 1v12a1 1 0 102 0V7h2v2.137a.5.5 0 00.748.434L13 5.998 6.748 2.426A.5.5 0 006 2.86ZM21 5h-5a1 1 0 100 2h5a1 1 0 100-2Zm0 6H9a1 1 0 000 2h12a1 1 0 000-2Zm0 6H9a1 1 0 000 2h12a1 1 0 000-2Z"; + let targetBtn = null; + const iconPaths = document.querySelectorAll('path[d="' + playNextPathData + '"]'); + if (iconPaths.length > 0) { + targetBtn = iconPaths[0].closest('ytmusic-menu-service-item-renderer'); + } + if (!targetBtn) { + targetBtn = Array.from(menuItems).find(el => el.textContent.toLowerCase().includes('next')) || menuItems[0]; + } + if (targetBtn) { + targetBtn.click(); + } + } + + // Step 3: Open the player bar 3-dot menu and wait for items to render + const playerBarMenuBtn = document.querySelector('.middle-controls-buttons.ytmusic-player-bar ytmusic-menu-renderer button'); + if (playerBarMenuBtn) { + const observer = new MutationObserver((mutations, obs) => { + const newMenuItems = document.querySelectorAll('ytmusic-menu-popup-renderer ytmusic-menu-service-item-renderer'); + if (newMenuItems.length > 0) { + obs.disconnect(); + fireQueueClick(newMenuItems); + document.body.click(); // Close the menu + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + playerBarMenuBtn.click(); + // Safety timeout: disconnect observer and close menu if items never rendered + setTimeout(() => { observer.disconnect(); document.body.click(); }, 2000); + } else { + console.log('[INJECTOR] Player bar menu not found — song may not be loaded yet'); + window.__targetVideoIdToInject = null; // Disarm + } + })('\(escapedVideoId)'); + """ + + self.logger.info("Injecting video \(videoId) into YouTube Music native queue") + webView.evaluateJavaScript(injectionScript) { _, error in + if let error { + self.logger.error("Failed to inject next song: \(error.localizedDescription)") + } + } + } +} From 40a5c9898e8328b4c4c32b63f761b27eb46aa42a Mon Sep 17 00:00:00 2001 From: tui <61665468+tui2019@users.noreply.github.com> Date: Thu, 7 May 2026 23:55:55 +0300 Subject: [PATCH 02/73] Restore pre-loading home page for faster SPA routing --- Sources/Kaset/Views/MiniPlayerWebView.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index 329bde0f9..0a7c4c8fd 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -244,6 +244,7 @@ final class SingletonPlayerWebView { var displayMode: DisplayMode = .hidden private var mediaControlUsesNextPrev: Bool + private var hasStartedHomePreload = false /// Tracks if lyrics high-frequency polling should be active /// Used to restore polling after full-page navigation @@ -290,9 +291,21 @@ final class SingletonPlayerWebView { #endif self.webView = newWebView + self.preloadHomePageIfNeeded() return newWebView } + private func preloadHomePageIfNeeded() { + guard !UITestConfig.isRunningUnitTests else { return } + guard !self.hasStartedHomePreload else { return } + guard self.currentVideoId == nil else { return } + guard let webView else { return } + + self.hasStartedHomePreload = true + self.logger.info("Preloading YT Music home page") + webView.load(URLRequest(url: URL(string: "https://music.youtube.com/")!)) + } + /// Ensures the WebView is in the given container's view hierarchy. func ensureInHierarchy(container: NSView) { guard let webView, webView.superview !== container else { return } From 764b016cfd6efbad0056f0a585a495f88869d048 Mon Sep 17 00:00:00 2001 From: tui <61665468+tui2019@users.noreply.github.com> Date: Sat, 9 May 2026 18:43:54 +0300 Subject: [PATCH 03/73] Refactor next() and previous() to use native YouTube Music built-in queue tools --- .../PlayerService+PlaybackControls.swift | 60 +------------------ 1 file changed, 1 insertion(+), 59 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 030d9d796..990704b3e 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -275,37 +275,6 @@ extension PlayerService { self.logger.debug("Skipping to next track") self.clearRestoredPlaybackSessionState() - if !self.queue.isEmpty { - if self.currentIndex < self.queue.count - 1 { - self.pushForwardSkipStackIfLeavingIndex(for: self.currentIndex + 1) - self.currentIndex += 1 - if let nextSong = self.queue[safe: self.currentIndex] { - await self.play(song: nextSong) - } - await self.fetchMoreMixSongsIfNeeded() - self.saveQueueForPersistence() - } else if self.repeatMode == .all { - self.pushForwardSkipStackIfLeavingIndex(for: 0) - self.currentIndex = 0 - if let firstSong = self.queue.first { - await self.play(song: firstSong) - } - self.saveQueueForPersistence() - } else if self.mixContinuationToken != nil { - let previousCount = self.queue.count - await self.fetchMoreMixSongsIfNeeded() - if self.queue.count > previousCount { - self.pushForwardSkipStackIfLeavingIndex(for: self.currentIndex + 1) - self.currentIndex += 1 - if let nextSong = self.queue[safe: self.currentIndex] { - await self.play(song: nextSong) - } - self.saveQueueForPersistence() - } - } - return - } - // 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 { @@ -323,33 +292,6 @@ extension PlayerService { self.logger.debug("Going to previous track") self.clearRestoredPlaybackSessionState() - if !self.queue.isEmpty { - if self.progress > 3 { - await self.seek(to: 0) - return - } - - if let priorIndex = self.popForwardSkipIndex(), self.queue.indices.contains(priorIndex) { - self.currentIndex = priorIndex - if let prevSong = self.queue[safe: priorIndex] { - await self.play(song: prevSong) - } - self.saveQueueForPersistence() - return - } - - if self.currentIndex > 0 { - self.currentIndex -= 1 - if let prevSong = self.queue[safe: self.currentIndex] { - await self.play(song: prevSong) - } - self.saveQueueForPersistence() - } else { - await self.seek(to: 0) - } - return - } - // Standalone artist episodes are intentionally not in the local queue. // Do not restart them or fall through to YouTube Music's ambient previous button. guard self.currentEpisode == nil else { @@ -359,7 +301,7 @@ extension PlayerService { if self.progress > 3 { await self.seek(to: 0) - } else { + } else if self.pendingPlayVideoId != nil { SingletonPlayerWebView.shared.previous() } } From e60078e0d679e51759e32bfee417228fade98ec0 Mon Sep 17 00:00:00 2001 From: tui <61665468+tui2019@users.noreply.github.com> Date: Sat, 9 May 2026 19:06:51 +0300 Subject: [PATCH 04/73] Fix web page preload on first launch by conditionally rendering PersistentPlayerView --- Sources/Kaset/Views/MainWindow.swift | 6 +++--- Sources/Kaset/Views/MiniPlayerViews.swift | 20 +++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index 2a27f2ffe..30c5c8ff2 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -105,11 +105,11 @@ struct MainWindow: View { DiagnosticsLogger.app.info("MainWindow: Login check complete") } - // Persistent WebView - always present once a video has been requested. + // Persistent WebView - always present once logged in. // Uses a SINGLETON WebView instance that persists for the app lifetime. // Keep it as a hidden 1×1 anchor for audio playback; do not reveal a mini overlay. - if let videoId = playerService.pendingPlayVideoId { - PersistentPlayerView(videoId: videoId, isExpanded: false) + if self.authService.state.isLoggedIn { + PersistentPlayerView(videoId: 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 bb83cb596..e217499ae 100644 --- a/Sources/Kaset/Views/MiniPlayerViews.swift +++ b/Sources/Kaset/Views/MiniPlayerViews.swift @@ -9,13 +9,13 @@ struct PersistentPlayerView: NSViewRepresentable { @Environment(WebKitManager.self) private var webKitManager @Environment(PlayerService.self) private var playerService - 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 @@ -33,11 +33,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 @@ -60,10 +61,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) } } } From 0fa771db33afe3d0f688e5cadd77c3e36d0f3f9d Mon Sep 17 00:00:00 2001 From: tui <61665468+tui2019@users.noreply.github.com> Date: Sat, 9 May 2026 19:36:48 +0300 Subject: [PATCH 05/73] Fix cloud-synced queue restoration on first launch --- .../Kaset/Services/Player/PlayerService+WebQueueSync.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index 26c40e576..e1361f3a3 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -589,7 +589,9 @@ extension PlayerService { func updateTrackMetadata(title: String, artist: String, thumbnailUrl: String, videoId observedVideoId: String?) { self.logger.debug("Track metadata updated: \(title) - \(artist)") - if self.isPendingRestoredLoadDeferred { + let isRestoringFromCloud = self.queue.isEmpty && !self.isKasetInitiatedPlayback && observedVideoId != nil + + if self.isPendingRestoredLoadDeferred || isRestoringFromCloud { self.applyDeferredRestoredMetadata( title: title, artist: artist, From 8744f0a3261e739742cb0b677870267d35fc3501 Mon Sep 17 00:00:00 2001 From: tui <61665468+tui2019@users.noreply.github.com> Date: Sat, 9 May 2026 19:59:07 +0300 Subject: [PATCH 06/73] Block autoplay during initial home page preload and unblock on manual resume --- .../Services/Player/PlayerService+PlaybackControls.swift | 5 ++++- Sources/Kaset/Views/MiniPlayerWebView.swift | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 990704b3e..7e4764a4d 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -224,10 +224,11 @@ extension PlayerService { /// Resumes playback. func resume() async { self.logger.debug("Resuming playback") + + SingletonPlayerWebView.shared.setAutoplayBlocked(false) if self.isPendingRestoredLoadDeferred { self.clearRestoredPlaybackSessionState() - SingletonPlayerWebView.shared.setAutoplayBlocked(false) self.showMiniPlayer = false self.state = .loading @@ -274,6 +275,7 @@ extension PlayerService { func next() async { self.logger.debug("Skipping to next track") self.clearRestoredPlaybackSessionState() + SingletonPlayerWebView.shared.setAutoplayBlocked(false) // Standalone artist episodes are intentionally not in the local queue. // Do not let them fall through to YouTube Music's ambient next button. @@ -291,6 +293,7 @@ extension PlayerService { func previous() async { self.logger.debug("Going to previous track") self.clearRestoredPlaybackSessionState() + SingletonPlayerWebView.shared.setAutoplayBlocked(false) // Standalone artist episodes are intentionally not in the local queue. // Do not restart them or fall through to YouTube Music's ambient previous button. diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index 0a7c4c8fd..cde06c2a1 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -275,10 +275,12 @@ final class SingletonPlayerWebView { // Dynamic startup state is refreshed before each full page load so the // next document gets current volume/autoplay flags at document start. + + let shouldBlockAutoplay = playerService.isRestoringPlaybackSession || playerService.pendingPlayVideoId == nil self.installUserScripts( on: configuration.userContentController, - isRestoringPlaybackSession: playerService.isRestoringPlaybackSession, + isRestoringPlaybackSession: shouldBlockAutoplay, targetVolume: playerService.volume ) From 13a221e5afe2e484027e3719a477e9f4c08ab65f Mon Sep 17 00:00:00 2001 From: Anka Date: Sat, 9 May 2026 18:12:54 +0000 Subject: [PATCH 07/73] Apply swiftformat --- .../Kaset/Services/Player/PlayerService+PlaybackControls.swift | 2 +- Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift | 3 ++- Sources/Kaset/Views/MainWindow.swift | 2 +- Sources/Kaset/Views/MiniPlayerWebView.swift | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 7e4764a4d..4f054af73 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -224,7 +224,7 @@ extension PlayerService { /// Resumes playback. func resume() async { self.logger.debug("Resuming playback") - + SingletonPlayerWebView.shared.setAutoplayBlocked(false) if self.isPendingRestoredLoadDeferred { diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index e1361f3a3..0685774af 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -564,7 +564,8 @@ extension PlayerService { // update our internal state without triggering another loadVideo navigation. if let expectedIndex = self.expectedQueueIndexAfterCurrentTrack(), let expectedSong = self.queue[safe: expectedIndex], - self.injectedWebQueueVideoId == expectedSong.videoId { + self.injectedWebQueueVideoId == expectedSong.videoId + { self.logger.info("Track ended natively. Injected track \(expectedSong.videoId) will auto-play; advancing queue index only.") self.injectedWebQueueVideoId = nil self.pushForwardSkipStackIfLeavingIndex(for: expectedIndex) diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index 30c5c8ff2..c704697b3 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -109,7 +109,7 @@ struct MainWindow: View { // 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 self.authService.state.isLoggedIn { - PersistentPlayerView(videoId: playerService.pendingPlayVideoId, isExpanded: false) + PersistentPlayerView(videoId: self.playerService.pendingPlayVideoId, isExpanded: false) .frame(width: 1, height: 1) .opacity(0) .allowsHitTesting(false) diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index cde06c2a1..1dc39a485 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -275,7 +275,7 @@ final class SingletonPlayerWebView { // Dynamic startup state is refreshed before each full page load so the // next document gets current volume/autoplay flags at document start. - + let shouldBlockAutoplay = playerService.isRestoringPlaybackSession || playerService.pendingPlayVideoId == nil self.installUserScripts( From 8c4fef027106b0eceb5fafc071e072ee259a4903 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Mon, 6 Jul 2026 21:18:55 -0700 Subject: [PATCH 08/73] chore: clarify feedback token local name Signed-off-by: Sertac Ozercan --- .../Services/Player/PlayerService+PlaybackControls.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 88c1336b1..fc7848ed8 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -452,8 +452,8 @@ extension PlayerService { } } - if let tokens = song.feedbackTokens { - self.currentTrackFeedbackTokens = tokens + if let details = song.feedbackTokens { + self.currentTrackFeedbackTokens = details self.currentTrackInLibrary = song.isInLibrary ?? false self.currentTrackLikeStatus = song.likeStatus ?? self.currentTrackLikeStatus } From f49ee0b12fa8d1a2daf7e0267ba203e2e7ddfd50 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Mon, 6 Jul 2026 21:29:06 -0700 Subject: [PATCH 09/73] fix: confirm web queue injection before trusting it Signed-off-by: Sertac Ozercan --- .github/workflows/tests.yml | 2 +- .../Player/PlayerService+Library.swift | 22 +- .../PlayerService+PlaybackControls.swift | 68 +++++- .../PlayerService+PlaybackRestoration.swift | 30 +-- .../Services/Player/PlayerService+Queue.swift | 10 +- .../Player/PlayerService+WebQueueSync.swift | 88 ++++++-- .../Kaset/Services/Player/PlayerService.swift | 19 +- Sources/Kaset/Views/MiniPlayerWebView.swift | 72 +++++-- ...gletonPlayerWebView+PlaybackControls.swift | 20 +- ...ingletonPlayerWebView+QueueInjection.swift | 202 +++++++++++++++--- Tests/KasetTests/AutoplayRecoveryTests.swift | 8 +- .../PlayerServiceLibraryTests.swift | 37 ++++ .../KasetTests/PlayerServiceQueueTests.swift | 20 ++ ...ayerServiceWebQueueSyncFollowUpTests.swift | 120 +++++++++++ .../PlayerServiceWebQueueSyncTests.swift | 153 +++++++++++++ 15 files changed, 765 insertions(+), 106 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f75b16960..f4af01c02 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -87,7 +87,7 @@ jobs: # (Xcode 26 + Sequoia) during XCTest discovery. The unit suite is # entirely Swift Testing; the only XCTest target is the manual # ParserPerformanceTests, which is invoked separately via xcodebuild. - run: swift test -q --skip KasetUITests --disable-xctest + run: swift test -q --skip KasetUITests --disable-xctest --no-parallel macos_ui_tests: name: macOS UI Tests (${{ matrix.os }}) diff --git a/Sources/Kaset/Services/Player/PlayerService+Library.swift b/Sources/Kaset/Services/Player/PlayerService+Library.swift index 59db58af8..f79b7f3ac 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Library.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Library.swift @@ -37,9 +37,14 @@ extension PlayerService { ) } - guard SongLikeStatusManager.shared.activeAccountID == activeAccountID, - self.currentTrack?.videoId == track.videoId - else { + guard self.currentTrack?.videoId == track.videoId else { return } + + let currentAccountID = SongLikeStatusManager.shared.activeAccountID + if currentAccountID != activeAccountID { + guard let currentAccountStatus = SongLikeStatusManager.shared.status(for: track.videoId, accountID: currentAccountID) else { + return + } + self.currentTrackLikeStatus = currentAccountStatus return } @@ -80,9 +85,14 @@ extension PlayerService { ) } - guard SongLikeStatusManager.shared.activeAccountID == activeAccountID, - self.currentTrack?.videoId == track.videoId - else { + guard self.currentTrack?.videoId == track.videoId else { return } + + let currentAccountID = SongLikeStatusManager.shared.activeAccountID + if currentAccountID != activeAccountID { + guard let currentAccountStatus = SongLikeStatusManager.shared.status(for: track.videoId, accountID: currentAccountID) else { + return + } + self.currentTrackLikeStatus = currentAccountStatus return } diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index fc7848ed8..bfcfaed51 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -84,6 +84,7 @@ extension PlayerService { self.logger.debug("play() called with videoId: \(videoId)") self.logger.info("Playing video: \(videoId)") self.clearRestoredPlaybackSessionState() + self.clearWebQueueInjectionState() self.currentEpisode = nil self.state = .loading self.songNearingEnd = false @@ -132,6 +133,7 @@ extension PlayerService { self.logger.info("Playing song: \(song.title)") self.logger.debug("Web load strategy: \(String(describing: webLoadStrategy))") self.clearRestoredPlaybackSessionState() + self.clearWebQueueInjectionState() self.currentEpisode = episode // Brief `.loading` until the observer reports playback; in-place restarts may flash loading briefly. self.state = .loading @@ -305,8 +307,37 @@ extension PlayerService { SingletonPlayerWebView.shared.setAutoplayBlocked(false) if self.isPendingRestoredLoadDeferred { - self.clearRestoredPlaybackSessionState() + 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 @@ -370,11 +401,19 @@ extension PlayerService { } } - guard let targetIndex else { return } + guard let targetIndex, let targetSong = self.queue[safe: targetIndex] else { return } self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) - self.advanceQueueStateForNativeNavigation(to: targetIndex) + if self.injectedWebQueueVideoId == targetSong.videoId { + self.advanceQueueStateForNativeNavigation(to: targetIndex) + self.injectedWebQueueVideoId = nil + SingletonPlayerWebView.shared.next() + } else { + self.injectedWebQueueVideoId = nil + await self.loadQueueSongForNavigation(at: targetIndex) + } + await self.fetchMoreMixSongsIfNeeded() await self.fillSmartShuffleWindow() - SingletonPlayerWebView.shared.next() + self.saveQueueForPersistence(syncWebQueue: false) return } @@ -403,14 +442,12 @@ extension PlayerService { } if let priorIndex = self.popForwardSkipIndex(), self.queue.indices.contains(priorIndex) { - self.advanceQueueStateForNativeNavigation(to: priorIndex) - SingletonPlayerWebView.shared.previous() + await self.loadQueueSongForNavigation(at: priorIndex) return } if self.currentIndex > 0 { - self.advanceQueueStateForNativeNavigation(to: self.currentIndex - 1) - SingletonPlayerWebView.shared.previous() + await self.loadQueueSongForNavigation(at: self.currentIndex - 1) } else { await self.seek(to: 0) } @@ -431,8 +468,16 @@ extension PlayerService { } } + /// Navigates to a queue song through Kaset's deterministic load path. + private func loadQueueSongForNavigation(at index: Int) async { + guard let song = self.queue[safe: index] else { return } + self.currentIndex = index + await self.play(song: song) + self.saveQueueForPersistence() + } + /// Updates Kaset's local queue pointer for native WebView queue navigation without forcing a page load. - private func advanceQueueStateForNativeNavigation(to index: Int) { + func advanceQueueStateForNativeNavigation(to index: Int) { guard let song = self.queue[safe: index] else { return } let trackChanged = self.currentTrack?.videoId != song.videoId @@ -440,6 +485,9 @@ extension PlayerService { self.currentTrack = song self.currentEpisode = nil self.pendingPlayVideoId = song.videoId + self.progress = 0 + self.duration = song.duration ?? 0 + self.state = .loading self.isKasetInitiatedPlayback = true self.songNearingEnd = false self.shouldSuppressAutoplayAfterQueueEnd = false @@ -458,7 +506,7 @@ extension PlayerService { self.currentTrackLikeStatus = song.likeStatus ?? self.currentTrackLikeStatus } - self.saveQueueForPersistence() + self.saveQueueForPersistence(syncWebQueue: false) } /// Seeks to a specific time. diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift index efcda0c73..c109ff9a0 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift @@ -12,14 +12,9 @@ extension PlayerService { self.progress = progress self.duration = duration self.state = .paused - if isPlaying { - if self.isAwaitingWebRestoredTrack { - SingletonPlayerWebView.shared.pause() - } else { - Task { @MainActor in - await self.resume() - } - } + if isPlaying, !self.hasIssuedAutoplayPauseDuringDeferredRestore { + self.hasIssuedAutoplayPauseDuringDeferredRestore = true + SingletonPlayerWebView.shared.pause() } return } @@ -53,6 +48,8 @@ extension PlayerService { guard let currentSong = queue[safe: currentIndex] else { return } self.clearRestoredPlaybackSessionState() + self.restoredPlaybackSessionGeneration &+= 1 + let restoreGeneration = self.restoredPlaybackSessionGeneration self.clearForwardSkipNavigationStack() if let entrySources, entrySources.count == queue.count { self.setQueue(entries: zip(queue, entrySources).map { song, source in @@ -69,6 +66,7 @@ extension PlayerService { self.songNearingEnd = false self.isKasetInitiatedPlayback = false self.isAwaitingWebRestoredTrack = true + self.hasIssuedAutoplayPauseDuringDeferredRestore = false let resolvedDuration = max(duration, currentSong.duration ?? 0) let clampedProgress = self.clampedRestoredProgress(progress, duration: resolvedDuration) @@ -99,20 +97,25 @@ extension PlayerService { self.currentTrackLikeStatus = cachedStatus } + // At app launch the cache may be empty and the persisted song may lack likeStatus. + // Fetch immediately; the fetch only applies if this persisted song is still current. + Task { [videoId = currentSong.videoId] in + await self.fetchSongMetadata(videoId: videoId) + } + // Give YT Music a chance to restore its server-synced track first. // If no track arrives from the web page in time, fall back to the persisted one. Task { try? await Task.sleep(for: .seconds(8)) - guard self.isPendingRestoredLoadDeferred, self.isAwaitingWebRestoredTrack else { return } + 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 - - // 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. - await self.fetchSongMetadata(videoId: currentSong.videoId) } } @@ -121,6 +124,7 @@ extension PlayerService { self.pendingRestoredSeek = nil self.isPendingRestoredLoadDeferred = false self.shouldForcePendingRestoredLoad = false + self.restoredPlaybackSessionGeneration &+= 1 self.isRestoringPlaybackSession = false self.shouldAutoResumeAfterRestoredLoad = false self.isAwaitingWebRestoredTrack = false diff --git a/Sources/Kaset/Services/Player/PlayerService+Queue.swift b/Sources/Kaset/Services/Player/PlayerService+Queue.swift index 42034cdc0..f256cba80 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Queue.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Queue.swift @@ -721,9 +721,10 @@ extension PlayerService { private static let savedPlaybackSessionKey = "kaset.saved.playbackSession" /// Saves the current queue to UserDefaults for restoration on next launch. - func saveQueueForPersistence() { + func saveQueueForPersistence(syncWebQueue shouldSyncWebQueue: Bool = true) { let queue = self.queue guard !queue.isEmpty else { + self.clearWebQueueInjectionState() if self.suppressNextEmptyQueuePersistence { self.suppressNextEmptyQueuePersistence = false self.logger.info("Skipped clearing saved playback session after guest-startup cleanup") @@ -742,6 +743,7 @@ extension PlayerService { let persistedEntries = Self.stripSuggested(from: self.queueEntries, keepingCurrentID: currentID) let persistableQueue = persistedEntries.map(\.song) guard !persistableQueue.isEmpty else { + self.clearWebQueueInjectionState() self.removeSavedPlaybackSession() return } @@ -784,8 +786,10 @@ extension PlayerService { self.restoredPlaybackSessionOwnerScope = ownerScope self.logger.info("Saved playback session with \(persistedQueue.count) songs at index \(safeIndex)") - // Re-sync the web queue in case the queue order or next song changed - self.syncWebQueue() + if shouldSyncWebQueue { + // Re-sync the web queue in case the queue order or next song changed + self.syncWebQueue() + } } catch { self.logger.error("Failed to save playback session: \(error.localizedDescription)") } diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index 0685774af..f31a7c434 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -21,10 +21,17 @@ extension PlayerService { 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 + } // Sync the web view's current video ID so Kaset knows the player is already on this track SingletonPlayerWebView.shared.currentVideoId = observedVideoId if observedVideoId == previousVideoId, !self.queue.isEmpty { + Task { + await self.fetchSongMetadata(videoId: observedVideoId) + } return } self.mixContinuationToken = nil @@ -138,15 +145,54 @@ extension PlayerService { guard self.state == .playing || self.state == .paused else { return } guard let nextIndex = self.expectedQueueIndexAfterCurrentTrack(), - let nextSong = self.queue[safe: nextIndex] else { return } + let nextSong = self.queue[safe: nextIndex] + else { + self.clearWebQueueInjectionState() + return + } - if self.injectedWebQueueVideoId != nextSong.videoId { - self.injectedWebQueueVideoId = nextSong.videoId - SingletonPlayerWebView.shared.injectNextSong(videoId: nextSong.videoId) - self.logger.info("Synced web queue: injected \(nextSong.videoId) to play next natively") + guard self.injectedWebQueueVideoId != nextSong.videoId, + self.pendingWebQueueInjectionVideoId != nextSong.videoId + else { return } + + self.pendingWebQueueInjectionVideoId = nextSong.videoId + if SingletonPlayerWebView.shared.injectNextSong(videoId: nextSong.videoId) { + self.logger.info("Syncing web queue: requested injection of \(nextSong.videoId) to play next natively") + } else { + self.pendingWebQueueInjectionVideoId = nil } } + /// Records the WebView result for an attempted native queue injection. + func handleWebQueueInjectionResult(videoId: String, success: Bool, reason: String?) { + guard self.pendingWebQueueInjectionVideoId == videoId else { + self.logger.debug("Ignoring web queue injection result for non-pending video \(videoId)") + return + } + self.pendingWebQueueInjectionVideoId = nil + + guard success 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") + } + private var canAdvanceNativeQueueAfterTrackEnd: Bool { self.shuffleEnabled || self.repeatMode == .one @@ -569,16 +615,10 @@ extension PlayerService { self.logger.info("Track ended natively. Injected track \(expectedSong.videoId) will auto-play; advancing queue index only.") self.injectedWebQueueVideoId = nil self.pushForwardSkipStackIfLeavingIndex(for: expectedIndex) - self.currentIndex = expectedIndex - self.currentTrack = expectedSong - self.isKasetInitiatedPlayback = true - self.resetTrackStatus() - if let cachedStatus = SongLikeStatusManager.shared.status(for: expectedSong.videoId) { - self.currentTrackLikeStatus = cachedStatus - } - self.saveQueueForPersistence() - // Pre-inject the *next* next track for the following transition - self.syncWebQueue() + self.advanceQueueStateForNativeNavigation(to: expectedIndex) + await self.fetchMoreMixSongsIfNeeded() + await self.fillSmartShuffleWindow() + self.saveQueueForPersistence(syncWebQueue: false) return } @@ -590,9 +630,22 @@ extension PlayerService { func updateTrackMetadata(title: String, artist: String, thumbnailUrl: String, videoId observedVideoId: String?) { self.logger.debug("Track metadata updated: \(title) - \(artist)") - let isRestoringFromCloud = self.queue.isEmpty && !self.isKasetInitiatedPlayback && observedVideoId != nil + 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 self.isPendingRestoredLoadDeferred || isRestoringFromCloud { + if isRestoringFromCloud { self.applyDeferredRestoredMetadata( title: title, artist: artist, @@ -676,6 +729,7 @@ extension PlayerService { ) if trackChanged { + self.clearWebQueueInjectionState() self.resetTrackStatus() // Immediately restore like status from SongLikeStatusManager cache if let cachedStatus = SongLikeStatusManager.shared.status(for: resolvedVideoId) { diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 94d6f176f..96893ce7a 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -196,9 +196,15 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// Whether a restored load should automatically resume after seeking to the saved position. var shouldAutoResumeAfterRestoredLoad: Bool = false + /// Monotonic generation for deferred restored-session fallback tasks. + var restoredPlaybackSessionGeneration: Int = 0 + /// Whether startup is waiting for YT Music to report its own server-restored track. var isAwaitingWebRestoredTrack: Bool = false + /// Whether Kaset has already issued the one-shot defensive pause while a deferred restored session is awaiting web metadata. + var hasIssuedAutoplayPauseDuringDeferredRestore: Bool = false + /// Like status of the current track. var currentTrackLikeStatus: LikeStatus = .indifferent @@ -490,6 +496,12 @@ final class PlayerService: NSObject, PlayerServiceProtocol { self.currentQueueEntryID = self.queueStorage[safe: self.currentIndex]?.id } + func clearWebQueueInjectionState() { + 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 @@ -573,11 +585,16 @@ 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 injected into YouTube Music's native "Up Next" queue. + /// 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 confirms the queue payload was swapped. + var pendingWebQueueInjectionVideoId: String? + /// Grace period instant - don't auto-close video window shortly after opening (uses monotonic clock) var videoWindowOpenedAt: ContinuousClock.Instant? diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index b6e14584a..37fa5655d 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -299,11 +299,13 @@ final class SingletonPlayerWebView { // Dynamic startup state is refreshed before each full page load so the // next document gets current volume/autoplay flags at document start. - let shouldBlockAutoplay = playerService.isRestoringPlaybackSession || playerService.pendingPlayVideoId == nil + let shouldBlockAutoplay = playerService.isRestoringPlaybackSession + || playerService.isPendingRestoredLoadDeferred + || playerService.pendingPlayVideoId == nil self.installUserScripts( on: configuration.userContentController, - isRestoringPlaybackSession: shouldBlockAutoplay, + shouldBlockAutoplay: shouldBlockAutoplay, targetVolume: playerService.volume ) @@ -455,7 +457,7 @@ final class SingletonPlayerWebView { self.installUserScripts( on: webView.configuration.userContentController, - isRestoringPlaybackSession: isRestoringPlaybackSession, + shouldBlockAutoplay: isRestoringPlaybackSession, targetVolume: currentVolume ) @@ -484,11 +486,20 @@ final class SingletonPlayerWebView { window.__kasetBlockAutoplay = \(isRestoringPlaybackSession ? "true" : "false"); """ webView.evaluateJavaScript(prepareScript, completionHandler: nil) - self.navigateViaRouter(videoId: videoId, fallbackURL: urlToLoad) + self.navigateViaRouter(videoId: videoId, fallbackURL: urlToLoad, generation: generation) } } - private func navigateViaRouter(videoId: String, fallbackURL: 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 navigateViaRouter(videoId: String, fallbackURL: URL, generation: Int) { guard let webView else { return } let host = webView.url?.host ?? "" @@ -498,15 +509,13 @@ final class SingletonPlayerWebView { return } - let escapedVideoId = videoId - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") + let videoIdLiteral = Self.javaScriptStringLiteral(videoId) let routerScript = """ (function() { const app = document.querySelector('ytmusic-app'); if (!app || typeof app.resolveCommand !== 'function') return false; try { - app.resolveCommand({ watchEndpoint: { videoId: '\(escapedVideoId)' } }); + app.resolveCommand({ watchEndpoint: { videoId: \(videoIdLiteral) } }); return true; } catch (_) { return false; @@ -516,6 +525,7 @@ final class SingletonPlayerWebView { webView.evaluateJavaScript(routerScript) { [weak self] result, _ in guard let self, let webView = self.webView else { return } + guard self.loadGeneration == generation, self.currentVideoId == videoId else { return } let didNavigate = result as? Bool ?? false if didNavigate { self.logger.info("Router navigation started for video: \(videoId)") @@ -529,15 +539,15 @@ final class SingletonPlayerWebView { /// Returns the JS snippet that hands the autoplay intent to the freshly loaded /// page's window. Restored sessions suppress autoplay so the reconcile path /// resumes at the saved seek rather than at 0s. - nonisolated static func autoplayIntentScript(isRestoringPlaybackSession: Bool) -> String { + nonisolated static func autoplayIntentScript(shouldBlockAutoplay: Bool) -> String { """ - window.__kasetAutoplayPending = \(isRestoringPlaybackSession ? "false" : "true"); - window.__kasetBlockAutoplay = \(isRestoringPlaybackSession ? "true" : "false"); + window.__kasetAutoplayPending = \(shouldBlockAutoplay ? "false" : "true"); + window.__kasetBlockAutoplay = \(shouldBlockAutoplay ? "true" : "false"); """ } nonisolated static func pageBootstrapScript( - isRestoringPlaybackSession: Bool, + shouldBlockAutoplay: Bool, targetVolume: Double ) -> String { let clampedVolume = if targetVolume.isFinite { @@ -547,14 +557,14 @@ final class SingletonPlayerWebView { } return """ - \(Self.autoplayIntentScript(isRestoringPlaybackSession: isRestoringPlaybackSession)) + \(Self.autoplayIntentScript(shouldBlockAutoplay: shouldBlockAutoplay)) window.__kasetTargetVolume = \(clampedVolume); """ } private func installUserScripts( on contentController: WKUserContentController, - isRestoringPlaybackSession: Bool, + shouldBlockAutoplay: Bool, targetVolume: Double ) { contentController.removeAllUserScripts() @@ -563,7 +573,7 @@ final class SingletonPlayerWebView { // `didFinish` is too late on fast or cached player loads. let pageBootstrapScript = WKUserScript( source: Self.pageBootstrapScript( - isRestoringPlaybackSession: isRestoringPlaybackSession, + shouldBlockAutoplay: shouldBlockAutoplay, targetVolume: targetVolume ), injectionTime: .atDocumentStart, @@ -614,11 +624,14 @@ final class SingletonPlayerWebView { func refreshInstalledUserScripts() { guard let webView else { return } - let currentVolume = self.coordinator?.playerService.volume ?? 1.0 - let isRestoringPlaybackSession = self.coordinator?.playerService.isRestoringPlaybackSession ?? false + let playerService = self.coordinator?.playerService + let currentVolume = playerService?.volume ?? 1.0 + let shouldBlockAutoplay = playerService?.isRestoringPlaybackSession == true + || playerService?.isPendingRestoredLoadDeferred == true + || playerService?.pendingPlayVideoId == nil self.installUserScripts( on: webView.configuration.userContentController, - isRestoringPlaybackSession: isRestoringPlaybackSession, + shouldBlockAutoplay: shouldBlockAutoplay, targetVolume: currentVolume ) } @@ -658,6 +671,8 @@ final class SingletonPlayerWebView { self.handleLyricsTimeUpdate(body: body) case "PLAYBACK_AUDIO_QUALITY_STATS": Self.logAudioQualityStats(body: body, observedVideoId: observedVideoId) + case "QUEUE_INJECTION_RESULT": + self.handleQueueInjectionResult(body: body, observedVideoId: observedVideoId) case "STATE_UPDATE": self.handleStateUpdate(body: body, observedVideoId: observedVideoId) default: @@ -690,6 +705,20 @@ final class SingletonPlayerWebView { } } + private func handleQueueInjectionResult(body: [String: Any], observedVideoId: String?) { + guard let observedVideoId else { return } + let success = body["success"] as? Bool ?? false + let reason = body["reason"] as? String + + Task { @MainActor in + self.playerService.handleWebQueueInjectionResult( + videoId: observedVideoId, + success: success, + reason: reason + ) + } + } + private func handleStateUpdate(body: [String: Any], observedVideoId: String?) { let isPlaying = body["isPlaying"] as? Bool ?? false let progress = body["progress"] as? Int ?? 0 @@ -1011,7 +1040,10 @@ final class SingletonPlayerWebView { } })(); """ - SingletonPlayerWebView.shared.setAutoplayBlocked(self.playerService.isPendingRestoredLoadDeferred) + let shouldBlockAutoplay = self.playerService.isRestoringPlaybackSession + || self.playerService.isPendingRestoredLoadDeferred + || self.playerService.pendingPlayVideoId == nil + SingletonPlayerWebView.shared.setAutoplayBlocked(shouldBlockAutoplay) webView.evaluateJavaScript(applyVolumeScript) { result, error in if let error { diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift index a97ff2bb1..e828eac85 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift @@ -9,17 +9,30 @@ extension SingletonPlayerWebView { let script = """ (function() { window.__kasetBlockAutoplay = \(blocked ? "true" : "false"); + if (window.__kasetAutoplayBlockTimer) { + clearInterval(window.__kasetAutoplayBlockTimer); + window.__kasetAutoplayBlockTimer = null; + } if (!window.__kasetBlockAutoplay) return 'autoplay-allowed'; window.__kasetAutoplayPending = false; let ticks = 0; const timer = setInterval(function() { + if (!window.__kasetBlockAutoplay) { + clearInterval(timer); + if (window.__kasetAutoplayBlockTimer === timer) window.__kasetAutoplayBlockTimer = null; + return; + } const video = document.querySelector('video'); if (video && !video.paused) { try { video.pause(); } catch (_) {} } ticks += 1; - if (ticks >= 20) clearInterval(timer); + if (ticks >= 20) { + clearInterval(timer); + if (window.__kasetAutoplayBlockTimer === timer) window.__kasetAutoplayBlockTimer = null; + } }, 150); + window.__kasetAutoplayBlockTimer = timer; return 'autoplay-blocked'; })(); """ @@ -147,6 +160,11 @@ extension SingletonPlayerWebView { let script = """ (function() { + window.__kasetBlockAutoplay = false; + if (window.__kasetAutoplayBlockTimer) { + clearInterval(window.__kasetAutoplayBlockTimer); + window.__kasetAutoplayBlockTimer = null; + } const video = document.querySelector('video'); if (video && video.paused) { video.play(); return 'played'; } return 'already-playing'; diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift b/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift index a99808061..e2f663bcb 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift @@ -3,43 +3,142 @@ import WebKit @MainActor extension SingletonPlayerWebView { + /// Cancels any page-side queue injection attempt that has not completed yet. + func cancelQueueInjection() { + guard let webView else { return } + let script = """ + (function() { + if (typeof window.__kasetCancelQueueInjectionAttempt === 'function') { + window.__kasetCancelQueueInjectionAttempt(); + } + window.__targetVideoIdToInject = null; + window.__kasetQueueInjectionClickActive = null; + window.__kasetQueueInjectionAttemptId = null; + return 'cancelled'; + })(); + """ + webView.evaluateJavaScript(script, completionHandler: nil) + } + /// Injects a song into YouTube Music's "Up Next" queue by intercepting JSON.stringify /// and simulating clicks on the player bar menu. This allows YouTube Music to seamlessly /// transition to the target song natively, achieving gapless playback. - func injectNextSong(videoId: String) { - guard let webView = self.webView else { return } + /// - Returns: `true` if the injection script was started. The actual success/failure result + /// is reported asynchronously through the singleton player bridge. + @discardableResult + // swiftlint:disable:next function_body_length + func injectNextSong(videoId: String) -> Bool { + guard let webView = self.webView else { return false } - let escapedVideoId = videoId - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "\\'") + let videoIdLiteral = Self.javaScriptStringLiteral(videoId) // This script: - // 1. Intercepts `JSON.stringify` to swap the video ID payload when "Play next" is clicked - // 2. Clicks the player bar 3-dot menu to open the context menu - // 3. Finds and clicks the "Play next" button (by SVG path data or text fallback) - // 4. Dismisses the menu by clicking document.body + // 1. Installs a one-time `JSON.stringify` wrapper. + // 2. Opens the current player bar menu and positively identifies "Play next". + // 3. Arms the payload swapper only for the click turn of that menu item so unrelated + // YouTube Music payloads cannot consume the target and falsely confirm injection. + // 4. Reports success only when that click-scoped payload is swapped. + // 5. Dismisses/disarms on timeout or failure so stale targets cannot affect later requests. let injectionScript = """ (function(targetVideoId) { - // Step 1: Arm the payload interceptor (once per page) + const bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.singletonPlayer; + const injectionAttemptId = String(Date.now()) + '-' + String(Math.random()); + let timeoutId = null; + let observer = null; + let didReport = false; + + function cancelAttempt() { + didReport = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (observer) { + observer.disconnect(); + observer = null; + } + if (window.__kasetQueueInjectionAttemptId === injectionAttemptId) { + window.__targetVideoIdToInject = null; + window.__kasetQueueInjectionClickActive = null; + window.__kasetQueueInjectionAttemptId = null; + window.__kasetQueueInjectionReport = null; + window.__kasetCancelQueueInjectionAttempt = null; + } + } + + window.__kasetCancelQueueInjectionAttempt = cancelAttempt; + + function report(success, reason, reportedVideoId) { + if (didReport) return; + didReport = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (window.__kasetQueueInjectionAttemptId === injectionAttemptId) { + window.__kasetQueueInjectionAttemptId = null; + } + if (window.__kasetQueueInjectionClickActive === injectionAttemptId) { + window.__kasetQueueInjectionClickActive = null; + } + if (window.__kasetCancelQueueInjectionAttempt === cancelAttempt) { + window.__kasetCancelQueueInjectionAttempt = null; + } + try { + if (bridge) { + bridge.postMessage({ + type: 'QUEUE_INJECTION_RESULT', + videoId: reportedVideoId || targetVideoId, + success: !!success, + reason: reason || '' + }); + } + } catch (_) {} + } + if (!window.__stringifyIntercepted) { const originalStringify = JSON.stringify; JSON.stringify = function(value, replacer, space) { - if (value && typeof value === 'object' && value.videoIds && Array.isArray(value.videoIds)) { - if (window.__targetVideoIdToInject) { - console.log('[INJECTOR] Swapping ' + value.videoIds[0] + ' -> ' + window.__targetVideoIdToInject); - value.videoIds = [window.__targetVideoIdToInject]; - window.__targetVideoIdToInject = null; + const shouldSwap = value + && typeof value === 'object' + && value.videoIds + && Array.isArray(value.videoIds) + && window.__targetVideoIdToInject + && window.__kasetQueueInjectionClickActive === window.__kasetQueueInjectionAttemptId; + + if (shouldSwap) { + const injectedVideoId = window.__targetVideoIdToInject; + console.log('[INJECTOR] Swapping ' + value.videoIds[0] + ' -> ' + injectedVideoId); + value.videoIds = [injectedVideoId]; + window.__targetVideoIdToInject = null; + window.__kasetQueueInjectionClickActive = null; + if (typeof window.__kasetQueueInjectionReport === 'function') { + window.__kasetQueueInjectionReport(true, 'swapped', injectedVideoId); } } + return originalStringify(value, replacer, space); }; window.__stringifyIntercepted = true; } - window.__targetVideoIdToInject = targetVideoId; + window.__kasetQueueInjectionAttemptId = injectionAttemptId; + window.__kasetQueueInjectionReport = report; + + function clearActiveTarget() { + if (window.__kasetQueueInjectionAttemptId === injectionAttemptId) { + window.__targetVideoIdToInject = null; + } + if (window.__kasetQueueInjectionClickActive === injectionAttemptId) { + window.__kasetQueueInjectionClickActive = null; + } + if (window.__kasetCancelQueueInjectionAttempt === cancelAttempt) { + window.__kasetCancelQueueInjectionAttempt = null; + } + } - // Step 2: Find "Play next" by its SVG icon path function fireQueueClick(menuItems) { + if (window.__kasetQueueInjectionAttemptId !== injectionAttemptId) return false; const playNextPathData = "M6 2.86V5H3a1 1 0 00-1 1v12a1 1 0 102 0V7h2v2.137a.5.5 0 00.748.434L13 5.998 6.748 2.426A.5.5 0 006 2.86ZM21 5h-5a1 1 0 100 2h5a1 1 0 100-2Zm0 6H9a1 1 0 000 2h12a1 1 0 000-2Zm0 6H9a1 1 0 000 2h12a1 1 0 000-2Z"; let targetBtn = null; const iconPaths = document.querySelectorAll('path[d="' + playNextPathData + '"]'); @@ -47,40 +146,83 @@ extension SingletonPlayerWebView { targetBtn = iconPaths[0].closest('ytmusic-menu-service-item-renderer'); } if (!targetBtn) { - targetBtn = Array.from(menuItems).find(el => el.textContent.toLowerCase().includes('next')) || menuItems[0]; + targetBtn = Array.from(menuItems).find(el => { + const text = (el.textContent || '').toLowerCase(); + const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase(); + return text.includes('next') || ariaLabel.includes('next'); + }); } - if (targetBtn) { - targetBtn.click(); + if (!targetBtn) { + clearActiveTarget(); + report(false, 'play-next-item-not-found'); + return false; } + + window.__targetVideoIdToInject = targetVideoId; + window.__kasetQueueInjectionClickActive = injectionAttemptId; + targetBtn.click(); + + // The player-bar menu command serializes synchronously from the click. If the + // target was not consumed by then, fail closed instead of leaving a target armed + // for unrelated YouTube Music requests. + setTimeout(() => { + if (didReport) return; + if (window.__kasetQueueInjectionClickActive === injectionAttemptId) { + clearActiveTarget(); + report(false, 'play-next-payload-not-observed'); + } + }, 0); + return true; } - // Step 3: Open the player bar 3-dot menu and wait for items to render const playerBarMenuBtn = document.querySelector('.middle-controls-buttons.ytmusic-player-bar ytmusic-menu-renderer button'); if (playerBarMenuBtn) { - const observer = new MutationObserver((mutations, obs) => { + observer = new MutationObserver((mutations, obs) => { + if (window.__kasetQueueInjectionAttemptId !== injectionAttemptId) { + obs.disconnect(); + return; + } const newMenuItems = document.querySelectorAll('ytmusic-menu-popup-renderer ytmusic-menu-service-item-renderer'); if (newMenuItems.length > 0) { obs.disconnect(); + observer = null; fireQueueClick(newMenuItems); - document.body.click(); // Close the menu + document.body.click(); } }); observer.observe(document.body, { childList: true, subtree: true }); playerBarMenuBtn.click(); - // Safety timeout: disconnect observer and close menu if items never rendered - setTimeout(() => { observer.disconnect(); document.body.click(); }, 2000); + timeoutId = setTimeout(() => { + if (observer) { + observer.disconnect(); + observer = null; + } + if (window.__kasetQueueInjectionAttemptId === injectionAttemptId) { + clearActiveTarget(); + document.body.click(); + report(false, 'menu-timeout'); + } + }, 2000); } else { console.log('[INJECTOR] Player bar menu not found — song may not be loaded yet'); - window.__targetVideoIdToInject = null; // Disarm + clearActiveTarget(); + report(false, 'player-bar-menu-not-found'); } - })('\(escapedVideoId)'); + })(\(videoIdLiteral)); """ self.logger.info("Injecting video \(videoId) into YouTube Music native queue") - webView.evaluateJavaScript(injectionScript) { _, error in - if let error { - self.logger.error("Failed to inject next song: \(error.localizedDescription)") + webView.evaluateJavaScript(injectionScript) { [weak self] _, error in + guard let error else { return } + Task { @MainActor [weak self] in + self?.logger.error("Failed to inject next song: \(error.localizedDescription)") + self?.coordinator?.playerService.handleWebQueueInjectionResult( + videoId: videoId, + success: false, + reason: error.localizedDescription + ) } } + return true } } diff --git a/Tests/KasetTests/AutoplayRecoveryTests.swift b/Tests/KasetTests/AutoplayRecoveryTests.swift index 0b89e42c1..665464fec 100644 --- a/Tests/KasetTests/AutoplayRecoveryTests.swift +++ b/Tests/KasetTests/AutoplayRecoveryTests.swift @@ -132,14 +132,14 @@ struct AutoplayRecoveryJSTests { struct AutoplayIntentScriptTests { @Test("Sets the pending flag to true for a fresh navigation") func setsPendingTrue() { - let script = SingletonPlayerWebView.autoplayIntentScript(isRestoringPlaybackSession: false) + let script = SingletonPlayerWebView.autoplayIntentScript(shouldBlockAutoplay: false) #expect(script.contains("window.__kasetAutoplayPending = true;")) #expect(script.contains("window.__kasetBlockAutoplay = false;")) } @Test("Sets the pending flag to false during a restored session") func clearsPendingForRestoredSession() { - let script = SingletonPlayerWebView.autoplayIntentScript(isRestoringPlaybackSession: true) + let script = SingletonPlayerWebView.autoplayIntentScript(shouldBlockAutoplay: true) #expect(script.contains("window.__kasetAutoplayPending = false;")) #expect(script.contains("window.__kasetBlockAutoplay = true;")) } @@ -147,7 +147,7 @@ struct AutoplayIntentScriptTests { @Test("Page bootstrap seeds autoplay intent and target volume") func pageBootstrapSeedsIntentAndTargetVolume() { let script = SingletonPlayerWebView.pageBootstrapScript( - isRestoringPlaybackSession: false, + shouldBlockAutoplay: false, targetVolume: 0.42 ) @@ -158,7 +158,7 @@ struct AutoplayIntentScriptTests { @Test("Page bootstrap clamps invalid target volume") func pageBootstrapClampsInvalidTargetVolume() { let script = SingletonPlayerWebView.pageBootstrapScript( - isRestoringPlaybackSession: false, + shouldBlockAutoplay: false, targetVolume: .infinity ) diff --git a/Tests/KasetTests/PlayerServiceLibraryTests.swift b/Tests/KasetTests/PlayerServiceLibraryTests.swift index 3dab162ee..432fef54b 100644 --- a/Tests/KasetTests/PlayerServiceLibraryTests.swift +++ b/Tests/KasetTests/PlayerServiceLibraryTests.swift @@ -96,6 +96,43 @@ struct PlayerServiceLibraryTests { #expect(reverted) } + @Test("likeCurrentTrack ignores old-account failure after new account status loads") + func likeCurrentTrackIgnoresOldAccountFailureAfterNewAccountStatusLoads() async { + self.mockClient.shouldThrowError = YTMusicError.networkError(underlying: URLError(.notConnectedToInternet)) + self.playerService.currentTrack = TestFixtures.makeSong(id: "test-video") + self.playerService.currentTrackLikeStatus = .indifferent + SongLikeStatusManager.shared.setActiveAccountID("old-account") + + self.playerService.likeCurrentTrack() + #expect(self.playerService.currentTrackLikeStatus == .like) + + SongLikeStatusManager.shared.setActiveAccountID("new-account") + SongLikeStatusManager.shared.setStatus(.dislike, for: "test-video") + self.playerService.currentTrackLikeStatus = .dislike + + try? await Task.sleep(for: .milliseconds(100)) + + #expect(self.playerService.currentTrackLikeStatus == .dislike) + } + + @Test("likeCurrentTrack ignores old-account failure when new account status is unknown") + func likeCurrentTrackIgnoresOldAccountFailureWhenNewAccountStatusUnknown() async { + self.mockClient.shouldThrowError = YTMusicError.networkError(underlying: URLError(.notConnectedToInternet)) + self.playerService.currentTrack = TestFixtures.makeSong(id: "test-video") + self.playerService.currentTrackLikeStatus = .indifferent + SongLikeStatusManager.shared.setActiveAccountID("old-account") + + self.playerService.likeCurrentTrack() + #expect(self.playerService.currentTrackLikeStatus == .like) + + SongLikeStatusManager.shared.setActiveAccountID("new-account") + self.playerService.currentTrackLikeStatus = .indifferent + + try? await Task.sleep(for: .milliseconds(100)) + + #expect(self.playerService.currentTrackLikeStatus == .indifferent) + } + @Test("likeCurrentTrack ignores stale completion after current track changes") func likeCurrentTrackIgnoresStaleCompletionAfterTrackChange() async { self.mockClient.rateSongDelay = .milliseconds(200) diff --git a/Tests/KasetTests/PlayerServiceQueueTests.swift b/Tests/KasetTests/PlayerServiceQueueTests.swift index 83c1018f3..2d332f179 100644 --- a/Tests/KasetTests/PlayerServiceQueueTests.swift +++ b/Tests/KasetTests/PlayerServiceQueueTests.swift @@ -570,6 +570,26 @@ struct PlayerServiceQueueTests { #expect(nextLaunchService.queue.isEmpty) } + @Test("Resume on an already-loaded restored session preserves the saved seek") + func resumeDeferredRestoredSessionAlreadyLoadedPreservesSeek() async { + let songs = TestFixtures.makeSongs(count: 2) + self.playerService.applyRestoredPlaybackSession( + queue: songs, + currentIndex: 1, + progress: 42, + duration: 240 + ) + SingletonPlayerWebView.shared.currentVideoId = songs[1].videoId + + await self.playerService.resume() + + #expect(self.playerService.pendingPlayVideoId == songs[1].videoId) + #expect(self.playerService.pendingRestoredSeek == 42) + #expect(self.playerService.isRestoringPlaybackSession == true) + #expect(self.playerService.shouldAutoResumeAfterRestoredLoad == true) + #expect(self.playerService.state == .loading) + } + @Test("Resume on a restored session loads through the hidden persistent player") func resumeDeferredRestoredSession() async { // Arrange diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift index 6a1625e0d..c4781cc03 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift @@ -353,6 +353,126 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.pendingRestoredSeek == 180) } + @Test("Deferred restored playback ignores stray playing updates after fallback") + func deferredRestoredPlaybackIgnoresStrayPlayingUpdatesAfterFallback() { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + ] + self.playerService.applyRestoredPlaybackSession( + queue: songs, + currentIndex: 0, + progress: 60, + duration: 180 + ) + self.playerService.isAwaitingWebRestoredTrack = false + + self.playerService.updatePlaybackState(isPlaying: true, progress: 61, duration: 180) + + #expect(self.playerService.isPendingRestoredLoadDeferred == true) + #expect(self.playerService.state == .paused) + #expect(self.playerService.pendingRestoredSeek == 60) + #expect(self.playerService.currentTrack?.videoId == "v1") + #expect(self.playerService.hasIssuedAutoplayPauseDuringDeferredRestore == true) + } + + @Test("Late web metadata after restored fallback does not replace persisted queue") + func lateWebMetadataAfterRestoredFallbackDoesNotReplaceQueue() { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + ] + + self.playerService.applyRestoredPlaybackSession( + queue: songs, + currentIndex: 0, + progress: 60, + duration: 180 + ) + self.playerService.isAwaitingWebRestoredTrack = false + + self.playerService.updateTrackMetadata( + title: "Late Web Track", + artist: "Web Artist", + thumbnailUrl: "", + videoId: "web-v1" + ) + + #expect(self.playerService.queue.map(\.videoId) == ["v1", "v2"]) + #expect(self.playerService.currentIndex == 0) + #expect(self.playerService.currentTrack?.videoId == "v1") + #expect(self.playerService.pendingPlayVideoId == "v1") + } + + @Test("Older restored fallback task cannot replace newer restored session") + func olderRestoredFallbackTaskCannotReplaceNewerRestoredSession() { + let first = [Song(id: "1", title: "First", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "first")] + let second = [Song(id: "2", title: "Second", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "second")] + + self.playerService.applyRestoredPlaybackSession(queue: first, currentIndex: 0, progress: 10, duration: 180) + let firstGeneration = self.playerService.restoredPlaybackSessionGeneration + self.playerService.applyRestoredPlaybackSession(queue: second, currentIndex: 0, progress: 20, duration: 200) + + #expect(self.playerService.restoredPlaybackSessionGeneration != firstGeneration) + #expect(self.playerService.currentTrack?.videoId == "second") + #expect(self.playerService.pendingPlayVideoId == "second") + #expect(self.playerService.pendingRestoredSeek == 20) + } + + @Test("Different server-restored track clears persisted seek") + func differentServerRestoredTrackClearsPersistedSeek() { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + ] + self.playerService.applyRestoredPlaybackSession( + queue: songs, + currentIndex: 0, + progress: 60, + duration: 180 + ) + + self.playerService.updateTrackMetadata( + title: "Server Song", + artist: "Server Artist", + thumbnailUrl: "", + videoId: "server-v2" + ) + + #expect(self.playerService.currentTrack?.videoId == "server-v2") + #expect(self.playerService.pendingPlayVideoId == "server-v2") + #expect(self.playerService.pendingRestoredSeek == nil) + #expect(self.playerService.isPendingRestoredLoadDeferred == true) + } + + @Test("Same-track restored metadata still refreshes song metadata") + func sameTrackRestoredMetadataRefreshesSongMetadata() async { + let mockClient = MockYTMusicClient() + self.playerService.setYTMusicClient(mockClient) + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + ] + + self.playerService.applyRestoredPlaybackSession( + queue: songs, + currentIndex: 0, + progress: 60, + duration: 180 + ) + + self.playerService.updateTrackMetadata( + title: "Song 1", + artist: "", + thumbnailUrl: "", + videoId: "v1" + ) + try? await Task.sleep(for: .milliseconds(50)) + + #expect(mockClient.getSongVideoIds.contains("v1")) + #expect(self.playerService.queue.map(\.videoId) == ["v1", "v2"]) + #expect(self.playerService.isAwaitingWebRestoredTrack == false) + } + @Test("Identity-switch reload is skipped while a restored session is deferred") func identitySwitchReloadSkippedWhenDeferred() { let songs = [ diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift index a7313bd31..34a67f314 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift @@ -2,6 +2,24 @@ import Foundation import Testing @testable import Kaset +@MainActor +private extension PlayerService { + var expectedQueueIndexForTesting: 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 + } + if self.repeatMode == .all { + return 0 + } + return nil + } +} + // MARK: - PlayerServiceWebQueueSyncTests /// Web queue sync, next/previous stack, repeat-one, metadata drift, and radio-related PlayerService tests. @@ -56,6 +74,120 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.pendingPlayVideoId == "v1") } + @Test("Native next does not pre-inject next-next track while WebView is still on current track") + func nativeNextDoesNotPreinjectNextNextTrack() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + + await self.playerService.next() + + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.pendingPlayVideoId == "v2") + #expect(self.playerService.injectedWebQueueVideoId == nil) + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + } + + @Test("Native next resets progress before persisting target queue song") + func nativeNextResetsProgressBeforePersistingTargetQueueSong() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.progress = 179 + self.playerService.duration = 180 + self.playerService.injectedWebQueueVideoId = "v2" + + await self.playerService.next() + + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(self.playerService.progress == 0) + #expect(self.playerService.duration == 200) + } + + @Test("Native next clears consumed injection marker for duplicate video IDs") + func nativeNextClearsConsumedInjectionMarkerForDuplicateVideoIDs() async { + let duplicate = Song(id: "dup", title: "Duplicate", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v2") + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + duplicate, + duplicate, + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + + await self.playerService.next() + + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.injectedWebQueueVideoId == nil) + #expect(self.playerService.expectedQueueIndexForTesting == 2) + } + + @Test("Saving an empty queue clears web queue injection state") + func savingEmptyQueueClearsWebQueueInjectionState() { + self.playerService.injectedWebQueueVideoId = "stale" + self.playerService.pendingWebQueueInjectionVideoId = "stale" + + self.playerService.saveQueueForPersistence() + + #expect(self.playerService.injectedWebQueueVideoId == nil) + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + } + + @Test("Removing injected next song clears web queue injection state") + func removingInjectedNextSongClearsWebQueueInjectionState() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + + self.playerService.removeFromQueue(videoIds: ["v2"]) + + #expect(self.playerService.queue.map(\.videoId) == ["v1"]) + #expect(self.playerService.injectedWebQueueVideoId == nil) + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + } + + @Test("Web queue injection result is trusted only after successful current next confirmation") + func webQueueInjectionResultRequiresSuccessfulExpectedNextConfirmation() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + + self.playerService.pendingWebQueueInjectionVideoId = "v3" + self.playerService.handleWebQueueInjectionResult(videoId: "v3", success: true, reason: nil) + #expect(self.playerService.injectedWebQueueVideoId == nil) + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + + self.playerService.pendingWebQueueInjectionVideoId = "v2" + self.playerService.handleWebQueueInjectionResult(videoId: "v2", success: false, reason: "timeout") + #expect(self.playerService.injectedWebQueueVideoId == nil) + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + + self.playerService.handleWebQueueInjectionResult(videoId: "v2", success: true, reason: "stale") + #expect(self.playerService.injectedWebQueueVideoId == nil) + + self.playerService.pendingWebQueueInjectionVideoId = "v2" + self.playerService.handleWebQueueInjectionResult(videoId: "v2", success: true, reason: "swapped") + #expect(self.playerService.injectedWebQueueVideoId == "v2") + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + } + // MARK: - Next with Shuffle Tests @Test("Next with repeat one advances to the following queue song") @@ -517,6 +649,27 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.currentTrack?.title == "Song 2") } + @Test("Injected track end waits for WebView confirmation before injecting next-next") + func injectedTrackEndDefersNextNextInjectionUntilPlaybackConfirmation() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), + ] + + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + + await self.playerService.handleTrackEnded(observedVideoId: "v1") + + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(self.playerService.state == .loading) + #expect(self.playerService.injectedWebQueueVideoId == nil) + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + } + @Test("Stale track-ended events do not double-advance the queue") func staleTrackEndedEventIsIgnored() async { let songs = [ From b4cbccf47a653dc87ac74d199294c557f415a03d Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Tue, 7 Jul 2026 19:23:18 -0700 Subject: [PATCH 10/73] fix: make manual next deterministic Signed-off-by: Sertac Ozercan --- .../Player/PlayerService+PlaybackControls.swift | 13 ++++--------- .../KasetTests/PlayerServiceWebQueueSyncTests.swift | 12 ++++++------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index bfcfaed51..dee66635e 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -401,16 +401,9 @@ extension PlayerService { } } - guard let targetIndex, let targetSong = self.queue[safe: targetIndex] else { return } + guard let targetIndex else { return } self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) - if self.injectedWebQueueVideoId == targetSong.videoId { - self.advanceQueueStateForNativeNavigation(to: targetIndex) - self.injectedWebQueueVideoId = nil - SingletonPlayerWebView.shared.next() - } else { - self.injectedWebQueueVideoId = nil - await self.loadQueueSongForNavigation(at: targetIndex) - } + await self.loadQueueSongForNavigation(at: targetIndex) await self.fetchMoreMixSongsIfNeeded() await self.fillSmartShuffleWindow() self.saveQueueForPersistence(syncWebQueue: false) @@ -472,6 +465,8 @@ extension PlayerService { private func loadQueueSongForNavigation(at index: Int) async { guard let song = self.queue[safe: index] else { return } self.currentIndex = index + self.progress = 0 + self.duration = song.duration ?? 0 await self.play(song: song) self.saveQueueForPersistence() } diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift index 34a67f314..1053ba82a 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift @@ -74,8 +74,8 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.pendingPlayVideoId == "v1") } - @Test("Native next does not pre-inject next-next track while WebView is still on current track") - func nativeNextDoesNotPreinjectNextNextTrack() async { + @Test("Manual next ignores native injection marker and loads target deterministically") + func manualNextIgnoresNativeInjectionMarkerAndLoadsTargetDeterministically() async { let songs = [ Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), @@ -93,8 +93,8 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) } - @Test("Native next resets progress before persisting target queue song") - func nativeNextResetsProgressBeforePersistingTargetQueueSong() async { + @Test("Manual next resets progress before persisting target queue song") + func manualNextResetsProgressBeforePersistingTargetQueueSong() async { let songs = [ Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), @@ -113,8 +113,8 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.duration == 200) } - @Test("Native next clears consumed injection marker for duplicate video IDs") - func nativeNextClearsConsumedInjectionMarkerForDuplicateVideoIDs() async { + @Test("Manual next clears consumed injection marker for duplicate video IDs") + func manualNextClearsConsumedInjectionMarkerForDuplicateVideoIDs() async { let duplicate = Song(id: "dup", title: "Duplicate", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v2") let songs = [ Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), From b2f23e32c822e11b730f3a07d246abb0202569db Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Tue, 7 Jul 2026 19:23:19 -0700 Subject: [PATCH 11/73] docs: document gapless playback architecture Signed-off-by: Sertac Ozercan --- .../adr/0026-gapless-playback-native-queue.md | 99 +++++++++++++++ docs/adr/README.md | 1 + docs/playback.md | 116 ++++++++++++------ 3 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 docs/adr/0026-gapless-playback-native-queue.md diff --git a/docs/adr/0026-gapless-playback-native-queue.md b/docs/adr/0026-gapless-playback-native-queue.md new file mode 100644 index 000000000..51325520a --- /dev/null +++ b/docs/adr/0026-gapless-playback-native-queue.md @@ -0,0 +1,99 @@ +# ADR-0026: Gapless Playback via YouTube Music Native Queue + +## Status + +Accepted + +## Context + +Kaset plays YouTube Music through a singleton hidden `WKWebView` because DRM-protected +YouTube Music audio cannot be played through `AVPlayer`. Before this change, queue +advancement usually meant navigating the WebView to a new `watch` URL for every +track. That approach had two user-visible problems: + +- a full page navigation could leave several seconds of silence between tracks; +- YouTube Music autoplay could briefly win the race and start an unrelated track + before Kaset corrected back to its local queue. + +Users reported this as both a gapless-playback feature request and a playback bug: +queued next tracks did not reliably start when the current track ended or when the +user pressed Next. + +A native audio engine is not available for YouTube Music, so the realistic goal is +not sample-perfect gapless playback. The goal is to avoid unnecessary WebView page +reloads and let YouTube Music's own player perform the transition whenever Kaset +can safely keep YouTube Music's native queue aligned with Kaset's queue. + +## Decision + +Kaset keeps its local queue as the source of truth, but mirrors the next expected +track into YouTube Music's native **Up Next** queue ahead of time. + +- `SingletonPlayerWebView` preloads the YouTube Music app shell after login so + subsequent loads can prefer in-page router navigation over full page loads. +- `loadVideo(videoId:)` first tries YouTube Music's internal SPA router with a + `watchEndpoint`; it falls back to a full `watch` URL only when the router is not + available or rejects the command. +- `PlayerService+WebQueueSync` calls `syncWebQueue()` only when playback is in a + stable state (`.playing` or `.paused`). It does not manipulate the player-bar DOM + during `.loading`/router navigation. +- `SingletonPlayerWebView+QueueInjection` opens the current player-bar menu, + positively identifies **Play next**, and uses a click-scoped `JSON.stringify` + interceptor to swap that command's `videoIds` payload to Kaset's expected next + video ID. +- Swift tracks queue injection as two separate states: + - `pendingWebQueueInjectionVideoId`: an injection attempt has started but the + WebView has not confirmed it; + - `injectedWebQueueVideoId`: the WebView reported that the **Play next** payload + was swapped for the currently expected next track. +- `handleTrackEnded(observedVideoId:)` trusts native auto-advance only when the + confirmed injected video ID still matches the current expected next queue entry. + Otherwise Kaset falls back to deterministic queue advancement through + `play(song:)` / `loadVideo(videoId:)`. +- Any deterministic navigation, queue replacement, empty queue persistence, or + stale result clears both Swift-side and page-side queue-injection state. +- Hidden preload and restored-session pages start with autoplay blocked. Explicit + user actions such as Resume/Next/Previous unblock autoplay. + +## Consequences + +### Positive + +- Queue transitions can be handled by YouTube Music's own player without reloading + the whole WebView page for every track. +- Manual Next/Previous stay aligned with Kaset's queue through deterministic + navigation, while natural track-end handling can still use native web-player + transitions when confirmed safe. +- Duplicate tracks, stale `ended` events, failed injection attempts, queue edits, + repeat modes, and mix/smart-shuffle continuation boundaries have explicit guard + paths and regression tests. +- If native injection fails or becomes stale, playback correctness wins over + gaplessness: Kaset performs a deterministic load instead of trusting the web + queue blindly. + +### Negative / Risks + +- The queue-injection path depends on YouTube Music DOM structure and command + payload shape, especially the player-bar menu and **Play next** command. +- The `JSON.stringify` interception is intentionally narrow and click-scoped, but + it is still more fragile than a public API would be. +- Real-world gaplessness still depends on YouTube Music buffering, WebKit timing, + network state, and YouTube's internal player behavior. +- The code must carefully cancel stale page-side injection attempts when Swift + state changes, because same-document SPA navigation keeps JavaScript globals + alive. + +## Validation + +The implementation adds/updates regression coverage in: + +- `PlayerServiceWebQueueSyncTests` +- `PlayerServiceWebQueueSyncFollowUpTests` +- `PlayerServiceQueueTests` +- `AutoplayRecoveryTests` +- `PlayerServiceLibraryTests` + +The covered cases include confirmed vs pending queue injection, stale injection +results, duplicate video IDs, empty/edited queues, manual Next/Previous, natural +track-end advancement, repeat modes, restored playback seeks, autoplay blocking, +and account-scoped like/dislike completion races. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9115098bb..c7f9669ee 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -60,3 +60,4 @@ What becomes easier or more difficult because of this change? | [0023](0023-brand-account-history-session-switch.md) | Brand-Account History via WebView Session-Identity Switch | Accepted | | [0024](0024-logged-in-guest-mode.md) | Logged-In Guest Mode | Accepted | | [0025](0025-smart-shuffle.md) | Smart Shuffle (tri-state shuffle with interleaved recommendations) | Accepted | +| [0026](0026-gapless-playback-native-queue.md) | Gapless Playback via YouTube Music Native Queue | Accepted | diff --git a/docs/playback.md b/docs/playback.md index c9b81d203..7afb4ca3a 100644 --- a/docs/playback.md +++ b/docs/playback.md @@ -10,7 +10,7 @@ YouTube Music uses DRM (Widevine) to protect Premium content. Native playback vi 2. **DRM**: Premium content requires Widevine CDM, only available in WebKit 3. **User Interaction**: YouTube requires a user gesture to start playback -Our solution: A **singleton WebView** that loads YouTube Music watch pages and plays audio through WebKit's native DRM support. +Our solution: A **singleton WebView** that keeps the YouTube Music app shell alive and plays audio through WebKit's native DRM support. When possible, Kaset uses YouTube Music's in-page router and native **Up Next** queue so track changes avoid full page reloads. ## Architecture @@ -18,12 +18,13 @@ Our solution: A **singleton WebView** that loads YouTube Music watch pages and p | Component | File | Purpose | |-----------|------|---------| -| `SingletonPlayerWebView` | `MiniPlayerWebView.swift` | Manages the one-and-only WebView | +| `SingletonPlayerWebView` | `MiniPlayerWebView.swift` | Manages the one-and-only WebView and router navigation | | `SingletonPlayerWebView+PlaybackPreferences` | `SingletonPlayerWebView+PlaybackPreferences.swift` | Installs and refreshes playback preference user scripts | | `SingletonPlayerWebView+PlaybackAudioQuality` | `SingletonPlayerWebView+PlaybackAudioQuality.swift` | Applies preferred YouTube audio quality and reports diagnostics | +| `SingletonPlayerWebView+QueueInjection` | `SingletonPlayerWebView+QueueInjection.swift` | Click-scoped Play Next injection for YouTube Music's native Up Next queue | | `PersistentPlayerView` | `MiniPlayerWebView.swift` | SwiftUI wrapper for the WebView | | `PlayerService` | `PlayerService.swift` | Playback state and control | -| `PlayerService+WebQueueSync` | `PlayerService+WebQueueSync.swift` | Keeps native queue state authoritative when WebView events drift | +| `PlayerService+WebQueueSync` | `PlayerService+WebQueueSync.swift` | Mirrors Kaset's expected next track into YouTube Music's native queue and reconciles WebView events | | `AppDelegate` | `AppDelegate.swift` | Window lifecycle for background audio | ### Singleton Pattern @@ -60,36 +61,45 @@ This sets: - `pendingPlayVideoId` = video ID - `showMiniPlayer` = `true` (shows toast for user interaction) -### 2. WebView Loads +### 2. WebView Preloads -`MainWindow` observes `pendingPlayVideoId`: +`MainWindow` keeps a hidden `PersistentPlayerView` mounted whenever the user is +logged in, even before a track is requested. The hidden view creates the singleton +WebView and preloads the YouTube Music home page so the app shell and router are +ready for later playback. ```swift -if let videoId = playerService.pendingPlayVideoId { - PersistentPlayerView(videoId: videoId, isExpanded: playerService.showMiniPlayer) - .frame(width: showMiniPlayer ? 160 : 1, height: showMiniPlayer ? 90 : 1) +if authService.state.isLoggedIn { + PersistentPlayerView(videoId: playerService.pendingPlayVideoId, isExpanded: false) + .frame(width: 1, height: 1) + .opacity(0) } ``` +Startup preload blocks autoplay unless the user explicitly starts playback. This +prevents YouTube Music from resuming hidden server-side playback in the 1×1 +WebView. + ### 3. Video Starts `PersistentPlayerView` either: -- Creates new WebView (first play) -- Reuses existing WebView (subsequent plays) -```swift -func makeNSView(context: Context) -> NSView { - let webView = SingletonPlayerWebView.shared.getWebView(...) +- creates the singleton WebView and preloads the home page; +- reuses the existing WebView; +- asks `SingletonPlayerWebView.loadVideo(videoId:)` to navigate when a pending + video should autoload. - // Load if different video - if SingletonPlayerWebView.shared.currentVideoId != videoId { - webView.load(URLRequest(url: watchURL)) - } +`loadVideo(videoId:)` prefers same-document router navigation: - return container -} +```swift +app.resolveCommand({ watchEndpoint: { videoId } }) ``` +If the router is unavailable, stale, or rejects the command, Kaset falls back to +loading `https://music.youtube.com/watch?v=`. The router callback is +bound to the current load generation so an older failed router attempt cannot +full-load a stale track over a newer request. + ### 4. State Updates The observer script continuously reports: @@ -119,29 +129,38 @@ Swift validates that the ended `videoId` still matches the expected queue song before advancing. This prevents stale `ended` events from double-advancing the queue after Kaset has already loaded the next track. +If Kaset previously confirmed that the expected next song was injected into +YouTube Music's native **Up Next** queue, `handleTrackEnded(observedVideoId:)` +lets YouTube Music auto-advance natively and only updates Kaset's queue pointer. +If injection is missing, stale, or failed, Kaset falls back to deterministic +`next()` / `play(song:)` navigation instead of trusting the web queue. + ## Track Changing -When user plays a different track: +When the user plays a different track: -1. `pendingPlayVideoId` changes -2. SwiftUI calls `updateNSView` (not `makeNSView`) -3. `SingletonPlayerWebView.loadVideo(videoId:)` called -4. Current audio paused, target volume prepared, new URL loaded +1. `pendingPlayVideoId` changes. +2. SwiftUI updates the hidden `PersistentPlayerView`. +3. `SingletonPlayerWebView.loadVideo(videoId:)` pauses the current element, + refreshes autoplay/volume bootstrap state, and tries YouTube Music's SPA + router. +4. If router navigation fails and this is still the latest load generation, + Kaset falls back to a full watch URL load. -```swift -func loadVideo(videoId: String) { - guard videoId != currentVideoId else { return } +Manual `next()` and `previous()` always update Kaset's local queue state first +and use deterministic Kaset navigation: - // Update ID immediately to prevent duplicate loads - currentVideoId = videoId +- `next()` advances to the next Kaset queue entry and uses `play(song:)` / + `loadVideo(videoId:)` instead of relying on YouTube Music's ambient Next + button. This prevents stale web queue state from repeating the current song or + jumping backward. +- `previous()` uses the queue stack / restart-threshold behavior and does not + blindly inherit YouTube Music's ambient previous item. +- Standalone artist episodes remain outside Kaset's local queue and ignore + queue-navigation fallthrough. - // Pause current, set the target volume, then load new - webView.evaluateJavaScript("document.querySelector('video')?.pause()") { _, _ in - webView.evaluateJavaScript("window.__kasetTargetVolume = currentVolume") - self.webView?.load(URLRequest(url: watchURL)) - } -} -``` +Native **Up Next** injection is used for natural track-end auto-advance, not for +manual Next button presses. When the WebView reports a new `videoId`, Kaset treats that as authoritative even if the DOM title/artist are still stale. This avoids a race where YouTube @@ -442,6 +461,31 @@ Kaset treats its own queue as the source of truth whenever one exists: - In smart shuffle mode the queue may also contain ephemeral `.suggested` entries, which are stripped before persistence (see [Smart Shuffle](#smart-shuffle)) +### Native Web Queue Injection + +For gapless-style transitions, Kaset mirrors the current expected next song into +YouTube Music's native **Up Next** queue: + +1. `syncWebQueue()` runs only when playback is stable (`.playing` or `.paused`). + It does not click menu items while router navigation is loading. +2. `SingletonPlayerWebView.injectNextSong(videoId:)` opens the player-bar menu, + positively identifies **Play next**, and arms a click-scoped payload swap. +3. The page reports `QUEUE_INJECTION_RESULT` back to Swift. +4. Swift promotes `pendingWebQueueInjectionVideoId` to `injectedWebQueueVideoId` + only if the result still matches the currently expected next queue entry. +5. Any deterministic navigation, queue replacement, empty queue persistence, or + stale result clears both Swift-side and page-side injection state. + +Important invariants: + +- A pending injection is not trusted for playback decisions. +- A confirmed injection is consumed when natural track-end auto-advance uses it. +- Duplicate video IDs are handled by clearing the consumed marker so the next + queue entry can be injected again. +- If injection fails, times out, or becomes stale, Kaset prioritizes correctness + and falls back to deterministic navigation. This may not be gapless, but it + prevents Kaset's UI/persistence from diverging from actual playback. + ## Mini Player UI A small toast in the bottom-right corner: From e5d087f855c37851f86764b9cf1d12d778564646 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Tue, 7 Jul 2026 19:39:34 -0700 Subject: [PATCH 12/73] fix: route player bar next through Kaset queue Signed-off-by: Sertac Ozercan --- .../Services/Player/PlayerService+PlaybackControls.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index dee66635e..6427d2b69 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -418,7 +418,7 @@ extension PlayerService { } if self.pendingPlayVideoId != nil { - SingletonPlayerWebView.shared.next() + self.logger.debug("Ignoring next without a Kaset queue") } } @@ -457,7 +457,7 @@ extension PlayerService { if self.progress > 3 { await self.seek(to: 0) } else if self.pendingPlayVideoId != nil { - SingletonPlayerWebView.shared.previous() + self.logger.debug("Ignoring previous without a Kaset queue") } } From 17888597b6ac4003ef42fd20de0d5e62f8878cc1 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Tue, 7 Jul 2026 20:05:05 -0700 Subject: [PATCH 13/73] fix(player): materialize standalone next queue Signed-off-by: Sertac Ozercan --- Sources/APIExplorer/main.swift | 179 +++++++++++++++++- .../PlayerService+PlaybackControls.swift | 13 +- .../PlayerServiceWebQueueSyncTests.swift | 24 +++ 3 files changed, 213 insertions(+), 3 deletions(-) diff --git a/Sources/APIExplorer/main.swift b/Sources/APIExplorer/main.swift index 38f82e89b..b91cf5a20 100755 --- a/Sources/APIExplorer/main.swift +++ b/Sources/APIExplorer/main.swift @@ -612,6 +612,165 @@ func analyzeResponse(_ data: [String: Any], verbose: Bool = false) -> String { return output } +// MARK: - QueueProbeSong + +private struct QueueProbeSong { + let videoId: String + let title: String + let artists: String +} + +private func textFromRuns(_ data: [String: Any]?) -> String? { + guard let data, + let runs = data["runs"] as? [[String: Any]] + else { return nil } + + let text = runs.compactMap { $0["text"] as? String }.joined() + return text.isEmpty ? nil : text +} + +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 = textFromRuns(videoRenderer["title"] as? [String: Any]) ?? "Unknown" + let artists = textFromRuns(videoRenderer["longBylineText"] as? [String: Any]) ?? "" + return QueueProbeSong(videoId: videoId, title: title, artists: artists) + } +} + +private func queueProbeContinuationToken(in data: [String: Any]) -> String? { + guard let renderer = playlistPanelRenderer(in: data), + let continuations = renderer["continuations"] as? [[String: Any]], + let firstContinuation = continuations.first, + let nextRadioData = firstContinuation["nextRadioContinuationData"] as? [String: Any] + else { return nil } + + return nextRadioData["continuation"] as? String +} + +private func queueProbeAutoplayVideoId(in data: [String: Any]) -> String? { + guard let autoplay = data["playerOverlays"] as? [String: Any], + let playerOverlayRenderer = autoplay["playerOverlayRenderer"] as? [String: Any], + let autoplayRenderer = playerOverlayRenderer["autoplay"] as? [String: Any], + let playerOverlayAutoplayRenderer = autoplayRenderer["playerOverlayAutoplayRenderer"] as? [String: Any], + let item = playerOverlayAutoplayRenderer["item"] as? [String: Any], + let compactVideoRenderer = item["compactVideoRenderer"] as? [String: Any] + else { return nil } + + return compactVideoRenderer["videoId"] as? String +} + +func probeQueue(videoId: String, playlistId: String? = nil, verbose: Bool = false, outputFile: String? = nil) async { + let resolvedPlaylistId = playlistId ?? "RDAMVM\(videoId)" + let body: [String: Any] = [ + "videoId": videoId, + "playlistId": resolvedPlaylistId, + "enablePersistentPlaylistPanel": true, + "isAudioOnly": true, + "tunerSettingValue": "AUTOMIX_SETTING_NORMAL", + ] + + print("🎧 Probing queue for videoId: \(videoId)") + print(" playlistId: \(resolvedPlaylistId)") + print(" endpoint: next") + if loadCookiesFromAppBackup() != nil, !forceUnauthenticatedRequests { + print(" auth: cookies available") + } else { + print(" auth: guest/no cookies") + } + print() + + do { + let (data, statusCode) = try await makeRequest(endpoint: "next", body: body, authenticated: !forceUnauthenticatedRequests && loadCookiesFromAppBackup() != nil) + print("✅ HTTP \(statusCode)") + if statusCode == 401 || statusCode == 403 { + print("❌ Authentication required") + return + } + + let songs = parseQueueProbeSongs(from: data) + let ids = songs.map(\.videoId) + let seedPositions = ids.enumerated().compactMap { index, id in id == videoId ? index : nil } + let firstPlayable = songs.first + let nextPlayable = songs.dropFirst().first + print("Queue summary:") + print(" • Parsed songs: \(songs.count)") + print(" • Seed positions: \(seedPositions.isEmpty ? "none" : seedPositions.map(String.init).joined(separator: ", "))") + print(" • First parsed id: \(firstPlayable?.videoId ?? "none")") + print(" • Second parsed id: \(nextPlayable?.videoId ?? "none")") + print(" • Autoplay overlay id: \(queueProbeAutoplayVideoId(in: data) ?? "none")") + print(" • Has continuation: \(queueProbeContinuationToken(in: data) == nil ? "no" : "yes")") + + if !songs.isEmpty { + print("\nFirst songs:") + for (index, song) in songs.prefix(10).enumerated() { + let marker = song.videoId == videoId ? " ← seed" : "" + let artistSuffix = song.artists.isEmpty ? "" : " — \(song.artists)" + print(" [\(index)] \(song.videoId) :: \(song.title)\(artistSuffix)\(marker)") + } + } + + if verbose { + print("\n📄 Raw response (pretty-printed):") + if let prettyData = try? JSONSerialization.data(withJSONObject: data, options: .prettyPrinted), + let prettyString = String(data: prettyData, encoding: .utf8) + { + print(prettyString) + } + } + + if let outputFile { + if let prettyData = try? JSONSerialization.data(withJSONObject: data, options: .prettyPrinted) { + let url = URL(fileURLWithPath: outputFile) + try prettyData.write(to: url) + print("\n💾 Saved to: \(outputFile)") + } + } + } catch { + print("❌ Error: \(error.localizedDescription)") + } +} + // MARK: - Commands /// Known endpoints that require authentication @@ -1285,8 +1444,11 @@ func probeSigninSwitchReal(nextURLString: String) async { return } // Normalize protocol-relative URLs. - if signin.hasPrefix("//") { signin = "https:" + signin } - else if signin.hasPrefix("/") { signin = "https://www.youtube.com" + signin } + if signin.hasPrefix("//") { + signin = "https:" + signin + } else if signin.hasPrefix("/") { + signin = "https://www.youtube.com" + signin + } // Rewrite only the `next` param. guard var comps = URLComponents(string: signin) else { @@ -1853,6 +2015,8 @@ func showHelp() { browse [params] Explore a browse endpoint action Explore an action endpoint (body as JSON) continuation [ep] Explore a continuation (ep: 'browse' or 'next') + queue-probe [playlistId] + Summarize the Music next/radio queue shape list List all known endpoints auth Check authentication status accounts Discover available accounts (via authuser) @@ -1905,6 +2069,7 @@ func showHelp() { swift run api-explorer action search '{"query":"never gonna give you up"}' swift run api-explorer action player '{"videoId":"dQw4w9WgXcQ"}' swift run api-explorer action next '{"playlistId":"RDEM...","videoId":"abc123"}' + swift run api-explorer queue-probe dQw4w9WgXcQ # Continuation (for pagination / infinite mix) swift run api-explorer continuation # browse endpoint (default) @@ -2029,6 +2194,16 @@ func runMain() async { token, endpoint: endpoint, verbose: verbose, outputFile: outputFile ) + case "queue-probe": + guard filteredArgs.count >= 2 else { + print("❌ Usage: queue-probe [playlistId]") + print(" playlistId defaults to RDAMVM, matching YTMusicClient.getRadioQueue") + return + } + let videoId = filteredArgs[1] + let playlistId = filteredArgs.count >= 3 ? filteredArgs[2] : nil + await probeQueue(videoId: videoId, playlistId: playlistId, verbose: verbose, outputFile: outputFile) + case "list": listEndpoints() diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 6427d2b69..bf1eed226 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -417,7 +417,18 @@ extension PlayerService { return } - if self.pendingPlayVideoId != nil { + if let currentTrack = self.currentTrack { + await self.fetchAndApplyRadioQueue(for: currentTrack.videoId) + if self.queue.indices.contains(self.currentIndex + 1) { + self.pushForwardSkipStackIfLeavingIndex(for: self.currentIndex + 1) + await self.loadQueueSongForNavigation(at: self.currentIndex + 1) + await self.fetchMoreMixSongsIfNeeded() + await self.fillSmartShuffleWindow() + self.saveQueueForPersistence(syncWebQueue: false) + } else { + self.logger.debug("Ignoring next without a Kaset queue") + } + } else if self.pendingPlayVideoId != nil { self.logger.debug("Ignoring next without a Kaset queue") } } diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift index 1053ba82a..fa41cf99a 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift @@ -74,6 +74,30 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.pendingPlayVideoId == "v1") } + @Test("Manual next without queue materializes radio queue through API") + func manualNextWithoutQueueMaterializesRadioQueueThroughAPI() async { + let mockClient = MockYTMusicClient() + let seed = Song(id: "seed", title: "Seed", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "seed-video") + let radioSongs = [ + Song(id: "radio-1", title: "Radio 1", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "radio-video-1"), + Song(id: "radio-2", title: "Radio 2", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "radio-video-2"), + ] + mockClient.radioQueueSongs[seed.videoId] = radioSongs + self.playerService.setYTMusicClient(mockClient) + self.playerService.currentTrack = seed + self.playerService.pendingPlayVideoId = seed.videoId + self.playerService.state = .playing + + await self.playerService.next() + + #expect(mockClient.getRadioQueueCalled == true) + #expect(mockClient.getRadioQueueVideoIds == [seed.videoId]) + #expect(self.playerService.queue.map(\.videoId) == ["seed-video", "radio-video-1", "radio-video-2"]) + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "radio-video-1") + #expect(self.playerService.progress == 0) + } + @Test("Manual next ignores native injection marker and loads target deterministically") func manualNextIgnoresNativeInjectionMarkerAndLoadsTargetDeterministically() async { let songs = [ From f1253ec1909104c2c8467c1a0e2ea4d3bac390e4 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Tue, 7 Jul 2026 21:08:12 -0700 Subject: [PATCH 14/73] fix(player): keep manual next target guarded Signed-off-by: Sertac Ozercan --- .../Player/PlayerService+WebQueueSync.swift | 6 +++- .../PlayerServiceWebQueueSyncTests.swift | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index f31a7c434..914031126 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -327,7 +327,11 @@ extension PlayerService { self.logger.info( "YouTube loaded different track '\(title)' (\(resolvedVideoId)), re-playing intended track '\(intendedSong.title)'" ) - self.isKasetInitiatedPlayback = false + // 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`. Task { await self.play(song: intendedSong, webLoadStrategy: .forceFullPageWhenSameVideoId) } diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift index fa41cf99a..db8e2da32 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift @@ -98,6 +98,41 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.progress == 0) } + @Test("Stale metadata after manual next cannot realign backward before intended confirmation") + func staleMetadataAfterManualNextCannotRealignBackwardBeforeIntendedConfirmation() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + + await self.playerService.next() + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + + // YouTube can emit stale metadata for the previous video while Kaset's + // manual navigation load is still in flight. Multiple stale frames must + // not be allowed to realign the native queue back to the old song. + self.playerService.updateTrackMetadata( + title: "Song 1", + artist: "", + thumbnailUrl: "", + videoId: "v1" + ) + self.playerService.updateTrackMetadata( + title: "Song 1", + artist: "", + thumbnailUrl: "", + videoId: "v1" + ) + + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(self.playerService.pendingPlayVideoId == "v2") + } + @Test("Manual next ignores native injection marker and loads target deterministically") func manualNextIgnoresNativeInjectionMarkerAndLoadsTargetDeterministically() async { let songs = [ From 80591e4468ad18b4df5cb43e55271e1ebc42f73a Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Tue, 7 Jul 2026 22:01:43 -0700 Subject: [PATCH 15/73] fix(player): protect manual queue skips from stale metadata Signed-off-by: Sertac Ozercan --- .../PlayerService+PlaybackControls.swift | 2 + .../Player/PlayerService+WebQueueSync.swift | 70 +++++++++++++++++++ .../Kaset/Services/Player/PlayerService.swift | 13 ++++ .../PlayerServiceWebQueueSyncTests.swift | 60 ++++++++++++++++ 4 files changed, 145 insertions(+) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index bf1eed226..ed3e5e872 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -478,6 +478,7 @@ extension PlayerService { self.currentIndex = index self.progress = 0 self.duration = song.duration ?? 0 + self.protectQueueNavigationTarget(song.videoId) await self.play(song: song) self.saveQueueForPersistence() } @@ -493,6 +494,7 @@ extension PlayerService { self.pendingPlayVideoId = song.videoId self.progress = 0 self.duration = song.duration ?? 0 + self.protectQueueNavigationTarget(song.videoId) self.state = .loading self.isKasetInitiatedPlayback = true self.songNearingEnd = false diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index 914031126..e40abfaf4 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -4,6 +4,9 @@ import Foundation @MainActor extension PlayerService { + private static let queueNavigationInFlightProtectionGrace: Duration = .seconds(20) + private static let queueNavigationConfirmedProtectionGrace: Duration = .seconds(8) + private func applyDeferredRestoredMetadata( title: String, artist: String, @@ -216,6 +219,59 @@ extension PlayerService { return nil } + func protectQueueNavigationTarget(_ videoId: String) { + self.protectedQueueNavigationVideoId = videoId + self.protectedQueueNavigationStartedAt = ContinuousClock.now + self.protectedQueueNavigationConfirmedAt = nil + } + + private func confirmQueueNavigationTarget(_ videoId: String) { + guard self.protectedQueueNavigationVideoId == videoId else { return } + if self.protectedQueueNavigationConfirmedAt == nil { + self.protectedQueueNavigationConfirmedAt = ContinuousClock.now + } + } + + @discardableResult + private func clearExpiredQueueNavigationProtectionIfNeeded() -> Bool { + let now = ContinuousClock.now + if let confirmedAt = self.protectedQueueNavigationConfirmedAt { + guard now - confirmedAt >= Self.queueNavigationConfirmedProtectionGrace else { return false } + } else if let startedAt = self.protectedQueueNavigationStartedAt { + guard now - startedAt >= Self.queueNavigationInFlightProtectionGrace else { return false } + } else { + return false + } + + self.protectedQueueNavigationVideoId = nil + self.protectedQueueNavigationStartedAt = nil + self.protectedQueueNavigationConfirmedAt = nil + return true + } + + private func rejectProtectedQueueNavigationDriftIfNeeded( + observedVideoId: String, + currentQueueSong: Song, + thumbnailUrl: String + ) -> Bool { + self.clearExpiredQueueNavigationProtectionIfNeeded() + + guard let normalizedObservedVideoId = self.normalizedObservedVideoId(observedVideoId), + let protectedVideoId = self.protectedQueueNavigationVideoId, + currentQueueSong.videoId == protectedVideoId, + normalizedObservedVideoId != protectedVideoId + else { return false } + + self.logger.info( + "Ignoring stale in-queue metadata for \(normalizedObservedVideoId); keeping protected queue target \(protectedVideoId)" + ) + self.keepQueueSongVisible(currentQueueSong, thumbnailUrl: thumbnailUrl) + Task { + await self.play(song: currentQueueSong, webLoadStrategy: .forceFullPageWhenSameVideoId) + } + return true + } + private func isRepeatAllWraparoundTrackEnd( observedVideoId: String, expectedCurrentVideoId: String @@ -289,6 +345,10 @@ extension PlayerService { thumbnailUrl: String, trackChanged: Bool ) -> Bool { + if self.clearExpiredQueueNavigationProtectionIfNeeded() { + self.isKasetInitiatedPlayback = false + } + guard self.isKasetInitiatedPlayback, !self.queue.isEmpty else { return false } @@ -300,6 +360,7 @@ extension PlayerService { let matchesObservedVideo = self.normalizedObservedVideoId(observedVideoId) == intendedSong.videoId if matchesObservedVideo, self.shouldKeepQueueMetadata(title: title, artist: artist, song: intendedSong) { + self.confirmQueueNavigationTarget(intendedSong.videoId) self.isKasetInitiatedPlayback = false self.logger.debug( "Confirmed intended videoId \(intendedSong.videoId) with incomplete metadata '\(title)'; keeping queue metadata" @@ -314,6 +375,7 @@ extension PlayerService { artist: artist, song: intendedSong ) { + self.confirmQueueNavigationTarget(intendedSong.videoId) self.isKasetInitiatedPlayback = false self.logger.debug("Confirmed Kaset-initiated playback for '\(intendedSong.title)'") return false @@ -474,6 +536,14 @@ extension PlayerService { return false } + if self.rejectProtectedQueueNavigationDriftIfNeeded( + observedVideoId: observedVideoId, + currentQueueSong: currentQueueSong, + thumbnailUrl: thumbnailUrl + ) { + return true + } + // Repeat one: autoplay can swap the video before title/artist update, so `trackChanged` may still be false. // Without this branch we fall through and assign `currentTrack` from YouTube, breaking UI sync. guard trackChanged || self.repeatMode == .one else { diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 96893ce7a..e8c8999a2 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -595,6 +595,19 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// only trusts injections after the WebView script confirms the queue payload was swapped. var pendingWebQueueInjectionVideoId: String? + /// 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? + /// Grace period instant - don't auto-close video window shortly after opening (uses monotonic clock) var videoWindowOpenedAt: ContinuousClock.Instant? diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift index db8e2da32..758245566 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift @@ -133,6 +133,66 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.pendingPlayVideoId == "v2") } + @Test("Stale metadata after manual next confirmation cannot realign backward") + func staleMetadataAfterManualNextConfirmationCannotRealignBackward() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + + await self.playerService.next() + #expect(self.playerService.currentIndex == 1) + + self.playerService.updateTrackMetadata( + title: "Song 2", + artist: "", + thumbnailUrl: "", + videoId: "v2" + ) + + // Stale old-song metadata can still arrive after the intended video was + // briefly confirmed. It must not be treated as a legitimate native + // in-queue move back to the previous item. + self.playerService.updateTrackMetadata( + title: "Song 1", + artist: "", + thumbnailUrl: "", + videoId: "v1" + ) + + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(self.playerService.pendingPlayVideoId == "v2") + } + + @Test("Expired unconfirmed manual next protection allows later in-queue movement") + func expiredUnconfirmedManualNextProtectionAllowsLaterInQueueMovement() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + + await self.playerService.next() + #expect(self.playerService.currentIndex == 1) + self.playerService.protectedQueueNavigationStartedAt = ContinuousClock.now - .seconds(25) + + self.playerService.updateTrackMetadata( + title: "Song 3", + artist: "", + thumbnailUrl: "", + videoId: "v3" + ) + + #expect(self.playerService.currentIndex == 2) + #expect(self.playerService.currentTrack?.videoId == "v3") + } + @Test("Manual next ignores native injection marker and loads target deterministically") func manualNextIgnoresNativeInjectionMarkerAndLoadsTargetDeterministically() async { let songs = [ From a81cf4dbbe37c96debd6fa4fdc6b363da8d2a939 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 20:45:13 -0700 Subject: [PATCH 16/73] fix(player): harden native queue handoff Signed-off-by: Sertac Ozercan --- Sources/APIExplorer/main.swift | 13 +- .../Services/Player/PlayerQueueModels.swift | 10 + .../PlayerService+PlaybackControls.swift | 26 +- .../PlayerService+PlaybackRestoration.swift | 35 ++ .../Services/Player/PlayerService+Queue.swift | 5 +- .../PlayerService+WebPlaybackIdentity.swift | 253 ++++++++ .../Player/PlayerService+WebQueueSync.swift | 114 +++- .../Kaset/Services/Player/PlayerService.swift | 31 +- Sources/Kaset/Views/MiniPlayerWebView.swift | 459 ++++++++++++--- ...ngletonPlayerWebView+NavigationState.swift | 41 ++ ...ingletonPlayerWebView+ObserverScript.swift | 141 ++++- ...gletonPlayerWebView+PlaybackControls.swift | 16 +- ...tonPlayerWebView+PlaybackPreferences.swift | 7 +- ...ingletonPlayerWebView+QueueInjection.swift | 507 +++++++++++----- .../PlaybackObserverIdentityTests.swift | 65 +++ ...PlayerServiceNativeQueueAdvanceTests.swift | 236 ++++++++ ...layerServiceWebPlaybackIdentityTests.swift | 356 ++++++++++++ ...ayerServiceWebQueueSyncFollowUpTests.swift | 75 +++ .../PlayerServiceWebQueueSyncTests.swift | 50 +- .../QueueInjectionScriptTests.swift | 544 ++++++++++++++++++ .../adr/0026-gapless-playback-native-queue.md | 60 +- docs/playback.md | 34 +- 22 files changed, 2731 insertions(+), 347 deletions(-) create mode 100644 Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift create mode 100644 Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift create mode 100644 Tests/KasetTests/PlaybackObserverIdentityTests.swift create mode 100644 Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift create mode 100644 Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift create mode 100644 Tests/KasetTests/QueueInjectionScriptTests.swift diff --git a/Sources/APIExplorer/main.swift b/Sources/APIExplorer/main.swift index 3ee0d76c8..ff787f83d 100755 --- a/Sources/APIExplorer/main.swift +++ b/Sources/APIExplorer/main.swift @@ -866,15 +866,6 @@ private struct QueueProbeSong { let artists: String } -private func textFromRuns(_ data: [String: Any]?) -> String? { - guard let data, - let runs = data["runs"] as? [[String: Any]] - else { return nil } - - let text = runs.compactMap { $0["text"] as? String }.joined() - return text.isEmpty ? nil : text -} - private func playlistPanelRenderer(in data: [String: Any]) -> [String: Any]? { guard let contents = data["contents"] as? [String: Any], let watchNextRenderer = contents["singleColumnMusicWatchNextResultsRenderer"] as? [String: Any], @@ -918,8 +909,8 @@ private func parseQueueProbeSongs(from data: [String: Any]) -> [QueueProbeSong] let videoId = videoRenderer["videoId"] as? String else { return nil } - let title = textFromRuns(videoRenderer["title"] as? [String: Any]) ?? "Unknown" - let artists = textFromRuns(videoRenderer["longBylineText"] as? [String: Any]) ?? "" + 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) } } diff --git a/Sources/Kaset/Services/Player/PlayerQueueModels.swift b/Sources/Kaset/Services/Player/PlayerQueueModels.swift index 756d635e1..9acd40491 100644 --- a/Sources/Kaset/Services/Player/PlayerQueueModels.swift +++ b/Sources/Kaset/Services/Player/PlayerQueueModels.swift @@ -15,6 +15,16 @@ struct QueueEntry: Identifiable, Hashable { var source: Source = .queued } +// MARK: - PendingNativeQueueAdvance + +struct PendingNativeQueueAdvance: Equatable, Sendable { + let sourceEntryID: UUID? + let sourceVideoId: String + let targetEntryID: UUID + let targetVideoId: String + let generation: Int +} + // MARK: - QueueState struct QueueState { diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 6d2bceade..e9ee44482 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -9,7 +9,7 @@ extension PlayerService { /// Whether the persistent player should navigate to the pending video immediately. var shouldAutoloadPendingVideo: Bool { - !self.isPendingRestoredLoadDeferred + !self.isPendingRestoredLoadDeferred && self.pendingNativeQueueAdvanceVideoId == nil } /// Toggles between popup and side panel queue display modes. @@ -85,6 +85,9 @@ extension PlayerService { self.logger.info("Playing video: \(videoId)") self.clearRestoredPlaybackSessionState() self.clearWebQueueInjectionState() + // An explicit Kaset load supersedes (rather than participates in) a + // native queue advance. The native-only path never calls `play`. + self.clearPendingNativeQueueAdvance() self.currentEpisode = nil self.state = .loading self.songNearingEnd = false @@ -134,6 +137,10 @@ extension PlayerService { self.logger.debug("Web load strategy: \(String(describing: webLoadStrategy))") self.clearRestoredPlaybackSessionState() self.clearWebQueueInjectionState() + // An explicit Kaset load supersedes (rather than participates in) a + // native queue advance. `advanceQueueStateForNativeNavigation` does not + // call this method while YouTube Music owns the transition. + self.clearPendingNativeQueueAdvance() self.currentEpisode = episode // Brief `.loading` until the observer reports playback; in-place restarts may flash loading briefly. self.state = .loading @@ -473,17 +480,24 @@ extension PlayerService { } /// Navigates to a queue song through Kaset's deterministic load path. - private func loadQueueSongForNavigation(at index: Int) async { + func loadQueueSongForNavigation( + at index: Int, + webLoadStrategy strategyOverride: SingletonPlayerWebView.VideoLoadStrategy? = nil + ) async { guard let song = self.queue[safe: index] else { return } self.currentIndex = index self.progress = 0 self.duration = song.duration ?? 0 self.protectQueueNavigationTarget(song.videoId) - await self.play(song: song) + let strategy: SingletonPlayerWebView.VideoLoadStrategy = strategyOverride + ?? (SingletonPlayerWebView.shared.currentVideoId == song.videoId + ? .preferInPlaceWhenSameVideoId + : .standard) + await self.play(song: song, webLoadStrategy: strategy) self.saveQueueForPersistence() } - /// Updates Kaset's local queue pointer for native WebView queue navigation without forcing a page load. + /// 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 } @@ -496,7 +510,7 @@ extension PlayerService { self.duration = song.duration ?? 0 self.protectQueueNavigationTarget(song.videoId) self.state = .loading - self.isKasetInitiatedPlayback = true + self.isKasetInitiatedPlayback = false self.songNearingEnd = false self.shouldSuppressAutoplayAfterQueueEnd = false self.currentTrackHasVideo = song.musicVideoType?.hasVideoContent ?? song.hasVideo ?? false @@ -682,6 +696,7 @@ extension PlayerService { self.currentEpisode = nil self.currentTrack = nil self.pendingPlayVideoId = nil + self.clearPendingNativeQueueAdvance() self.progress = 0 self.currentLyricsLineIndex = nil self.currentLyricsDisplayTimeMs = nil @@ -718,6 +733,7 @@ extension PlayerService { self.shouldSuppressAutoplayAfterQueueEnd = false self.currentEpisode = nil self.currentTrack = nil + self.clearPendingNativeQueueAdvance() self.progress = 0 self.duration = 0 } diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift index c109ff9a0..55b1a1324 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift @@ -6,6 +6,41 @@ import Foundation extension PlayerService { /// Updates playback state from the persistent WebView observer. func updatePlaybackState(isPlaying: Bool, progress: Double, duration: Double) { + self.applyPlaybackStateObservation( + isPlaying: isPlaying, + progress: progress, + duration: duration + ) + } + + /// 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? + ) { + guard self.observedPlaybackMatchesCurrentTarget(videoId: observedVideoId) else { + self.logger.debug( + "Ignoring playback state for stale video \(observedVideoId ?? "unknown")" + ) + return + } + + self.applyPlaybackStateObservation( + isPlaying: isPlaying, + progress: progress, + duration: duration + ) + } + + private func applyPlaybackStateObservation( + isPlaying: Bool, + progress: Double, + duration: Double + ) { let previousProgress = self.progress if self.isPendingRestoredLoadDeferred { diff --git a/Sources/Kaset/Services/Player/PlayerService+Queue.swift b/Sources/Kaset/Services/Player/PlayerService+Queue.swift index cf7ac91fa..bc992eb67 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Queue.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Queue.swift @@ -250,7 +250,10 @@ extension PlayerService { self.clearForwardSkipNavigationStack() self.recordQueueStateForUndo() - let entries = newQueue.map { QueueEntry(id: UUID(), song: $0) } + let currentEntryID = self.currentQueueEntryID ?? UUID() + let entries = newQueue.enumerated().map { index, song in + QueueEntry(id: index == 0 ? currentEntryID : UUID(), song: song) + } if self.shuffleEnabled { self.materializeShuffleQueue( entries: entries, diff --git a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift new file mode 100644 index 000000000..38bd43ce6 --- /dev/null +++ b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift @@ -0,0 +1,253 @@ +import Foundation + +// MARK: - Web Playback Identity + +@MainActor +extension PlayerService { + private static let nativeQueueAdvanceTimeout: Duration = .seconds(3) + + /// Reconciles WebView metadata before playback state is applied. + /// + /// The page's `trackChanged` flag is advisory: YouTube can update its internal + /// video ID before the observer's title/artist state catches up, then report the + /// mismatched video with `trackChanged == false` on later ticks. Video identity + /// remains authoritative, so any mismatch must still pass through queue-drift + /// reconciliation. + /// + /// - Returns: Whether progress/play state from this observation belongs to the + /// current Kaset queue target after reconciliation. + func reconcileWebPlaybackMetadata( + title: String, + artist: String, + thumbnailUrl: String, + observedVideoId: String?, + mediaVideoId: String? = nil, + bridgeTrackChanged: Bool + ) -> Bool { + let normalizedLogicalVideoId = self.normalizedWebPlaybackVideoId(observedVideoId) + let normalizedMediaVideoId = self.normalizedWebPlaybackVideoId(mediaVideoId) + let identitiesCoherent = normalizedLogicalVideoId != nil + && normalizedLogicalVideoId == normalizedMediaVideoId + let authoritativeVideoId = normalizedMediaVideoId + let expectedVideoId = self.expectedPlaybackVideoId + let videoIdMismatch = authoritativeVideoId.map { $0 != expectedVideoId } ?? false + let titleMismatchWithoutVideoId = self.repeatMode == .one + && authoritativeVideoId == nil + && !title.isEmpty + && title != self.currentTrack?.title + 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 + || titleMismatchWithoutVideoId + || textualMetadataChanged) + if hasObservedMetadata, videoIdMismatch || shouldReconcileMetadata { + self.updateTrackMetadata( + title: identitiesCoherent ? title : "", + artist: identitiesCoherent ? artist : "", + thumbnailUrl: identitiesCoherent ? thumbnailUrl : "", + videoId: authoritativeVideoId + ) + } + + guard let authoritativeVideoId else { return true } + return self.observedPlaybackMatchesCurrentTarget(videoId: authoritativeVideoId) + } + + /// Whether an identity-bearing WebView playback observation belongs to + /// Kaset's current queue target. Once a target identity is known, transient + /// identityless ticks are rejected because they may belong to the outgoing video. + func observedPlaybackMatchesCurrentTarget(videoId observedVideoId: String?) -> Bool { + guard let expectedVideoId = self.expectedPlaybackVideoId else { return true } + guard let observedVideoId = self.normalizedWebPlaybackVideoId(observedVideoId) else { return false } + return observedVideoId == expectedVideoId + } + + var expectedPlaybackVideoId: String? { + self.pendingNativeQueueAdvanceVideoId + ?? self.queue[safe: self.currentIndex]?.videoId + ?? self.currentTrack?.videoId + ?? self.pendingPlayVideoId + } + + var isPendingNativeQueueAdvanceValid: Bool { + guard let pending = self.pendingNativeQueueAdvance, + let sourceEntryID = pending.sourceEntryID, + let sourceIndex = self.queueEntryIDs.firstIndex(of: sourceEntryID), + sourceIndex == self.currentIndex, + self.queue[safe: sourceIndex]?.videoId == pending.sourceVideoId, + let expectedTargetIndex = self.expectedQueueIndexAfterCurrentTrack(), + self.queueEntryIDs[safe: expectedTargetIndex] == pending.targetEntryID, + self.queue[safe: expectedTargetIndex]?.videoId == pending.targetVideoId + else { + return false + } + return true + } + + /// Starts a bounded native handoff without changing the visible queue pointer. + /// The pointer moves only after the media-bound observer reports the expected target. + func beginPendingNativeQueueAdvance(to index: Int) { + guard let targetEntry = self.queueEntries[safe: index], + let sourceVideoId = self.queue[safe: self.currentIndex]?.videoId + else { + return + } + + self.clearPendingNativeQueueAdvance() + let generation = self.pendingNativeQueueAdvanceGeneration + self.pendingNativeQueueAdvance = PendingNativeQueueAdvance( + sourceEntryID: self.currentQueueEntryID, + sourceVideoId: sourceVideoId, + targetEntryID: targetEntry.id, + targetVideoId: targetEntry.song.videoId, + generation: generation + ) + self.state = .loading + self.songNearingEnd = false + self.isKasetInitiatedPlayback = false + + Task { + try? await Task.sleep(for: Self.nativeQueueAdvanceTimeout) + await self.handleNativeQueueAdvanceTimeout(generation: generation) + } + } + + /// Reconciles an authoritative media-bound observation during a native handoff. + /// - Returns: Whether the caller should continue applying this observation. + func reconcilePendingNativeQueueAdvanceObservation(videoId: String?) async -> Bool { + guard let pending = self.pendingNativeQueueAdvance else { return true } + guard let videoId = self.normalizedWebPlaybackVideoId(videoId) else { return false } + + if videoId == pending.sourceVideoId { + // The outgoing element can emit a final pause/time update after `ended`. + return false + } + + if videoId == pending.targetVideoId, + await self.confirmPendingNativeQueueAdvance(videoId: videoId) + { + return true + } + + await self.fallbackPendingNativeQueueAdvance( + generation: pending.generation, + reason: "observed unexpected native video \(videoId)" + ) + return false + } + + func handleNativeQueueAdvanceTimeout(generation: Int) async { + await self.fallbackPendingNativeQueueAdvance( + generation: generation, + reason: "timed out waiting for expected native media" + ) + } + + func clearPendingNativeQueueAdvance() { + self.pendingNativeQueueAdvanceGeneration &+= 1 + self.pendingNativeQueueAdvance = nil + } + + @discardableResult + private func confirmPendingNativeQueueAdvance(videoId: String) async -> Bool { + guard let pending = self.pendingNativeQueueAdvance, + pending.targetVideoId == videoId, + self.isPendingNativeQueueAdvanceValid, + let targetIndex = self.expectedQueueIndexAfterCurrentTrack() + else { + return false + } + + self.clearPendingNativeQueueAdvance() + self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) + self.advanceQueueStateForNativeNavigation(to: targetIndex) + SingletonPlayerWebView.shared.currentVideoId = videoId + self.logger.info("Confirmed native queue advance to \(videoId)") + + await self.fetchMoreMixSongsIfNeeded() + await self.fillSmartShuffleWindow() + self.saveQueueForPersistence(syncWebQueue: false) + return true + } + + func fallbackInvalidatedNativeQueueAdvance( + generation: Int, + reason: String + ) async { + guard let pending = self.pendingNativeQueueAdvance, + pending.generation == generation, + !self.isPendingNativeQueueAdvanceValid + else { + return + } + + await self.fallbackPendingNativeQueueAdvance( + generation: generation, + reason: reason + ) + } + + private func fallbackPendingNativeQueueAdvance( + generation: Int, + reason: String + ) async { + guard let pending = self.pendingNativeQueueAdvance, + pending.generation == generation + else { + return + } + + let sourceIndex = pending.sourceEntryID.flatMap { self.queueEntryIDs.firstIndex(of: $0) } + let targetIndex: Int? + if let sourceIndex { + // When the source still exists, a nil expected successor means the + // queue now ends here. Do not turn that into a replay of the source. + self.currentIndex = sourceIndex + targetIndex = self.expectedQueueIndexAfterCurrentTrack() + } else { + // Queue mutation helpers realign `currentIndex` after removing the source; + // that post-edit position is now authoritative. + targetIndex = self.queue.indices.contains(self.currentIndex) + ? self.currentIndex + : self.queue.indices.first + } + self.clearPendingNativeQueueAdvance() + + guard let targetIndex, + let targetSong = self.queue[safe: targetIndex] + else { + self.logger.warning("Native queue advance fallback has no remaining target: \(reason)") + self.shouldSuppressAutoplayAfterQueueEnd = true + self.markPlaybackEnded() + await self.pause() + return + } + + self.logger.warning( + "Native queue advance to \(pending.targetVideoId) failed; loading current expected target \(targetSong.videoId): \(reason)" + ) + self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) + // The tracked WebView ID still describes the pre-handoff source, while + // the actual media may already be an unexpected target. Force navigation + // when IDs happen to match instead of restarting the wrong media in place. + await self.loadQueueSongForNavigation( + at: targetIndex, + webLoadStrategy: .forceFullPageWhenSameVideoId + ) + } + + private func normalizedWebPlaybackVideoId(_ videoId: String?) -> String? { + guard let videoId, !videoId.isEmpty else { return nil } + return videoId + } +} diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index e40abfaf4..5c7d89c3b 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -6,6 +6,7 @@ import Foundation extension PlayerService { 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, @@ -136,19 +137,20 @@ extension PlayerService { } /// Synchronizes Kaset's expected next track with YouTube Music's native "Up Next" queue. - /// By injecting the next track ahead of time, we achieve true gapless playback when the current track ends. + /// 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` (SPA navigation), the player bar DOM is in flux and clicking the 3-dot - /// menu would interfere with `resolveCommand`. The injection is safely deferred to - /// `confirmPlaybackStarted()` which fires once the song is actually playing. + /// 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 DOM during navigation — the MutationObserver in - // injectNextSong conflicts with resolveCommand's DOM mutations. + // Never manipulate the player-bar menu while its document is being replaced. guard self.state == .playing || self.state == .paused else { return } + guard !SingletonPlayerWebView.shared.isDocumentNavigationInProgress else { return } guard let nextIndex = self.expectedQueueIndexAfterCurrentTrack(), - let nextSong = self.queue[safe: nextIndex] + let nextSong = self.queue[safe: nextIndex], + let sourceVideoId = self.expectedPlaybackVideoId else { self.clearWebQueueInjectionState() return @@ -158,23 +160,64 @@ extension PlayerService { self.pendingWebQueueInjectionVideoId != nextSong.videoId else { return } + // Duplicate video IDs need an explicit in-place restart so the media + // generation advances with the queue entry. Do not trust native Up Next + // for a same-ID transition. + guard nextSong.videoId != sourceVideoId else { + self.clearWebQueueInjectionState() + return + } + self.pendingWebQueueInjectionVideoId = nextSong.videoId - if SingletonPlayerWebView.shared.injectNextSong(videoId: 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, success: Bool, reason: String?) { - guard self.pendingWebQueueInjectionVideoId == videoId else { + func handleWebQueueInjectionResult( + videoId: String, + attemptGeneration: Int, + success: Bool, + reason: String? + ) { + guard self.webQueueInjectionGeneration == attemptGeneration, + self.pendingWebQueueInjectionVideoId == videoId + else { self.logger.debug("Ignoring web queue injection result for non-pending video \(videoId)") return } + self.webQueueInjectionGeneration &+= 1 self.pendingWebQueueInjectionVideoId = nil - guard success else { + guard success, reason == "queue-readback-confirmed" else { if self.injectedWebQueueVideoId == videoId { self.injectedWebQueueVideoId = nil } @@ -204,7 +247,7 @@ extension PlayerService { || self.mixContinuationToken != nil } - private func expectedQueueIndexAfterCurrentTrack() -> Int? { + func expectedQueueIndexAfterCurrentTrack() -> Int? { guard !self.queue.isEmpty else { return nil } if self.repeatMode == .one { return self.currentIndex @@ -639,6 +682,25 @@ extension PlayerService { self.markPlaybackEnded() return } + if let pending = self.pendingNativeQueueAdvance { + let normalizedEndedVideoId = self.normalizedObservedVideoId(observedVideoId) + if normalizedEndedVideoId == pending.sourceVideoId { + self.logger.debug("Ignoring duplicate ended event from native handoff source") + return + } + guard normalizedEndedVideoId == pending.targetVideoId, + await self.reconcilePendingNativeQueueAdvanceObservation( + videoId: normalizedEndedVideoId + ) + else { + if normalizedEndedVideoId != nil { + _ = await self.reconcilePendingNativeQueueAdvanceObservation( + videoId: normalizedEndedVideoId + ) + } + return + } + } if let observedVideoId = self.normalizedObservedVideoId(observedVideoId) { let currentQueueVideoId = self.queue[safe: self.currentIndex]?.videoId let expectedCurrentVideoId = currentQueueVideoId ?? self.currentTrack?.videoId ?? self.pendingPlayVideoId @@ -679,20 +741,19 @@ extension PlayerService { return } - // Check if the next expected track was already injected into the web queue. - // If so, YouTube Music will auto-advance to it natively — we just need to - // update our internal state without triggering another loadVideo navigation. + // A readback-confirmed YouTube Music queue entry may advance without + // navigation. Keep the outgoing song visible until the media-bound observer + // confirms the exact expected target; a wrong or missing transition falls + // back to deterministic loading after a short bounded wait. if let expectedIndex = self.expectedQueueIndexAfterCurrentTrack(), let expectedSong = self.queue[safe: expectedIndex], self.injectedWebQueueVideoId == expectedSong.videoId { - self.logger.info("Track ended natively. Injected track \(expectedSong.videoId) will auto-play; advancing queue index only.") + self.logger.info( + "Track ended with verified native next \(expectedSong.videoId); awaiting media confirmation" + ) self.injectedWebQueueVideoId = nil - self.pushForwardSkipStackIfLeavingIndex(for: expectedIndex) - self.advanceQueueStateForNativeNavigation(to: expectedIndex) - await self.fetchMoreMixSongsIfNeeded() - await self.fillSmartShuffleWindow() - self.saveQueueForPersistence(syncWebQueue: false) + self.beginPendingNativeQueueAdvance(to: expectedIndex) return } @@ -732,9 +793,10 @@ extension PlayerService { let thumbnailURL = URL(string: thumbnailUrl) let artistObj = Artist(id: "unknown", name: artist) let resolvedVideoId = self.resolvedObservedVideoId(observedVideoId) + let videoIdChanged = self.currentTrack?.videoId != resolvedVideoId let trackChanged = self.currentTrack?.title != title || self.currentTrack?.artistsDisplay != artist - || self.currentTrack?.videoId != resolvedVideoId + || videoIdChanged if self.suppressUnexpectedAutoplayAfterQueueEndIfNeeded( trackChanged: trackChanged, @@ -803,15 +865,17 @@ extension PlayerService { ) if trackChanged { - self.clearWebQueueInjectionState() self.resetTrackStatus() // Immediately restore like status from SongLikeStatusManager cache if let cachedStatus = SongLikeStatusManager.shared.status(for: resolvedVideoId) { self.currentTrackLikeStatus = cachedStatus } - // Re-sync the web queue since the track changed natively - self.syncWebQueue() + if videoIdChanged { + self.clearWebQueueInjectionState() + // Re-sync the web queue since the playing video changed natively. + self.syncWebQueue() + } } } } diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 9056b8e5f..e4c51367b 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -180,6 +180,18 @@ 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 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 @@ -528,9 +540,21 @@ final class PlayerService: NSObject, PlayerServiceProtocol { } func setQueue(entries: [QueueEntry]) { + let pendingGeneration = self.pendingNativeQueueAdvance?.generation self.queueStorage = entries self.synchronizeCurrentQueueEntryID() self.queueDidChangeForEnrichment() + + if let pendingGeneration, + !self.isPendingNativeQueueAdvanceValid + { + Task { + await self.fallbackInvalidatedNativeQueueAdvance( + generation: pendingGeneration, + reason: "queue adjacency changed" + ) + } + } } func synchronizeCurrentQueueEntryID() { @@ -538,6 +562,7 @@ final class PlayerService: NSObject, PlayerServiceProtocol { } func clearWebQueueInjectionState() { + self.webQueueInjectionGeneration &+= 1 SingletonPlayerWebView.shared.cancelQueueInjection() self.injectedWebQueueVideoId = nil self.pendingWebQueueInjectionVideoId = nil @@ -635,9 +660,13 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// 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 confirms the queue payload was swapped. + /// 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. diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index 93a3d57b1..4519df05b 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -3,6 +3,82 @@ import os import SwiftUI import WebKit +// MARK: - WebPlaybackIdentityTransition + +enum WebPlaybackIdentityTransition { + static func isConfirmed( + observedVideoId: String?, + lastAcceptedObservedVideoId: String?, + expectedVideoIdBeforeReconciliation: String? + ) -> Bool { + guard let observedVideoId else { return false } + if let lastAcceptedObservedVideoId { + return observedVideoId != lastAcceptedObservedVideoId + } + guard let expectedVideoIdBeforeReconciliation else { return false } + return observedVideoId != expectedVideoIdBeforeReconciliation + } + + static func shouldAcceptMediaState( + queueEntryChanged: Bool, + observerEpoch: Double, + lastAcceptedObserverEpoch: Double?, + mediaGeneration: Int, + lastAcceptedMediaGeneration: Int? + ) -> Bool { + guard self.isObservationOrdered( + observerEpoch: observerEpoch, + lastAcceptedObserverEpoch: lastAcceptedObserverEpoch, + mediaGeneration: mediaGeneration, + lastAcceptedMediaGeneration: lastAcceptedMediaGeneration + ) else { + return false + } + guard let lastAcceptedObserverEpoch else { return true } + if observerEpoch > lastAcceptedObserverEpoch { + return true + } + guard let lastAcceptedMediaGeneration else { return true } + if mediaGeneration < lastAcceptedMediaGeneration { + return false + } + return !queueEntryChanged || mediaGeneration > lastAcceptedMediaGeneration + } + + static func isObservationOrdered( + observerEpoch: Double, + lastAcceptedObserverEpoch: Double?, + mediaGeneration: Int, + lastAcceptedMediaGeneration: Int? + ) -> Bool { + guard let lastAcceptedObserverEpoch else { return true } + if observerEpoch < lastAcceptedObserverEpoch { + return false + } + if observerEpoch > lastAcceptedObserverEpoch { + return true + } + guard let lastAcceptedMediaGeneration else { return true } + return mediaGeneration >= lastAcceptedMediaGeneration + } + + static func shouldHandleDeferredIdentitylessObservation( + isDeferred: Bool, + observedVideoId: String?, + mediaVideoId: String? + ) -> Bool { + isDeferred && observedVideoId == nil && mediaVideoId == nil + } + + static func didQueueEntryChange( + hasBaseline: Bool, + lastAcceptedQueueEntryID: UUID?, + currentQueueEntryID: UUID? + ) -> Bool { + hasBaseline && lastAcceptedQueueEntryID != currentQueueEntryID + } +} + // MARK: - MiniPlayerWebView /// A visible WebView that displays the YouTube Music player. @@ -221,7 +297,7 @@ struct MiniPlayerWebView: NSViewRepresentable { /// - Video mode CSS injection (SingletonPlayerWebView+VideoMode.swift) /// - Observer script (SingletonPlayerWebView+ObserverScript.swift) @MainActor -final class SingletonPlayerWebView { +final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length static let shared = SingletonPlayerWebView() private(set) var webView: WKWebView? @@ -232,6 +308,10 @@ final class SingletonPlayerWebView { var coordinator: Coordinator? let logger = DiagnosticsLogger.player private var loadGeneration = 0 + private var documentTokenGeneration = 0 + var pendingDocumentToken: Int? + var activeDocumentNavigation: WKNavigation? + var isDocumentNavigationInProgress = false /// Current display mode for the WebView. enum DisplayMode { @@ -387,6 +467,9 @@ final class SingletonPlayerWebView { guard let webView else { return } self.logger.info("Tearing down singleton music WebView") self.loadGeneration += 1 + self.pendingDocumentToken = nil + self.activeDocumentNavigation = nil + self.isDocumentNavigationInProgress = false self.currentVideoId = nil webView.evaluateJavaScript("document.querySelector('video')?.pause()", completionHandler: nil) webView.loadHTMLString("", baseURL: nil) @@ -560,7 +643,8 @@ final class SingletonPlayerWebView { nonisolated static func pageBootstrapScript( shouldBlockAutoplay: Bool, - targetVolume: Double + targetVolume: Double, + documentToken: Int = 0 ) -> String { let clampedVolume = if targetVolume.isFinite { min(max(targetVolume, 0), 1) @@ -571,6 +655,7 @@ final class SingletonPlayerWebView { return """ \(Self.autoplayIntentScript(shouldBlockAutoplay: shouldBlockAutoplay)) window.__kasetTargetVolume = \(clampedVolume); + window.__kasetDocumentToken = \(documentToken); """ } @@ -580,13 +665,17 @@ final class SingletonPlayerWebView { targetVolume: Double ) { contentController.removeAllUserScripts() + self.documentTokenGeneration &+= 1 + let documentToken = self.documentTokenGeneration + self.pendingDocumentToken = documentToken // Autoplay intent must exist before media lifecycle events like `canplay`. // `didFinish` is too late on fast or cached player loads. let pageBootstrapScript = WKUserScript( source: Self.pageBootstrapScript( shouldBlockAutoplay: shouldBlockAutoplay, - targetVolume: targetVolume + targetVolume: targetVolume, + documentToken: documentToken ), injectionTime: .atDocumentStart, forMainFrameOnly: true @@ -652,56 +741,119 @@ final class SingletonPlayerWebView { final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { let playerService: PlayerService + private var lastAcceptedObservedVideoId: String? + private var lastAcceptedObserverEpoch: Double? + private var lastAcceptedMediaGeneration: Int? + private var lastAcceptedQueueEntryID: UUID? + private var hasAcceptedQueueEntryBaseline = false + private var documentGeneration = 0 + private var activeDocumentToken: Int? + private var playbackBridgeTask: Task? init(playerService: PlayerService) { self.playerService = playerService } func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { - guard let body = message.body as? [String: Any], + guard message.webView === SingletonPlayerWebView.shared.webView, + SingletonPlayerWebView.shared.coordinator === self, + let body = message.body as? [String: Any], let type = body["type"] as? String else { return } + if Self.isDocumentScopedMessage(type) { + guard let activeDocumentToken = self.activeDocumentToken, + body["documentToken"] as? Int == activeDocumentToken + else { + return + } + } + + let messageGeneration = self.documentGeneration let observedVideoId = Self.observedVideoId(from: body) switch type { case "TRACK_ENDED": - Task { @MainActor in - await self.playerService.handleTrackEnded(observedVideoId: observedVideoId) + self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in + await coordinator.playerService.handleTrackEnded(observedVideoId: observedVideoId) } case "REMOTE_NEXT": Task { @MainActor in + guard self.isCurrentDocument(messageGeneration) else { return } await self.playerService.next() } case "REMOTE_PREVIOUS": Task { @MainActor in + guard self.isCurrentDocument(messageGeneration) else { return } await self.playerService.previous() } case "AIRPLAY_STATUS": - self.handleAirPlayStatusUpdate(body: body) + self.handleAirPlayStatusUpdate(body: body, messageGeneration: messageGeneration) case "LYRICS_LINE": - self.handleLyricsLineUpdate(body: body) + self.handleLyricsLineUpdate(body: body, messageGeneration: messageGeneration) case "PLAYBACK_AUDIO_QUALITY_STATS": Self.logAudioQualityStats(body: body, observedVideoId: observedVideoId) case "QUEUE_INJECTION_RESULT": - self.handleQueueInjectionResult(body: body, observedVideoId: observedVideoId) + self.handleQueueInjectionResult( + body: body, + observedVideoId: observedVideoId, + messageGeneration: messageGeneration + ) case "STATE_UPDATE": - self.handleStateUpdate(body: body, observedVideoId: observedVideoId) + self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in + await coordinator.handleStateUpdate( + body: body, + observedVideoId: observedVideoId, + messageGeneration: messageGeneration + ) + } default: return } } + private func enqueuePlaybackBridgeMessage( + generation: Int, + operation: @escaping @MainActor (Coordinator) async -> Void + ) { + let previousTask = self.playbackBridgeTask + self.playbackBridgeTask = Task { @MainActor [weak self] in + _ = await previousTask?.value + guard let self, + !Task.isCancelled, + self.isCurrentDocument(generation) + else { + return + } + await operation(self) + } + } + + private static func isDocumentScopedMessage(_ type: String) -> Bool { + switch type { + case "TRACK_ENDED", "REMOTE_NEXT", "REMOTE_PREVIOUS", "AIRPLAY_STATUS", "LYRICS_LINE", "QUEUE_INJECTION_RESULT", "STATE_UPDATE": + true + default: + false + } + } + + private func isCurrentDocument(_ generation: Int) -> Bool { + SingletonPlayerWebView.shared.coordinator === self + && generation == self.documentGeneration + } + private static func observedVideoId(from body: [String: Any]) -> String? { guard let videoId = body["videoId"] as? String, !videoId.isEmpty else { return nil } return videoId } - private func handleAirPlayStatusUpdate(body: [String: Any]) { + private func handleAirPlayStatusUpdate(body: [String: Any], messageGeneration: Int) { let isConnected = body["isConnected"] as? Bool ?? false let wasRequested = body["wasRequested"] as? Bool ?? false Task { @MainActor in + guard self.isCurrentDocument(messageGeneration) else { return } self.playerService.updateAirPlayStatus( isConnected: isConnected, wasRequested: wasRequested @@ -709,12 +861,13 @@ final class SingletonPlayerWebView { } } - private func handleLyricsLineUpdate(body: [String: Any]) { + private func handleLyricsLineUpdate(body: [String: Any], messageGeneration: Int) { let lineIndex = body["lineIndex"] as? Int ?? -1 let normalizedLineIndex = lineIndex >= 0 ? lineIndex : nil let displayTimeMs = body["timeMs"] as? Int Task { @MainActor in + guard self.isCurrentDocument(messageGeneration) else { return } guard self.playerService.currentLyricsLineIndex != normalizedLineIndex || self.playerService.currentLyricsDisplayTimeMs != displayTimeMs else { return } @@ -723,90 +876,27 @@ final class SingletonPlayerWebView { } } - private func handleQueueInjectionResult(body: [String: Any], observedVideoId: String?) { + private func handleQueueInjectionResult( + body: [String: Any], + observedVideoId: String?, + messageGeneration: Int + ) { guard let observedVideoId else { return } let success = body["success"] as? Bool ?? false let reason = body["reason"] as? String + guard let attemptGeneration = body["attemptGeneration"] as? Int else { return } Task { @MainActor in + guard self.isCurrentDocument(messageGeneration) else { return } self.playerService.handleWebQueueInjectionResult( videoId: observedVideoId, + attemptGeneration: attemptGeneration, success: success, reason: reason ) } } - private func handleStateUpdate(body: [String: Any], observedVideoId: String?) { - let isPlaying = body["isPlaying"] as? Bool ?? false - let progress = body["progress"] as? Int ?? 0 - let duration = body["duration"] as? Int ?? 0 - let title = body["title"] as? String ?? "" - let artist = body["artist"] as? String ?? "" - let thumbnailUrl = body["thumbnailUrl"] as? String ?? "" - let trackChanged = body["trackChanged"] as? Bool ?? false - let likeStatus = Self.likeStatus(from: body["likeStatus"] as? String) - let hasVideo = body["hasVideo"] as? Bool ?? false - - Task { @MainActor in - self.playerService.updatePlaybackState( - isPlaying: isPlaying, - progress: Double(progress), - duration: Double(duration) - ) - - // Update video availability - self.playerService.updateVideoAvailability(hasVideo: hasVideo) - - // Update like status only when track changes (initial state) - if trackChanged { - self.playerService.updateLikeStatus(likeStatus) - } - - 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 shouldReconcileMetadata { - self.playerService.updateTrackMetadata( - title: title, - artist: artist, - thumbnailUrl: thumbnailUrl, - videoId: observedVideoId - ) - - // 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 - - 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 - } - } - } - } - - private static func likeStatus(from rawValue: String?) -> LikeStatus { - switch rawValue { - case "LIKE": - .like - case "DISLIKE": - .dislike - default: - .indifferent - } - } - private static let allowedAudioQualityStatsKeys: Set = [ "afmt", "audioBitrate", @@ -1015,12 +1105,36 @@ final class SingletonPlayerWebView { decisionHandler(.allow) } - func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + let startedCurrentNavigation = SingletonPlayerWebView.shared.beginDocumentNavigation( + navigation, + in: webView + ) + if startedCurrentNavigation { + self.playerService.clearWebQueueInjectionState() + } SingletonPlayerWebView.shared.webKitManager?.extensionHostWebViewDidStartNavigation(webView) } - func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + guard SingletonPlayerWebView.shared.commitDocumentNavigation(navigation, in: webView) else { + return + } + self.playbackBridgeTask?.cancel() + self.playbackBridgeTask = nil + self.activeDocumentToken = SingletonPlayerWebView.shared.pendingDocumentToken + self.documentGeneration &+= 1 + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + let finishedActiveNavigation = SingletonPlayerWebView.shared.finishDocumentNavigation( + navigation, + in: webView + ) SingletonPlayerWebView.shared.webKitManager?.extensionHostWebViewDidFinishNavigation(webView) + if finishedActiveNavigation { + self.playerService.syncWebQueue() + } DiagnosticsLogger.player.info( "Singleton WebView finished loading: \(webView.url?.absoluteString ?? "nil")" ) @@ -1087,12 +1201,30 @@ final class SingletonPlayerWebView { } } - func webView(_ webView: WKWebView, didFail _: WKNavigation!, withError _: Error) { + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError _: Error) { + let failedActiveNavigation = SingletonPlayerWebView.shared.finishDocumentNavigation( + navigation, + in: webView + ) SingletonPlayerWebView.shared.webKitManager?.extensionHostWebViewDidFailNavigation(webView) + if failedActiveNavigation { + self.playerService.syncWebQueue() + } } - func webView(_ webView: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError _: Error + ) { + let failedActiveNavigation = SingletonPlayerWebView.shared.finishDocumentNavigation( + navigation, + in: webView + ) SingletonPlayerWebView.shared.webKitManager?.extensionHostWebViewDidFailNavigation(webView) + if failedActiveNavigation { + self.playerService.syncWebQueue() + } } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { @@ -1117,3 +1249,156 @@ final class SingletonPlayerWebView { } } } + +private extension SingletonPlayerWebView.Coordinator { + private func handleStateUpdate( + body: [String: Any], + observedVideoId: String?, + messageGeneration: Int + ) async { + let isPlaying = body["isPlaying"] as? Bool ?? false + let progress = body["progress"] as? Int ?? 0 + let duration = body["duration"] as? Int ?? 0 + let title = body["title"] as? String ?? "" + let artist = body["artist"] as? String ?? "" + let thumbnailUrl = body["thumbnailUrl"] as? String ?? "" + let trackChanged = body["trackChanged"] as? Bool ?? false + let likeStatus = Self.likeStatus(from: body["likeStatus"] as? String) + let hasVideo = body["hasVideo"] as? Bool ?? false + let mediaVideoId = Self.nonEmptyVideoId(body["mediaVideoId"]) + let mediaGeneration = body["mediaGeneration"] as? Int ?? 0 + let observerEpoch = body["observerEpoch"] as? Double ?? 0 + + guard self.isCurrentDocument(messageGeneration) else { return } + guard WebPlaybackIdentityTransition.isObservationOrdered( + observerEpoch: observerEpoch, + lastAcceptedObserverEpoch: self.lastAcceptedObserverEpoch, + mediaGeneration: mediaGeneration, + lastAcceptedMediaGeneration: self.lastAcceptedMediaGeneration + ) else { + return + } + + let shouldContinuePendingAdvance = await self.playerService + .reconcilePendingNativeQueueAdvanceObservation(videoId: mediaVideoId) + guard self.isCurrentDocument(messageGeneration), + shouldContinuePendingAdvance + else { + return + } + + if WebPlaybackIdentityTransition.shouldHandleDeferredIdentitylessObservation( + isDeferred: self.playerService.isPendingRestoredLoadDeferred, + observedVideoId: observedVideoId, + mediaVideoId: mediaVideoId + ) { + self.playerService.updatePlaybackState( + isPlaying: isPlaying, + progress: self.playerService.progress, + duration: self.playerService.duration + ) + return + } + + let expectedVideoIdBeforeReconciliation = self.playerService.currentTrack?.videoId + ?? self.playerService.pendingPlayVideoId + let shouldApplyPlaybackState = self.playerService.reconcileWebPlaybackMetadata( + title: title, + artist: artist, + thumbnailUrl: thumbnailUrl, + observedVideoId: observedVideoId, + mediaVideoId: mediaVideoId, + bridgeTrackChanged: trackChanged + ) + + 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 + ) + + guard shouldApplyPlaybackState, + self.playerService.observedPlaybackMatchesCurrentTarget(videoId: mediaVideoId), + shouldAcceptMediaState + else { + return + } + + let acceptedVideoId = mediaVideoId + 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: Double(progress), + duration: Double(duration), + observedVideoId: mediaVideoId + ) + self.lastAcceptedObserverEpoch = observerEpoch + self.lastAcceptedMediaGeneration = mediaGeneration + self.lastAcceptedQueueEntryID = currentQueueEntryID + self.hasAcceptedQueueEntryBaseline = true + + // Apply per-track state only after video identity has reconciled. + self.playerService.updateVideoAvailability(hasVideo: hasVideo) + let logicalMatchesMedia = observedVideoId != nil && observedVideoId == mediaVideoId + 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 + } + + private static func nonEmptyVideoId(_ value: Any?) -> String? { + guard let videoId = value as? String, !videoId.isEmpty else { return nil } + return videoId + } + + private static func likeStatus(from rawValue: String?) -> LikeStatus { + switch rawValue { + case "LIKE": + .like + case "DISLIKE": + .dislike + default: + .indifferent + } + } +} diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift b/Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift new file mode 100644 index 000000000..76c8fd3cb --- /dev/null +++ b/Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift @@ -0,0 +1,41 @@ +import WebKit + +@MainActor +extension SingletonPlayerWebView { + @discardableResult + func beginDocumentNavigation(_ navigation: WKNavigation?, in webView: WKWebView) -> Bool { + guard webView === self.webView else { return false } + self.activeDocumentNavigation = navigation + self.isDocumentNavigationInProgress = true + return true + } + + @discardableResult + func commitDocumentNavigation(_ navigation: WKNavigation?, in webView: WKWebView) -> Bool { + guard webView === self.webView else { return false } + switch (self.activeDocumentNavigation, navigation) { + case let (active?, committed?) where active === committed: + return true + case (nil, nil): + return true + default: + return false + } + } + + @discardableResult + func finishDocumentNavigation(_ navigation: WKNavigation?, in webView: WKWebView) -> Bool { + guard webView === self.webView else { return false } + switch (self.activeDocumentNavigation, navigation) { + case let (active?, finished?) where active === finished: + break + case (nil, nil): + break + default: + return false + } + self.activeDocumentNavigation = nil + self.isDocumentNavigationInProgress = false + return true + } +} diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift index d3a751f00..a0a3a7d06 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift @@ -16,16 +16,41 @@ extension SingletonPlayerWebView { """ } + nonisolated static var mediaIdentityBindingDecisionFunctionJS: String { + """ + function __kasetShouldBindMediaIdentity( + sourceChanged, + mediaTimeReset, + identityCorrectionEvidence + ) { + return sourceChanged + || mediaTimeReset + || identityCorrectionEvidence; + } + """ + } + /// Observer script for playback state. nonisolated static var observerScript: String { """ (function() { 'use strict'; const bridge = window.webkit.messageHandlers.singletonPlayer; + const observerEpoch = (window.performance && performance.timeOrigin) + ? performance.timeOrigin : Date.now(); + const documentToken = Number(window.__kasetDocumentToken || 0); \(autoplayRecoveryFunctionJS) + \(mediaIdentityBindingDecisionFunctionJS) let lastTitle = ''; let lastArtist = ''; let lastVideoId = ''; + let mediaVideoId = ''; + let mediaSource = ''; + let mediaGeneration = 0; + let lastMediaCurrentTime = 0; + let mediaIdentityUncertain = false; + let mediaIdentityTransitionFromVideoId = ''; + let mediaIdentityIsInitialBinding = false; let isPollingActive = false; let pollIntervalId = null; let lastUpdateTime = 0; @@ -74,11 +99,20 @@ extension SingletonPlayerWebView { if (video.__kasetListenersAttached) return; video.__kasetListenersAttached = true; + // If metadata is already loaded, establish the current media + // immediately. Otherwise the first `loadedmetadata` event owns + // the initial bind and must not look like a second transition. + if (video.readyState >= 1) { + bindMediaIdentity(video, true, false); + } + video.addEventListener('play', startPolling); video.addEventListener('playing', startPolling); // Enforce volume on playing event to catch all track changes // (auto-advance, SPA navigation, button clicks) video.addEventListener('playing', () => { + confirmMediaIdentityOnPlaying(video); + bindMediaIdentity(video, false, false); if (window.__kasetBlockAutoplay) { try { video.pause(); } catch (_) {} return; @@ -106,6 +140,8 @@ extension SingletonPlayerWebView { bridge.postMessage({ type: 'AIRPLAY_STATUS', + observerEpoch: observerEpoch, + documentToken: documentToken, isConnected: isWireless, wasConnected: wasConnected, wasRequested: window.__kasetAirPlayRequested || false @@ -118,6 +154,8 @@ extension SingletonPlayerWebView { window.__kasetAirPlayConnected = true; bridge.postMessage({ type: 'AIRPLAY_STATUS', + observerEpoch: observerEpoch, + documentToken: documentToken, isConnected: true, wasConnected: false, wasRequested: window.__kasetAirPlayRequested || false @@ -126,6 +164,8 @@ extension SingletonPlayerWebView { window.__kasetAirPlayConnected = false; bridge.postMessage({ type: 'AIRPLAY_STATUS', + observerEpoch: observerEpoch, + documentToken: documentToken, isConnected: false, wasConnected: true, wasRequested: true @@ -144,9 +184,14 @@ extension SingletonPlayerWebView { // Enforce volume at media lifecycle events where YouTube resets volume. // YouTube's player often restores its stored volume at these points. - video.addEventListener('loadedmetadata', () => enforceVolumeNow()); + video.addEventListener('loadedmetadata', () => { + bindMediaIdentity(video, true, true); + enforceVolumeNow(); + sendUpdate(true); + }); video.addEventListener('loadeddata', () => enforceVolumeNow()); function recoverAutoplayIfNeeded() { + bindMediaIdentity(video, false, false); enforceVolumeNow(); if (window.__kasetBlockAutoplay) { try { video.pause(); } catch (_) {} @@ -226,6 +271,51 @@ extension SingletonPlayerWebView { } } + function bindMediaIdentity(video, force, transitionEvidence) { + const videoId = currentVideoId(); + const source = video.currentSrc || video.src || ''; + const currentTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; + if (!force && source === mediaSource) return; + const previousMediaVideoId = mediaVideoId; + const sourceChanged = source !== mediaSource; + mediaGeneration += 1; + mediaVideoId = videoId; + mediaSource = source; + lastMediaCurrentTime = currentTime; + mediaIdentityIsInitialBinding = !previousMediaVideoId; + mediaIdentityTransitionFromVideoId = previousMediaVideoId || videoId; + mediaIdentityUncertain = !videoId + || mediaIdentityIsInitialBinding + || ((sourceChanged || transitionEvidence) + && videoId === previousMediaVideoId); + if (!mediaIdentityUncertain) { + mediaIdentityTransitionFromVideoId = ''; + mediaIdentityIsInitialBinding = false; + } + } + + function confirmMediaIdentityOnPlaying(video) { + if (!mediaIdentityUncertain) return; + const videoId = currentVideoId(); + if (!videoId) return; + if (mediaIdentityIsInitialBinding || videoId !== mediaIdentityTransitionFromVideoId) { + bindMediaIdentity(video, true, false); + } + } + + window.__kasetAdvanceMediaGeneration = function() { + const video = document.querySelector('video'); + if (!video) return false; + mediaGeneration += 1; + mediaVideoId = currentVideoId(); + mediaSource = video.currentSrc || video.src || ''; + mediaIdentityUncertain = !mediaVideoId; + mediaIdentityTransitionFromVideoId = ''; + mediaIdentityIsInitialBinding = false; + sendUpdate(true); + return true; + }; + let lyricsPollTimeoutId = null; let lyricsPollActive = false; let lyricsLineRanges = []; @@ -258,6 +348,8 @@ extension SingletonPlayerWebView { lastLyricsBucket = bucket.bucket; bridge.postMessage({ type: 'LYRICS_LINE', + observerEpoch: observerEpoch, + documentToken: documentToken, lineIndex: bucket.lineIndex, bucket: bucket.bucket, timeMs: timeMs @@ -366,9 +458,21 @@ extension SingletonPlayerWebView { } function sendTrackEnded() { - const endedVideoId = lastVideoId || currentVideoId(); + const logicalVideoId = currentVideoId(); + let endedVideoId = mediaVideoId || lastVideoId || logicalVideoId; + if (mediaIdentityUncertain) { + if (logicalVideoId + && logicalVideoId !== mediaIdentityTransitionFromVideoId) { + endedVideoId = logicalVideoId; + } else if (lastVideoId + && lastVideoId !== mediaIdentityTransitionFromVideoId) { + endedVideoId = lastVideoId; + } + } bridge.postMessage({ type: 'TRACK_ENDED', + observerEpoch: observerEpoch, + documentToken: documentToken, videoId: endedVideoId }); } @@ -419,6 +523,35 @@ extension SingletonPlayerWebView { let title = titleEl ? titleEl.textContent.trim() : ''; let artist = artistEl ? artistEl.textContent.trim() : ''; const videoId = currentVideoId(); + if (video && videoId && videoId !== mediaVideoId) { + const mediaTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; + const source = video.currentSrc || video.src || ''; + const mediaTimeReset = mediaTime + 2 < lastMediaCurrentTime; + const initialEmptyIdentityResolved = mediaIdentityUncertain + && mediaIdentityIsInitialBinding + && !mediaVideoId + && !!videoId; + const transitionIdentityResolved = mediaIdentityUncertain + && !mediaIdentityIsInitialBinding + && !!mediaIdentityTransitionFromVideoId + && videoId !== mediaIdentityTransitionFromVideoId; + const identityCorrectionEvidence = initialEmptyIdentityResolved + || transitionIdentityResolved; + if (__kasetShouldBindMediaIdentity( + source !== mediaSource, + mediaTimeReset, + identityCorrectionEvidence + )) { + bindMediaIdentity( + video, + true, + source !== mediaSource || mediaTimeReset + ); + } + } + if (video && videoId && videoId === mediaVideoId) { + lastMediaCurrentTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; + } let thumbnailUrl = ''; // Prefer player API metadata when the DOM appears to be lagging behind the actual video. @@ -482,6 +615,10 @@ extension SingletonPlayerWebView { title: title, artist: artist, videoId: videoId, + mediaVideoId: mediaVideoId, + mediaGeneration: mediaGeneration, + observerEpoch: observerEpoch, + documentToken: documentToken, thumbnailUrl: thumbnailUrl, trackChanged: trackChanged, likeStatus: likeStatus, diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift index e828eac85..ebf1ed4f8 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift @@ -257,8 +257,20 @@ extension SingletonPlayerWebView { /// Seeks to the start and resumes playback without a full page load (repeat-one, same-URL recovery). func restartInPlaceFromBeginning() { - self.seek(to: 0) - self.play() + guard let webView else { return } + let script = """ + (function() { + const video = document.querySelector('video'); + if (!video) return 'no-video'; + video.currentTime = 0; + if (typeof window.__kasetAdvanceMediaGeneration === 'function') { + window.__kasetAdvanceMediaGeneration(); + } + video.play(); + return 'restarted'; + })(); + """ + webView.evaluateJavaScript(script, completionHandler: nil) } /// Set volume (0.0 - 1.0). diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift index 8819a8c2a..ec8427652 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift @@ -123,6 +123,9 @@ extension SingletonPlayerWebView { static var mediaControlOverrideScript: String { """ (function() { + const observerEpoch = (window.performance && performance.timeOrigin) + ? performance.timeOrigin : Date.now(); + const documentToken = Number(window.__kasetDocumentToken || 0); if (typeof window.__kasetUseNextPrev !== 'boolean') { try { window.__kasetUseNextPrev = @@ -153,11 +156,11 @@ extension SingletonPlayerWebView { ms.setActionHandler('seekbackward', null); ms.setActionHandler('nexttrack', function() { window.webkit.messageHandlers.singletonPlayer - .postMessage({ type: 'REMOTE_NEXT' }); + .postMessage({ type: 'REMOTE_NEXT', observerEpoch: observerEpoch, documentToken: documentToken }); }); ms.setActionHandler('previoustrack', function() { window.webkit.messageHandlers.singletonPlayer - .postMessage({ type: 'REMOTE_PREVIOUS' }); + .postMessage({ type: 'REMOTE_PREVIOUS', observerEpoch: observerEpoch, documentToken: documentToken }); }); }); } catch (e) {} diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift b/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift index e2f663bcb..697e161a6 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift @@ -11,84 +11,124 @@ extension SingletonPlayerWebView { if (typeof window.__kasetCancelQueueInjectionAttempt === 'function') { window.__kasetCancelQueueInjectionAttempt(); } - window.__targetVideoIdToInject = null; - window.__kasetQueueInjectionClickActive = null; window.__kasetQueueInjectionAttemptId = null; + window.__kasetQueueInjectionReport = null; + window.__kasetCancelQueueInjectionAttempt = null; return 'cancelled'; })(); """ webView.evaluateJavaScript(script, completionHandler: nil) } - /// Injects a song into YouTube Music's "Up Next" queue by intercepting JSON.stringify - /// and simulating clicks on the player bar menu. This allows YouTube Music to seamlessly - /// transition to the target song natively, achieving gapless playback. - /// - Returns: `true` if the injection script was started. The actual success/failure result - /// is reported asynchronously through the singleton player bridge. + /// Injects a song into YouTube Music's native "Up Next" queue. + /// + /// The player-bar **Play next** item already carries a `queueAddEndpoint`. Kaset + /// temporarily substitutes its queue-target video ID, invokes the menu item's + /// native click path, and immediately restores the endpoint. The attempt is + /// trusted only after the visible queue model confirms the expected target after + /// the source video captured by Swift. + /// + /// - Returns: `true` if the injection script was started. Completion is reported + /// asynchronously through `QUEUE_INJECTION_RESULT`. @discardableResult - // swiftlint:disable:next function_body_length - func injectNextSong(videoId: String) -> Bool { - guard let webView = self.webView else { return false } + func injectNextSong( + videoId: String, + afterVideoId sourceVideoId: String, + attemptGeneration: Int + ) -> Bool { + guard let webView else { return false } - let videoIdLiteral = Self.javaScriptStringLiteral(videoId) + let injectionScript = Self.queueInjectionScript( + videoId: videoId, + afterVideoId: sourceVideoId, + attemptGeneration: attemptGeneration + ) + self.logger.info("Injecting video \(videoId) into YouTube Music native queue") + webView.evaluateJavaScript(injectionScript) { [weak self] _, error in + guard let error else { return } + Task { @MainActor [weak self] in + self?.logger.error("Failed to inject next song: \(error.localizedDescription)") + self?.coordinator?.playerService.handleWebQueueInjectionResult( + videoId: videoId, + attemptGeneration: attemptGeneration, + success: false, + reason: error.localizedDescription + ) + } + } + return true + } - // This script: - // 1. Installs a one-time `JSON.stringify` wrapper. - // 2. Opens the current player bar menu and positively identifies "Play next". - // 3. Arms the payload swapper only for the click turn of that menu item so unrelated - // YouTube Music payloads cannot consume the target and falsely confirm injection. - // 4. Reports success only when that click-scoped payload is swapped. - // 5. Dismisses/disarms on timeout or failure so stale targets cannot affect later requests. - let injectionScript = """ - (function(targetVideoId) { - const bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.singletonPlayer; - const injectionAttemptId = String(Date.now()) + '-' + String(Math.random()); - let timeoutId = null; - let observer = null; + // swiftlint:disable:next function_body_length + nonisolated static func queueInjectionScript( + videoId: String, + afterVideoId sourceVideoId: String, + attemptGeneration: Int = 1 + ) -> String { + let videoIdLiteral = Self.javaScriptStringLiteral(videoId) + let sourceVideoIdLiteral = Self.javaScriptStringLiteral(sourceVideoId) + return """ + (function(targetVideoId, sourceVideoId) { + const bridge = window.webkit && window.webkit.messageHandlers + && window.webkit.messageHandlers.singletonPlayer; + const injectionAttemptId = \(attemptGeneration); + const documentToken = Number(window.__kasetDocumentToken || 0); + const startedAt = Date.now(); + let menuObserver = null; + let menuWaitTimerId = null; + let menuTimeoutId = null; + let verificationTimerId = null; let didReport = false; + let didOpenMenu = false; + let didDispatchCommand = false; - function cancelAttempt() { - didReport = true; - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (observer) { - observer.disconnect(); - observer = null; - } - if (window.__kasetQueueInjectionAttemptId === injectionAttemptId) { - window.__targetVideoIdToInject = null; - window.__kasetQueueInjectionClickActive = null; - window.__kasetQueueInjectionAttemptId = null; - window.__kasetQueueInjectionReport = null; - window.__kasetCancelQueueInjectionAttempt = null; - } + if (typeof window.__kasetCancelQueueInjectionAttempt === 'function') { + window.__kasetCancelQueueInjectionAttempt(); } - window.__kasetCancelQueueInjectionAttempt = cancelAttempt; + function clearTimer(timerId) { + if (timerId !== null) clearTimeout(timerId); + } + + function clearGlobals() { + if (window.__kasetQueueInjectionAttemptId !== injectionAttemptId) return; + window.__kasetQueueInjectionAttemptId = null; + window.__kasetQueueInjectionReport = null; + window.__kasetCancelQueueInjectionAttempt = null; + } + + function dismissPlayerMenu() { + if (!didOpenMenu) return; + didOpenMenu = false; + document.body.click(); + } + + function finishAttempt() { + clearTimer(menuWaitTimerId); + clearTimer(menuTimeoutId); + clearTimer(verificationTimerId); + menuWaitTimerId = null; + menuTimeoutId = null; + verificationTimerId = null; + if (menuObserver) { + menuObserver.disconnect(); + menuObserver = null; + } + dismissPlayerMenu(); + clearGlobals(); + } function report(success, reason, reportedVideoId) { if (didReport) return; didReport = true; - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (window.__kasetQueueInjectionAttemptId === injectionAttemptId) { - window.__kasetQueueInjectionAttemptId = null; - } - if (window.__kasetQueueInjectionClickActive === injectionAttemptId) { - window.__kasetQueueInjectionClickActive = null; - } - if (window.__kasetCancelQueueInjectionAttempt === cancelAttempt) { - window.__kasetCancelQueueInjectionAttempt = null; - } + finishAttempt(); try { if (bridge) { bridge.postMessage({ type: 'QUEUE_INJECTION_RESULT', videoId: reportedVideoId || targetVideoId, + attemptGeneration: injectionAttemptId, + documentToken: documentToken, success: !!success, reason: reason || '' }); @@ -96,133 +136,286 @@ extension SingletonPlayerWebView { } catch (_) {} } - if (!window.__stringifyIntercepted) { - const originalStringify = JSON.stringify; - JSON.stringify = function(value, replacer, space) { - const shouldSwap = value - && typeof value === 'object' - && value.videoIds - && Array.isArray(value.videoIds) - && window.__targetVideoIdToInject - && window.__kasetQueueInjectionClickActive === window.__kasetQueueInjectionAttemptId; - - if (shouldSwap) { - const injectedVideoId = window.__targetVideoIdToInject; - console.log('[INJECTOR] Swapping ' + value.videoIds[0] + ' -> ' + injectedVideoId); - value.videoIds = [injectedVideoId]; - window.__targetVideoIdToInject = null; - window.__kasetQueueInjectionClickActive = null; - if (typeof window.__kasetQueueInjectionReport === 'function') { - window.__kasetQueueInjectionReport(true, 'swapped', injectedVideoId); - } - } - - return originalStringify(value, replacer, space); - }; - window.__stringifyIntercepted = true; + function cancelAttempt() { + if (didReport) return; + didReport = true; + finishAttempt(); } window.__kasetQueueInjectionAttemptId = injectionAttemptId; window.__kasetQueueInjectionReport = report; + window.__kasetCancelQueueInjectionAttempt = cancelAttempt; + + function collectVideoIds(root) { + const ids = []; + const seenObjects = new Set(); + function visit(value, depth) { + if (!value || depth > 7 || ids.length >= 40) return; + if (typeof value !== 'object') return; + if (seenObjects.has(value)) return; + seenObjects.add(value); + if (Array.isArray(value)) { + for (const item of value.slice(0, 100)) visit(item, depth + 1); + return; + } + for (const [key, child] of Object.entries(value)) { + if ((key === 'videoId' || key === 'video_id') + && typeof child === 'string' && child.length > 0) { + if (!ids.includes(child)) ids.push(child); + } else if (typeof child === 'object') { + visit(child, depth + 1); + } + if (ids.length >= 40) break; + } + } + visit(root, 0); + return ids; + } - function clearActiveTarget() { - if (window.__kasetQueueInjectionAttemptId === injectionAttemptId) { - window.__targetVideoIdToInject = null; + function queueItemIsCurrent(item) { + if (item.__kasetIsCurrent === true) return true; + if (typeof item.hasAttribute === 'function' + && (item.hasAttribute('selected') + || item.hasAttribute('is-playlist-panel-video-renderer-selected'))) { + return true; } - if (window.__kasetQueueInjectionClickActive === injectionAttemptId) { - window.__kasetQueueInjectionClickActive = null; + if (typeof item.getAttribute === 'function') { + const ariaCurrent = item.getAttribute('aria-current'); + if (ariaCurrent === 'true' || ariaCurrent === 'page') return true; } - if (window.__kasetCancelQueueInjectionAttempt === cancelAttempt) { - window.__kasetCancelQueueInjectionAttempt = null; + return !!(item.classList && typeof item.classList.contains === 'function' + && item.classList.contains('selected')); + } + + function currentQueueItems() { + const queueItems = Array.from(document.querySelectorAll( + 'ytmusic-player-queue-item, ytmusic-player-queue-item-renderer, ' + + 'ytmusic-player-queue ytmusic-responsive-list-item-renderer' + )); + const items = []; + for (const item of queueItems) { + const ids = collectVideoIds(item.data || item.__data || null); + if (ids.length > 0) { + items.push({ videoId: ids[0], isCurrent: queueItemIsCurrent(item) }); + } } + return items; } - function fireQueueClick(menuItems) { - if (window.__kasetQueueInjectionAttemptId !== injectionAttemptId) return false; + function queueItemVideoIds(items) { + return items.map(item => item.videoId); + } + + function queueOccurrencesMatch(lhs, rhs) { + return lhs.length === rhs.length + && lhs.every((videoId, index) => videoId === rhs[index]); + } + + function hasExpectedNextOccurrence(items) { + const currentIndex = items.findIndex(item => item.isCurrent); + if (currentIndex < 0) return false; + return items[currentIndex].videoId === sourceVideoId + && !!items[currentIndex + 1] + && items[currentIndex + 1].videoId === targetVideoId; + } + + function currentPlayerVideoId() { + const ytmusicPlayer = document.querySelector('ytmusic-player'); + if (ytmusicPlayer && ytmusicPlayer.playerApi + && typeof ytmusicPlayer.playerApi.getVideoData === 'function') { + const data = ytmusicPlayer.playerApi.getVideoData(); + const videoId = data && (data.video_id || data.videoId); + if (videoId) return videoId; + } + const moviePlayer = document.getElementById('movie_player'); + if (moviePlayer && typeof moviePlayer.getVideoData === 'function') { + const data = moviePlayer.getVideoData(); + const videoId = data && (data.video_id || data.videoId); + if (videoId) return videoId; + } + return ''; + } + + function findPlayNextItem(menuItems) { const playNextPathData = "M6 2.86V5H3a1 1 0 00-1 1v12a1 1 0 102 0V7h2v2.137a.5.5 0 00.748.434L13 5.998 6.748 2.426A.5.5 0 006 2.86ZM21 5h-5a1 1 0 100 2h5a1 1 0 100-2Zm0 6H9a1 1 0 000 2h12a1 1 0 000-2Zm0 6H9a1 1 0 000 2h12a1 1 0 000-2Z"; - let targetBtn = null; - const iconPaths = document.querySelectorAll('path[d="' + playNextPathData + '"]'); - if (iconPaths.length > 0) { - targetBtn = iconPaths[0].closest('ytmusic-menu-service-item-renderer'); - } - if (!targetBtn) { - targetBtn = Array.from(menuItems).find(el => { - const text = (el.textContent || '').toLowerCase(); - const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase(); - return text.includes('next') || ariaLabel.includes('next'); - }); - } - if (!targetBtn) { - clearActiveTarget(); + const iconItem = Array.from(menuItems).find(item => + typeof item.querySelector === 'function' + && item.querySelector('path[d="' + playNextPathData + '"]') + ); + if (iconItem) return iconItem; + return Array.from(menuItems).find(item => { + const text = (item.textContent || '').toLowerCase(); + const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase(); + return text.includes('next') || ariaLabel.includes('next'); + }) || null; + } + + function dispatchVerifiedQueueClick(menuItems) { + if (window.__kasetQueueInjectionAttemptId !== injectionAttemptId) return false; + const targetItem = findPlayNextItem(menuItems); + if (!targetItem) { report(false, 'play-next-item-not-found'); return false; } - window.__targetVideoIdToInject = targetVideoId; - window.__kasetQueueInjectionClickActive = injectionAttemptId; - targetBtn.click(); + const targetData = targetItem.data || targetItem.__data || null; + const serviceEndpoint = targetData + && (targetData.serviceEndpoint || targetData.navigationEndpoint || targetData.endpoint); + const queueAddEndpoint = serviceEndpoint && serviceEndpoint.queueAddEndpoint; + const queueTarget = queueAddEndpoint && queueAddEndpoint.queueTarget; + if (!queueAddEndpoint + || !queueTarget + || typeof queueTarget.videoId !== 'string' + || typeof targetItem.click !== 'function') { + report(false, 'play-next-endpoint-unavailable'); + return false; + } + const activeVideoId = currentPlayerVideoId(); + if (activeVideoId && activeVideoId !== sourceVideoId) { + report(false, 'source-video-changed'); + return false; + } + if (queueTarget.videoId !== sourceVideoId) { + dismissPlayerMenu(); + if (Date.now() - startedAt < 8000) { + menuWaitTimerId = setTimeout(openPlayerBarMenuWhenReady, 100); + } else { + report(false, 'menu-source-mismatch'); + } + return false; + } + const queueItemsBeforeClick = currentQueueItems(); + if (hasExpectedNextOccurrence(queueItemsBeforeClick)) { + dismissPlayerMenu(); + report(true, 'queue-readback-confirmed', targetVideoId); + return true; + } + if (queueItemsBeforeClick[0] + && !queueItemsBeforeClick[0].isCurrent + && queueItemsBeforeClick[0].videoId === sourceVideoId + && queueItemsBeforeClick[1] + && queueItemsBeforeClick[1].videoId === targetVideoId) { + report(false, 'queue-readback-ambiguous'); + return false; + } - // The player-bar menu command serializes synchronously from the click. If the - // target was not consumed by then, fail closed instead of leaving a target armed - // for unrelated YouTube Music requests. - setTimeout(() => { - if (didReport) return; - if (window.__kasetQueueInjectionClickActive === injectionAttemptId) { - clearActiveTarget(); - report(false, 'play-next-payload-not-observed'); + didDispatchCommand = true; + const queueOccurrencesBeforeClick = queueItemVideoIds(queueItemsBeforeClick); + const onEmptyWatchEndpoint = queueTarget.onEmptyQueue + && queueTarget.onEmptyQueue.watchEndpoint; + const originalTargetVideoId = queueTarget.videoId; + const originalOnEmptyVideoId = onEmptyWatchEndpoint + && onEmptyWatchEndpoint.videoId; + + function restoreEndpoint() { + queueTarget.videoId = originalTargetVideoId; + if (onEmptyWatchEndpoint) { + onEmptyWatchEndpoint.videoId = originalOnEmptyVideoId; } - }, 0); - return true; - } + } - const playerBarMenuBtn = document.querySelector('.middle-controls-buttons.ytmusic-player-bar ytmusic-menu-renderer button'); - if (playerBarMenuBtn) { - observer = new MutationObserver((mutations, obs) => { - if (window.__kasetQueueInjectionAttemptId !== injectionAttemptId) { - obs.disconnect(); + queueTarget.videoId = targetVideoId; + if (onEmptyWatchEndpoint) { + onEmptyWatchEndpoint.videoId = targetVideoId; + } + + try { + targetItem.click(); + } catch (_) { + restoreEndpoint(); + report(false, 'play-next-click-threw'); + return false; + } + setTimeout(restoreEndpoint, 0); + dismissPlayerMenu(); + + const verificationStartedAt = Date.now(); + function verifyQueuedTarget() { + if (didReport || window.__kasetQueueInjectionAttemptId !== injectionAttemptId) { return; } - const newMenuItems = document.querySelectorAll('ytmusic-menu-popup-renderer ytmusic-menu-service-item-renderer'); - if (newMenuItems.length > 0) { - obs.disconnect(); - observer = null; - fireQueueClick(newMenuItems); - document.body.click(); + const items = currentQueueItems(); + const ids = queueItemVideoIds(items); + const queueChanged = !queueOccurrencesMatch( + queueOccurrencesBeforeClick, + ids + ); + const activeVideoIdAfterClick = currentPlayerVideoId(); + if (activeVideoIdAfterClick && activeVideoIdAfterClick !== sourceVideoId) { + report(false, 'source-video-changed-before-readback'); + return; } - }); - observer.observe(document.body, { childList: true, subtree: true }); - playerBarMenuBtn.click(); - timeoutId = setTimeout(() => { - if (observer) { - observer.disconnect(); - observer = null; + if (queueChanged && hasExpectedNextOccurrence(items)) { + report(true, 'queue-readback-confirmed', targetVideoId); + return; } - if (window.__kasetQueueInjectionAttemptId === injectionAttemptId) { - clearActiveTarget(); - document.body.click(); - report(false, 'menu-timeout'); + if (Date.now() - verificationStartedAt >= 6000) { + report(false, 'queue-readback-timeout'); + return; } - }, 2000); - } else { - console.log('[INJECTOR] Player bar menu not found — song may not be loaded yet'); - clearActiveTarget(); - report(false, 'player-bar-menu-not-found'); + verificationTimerId = setTimeout(verifyQueuedTarget, 100); + } + verificationTimerId = setTimeout(verifyQueuedTarget, 0); + return true; } - })(\(videoIdLiteral)); - """ - self.logger.info("Injecting video \(videoId) into YouTube Music native queue") - webView.evaluateJavaScript(injectionScript) { [weak self] _, error in - guard let error else { return } - Task { @MainActor [weak self] in - self?.logger.error("Failed to inject next song: \(error.localizedDescription)") - self?.coordinator?.playerService.handleWebQueueInjectionResult( - videoId: videoId, - success: false, - reason: error.localizedDescription - ) + function findPlayerBarMenuButton() { + const menuRendererButton = document.querySelector( + '.middle-controls-buttons.ytmusic-player-bar ytmusic-menu-renderer button' + ); + if (menuRendererButton) return menuRendererButton; + + // Compatibility fallback for the newer player-bar button shape. + return Array.from(document.querySelectorAll('ytmusic-player-bar button')).find(button => + (button.getAttribute('aria-label') || '').toLowerCase() === 'action menu' + ) || null; } - } - return true + + function openPlayerBarMenuWhenReady() { + if (didReport || window.__kasetQueueInjectionAttemptId !== injectionAttemptId) return; + const playerBarMenuButton = findPlayerBarMenuButton(); + if (!playerBarMenuButton) { + if (Date.now() - startedAt < 8000) { + menuWaitTimerId = setTimeout(openPlayerBarMenuWhenReady, 100); + } else { + report(false, 'player-bar-menu-not-found'); + } + return; + } + + function tryDispatchFromMenu() { + if (didReport || didDispatchCommand + || window.__kasetQueueInjectionAttemptId !== injectionAttemptId) { + return; + } + const menuItems = document.querySelectorAll( + 'ytmusic-menu-popup-renderer ytmusic-menu-service-item-renderer' + ); + if (menuItems.length === 0) return; + if (menuObserver) { + menuObserver.disconnect(); + menuObserver = null; + } + clearTimer(menuTimeoutId); + menuTimeoutId = null; + dispatchVerifiedQueueClick(menuItems); + } + + menuObserver = new MutationObserver(() => tryDispatchFromMenu()); + menuObserver.observe(document.body, { childList: true, subtree: true }); + didOpenMenu = true; + menuTimeoutId = setTimeout(() => { + if (didReport) return; + report(false, 'menu-timeout'); + }, 5000); + playerBarMenuButton.click(); + // Menus can stay mounted between SPA navigations and only toggle + // visibility, producing no child-list mutation. + setTimeout(tryDispatchFromMenu, 0); + } + + openPlayerBarMenuWhenReady(); + })(\(videoIdLiteral), \(sourceVideoIdLiteral)); + """ } } diff --git a/Tests/KasetTests/PlaybackObserverIdentityTests.swift b/Tests/KasetTests/PlaybackObserverIdentityTests.swift new file mode 100644 index 000000000..7fa0f4c7d --- /dev/null +++ b/Tests/KasetTests/PlaybackObserverIdentityTests.swift @@ -0,0 +1,65 @@ +import JavaScriptCore +import Testing +import WebKit +@testable import Kaset + +@Suite(.tags(.service)) +@MainActor +struct PlaybackObserverIdentityTests { + @Test("Observer reports media-bound identity and generation with playback state") + func observerReportsMediaIdentity() { + let script = SingletonPlayerWebView.observerScript + + #expect(script.contains("bindMediaIdentity(video, true, false)")) + #expect(script.contains("mediaVideoId: mediaVideoId")) + #expect(script.contains("mediaGeneration: mediaGeneration")) + #expect(script.contains("observerEpoch: observerEpoch")) + #expect(script.contains("window.__kasetAdvanceMediaGeneration")) + #expect(script.contains("const mediaTimeReset = mediaTime + 2 < lastMediaCurrentTime")) + #expect(script.contains("if (video.readyState >= 1)")) + #expect(script.contains("mediaIdentityTransitionFromVideoId")) + #expect(script.contains("confirmMediaIdentityOnPlaying")) + #expect(script.contains("identityCorrectionEvidence")) + #expect(script.contains("initialEmptyIdentityResolved")) + #expect(script.contains("observerEpoch: observerEpoch")) + } + + @Test("Uncertain media identity rebinds only with explicit correction evidence") + func uncertainMediaIdentityRequiresCorrectionEvidence() throws { + let context = try #require(JSContext()) + context.evaluateScript(SingletonPlayerWebView.mediaIdentityBindingDecisionFunctionJS) + + #expect(context.evaluateScript( + "__kasetShouldBindMediaIdentity(false, false, true)" + )?.toBool() == true) + #expect(context.evaluateScript( + "__kasetShouldBindMediaIdentity(false, false, false)" + )?.toBool() == false) + } + + @Test("A stale WebView cannot start the current document navigation gate") + func staleWebViewNavigationStartIsIgnored() { + let staleWebView = WKWebView() + let previousState = SingletonPlayerWebView.shared.isDocumentNavigationInProgress + defer { SingletonPlayerWebView.shared.isDocumentNavigationInProgress = previousState } + + let accepted = SingletonPlayerWebView.shared.beginDocumentNavigation(nil, in: staleWebView) + + #expect(!accepted) + #expect(SingletonPlayerWebView.shared.isDocumentNavigationInProgress == previousState) + } + + @Test("Logical ID drift without a media transition does not rebind identity") + func logicalIDDriftAloneDoesNotBindMediaIdentity() throws { + let context = try #require(JSContext()) + context.evaluateScript(SingletonPlayerWebView.mediaIdentityBindingDecisionFunctionJS) + + #expect(context.evaluateScript( + "__kasetShouldBindMediaIdentity(false, false, false)" + )?.toBool() == false) + #expect(context.evaluateScript( + "__kasetShouldBindMediaIdentity(false, false, false)" + )?.toBool() == false) + #expect(!SingletonPlayerWebView.observerScript.contains("mediaTime < 5")) + } +} diff --git a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift new file mode 100644 index 000000000..b64d39f12 --- /dev/null +++ b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing +@testable import Kaset + +extension PlayerServiceWebQueueSyncTests { + @Test("Injected track end keeps the outgoing song visible until expected media confirmation") + func injectedTrackEndWaitsForExpectedMediaConfirmation() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), + ] + + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + let previousWebVideoId = SingletonPlayerWebView.shared.currentVideoId + defer { SingletonPlayerWebView.shared.currentVideoId = previousWebVideoId } + SingletonPlayerWebView.shared.currentVideoId = "v1" + + await self.playerService.handleTrackEnded(observedVideoId: "v1") + + #expect(self.playerService.currentIndex == 0) + #expect(self.playerService.currentTrack?.videoId == "v1") + #expect(self.playerService.state == .loading) + #expect(self.playerService.injectedWebQueueVideoId == nil) + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == "v2") + #expect(!self.playerService.shouldAutoloadPendingVideo) + #expect(SingletonPlayerWebView.shared.currentVideoId == "v1") + + let shouldContinue = await self.playerService.reconcilePendingNativeQueueAdvanceObservation( + videoId: "v2" + ) + + #expect(shouldContinue) + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == nil) + #expect(self.playerService.shouldAutoloadPendingVideo) + #expect(SingletonPlayerWebView.shared.currentVideoId == "v2") + } + + @Test("Duplicate ended events do not bypass a pending native handoff") + func duplicateEndedEventDoesNotBypassPendingNativeHandoff() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + + await self.playerService.handleTrackEnded(observedVideoId: "v1") + await self.playerService.handleTrackEnded(observedVideoId: "v1") + + #expect(self.playerService.currentIndex == 0) + #expect(self.playerService.currentTrack?.videoId == "v1") + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == "v2") + self.playerService.clearPendingNativeQueueAdvance() + } + + @Test("A target ended event confirms the handoff and continues to its successor") + func targetEndedBeforeStateConfirmationContinuesQueue() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 1, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], duration: 220, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + await self.playerService.handleTrackEnded(observedVideoId: "v1") + + await self.playerService.handleTrackEnded(observedVideoId: "v2") + + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == nil) + #expect(self.playerService.currentIndex == 2) + #expect(self.playerService.currentTrack?.videoId == "v3") + #expect(self.playerService.pendingPlayVideoId == "v3") + } + + @Test("Outgoing media observations wait for the pending native queue target") + func outgoingMediaWaitsForPendingNativeQueueTarget() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + await self.playerService.handleTrackEnded(observedVideoId: "v1") + + let shouldContinue = await self.playerService.reconcilePendingNativeQueueAdvanceObservation( + videoId: "v1" + ) + + #expect(!shouldContinue) + #expect(self.playerService.currentIndex == 0) + #expect(self.playerService.currentTrack?.videoId == "v1") + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == "v2") + self.playerService.clearPendingNativeQueueAdvance() + } + + @Test("Unexpected native autoplay falls back to the expected queue target") + func unexpectedNativeAutoplayFallsBackToExpectedTarget() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + await self.playerService.handleTrackEnded(observedVideoId: "v1") + + let shouldContinue = await self.playerService.reconcilePendingNativeQueueAdvanceObservation( + videoId: "unexpected-video" + ) + + #expect(!shouldContinue) + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(self.playerService.pendingPlayVideoId == "v2") + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == nil) + #expect(self.playerService.state == .loading) + } + + @Test("Queue reordering during a native handoff recovers to the newly next entry") + func queueReorderDuringNativeHandoffUsesNewAdjacency() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], duration: 220, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + await self.playerService.handleTrackEnded(observedVideoId: "v1") + let entries = self.playerService.queueEntries + + self.playerService.setQueue(entries: [entries[0], entries[2], entries[1]]) + try? await Task.sleep(for: .milliseconds(20)) + + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == nil) + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v3") + #expect(self.playerService.pendingPlayVideoId == "v3") + } + + @Test("Removing the source entry follows the queue's realigned current position") + func removingSourceEntryUsesRealignedQueuePosition() async { + let songs = [ + Song(id: "a", title: "A", artists: [], duration: 180, videoId: "va"), + Song(id: "b", title: "B", artists: [], duration: 180, videoId: "vb"), + Song(id: "c", title: "C", artists: [], duration: 180, videoId: "vc"), + Song(id: "d", title: "D", artists: [], duration: 180, videoId: "vd"), + ] + await self.playerService.playQueue(songs, startingAt: 1) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "vc" + await self.playerService.handleTrackEnded(observedVideoId: "vb") + let sourceEntryID = self.playerService.queueEntries[1].id + + self.playerService.removeFromQueue(entryIDs: [sourceEntryID]) + try? await Task.sleep(for: .milliseconds(20)) + + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == nil) + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "vc") + #expect(self.playerService.pendingPlayVideoId == "vc") + } + + @Test("Transient invalid adjacency does not cancel a restored valid handoff") + func transientQueueInvalidationRevalidatesBeforeFallback() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], duration: 220, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + await self.playerService.handleTrackEnded(observedVideoId: "v1") + let entries = self.playerService.queueEntries + + self.playerService.setQueue(entries: [entries[0], entries[2], entries[1]]) + self.playerService.setQueue(entries: entries) + await Task.yield() + + #expect(self.playerService.currentIndex == 0) + #expect(self.playerService.currentTrack?.videoId == "v1") + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == "v2") + self.playerService.clearPendingNativeQueueAdvance() + } + + @Test("Removing the only successor during a native handoff ends playback") + func removingOnlySuccessorDuringNativeHandoffEndsPlayback() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + await self.playerService.handleTrackEnded(observedVideoId: "v1") + let sourceEntry = self.playerService.queueEntries[0] + + self.playerService.setQueue(entries: [sourceEntry]) + try? await Task.sleep(for: .milliseconds(20)) + + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == nil) + #expect(self.playerService.currentIndex == 0) + #expect(self.playerService.currentTrack?.videoId == "v1") + #expect(self.playerService.state == .ended) + } + + @Test("Native queue advance timeout deterministically loads the expected target") + func nativeQueueAdvanceTimeoutLoadsExpectedTarget() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "v2" + await self.playerService.handleTrackEnded(observedVideoId: "v1") + let generation = self.playerService.pendingNativeQueueAdvanceGeneration + + await self.playerService.handleNativeQueueAdvanceTimeout(generation: generation) + + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == nil) + #expect(self.playerService.state == .loading) + } +} diff --git a/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift b/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift new file mode 100644 index 000000000..086fe2b09 --- /dev/null +++ b/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift @@ -0,0 +1,356 @@ +import Foundation +import Testing +@testable import Kaset + +// MARK: - PlaybackMediaGenerationCase + +struct PlaybackMediaGenerationCase: Sendable { + let queueEntryChanged: Bool + let epoch: Double + let lastEpoch: Double? + let generation: Int + let lastGeneration: Int? + let expected: Bool +} + +extension PlayerServiceWebQueueSyncTests { + // MARK: - Web Playback Identity Reconciliation + + @Test("Mismatched Web video ID reconciles when bridge trackChanged is false") + func mismatchedWebVideoIDReconcilesWithoutTrackChangedFlag() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + await self.playerService.next() + + let confirmedTargetShouldApply = self.playerService.reconcileWebPlaybackMetadata( + title: "Song 2", + artist: "", + thumbnailUrl: "", + observedVideoId: "v2", + mediaVideoId: "v2", + bridgeTrackChanged: true + ) + #expect(confirmedTargetShouldApply) + + let staleEarlierSongShouldApply = self.playerService.reconcileWebPlaybackMetadata( + title: "Song 1", + artist: "", + thumbnailUrl: "", + observedVideoId: "v1", + mediaVideoId: "v1", + bridgeTrackChanged: false + ) + + #expect(!staleEarlierSongShouldApply) + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(self.playerService.pendingPlayVideoId == "v2") + } + + @Test("Logical next metadata cannot advance the queue before media identity changes") + func logicalNextMetadataWaitsForMediaIdentity() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + + let shouldApply = self.playerService.reconcileWebPlaybackMetadata( + title: "Song 2", + artist: "", + thumbnailUrl: "", + observedVideoId: "v2", + mediaVideoId: "v1", + bridgeTrackChanged: true + ) + + #expect(shouldApply) + #expect(self.playerService.currentIndex == 0) + #expect(self.playerService.currentTrack?.videoId == "v1") + } + + @Test("Logical-only next metadata cannot advance the queue before media identity exists") + func logicalOnlyNextMetadataWaitsForMediaIdentity() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + + let shouldApply = self.playerService.reconcileWebPlaybackMetadata( + title: "Song 2", + artist: "", + thumbnailUrl: "", + observedVideoId: "v2", + mediaVideoId: nil, + bridgeTrackChanged: true + ) + + #expect(shouldApply) + #expect(self.playerService.currentIndex == 0) + #expect(self.playerService.currentTrack?.videoId == "v1") + } + + @Test("Media identity realigns the queue while logical metadata still lags") + func mediaIdentityRealignsBeforeLogicalMetadata() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.isKasetInitiatedPlayback = false + + let shouldApply = self.playerService.reconcileWebPlaybackMetadata( + title: "Song 1", + artist: "", + thumbnailUrl: "", + observedVideoId: "v1", + mediaVideoId: "v2", + bridgeTrackChanged: false + ) + + #expect(shouldApply) + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(self.playerService.currentTrack?.title == "Song 2") + } + + @Test("Missing logical identity preserves media-bound queue metadata") + func missingLogicalIdentityPreservesMediaTargetMetadata() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.isKasetInitiatedPlayback = false + + let shouldApply = self.playerService.reconcileWebPlaybackMetadata( + title: "Stale Song 1", + artist: "Stale Artist", + thumbnailUrl: "", + observedVideoId: nil, + mediaVideoId: "v2", + bridgeTrackChanged: true + ) + + #expect(shouldApply) + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(self.playerService.currentTrack?.title == "Song 2") + } + + @Test("Coherent text metadata is applied after an earlier logical lead") + func coherentMetadataCatchesUpAfterLogicalLead() async { + let artist = Artist(id: "artist", name: "Old Artist") + let song = Song(id: "1", title: "Old Title", artists: [artist], duration: 180, videoId: "v1") + await self.playerService.playQueue([song], startingAt: 0) + self.playerService.state = .playing + self.playerService.isKasetInitiatedPlayback = false + + _ = self.playerService.reconcileWebPlaybackMetadata( + title: "New Title", + artist: "New Artist", + thumbnailUrl: "", + observedVideoId: "logical-lead", + mediaVideoId: "v1", + bridgeTrackChanged: true + ) + let shouldApply = self.playerService.reconcileWebPlaybackMetadata( + title: "New Title", + artist: "New Artist", + thumbnailUrl: "", + observedVideoId: "v1", + mediaVideoId: "v1", + bridgeTrackChanged: false + ) + + #expect(shouldApply) + #expect(self.playerService.currentTrack?.title == "New Title") + #expect(self.playerService.currentTrack?.artistsDisplay == "New Artist") + } + + @Test("Admissible next Web video realigns queue when bridge trackChanged is false") + func nextWebVideoRealignsWithoutTrackChangedFlag() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + + let currentSongShouldApply = self.playerService.reconcileWebPlaybackMetadata( + title: "Song 1", + artist: "", + thumbnailUrl: "", + observedVideoId: "v1", + mediaVideoId: "v1", + bridgeTrackChanged: true + ) + #expect(currentSongShouldApply) + + let nextSongShouldApply = self.playerService.reconcileWebPlaybackMetadata( + title: "Song 2", + artist: "", + thumbnailUrl: "", + observedVideoId: "v2", + mediaVideoId: "v2", + bridgeTrackChanged: false + ) + + #expect(nextSongShouldApply) + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.currentTrack?.videoId == "v2") + } + + @Test("Playback state from an earlier Web video cannot overwrite the queue target") + func earlierWebVideoPlaybackStateDoesNotOverwriteQueueTarget() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + await self.playerService.next() + + self.playerService.updatePlaybackState( + isPlaying: false, + progress: 179, + duration: 180, + observedVideoId: "v1" + ) + + #expect(self.playerService.currentIndex == 1) + #expect(self.playerService.progress == 0) + #expect(self.playerService.duration == 200) + #expect(!self.playerService.songNearingEnd) + + let identitylessLogicalObservationShouldContinue = self.playerService.reconcileWebPlaybackMetadata( + title: "", + artist: "", + thumbnailUrl: "", + observedVideoId: nil, + bridgeTrackChanged: false + ) + #expect(identitylessLogicalObservationShouldContinue) + #expect(self.playerService.observedPlaybackMatchesCurrentTarget(videoId: "v2")) + + self.playerService.updatePlaybackState( + isPlaying: false, + progress: 179, + duration: 180, + observedVideoId: nil + ) + + #expect(self.playerService.progress == 0) + #expect(self.playerService.duration == 200) + #expect(!self.playerService.songNearingEnd) + + self.playerService.updatePlaybackState( + isPlaying: true, + progress: 1, + duration: 200, + observedVideoId: "v2" + ) + + #expect(self.playerService.state == .playing) + #expect(self.playerService.progress == 1) + #expect(self.playerService.duration == 200) + } + + @Test( + "Accepted Web video transition detection distinguishes initial confirmation from a real change", + arguments: [ + (observed: "v2", lastAccepted: String?.none, expectedBefore: Optional("v2"), expected: false), + (observed: "v2", lastAccepted: String?.none, expectedBefore: Optional("v1"), expected: true), + (observed: "v2", lastAccepted: Optional("v1"), expectedBefore: Optional("v2"), expected: true), + (observed: "v2", lastAccepted: Optional("v2"), expectedBefore: Optional("v2"), expected: false), + ] + ) + func acceptedWebVideoTransitionDetection( + observed: String, + lastAccepted: String?, + expectedBefore: String?, + expected: Bool + ) { + #expect(WebPlaybackIdentityTransition.isConfirmed( + observedVideoId: observed, + lastAcceptedObservedVideoId: lastAccepted, + expectedVideoIdBeforeReconciliation: expectedBefore + ) == expected) + } + + @Test( + "Playback media generation must advance when the queue entry changes", + arguments: [ + PlaybackMediaGenerationCase(queueEntryChanged: false, epoch: 10, lastEpoch: 10, generation: 4, lastGeneration: 4, expected: true), + PlaybackMediaGenerationCase(queueEntryChanged: true, epoch: 11, lastEpoch: 10, generation: 4, lastGeneration: 4, expected: true), + PlaybackMediaGenerationCase(queueEntryChanged: true, epoch: 10, lastEpoch: 10, generation: 4, lastGeneration: 4, expected: false), + PlaybackMediaGenerationCase(queueEntryChanged: true, epoch: 10, lastEpoch: 10, generation: 5, lastGeneration: 4, expected: true), + PlaybackMediaGenerationCase(queueEntryChanged: false, epoch: 9, lastEpoch: 10, generation: 5, lastGeneration: 4, expected: false), + PlaybackMediaGenerationCase(queueEntryChanged: false, epoch: 10, lastEpoch: 10, generation: 3, lastGeneration: 4, expected: false), + ] + ) + func playbackMediaGenerationGate(testCase: PlaybackMediaGenerationCase) { + #expect(WebPlaybackIdentityTransition.shouldAcceptMediaState( + queueEntryChanged: testCase.queueEntryChanged, + observerEpoch: testCase.epoch, + lastAcceptedObserverEpoch: testCase.lastEpoch, + mediaGeneration: testCase.generation, + lastAcceptedMediaGeneration: testCase.lastGeneration + ) == testCase.expected) + } + + @Test( + "Deferred restore handles only fully identityless observations", + arguments: [ + (isDeferred: true, logical: String?.none, media: String?.none, expected: true), + (isDeferred: false, logical: String?.none, media: String?.none, expected: false), + (isDeferred: true, logical: Optional("v1"), media: String?.none, expected: false), + (isDeferred: true, logical: String?.none, media: Optional("v1"), expected: false), + ] + ) + func deferredIdentitylessObservationGate( + isDeferred: Bool, + logical: String?, + media: String?, + expected: Bool + ) { + #expect(WebPlaybackIdentityTransition.shouldHandleDeferredIdentitylessObservation( + isDeferred: isDeferred, + observedVideoId: logical, + mediaVideoId: media + ) == expected) + } + + @Test("Accepted nil queue-entry baseline detects a later queued entry") + func nilQueueEntryBaselineDetectsQueuedEntry() { + let entryID = UUID() + + #expect(!WebPlaybackIdentityTransition.didQueueEntryChange( + hasBaseline: false, + lastAcceptedQueueEntryID: nil, + currentQueueEntryID: entryID + )) + #expect(WebPlaybackIdentityTransition.didQueueEntryChange( + hasBaseline: true, + lastAcceptedQueueEntryID: nil, + currentQueueEntryID: entryID + )) + #expect(!WebPlaybackIdentityTransition.didQueueEntryChange( + hasBaseline: true, + lastAcceptedQueueEntryID: entryID, + currentQueueEntryID: entryID + )) + } +} diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift index c4781cc03..a1f81de1d 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift @@ -3,6 +3,81 @@ import Testing @testable import Kaset extension PlayerServiceWebQueueSyncTests { + @Test("Radio queue replacement preserves the current playback occurrence ID") + func radioQueueReplacementPreservesCurrentEntryID() async { + let seed = Song(id: "seed", title: "Seed", artists: [], duration: 180, videoId: "seed-video") + let suggestion = Song(id: "next", title: "Next", artists: [], duration: 200, videoId: "next-video") + let mockClient = MockYTMusicClient() + mockClient.radioQueueSongs[seed.videoId] = [seed, suggestion] + self.playerService.setYTMusicClient(mockClient) + await self.playerService.playQueue([seed], startingAt: 0) + let originalEntryID = self.playerService.currentQueueEntryID + + await self.playerService.fetchAndApplyRadioQueue(for: seed.videoId) + + #expect(self.playerService.currentQueueEntryID == originalEntryID) + #expect(self.playerService.queue.map(\.videoId) == ["seed-video", "next-video"]) + } + + @Test("Web queue injection waits for full-page navigation to settle") + func webQueueInjectionWaitsForDocumentNavigation() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + let previousNavigationState = SingletonPlayerWebView.shared.isDocumentNavigationInProgress + defer { SingletonPlayerWebView.shared.isDocumentNavigationInProgress = previousNavigationState } + SingletonPlayerWebView.shared.isDocumentNavigationInProgress = true + let generation = self.playerService.webQueueInjectionGeneration + + self.playerService.syncWebQueue() + + #expect(self.playerService.webQueueInjectionGeneration == generation) + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + } + + @Test("Native queue injection skips duplicate consecutive video IDs") + func nativeQueueInjectionSkipsDuplicateVideoIDs() async { + let duplicate = Song( + id: "duplicate", + title: "Duplicate", + artists: [], + duration: 180, + videoId: "same-video" + ) + await self.playerService.playQueue([duplicate, duplicate], startingAt: 0) + self.playerService.state = .playing + + self.playerService.syncWebQueue() + + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + #expect(self.playerService.injectedWebQueueVideoId == nil) + } + + @Test("Metadata-only updates keep a confirmed native queue injection") + func metadataOnlyUpdateKeepsNativeQueueInjection() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.isKasetInitiatedPlayback = false + self.playerService.injectedWebQueueVideoId = "v2" + + self.playerService.updateTrackMetadata( + title: "Updated Song 1", + artist: "Updated Artist", + thumbnailUrl: "", + videoId: "v1" + ) + + #expect(self.playerService.currentTrack?.videoId == "v1") + #expect(self.playerService.injectedWebQueueVideoId == "v2") + } + // MARK: - Play From Queue Tests @Test("Play from queue valid index") diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift index 758245566..6f22b432d 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift @@ -288,21 +288,42 @@ struct PlayerServiceWebQueueSyncTests { ] await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.webQueueInjectionGeneration = 1 self.playerService.pendingWebQueueInjectionVideoId = "v3" - self.playerService.handleWebQueueInjectionResult(videoId: "v3", success: true, reason: nil) + self.playerService.handleWebQueueInjectionResult( + videoId: "v3", + attemptGeneration: 1, + success: true, + reason: nil + ) #expect(self.playerService.injectedWebQueueVideoId == nil) #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) self.playerService.pendingWebQueueInjectionVideoId = "v2" - self.playerService.handleWebQueueInjectionResult(videoId: "v2", success: false, reason: "timeout") + self.playerService.handleWebQueueInjectionResult( + videoId: "v2", + attemptGeneration: 2, + success: false, + reason: "timeout" + ) #expect(self.playerService.injectedWebQueueVideoId == nil) #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) - self.playerService.handleWebQueueInjectionResult(videoId: "v2", success: true, reason: "stale") + self.playerService.handleWebQueueInjectionResult( + videoId: "v2", + attemptGeneration: 2, + success: true, + reason: "stale" + ) #expect(self.playerService.injectedWebQueueVideoId == nil) self.playerService.pendingWebQueueInjectionVideoId = "v2" - self.playerService.handleWebQueueInjectionResult(videoId: "v2", success: true, reason: "swapped") + self.playerService.handleWebQueueInjectionResult( + videoId: "v2", + attemptGeneration: 3, + success: true, + reason: "queue-readback-confirmed" + ) #expect(self.playerService.injectedWebQueueVideoId == "v2") #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) } @@ -768,27 +789,6 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.currentTrack?.title == "Song 2") } - @Test("Injected track end waits for WebView confirmation before injecting next-next") - func injectedTrackEndDefersNextNextInjectionUntilPlaybackConfirmation() async { - let songs = [ - Song(id: "1", title: "Song 1", artists: [], album: nil, duration: 180, thumbnailURL: nil, videoId: "v1"), - Song(id: "2", title: "Song 2", artists: [], album: nil, duration: 200, thumbnailURL: nil, videoId: "v2"), - Song(id: "3", title: "Song 3", artists: [], album: nil, duration: 220, thumbnailURL: nil, videoId: "v3"), - ] - - await self.playerService.playQueue(songs, startingAt: 0) - self.playerService.state = .playing - self.playerService.injectedWebQueueVideoId = "v2" - - await self.playerService.handleTrackEnded(observedVideoId: "v1") - - #expect(self.playerService.currentIndex == 1) - #expect(self.playerService.currentTrack?.videoId == "v2") - #expect(self.playerService.state == .loading) - #expect(self.playerService.injectedWebQueueVideoId == nil) - #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) - } - @Test("Stale track-ended events do not double-advance the queue") func staleTrackEndedEventIsIgnored() async { let songs = [ diff --git a/Tests/KasetTests/QueueInjectionScriptTests.swift b/Tests/KasetTests/QueueInjectionScriptTests.swift new file mode 100644 index 000000000..fef331fa7 --- /dev/null +++ b/Tests/KasetTests/QueueInjectionScriptTests.swift @@ -0,0 +1,544 @@ +import JavaScriptCore +import Testing +@testable import Kaset + +@Suite(.serialized, .tags(.service)) +@MainActor +struct QueueInjectionScriptTests { + @Test("Queue injection confirms only after the target is the actual next queue item") + func confirmsQueueReadback() throws { + let context = try #require(self.makeContext(menuAvailableAfterLookup: 1)) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video", + attemptGeneration: 7 + ), + in: context + ) + self.runTimersUntilMessage(in: context) + + #expect(context.evaluateScript("messageCount")?.toInt32() == 1) + #expect(context.evaluateScript("lastMessage.success")?.toBool() == true) + #expect(context.evaluateScript("lastMessage.reason")?.toString() == "queue-readback-confirmed") + #expect(context.evaluateScript("lastMessage.videoId")?.toString() == "target-video") + #expect(context.evaluateScript("lastMessage.attemptGeneration")?.toInt32() == 7) + #expect(context.evaluateScript("clickedVideoId")?.toString() == "target-video") + #expect(context.evaluateScript("clickedFallbackVideoId")?.toString() == "target-video") + #expect(context.evaluateScript("queueVideoIds[1]")?.toString() == "target-video") + #expect(context.evaluateScript("originalQueueTarget.videoId")?.toString() == "source-video") + #expect(context.evaluateScript("originalQueueTarget.onEmptyQueue.watchEndpoint.videoId")?.toString() == "source-video") + } + + @Test("Queue injection waits for the player-bar action menu to mount") + func waitsForPlayerBarMenu() throws { + let context = try #require(self.makeContext(menuAvailableAfterLookup: 3)) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "delayed-target", + afterVideoId: "source-video" + ), + in: context + ) + self.runTimersUntilMessage(in: context) + + #expect(context.evaluateScript("menuLookupCount")?.toInt32() ?? 0 >= 3) + #expect(context.evaluateScript("lastMessage.success")?.toBool() == true) + #expect(context.evaluateScript("queueVideoIds[1]")?.toString() == "delayed-target") + } + + @Test("Queue injection uses the native click path without global payload rewriting") + func usesNativeClickPath() { + let script = SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ) + + #expect(script.contains("targetItem.click()")) + #expect(script.contains("queue-readback-confirmed")) + #expect(!script.contains("app.resolveCommand")) + #expect(!script.contains("JSON.stringify =")) + } + + @Test("An already aligned queue is confirmed without clicking Play Next again") + func alreadyAlignedQueueIsANoOpSuccess() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + clickMode: "duplicateInsert", + initialQueueVideoIds: ["source-video", "target-video", "autoplay-video"], + queueCurrentIndex: 0 + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + + #expect(context.evaluateScript("lastMessage.success")?.toBool() == true) + #expect(context.evaluateScript("lastMessage.reason")?.toString() == "queue-readback-confirmed") + #expect(context.evaluateScript("clickedVideoId === null")?.toBool() == true) + #expect(context.evaluateScript("queueVideoIds.length")?.toInt32() == 3) + } + + @Test("A historical target before the selected source cannot confirm readback") + func historicalTargetBeforeCurrentSourceDoesNotConfirm() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + clickMode: "noInsert", + initialQueueVideoIds: ["target-video", "source-video", "other-next"], + queueCurrentIndex: 1 + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + self.runTimersUntilMessage(in: context, limit: 200) + + #expect(context.evaluateScript("lastMessage.success")?.toBool() == false) + #expect(context.evaluateScript("lastMessage.reason")?.toString() == "queue-readback-timeout") + } + + @Test("Queue readback fails closed when the selected source row is not rendered") + func missingSelectedSourceRowDoesNotConfirm() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + clickMode: "noInsert", + initialQueueVideoIds: ["target-video", "history-video"], + queueCurrentIndex: nil + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + self.runTimersUntilMessage(in: context, limit: 200) + + #expect(context.evaluateScript("lastMessage.success")?.toBool() == false) + #expect(context.evaluateScript("lastMessage.reason")?.toString() == "queue-readback-timeout") + } + + @Test("Queue readback scans beyond the first forty rendered rows") + func queueReadbackFindsLateSelectedSource() throws { + var videoIds = (0 ..< 45).map { "history-\($0)" } + videoIds.append(contentsOf: ["source-video", "other-next"]) + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + initialQueueVideoIds: videoIds, + queueCurrentIndex: 45 + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + self.runTimersUntilMessage(in: context) + + #expect(context.evaluateScript("lastMessage.success")?.toBool() == true) + #expect(context.evaluateScript("queueVideoIds[46]")?.toString() == "target-video") + } + + @Test("Queue readback resolves a nonzero selected source row") + func confirmsTargetAfterNonzeroCurrentRow() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + initialQueueVideoIds: ["history-video", "source-video", "other-next"], + queueCurrentIndex: 1 + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + self.runTimersUntilMessage(in: context) + + #expect(context.evaluateScript("lastMessage.success")?.toBool() == true) + #expect(context.evaluateScript("queueVideoIds[2]")?.toString() == "target-video") + } + + @Test("Queue injection fails closed when readback never shows the target") + func readbackTimeoutDoesNotConfirmInjection() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + clickMode: "noInsert" + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + self.runTimersUntilMessage(in: context, limit: 200) + + #expect(context.evaluateScript("messageCount")?.toInt32() == 1) + #expect(context.evaluateScript("lastMessage.success")?.toBool() == false) + #expect(context.evaluateScript("lastMessage.reason")?.toString() == "queue-readback-timeout") + #expect(context.evaluateScript("queueVideoIds[1]")?.toString() == "autoplay-video") + } + + @Test("A later duplicate source occurrence cannot falsely confirm stale readback") + func laterDuplicateSourceDoesNotConfirmStaleQueue() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + clickMode: "noInsert", + initialQueueVideoIds: ["wrong-next", "source-video", "target-video"] + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + self.runTimersUntilMessage(in: context, limit: 200) + + #expect(context.evaluateScript("lastMessage.success")?.toBool() == false) + #expect(context.evaluateScript("lastMessage.reason")?.toString() == "queue-readback-timeout") + #expect(context.evaluateScript("queueVideoIds[0]")?.toString() == "wrong-next") + } + + @Test("Queue injection reports a native Play Next click exception") + func clickExceptionDoesNotConfirmInjection() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + clickMode: "throw" + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + + #expect(context.evaluateScript("messageCount")?.toInt32() == 1) + #expect(context.evaluateScript("lastMessage.success")?.toBool() == false) + #expect(context.evaluateScript("lastMessage.reason")?.toString() == "play-next-click-threw") + #expect(context.evaluateScript("originalQueueTarget.videoId")?.toString() == "source-video") + } + + @Test("Queue readback cannot confirm after the active source changes") + func readbackAfterSourceChangeFailsClosed() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + clickMode: "insertThenSourceChanges" + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + self.runTimersUntilMessage(in: context) + + #expect(context.evaluateScript("lastMessage.success")?.toBool() == false) + #expect(context.evaluateScript("lastMessage.reason")?.toString() == "source-video-changed-before-readback") + #expect(context.evaluateScript("queueVideoIds[1]")?.toString() == "target-video") + } + + @Test("Queue injection dismisses the player menu on pre-dispatch failure") + func dismissesMenuOnMissingPlayNextItem() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + itemLabel: "Add to queue" + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + + #expect(context.evaluateScript("messageCount")?.toInt32() == 1) + #expect(context.evaluateScript("lastMessage.reason")?.toString() == "play-next-item-not-found") + #expect(context.evaluateScript("bodyClickCount")?.toInt32() == 1) + } + + @Test("Delayed queue injection does not click after the source video changes") + func rejectsChangedSourceVideo() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + originalVideoId: "source-video", + currentVideoId: "new-source-video" + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + + #expect(context.evaluateScript("messageCount")?.toInt32() == 1) + #expect(context.evaluateScript("lastMessage.success")?.toBool() == false) + #expect(context.evaluateScript("lastMessage.reason")?.toString() == "source-video-changed") + #expect(context.evaluateScript("clickedVideoId === null")?.toBool() == true) + } + + @Test("Queue injection retries while the menu endpoint catches up to the current source") + func retriesTransientMenuSourceMismatch() throws { + let context = try #require(self.makeContext( + menuAvailableAfterLookup: 1, + originalVideoId: "stale-source-video", + currentVideoId: "source-video", + refreshMenuSourceOnTimer: true + )) + + self.evaluate( + SingletonPlayerWebView.queueInjectionScript( + videoId: "target-video", + afterVideoId: "source-video" + ), + in: context + ) + self.runTimersUntilMessage(in: context) + + #expect(context.evaluateScript("lastMessage.success")?.toBool() == true) + #expect(context.evaluateScript("clickedVideoId")?.toString() == "target-video") + } + + // swiftlint:disable:next function_body_length + private func makeContext( + menuAvailableAfterLookup: Int, + clickMode: String = "insertTarget", + itemLabel: String = "Play next", + originalVideoId: String = "source-video", + currentVideoId: String? = nil, + refreshMenuSourceOnTimer: Bool = false, + initialQueueVideoIds: [String]? = nil, + queueCurrentIndex: Int? = 0 + ) -> JSContext? { + guard let context = JSContext() else { return nil } + let clickModeLiteral = SingletonPlayerWebView.javaScriptStringLiteral(clickMode) + let itemLabelLiteral = SingletonPlayerWebView.javaScriptStringLiteral(itemLabel) + let originalVideoIdLiteral = SingletonPlayerWebView.javaScriptStringLiteral(originalVideoId) + let currentVideoIdLiteral = SingletonPlayerWebView.javaScriptStringLiteral(currentVideoId ?? originalVideoId) + let initialQueueVideoIds = initialQueueVideoIds ?? [originalVideoId, "autoplay-video"] + let initialQueueLiteral = "[" + initialQueueVideoIds + .map(SingletonPlayerWebView.javaScriptStringLiteral) + .joined(separator: ",") + "]" + let queueCurrentIndexLiteral = queueCurrentIndex.map(String.init) ?? "null" + self.evaluate( + """ + var window = this; + var messageCount = 0; + var lastMessage = null; + var clickedVideoId = null; + var clickedFallbackVideoId = null; + var menuLookupCount = 0; + var bodyClickCount = 0; + var clickMode = \(clickModeLiteral); + var activeVideoId = \(currentVideoIdLiteral); + var timerID = 0; + var timerSequence = 0; + var timers = []; + var fakeNow = 0; + var refreshMenuSourceOnTimer = \(refreshMenuSourceOnTimer ? "true" : "false"); + var queueVideoIds = \(initialQueueLiteral); + var queueCurrentIndexValue = \(queueCurrentIndexLiteral); + Date.now = function() { return fakeNow; }; + function setTimeout(callback, delay) { + timerID += 1; + timerSequence += 1; + timers.push({ + id: timerID, + callback: callback, + dueAt: fakeNow + delay, + sequence: timerSequence, + cancelled: false + }); + return timerID; + } + function clearTimeout(id) { + const timer = timers.find(function(candidate) { return candidate.id === id; }); + if (timer) timer.cancelled = true; + } + function runNextTimer() { + timers.sort(function(lhs, rhs) { + if (lhs.dueAt !== rhs.dueAt) return lhs.dueAt - rhs.dueAt; + return lhs.sequence - rhs.sequence; + }); + while (timers.length > 0) { + const timer = timers.shift(); + if (!timer.cancelled) { + fakeNow = timer.dueAt; + if (refreshMenuSourceOnTimer) { + originalQueueTarget.videoId = \(currentVideoIdLiteral); + originalQueueTarget.onEmptyQueue.watchEndpoint.videoId = \(currentVideoIdLiteral); + if (queueCurrentIndexValue !== null) { + queueVideoIds[queueCurrentIndexValue] = \(currentVideoIdLiteral); + } + refreshMenuSourceOnTimer = false; + } + timer.callback(); + return true; + } + } + return false; + } + + window.webkit = { + messageHandlers: { + singletonPlayer: { + postMessage: function(message) { + if (message.type === 'QUEUE_INJECTION_RESULT') { + messageCount += 1; + lastMessage = message; + } + } + } + } + }; + + var originalQueueTarget = { + videoId: \(originalVideoIdLiteral), + onEmptyQueue: { watchEndpoint: { videoId: \(originalVideoIdLiteral) } }, + backingQueuePlaylistId: '' + }; + var playNextItem = { + textContent: \(itemLabelLiteral), + getAttribute: function() { return ''; }, + querySelector: function() { return null; }, + data: { + serviceEndpoint: { + clickTrackingParams: 'tracking', + queueAddEndpoint: { + queueTarget: originalQueueTarget, + queueInsertPosition: 'INSERT_AFTER_CURRENT_VIDEO', + commands: [] + } + } + }, + click: function() { + clickedVideoId = originalQueueTarget.videoId; + clickedFallbackVideoId = originalQueueTarget.onEmptyQueue.watchEndpoint.videoId; + if (clickMode === 'throw') throw new Error('click failed'); + if (clickMode === 'insertTarget' + || clickMode === 'duplicateInsert' + || clickMode === 'insertThenSourceChanges') { + const insertedVideoId = clickedVideoId; + setTimeout(function() { + if (queueCurrentIndexValue !== null) { + queueVideoIds.splice(queueCurrentIndexValue + 1, 0, insertedVideoId); + } else { + queueVideoIds.unshift(insertedVideoId); + } + if (clickMode === 'insertThenSourceChanges') { + activeVideoId = 'advanced-video'; + } + }, 250); + } + } + }; + var ytmusicPlayer = { + playerApi: { + getVideoData: function() { return { video_id: activeVideoId }; } + } + }; + var lastObserver = null; + function MutationObserver(callback) { + this.callback = callback; + lastObserver = this; + } + MutationObserver.prototype.observe = function() {}; + MutationObserver.prototype.disconnect = function() {}; + + var menuButton = { + getAttribute: function(name) { + return name === 'aria-label' ? 'Action menu' : ''; + }, + click: function() { + if (lastObserver) lastObserver.callback([], lastObserver); + } + }; + function queueItems() { + return queueVideoIds.map(function(videoId, index) { + return { + __kasetIsCurrent: queueCurrentIndexValue === index, + data: { playlistItemData: { videoId: videoId } }, + hasAttribute: function(name) { + return this.__kasetIsCurrent && name === 'selected'; + }, + getAttribute: function(name) { + return this.__kasetIsCurrent && name === 'aria-current' ? 'true' : null; + }, + classList: { contains: function() { return false; } } + }; + }); + } + var document = { + body: { click: function() { bodyClickCount += 1; } }, + querySelector: function(selector) { + if (selector === 'ytmusic-player') return ytmusicPlayer; + if (selector === 'ytmusic-player-queue') { + return { data: { contents: queueItems().map(function(item) { return item.data; }) } }; + } + if (selector.indexOf('.middle-controls-buttons.ytmusic-player-bar') === 0) { + menuLookupCount += 1; + return menuLookupCount >= \(menuAvailableAfterLookup) ? menuButton : null; + } + return null; + }, + querySelectorAll: function(selector) { + if (selector === 'ytmusic-menu-popup-renderer ytmusic-menu-service-item-renderer') { + return [playNextItem]; + } + if (selector === 'ytmusic-player-bar button') { + return menuLookupCount >= \(menuAvailableAfterLookup) ? [menuButton] : []; + } + if (selector.indexOf('ytmusic-player-queue-item') >= 0) { + return queueItems(); + } + return []; + }, + getElementById: function() { return null; } + }; + """, + in: context + ) + return context + } + + private func runTimersUntilMessage(in context: JSContext, limit: Int = 100) { + self.evaluate( + """ + for (let i = 0; i < \(limit) && messageCount === 0; i += 1) { + if (!runNextTimer()) break; + } + """, + in: context + ) + } + + private func evaluate(_ script: String, in context: JSContext) { + context.exception = nil + _ = context.evaluateScript(script) + #expect(context.exception == nil, Comment(rawValue: context.exception?.toString() ?? "Unknown JavaScript error")) + } +} diff --git a/docs/adr/0026-gapless-playback-native-queue.md b/docs/adr/0026-gapless-playback-native-queue.md index 51325520a..fc5011eab 100644 --- a/docs/adr/0026-gapless-playback-native-queue.md +++ b/docs/adr/0026-gapless-playback-native-queue.md @@ -35,23 +35,39 @@ track into YouTube Music's native **Up Next** queue ahead of time. `watchEndpoint`; it falls back to a full `watch` URL only when the router is not available or rejects the command. - `PlayerService+WebQueueSync` calls `syncWebQueue()` only when playback is in a - stable state (`.playing` or `.paused`). It does not manipulate the player-bar DOM - during `.loading`/router navigation. + stable state (`.playing` or `.paused`) and no main-document navigation is in + progress. A navigation completion retries synchronization when playback had + already started before `WKNavigationDelegate.didFinish`. - `SingletonPlayerWebView+QueueInjection` opens the current player-bar menu, - positively identifies **Play next**, and uses a click-scoped `JSON.stringify` - interceptor to swap that command's `videoIds` payload to Kaset's expected next - video ID. + positively identifies **Play next**, temporarily replaces its native + `queueAddEndpoint` target, invokes the menu item's own click path, and restores the + endpoint immediately afterward. The script waits briefly for the player-bar action + menu to mount after SPA navigation instead of treating the first missing DOM node + as a permanent injection failure. +- Before dispatch, the menu endpoint's source video ID must still match Kaset's + current queue song. The page preserves rendered queue-row occurrences, accepts an already aligned queue without clicking again, otherwise requires a + post-click queue change, and reports success only when the expected target becomes + the actual next row (whether or not the current row is rendered). Stale, duplicate, + malformed, or unconfirmed clicks fail closed. - Swift tracks queue injection as two separate states: - `pendingWebQueueInjectionVideoId`: an injection attempt has started but the WebView has not confirmed it; - - `injectedWebQueueVideoId`: the WebView reported that the **Play next** payload - was swapped for the currently expected next track. -- `handleTrackEnded(observedVideoId:)` trusts native auto-advance only when the - confirmed injected video ID still matches the current expected next queue entry. - Otherwise Kaset falls back to deterministic queue advancement through + - `injectedWebQueueVideoId`: the WebView confirmed that the source-bound **Play + next** click inserted the currently expected next track. +- `handleTrackEnded(observedVideoId:)` starts a bounded native handoff only when + the readback-confirmed injected video ID still matches the expected next entry. + The outgoing song remains visible until the media-bound observer reports that + exact target. A wrong video or a three-second timeout falls back through `play(song:)` / `loadVideo(videoId:)`. -- Any deterministic navigation, queue replacement, empty queue persistence, or - stale result clears both Swift-side and page-side queue-injection state. +- Radio-queue enrichment preserves the active queue-entry UUID so replacing API + metadata does not look like a new playback occurrence to the media-generation guard. +- A queue edit during a pending native handoff revalidates source/target UUID + adjacency. If adjacency changes, Kaset deterministically loads the newly expected + next entry rather than the stale target. +- Main-document completion/failure callbacks are matched to the latest `WKNavigation` + and current `WKWebView` before they clear the injection gate. +- Any deterministic navigation, queue replacement, empty queue persistence, or stale + result clears both Swift-side and page-side queue-injection state. - Hidden preload and restored-session pages start with autoplay blocked. Explicit user actions such as Resume/Next/Previous unblock autoplay. @@ -62,8 +78,8 @@ track into YouTube Music's native **Up Next** queue ahead of time. - Queue transitions can be handled by YouTube Music's own player without reloading the whole WebView page for every track. - Manual Next/Previous stay aligned with Kaset's queue through deterministic - navigation, while natural track-end handling can still use native web-player - transitions when confirmed safe. + navigation, while natural track-end handling uses native web-player transitions + only after both queue readback and actual media identity are confirmed. - Duplicate tracks, stale `ended` events, failed injection attempts, queue edits, repeat modes, and mix/smart-shuffle continuation boundaries have explicit guard paths and regression tests. @@ -75,8 +91,9 @@ track into YouTube Music's native **Up Next** queue ahead of time. - The queue-injection path depends on YouTube Music DOM structure and command payload shape, especially the player-bar menu and **Play next** command. -- The `JSON.stringify` interception is intentionally narrow and click-scoped, but - it is still more fragile than a public API would be. +- The implementation uses YouTube Music's internal `queueAddEndpoint`, menu-click, + and queue-renderer contracts, which are more fragile than a public API would be. +- Command dispatch no longer rewrites arbitrary YouTube Music JSON payloads. - Real-world gaplessness still depends on YouTube Music buffering, WebKit timing, network state, and YouTube's internal player behavior. - The code must carefully cancel stale page-side injection attempts when Swift @@ -89,11 +106,14 @@ The implementation adds/updates regression coverage in: - `PlayerServiceWebQueueSyncTests` - `PlayerServiceWebQueueSyncFollowUpTests` +- `PlaybackObserverIdentityTests` +- `QueueInjectionScriptTests` - `PlayerServiceQueueTests` - `AutoplayRecoveryTests` - `PlayerServiceLibraryTests` -The covered cases include confirmed vs pending queue injection, stale injection -results, duplicate video IDs, empty/edited queues, manual Next/Previous, natural -track-end advancement, repeat modes, restored playback seeks, autoplay blocking, -and account-scoped like/dislike completion races. +The covered cases include queue readback confirmation, stale injection results, +duplicate video IDs, navigation races, native handoff confirmation/wrong-track/timeout +fallbacks, active queue-entry identity preservation, empty/edited queues, manual +Next/Previous, repeat modes, restored playback seeks, autoplay blocking, and +account-scoped like/dislike completion races. diff --git a/docs/playback.md b/docs/playback.md index 7afb4ca3a..febc0fbde 100644 --- a/docs/playback.md +++ b/docs/playback.md @@ -451,9 +451,13 @@ func userContentController(_:, didReceive message: WKScriptMessage) { ### Queue Authority Kaset treats its own queue as the source of truth whenever one exists: -- `handleTrackEnded(observedVideoId:)` advances the native queue immediately +- `handleTrackEnded(observedVideoId:)` keeps the outgoing queue entry visible until + the expected native next media identity is confirmed - `updateTrackMetadata(...)` accepts `videoId`-only transitions even if the DOM metadata is temporarily blank or stale +- Playback progress/duration is accepted only when the observer's media-bound + video ID and generation match the active queue entry; logical metadata changes + cannot assign the outgoing media element's near-end state to the next song - If YouTube advances to an unexpected track near the end of a song, Kaset replays the expected queue track instead of inheriting YouTube autoplay - At the end of a non-repeating queue, Kaset marks playback ended rather than @@ -466,20 +470,32 @@ Kaset treats its own queue as the source of truth whenever one exists: For gapless-style transitions, Kaset mirrors the current expected next song into YouTube Music's native **Up Next** queue: -1. `syncWebQueue()` runs only when playback is stable (`.playing` or `.paused`). - It does not click menu items while router navigation is loading. -2. `SingletonPlayerWebView.injectNextSong(videoId:)` opens the player-bar menu, - positively identifies **Play next**, and arms a click-scoped payload swap. -3. The page reports `QUEUE_INJECTION_RESULT` back to Swift. +1. `syncWebQueue()` runs only when playback is stable (`.playing` or `.paused`) + and no main-document navigation is in progress. +2. `SingletonPlayerWebView.injectNextSong(videoId:afterVideoId:)` opens the player-bar menu, + waits for the action menu to mount, positively identifies **Play next**, temporarily + replaces that item's `queueAddEndpoint` target, invokes its native click path, and + immediately restores the endpoint. The source video in the menu endpoint must still + match Kaset's current queue song. +3. The script preserves rendered queue-row occurrences, accepts an already aligned queue without another click, otherwise requires the + queue to change after the click, and reports success only when the target is the actual + next row. This avoids false confirmation from later duplicate video IDs. 4. Swift promotes `pendingWebQueueInjectionVideoId` to `injectedWebQueueVideoId` - only if the result still matches the currently expected next queue entry. -5. Any deterministic navigation, queue replacement, empty queue persistence, or + only if that readback result still matches the currently expected next entry. +5. At natural end, Kaset waits for the media-bound observer to report the same target + before moving its visible queue pointer. Unexpected media, a bounded timeout, or + changed source/target queue adjacency triggers deterministic navigation to the + current expected next entry. +6. Any deterministic navigation, queue replacement, empty queue persistence, or stale result clears both Swift-side and page-side injection state. Important invariants: - A pending injection is not trusted for playback decisions. -- A confirmed injection is consumed when natural track-end auto-advance uses it. +- Injection dispatch never mutates unrelated JSON payloads and fails closed when + the source track changes or queue readback does not confirm the expected insertion. +- Queue readback confirmation authorizes a native handoff attempt; actual queue state + advances only after media-bound target confirmation. - Duplicate video IDs are handled by clearing the consumed marker so the next queue entry can be injected again. - If injection fails, times out, or becomes stale, Kaset prioritizes correctness From 6777d5e8315dff0015eb7f3b254b53409019f224 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 20:51:53 -0700 Subject: [PATCH 17/73] fix(player): serialize playback bridge events Signed-off-by: Sertac Ozercan --- .../Services/Player/PlayerQueueModels.swift | 2 +- .../PlayerService+WebPlaybackIdentity.swift | 42 +++++++++--- .../Player/PlayerService+WebQueueSync.swift | 4 +- .../Kaset/Services/Player/PlayerService.swift | 6 +- Sources/Kaset/Views/MiniPlayerWebView.swift | 66 +++++++++---------- ...ingletonPlayerWebView+ObserverScript.swift | 31 ++++----- ...tonPlayerWebView+PlaybackPreferences.swift | 6 +- ...ingletonPlayerWebView+QueueInjection.swift | 4 +- ...layerServiceWebPlaybackIdentityTests.swift | 2 +- 9 files changed, 92 insertions(+), 71 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerQueueModels.swift b/Sources/Kaset/Services/Player/PlayerQueueModels.swift index 9acd40491..9c57f38fd 100644 --- a/Sources/Kaset/Services/Player/PlayerQueueModels.swift +++ b/Sources/Kaset/Services/Player/PlayerQueueModels.swift @@ -17,7 +17,7 @@ struct QueueEntry: Identifiable, Hashable { // MARK: - PendingNativeQueueAdvance -struct PendingNativeQueueAdvance: Equatable, Sendable { +struct PendingNativeQueueAdvance: Equatable { let sourceEntryID: UUID? let sourceVideoId: String let targetEntryID: UUID diff --git a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift index 38bd43ce6..0bd078c44 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift @@ -31,10 +31,6 @@ extension PlayerService { let authoritativeVideoId = normalizedMediaVideoId let expectedVideoId = self.expectedPlaybackVideoId let videoIdMismatch = authoritativeVideoId.map { $0 != expectedVideoId } ?? false - let titleMismatchWithoutVideoId = self.repeatMode == .one - && authoritativeVideoId == nil - && !title.isEmpty - && title != self.currentTrack?.title let hasObservedMetadata = authoritativeVideoId != nil || !title.isEmpty let thumbnailMetadataChanged = !thumbnailUrl.isEmpty && thumbnailUrl != self.currentTrack?.thumbnailURL?.absoluteString @@ -48,7 +44,6 @@ extension PlayerService { let shouldReconcileMetadata = identitiesCoherent && (bridgeTrackChanged || videoIdMismatch - || titleMismatchWithoutVideoId || textualMetadataChanged) if hasObservedMetadata, videoIdMismatch || shouldReconcileMetadata { self.updateTrackMetadata( @@ -156,6 +151,7 @@ extension PlayerService { func clearPendingNativeQueueAdvance() { self.pendingNativeQueueAdvanceGeneration &+= 1 self.pendingNativeQueueAdvance = nil + self.clearNativeQueueMaintenance() } @discardableResult @@ -174,9 +170,7 @@ extension PlayerService { SingletonPlayerWebView.shared.currentVideoId = videoId self.logger.info("Confirmed native queue advance to \(videoId)") - await self.fetchMoreMixSongsIfNeeded() - await self.fillSmartShuffleWindow() - self.saveQueueForPersistence(syncWebQueue: false) + self.scheduleNativeQueueMaintenance() return true } @@ -246,6 +240,38 @@ extension PlayerService { ) } + func awaitNativeQueueMaintenanceIfNeeded() async { + let task = self.nativeQueueMaintenanceTask + await task?.value + } + + private func scheduleNativeQueueMaintenance() { + self.nativeQueueMaintenanceGeneration &+= 1 + let generation = self.nativeQueueMaintenanceGeneration + self.nativeQueueMaintenanceTask?.cancel() + self.nativeQueueMaintenanceTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.fetchMoreMixSongsIfNeeded() + guard !Task.isCancelled, + self.nativeQueueMaintenanceGeneration == generation + else { return } + await self.fillSmartShuffleWindow() + guard !Task.isCancelled, + self.nativeQueueMaintenanceGeneration == generation + else { return } + self.saveQueueForPersistence(syncWebQueue: false) + if self.nativeQueueMaintenanceGeneration == generation { + self.nativeQueueMaintenanceTask = nil + } + } + } + + private func clearNativeQueueMaintenance() { + self.nativeQueueMaintenanceGeneration &+= 1 + self.nativeQueueMaintenanceTask?.cancel() + self.nativeQueueMaintenanceTask = nil + } + private func normalizedWebPlaybackVideoId(_ videoId: String?) -> String? { guard let videoId, !videoId.isEmpty else { return nil } return videoId diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index 5c7d89c3b..098bc73fa 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -336,6 +336,7 @@ extension PlayerService { private func keepQueueSongVisible(_ song: Song, thumbnailUrl: String) { let intendedThumbnailURL = URL(string: thumbnailUrl) ?? song.thumbnailURL + let fb = song.feedbackTokens self.currentTrack = Song( id: song.id, title: song.title, @@ -348,7 +349,7 @@ extension PlayerService { musicVideoType: song.musicVideoType, likeStatus: song.likeStatus, isInLibrary: song.isInLibrary, - feedbackTokens: song.feedbackTokens + feedbackTokens: fb ) } @@ -673,6 +674,7 @@ extension PlayerService { /// Handles a natural track completion reported directly by the WebView. func handleTrackEnded(observedVideoId: String?) async { self.logger.debug("Track ended reported by WebView: \(observedVideoId ?? "unknown")") + await self.awaitNativeQueueMaintenanceIfNeeded() self.songNearingEnd = false guard !self.queue.isEmpty else { if self.repeatMode == .one, self.currentTrack != nil || self.pendingPlayVideoId != nil { diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index e4c51367b..6f013abe9 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -8,6 +8,8 @@ import os @MainActor @Observable final class PlayerService: NSObject, PlayerServiceProtocol { + typealias TrackFB = FeedbackTokens + /// Shared instance for AppleScript access. /// /// **Safety Invariant:** This property is set exactly once during app initialization @@ -187,6 +189,8 @@ final class PlayerService: NSObject, PlayerServiceProtocol { var pendingNativeQueueAdvance: PendingNativeQueueAdvance? var pendingNativeQueueAdvanceGeneration: Int = 0 + var nativeQueueMaintenanceGeneration: Int = 0 + var nativeQueueMaintenanceTask: Task? var pendingNativeQueueAdvanceVideoId: String? { self.pendingNativeQueueAdvance?.targetVideoId @@ -228,7 +232,7 @@ final class PlayerService: NSObject, PlayerServiceProtocol { var currentTrackInLibrary: Bool = false /// Feedback tokens for the current track (used for library add/remove). - var currentTrackFeedbackTokens: FeedbackTokens? + var currentTrackFeedbackTokens: TrackFB? /// Whether the lyrics panel is visible. var showLyrics: Bool = false { diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index 4519df05b..91fda0af6 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -308,8 +308,8 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length var coordinator: Coordinator? let logger = DiagnosticsLogger.player private var loadGeneration = 0 - private var documentTokenGeneration = 0 - var pendingDocumentToken: Int? + private var documentIDGeneration = 0 + var pendingDocumentID: Int? var activeDocumentNavigation: WKNavigation? var isDocumentNavigationInProgress = false @@ -467,7 +467,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length guard let webView else { return } self.logger.info("Tearing down singleton music WebView") self.loadGeneration += 1 - self.pendingDocumentToken = nil + self.pendingDocumentID = nil self.activeDocumentNavigation = nil self.isDocumentNavigationInProgress = false self.currentVideoId = nil @@ -644,7 +644,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length nonisolated static func pageBootstrapScript( shouldBlockAutoplay: Bool, targetVolume: Double, - documentToken: Int = 0 + documentID: Int = 0 ) -> String { let clampedVolume = if targetVolume.isFinite { min(max(targetVolume, 0), 1) @@ -655,7 +655,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length return """ \(Self.autoplayIntentScript(shouldBlockAutoplay: shouldBlockAutoplay)) window.__kasetTargetVolume = \(clampedVolume); - window.__kasetDocumentToken = \(documentToken); + window.__kasetDocumentID = \(documentID); """ } @@ -665,9 +665,9 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length targetVolume: Double ) { contentController.removeAllUserScripts() - self.documentTokenGeneration &+= 1 - let documentToken = self.documentTokenGeneration - self.pendingDocumentToken = documentToken + self.documentIDGeneration &+= 1 + let documentID = self.documentIDGeneration + self.pendingDocumentID = documentID // Autoplay intent must exist before media lifecycle events like `canplay`. // `didFinish` is too late on fast or cached player loads. @@ -675,7 +675,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length source: Self.pageBootstrapScript( shouldBlockAutoplay: shouldBlockAutoplay, targetVolume: targetVolume, - documentToken: documentToken + documentID: documentID ), injectionTime: .atDocumentStart, forMainFrameOnly: true @@ -747,7 +747,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length private var lastAcceptedQueueEntryID: UUID? private var hasAcceptedQueueEntryBaseline = false private var documentGeneration = 0 - private var activeDocumentToken: Int? + private var activeDocumentID: Int? private var playbackBridgeTask: Task? init(playerService: PlayerService) { @@ -762,8 +762,8 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length else { return } if Self.isDocumentScopedMessage(type) { - guard let activeDocumentToken = self.activeDocumentToken, - body["documentToken"] as? Int == activeDocumentToken + guard let activeDocumentID = self.activeDocumentID, + body["documentID"] as? Int == activeDocumentID else { return } @@ -774,18 +774,17 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length switch type { case "TRACK_ENDED": + guard (body["mediaIdentityUncertain"] as? Bool) != true else { return } self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in await coordinator.playerService.handleTrackEnded(observedVideoId: observedVideoId) } case "REMOTE_NEXT": - Task { @MainActor in - guard self.isCurrentDocument(messageGeneration) else { return } - await self.playerService.next() + self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in + await coordinator.playerService.next() } case "REMOTE_PREVIOUS": - Task { @MainActor in - guard self.isCurrentDocument(messageGeneration) else { return } - await self.playerService.previous() + self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in + await coordinator.playerService.previous() } case "AIRPLAY_STATUS": self.handleAirPlayStatusUpdate(body: body, messageGeneration: messageGeneration) @@ -794,11 +793,12 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length case "PLAYBACK_AUDIO_QUALITY_STATS": Self.logAudioQualityStats(body: body, observedVideoId: observedVideoId) case "QUEUE_INJECTION_RESULT": - self.handleQueueInjectionResult( - body: body, - observedVideoId: observedVideoId, - messageGeneration: messageGeneration - ) + self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in + coordinator.handleQueueInjectionResult( + body: body, + observedVideoId: observedVideoId + ) + } case "STATE_UPDATE": self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in await coordinator.handleStateUpdate( @@ -878,23 +878,19 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length private func handleQueueInjectionResult( body: [String: Any], - observedVideoId: String?, - messageGeneration: Int + observedVideoId: String? ) { guard let observedVideoId else { return } let success = body["success"] as? Bool ?? false let reason = body["reason"] as? String guard let attemptGeneration = body["attemptGeneration"] as? Int else { return } - Task { @MainActor in - guard self.isCurrentDocument(messageGeneration) else { return } - self.playerService.handleWebQueueInjectionResult( - videoId: observedVideoId, - attemptGeneration: attemptGeneration, - success: success, - reason: reason - ) - } + self.playerService.handleWebQueueInjectionResult( + videoId: observedVideoId, + attemptGeneration: attemptGeneration, + success: success, + reason: reason + ) } private static let allowedAudioQualityStatsKeys: Set = [ @@ -1122,7 +1118,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length } self.playbackBridgeTask?.cancel() self.playbackBridgeTask = nil - self.activeDocumentToken = SingletonPlayerWebView.shared.pendingDocumentToken + self.activeDocumentID = SingletonPlayerWebView.shared.pendingDocumentID self.documentGeneration &+= 1 } diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift index a0a3a7d06..12c1eed5f 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift @@ -38,7 +38,7 @@ extension SingletonPlayerWebView { const bridge = window.webkit.messageHandlers.singletonPlayer; const observerEpoch = (window.performance && performance.timeOrigin) ? performance.timeOrigin : Date.now(); - const documentToken = Number(window.__kasetDocumentToken || 0); + const documentID = Number(window.__kasetDocumentID || 0); \(autoplayRecoveryFunctionJS) \(mediaIdentityBindingDecisionFunctionJS) let lastTitle = ''; @@ -141,7 +141,7 @@ extension SingletonPlayerWebView { bridge.postMessage({ type: 'AIRPLAY_STATUS', observerEpoch: observerEpoch, - documentToken: documentToken, + documentID: documentID, isConnected: isWireless, wasConnected: wasConnected, wasRequested: window.__kasetAirPlayRequested || false @@ -155,7 +155,7 @@ extension SingletonPlayerWebView { bridge.postMessage({ type: 'AIRPLAY_STATUS', observerEpoch: observerEpoch, - documentToken: documentToken, + documentID: documentID, isConnected: true, wasConnected: false, wasRequested: window.__kasetAirPlayRequested || false @@ -165,7 +165,7 @@ extension SingletonPlayerWebView { bridge.postMessage({ type: 'AIRPLAY_STATUS', observerEpoch: observerEpoch, - documentToken: documentToken, + documentID: documentID, isConnected: false, wasConnected: true, wasRequested: true @@ -349,7 +349,7 @@ extension SingletonPlayerWebView { bridge.postMessage({ type: 'LYRICS_LINE', observerEpoch: observerEpoch, - documentToken: documentToken, + documentID: documentID, lineIndex: bucket.lineIndex, bucket: bucket.bucket, timeMs: timeMs @@ -458,22 +458,15 @@ extension SingletonPlayerWebView { } function sendTrackEnded() { - const logicalVideoId = currentVideoId(); - let endedVideoId = mediaVideoId || lastVideoId || logicalVideoId; - if (mediaIdentityUncertain) { - if (logicalVideoId - && logicalVideoId !== mediaIdentityTransitionFromVideoId) { - endedVideoId = logicalVideoId; - } else if (lastVideoId - && lastVideoId !== mediaIdentityTransitionFromVideoId) { - endedVideoId = lastVideoId; - } - } + const endedVideoId = mediaIdentityUncertain + ? '' : (mediaVideoId || lastVideoId || currentVideoId()); bridge.postMessage({ type: 'TRACK_ENDED', observerEpoch: observerEpoch, - documentToken: documentToken, - videoId: endedVideoId + documentID: documentID, + videoId: endedVideoId, + mediaGeneration: mediaGeneration, + mediaIdentityUncertain: mediaIdentityUncertain }); } @@ -618,7 +611,7 @@ extension SingletonPlayerWebView { mediaVideoId: mediaVideoId, mediaGeneration: mediaGeneration, observerEpoch: observerEpoch, - documentToken: documentToken, + documentID: documentID, thumbnailUrl: thumbnailUrl, trackChanged: trackChanged, likeStatus: likeStatus, diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift index ec8427652..8e91b92e6 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackPreferences.swift @@ -125,7 +125,7 @@ extension SingletonPlayerWebView { (function() { const observerEpoch = (window.performance && performance.timeOrigin) ? performance.timeOrigin : Date.now(); - const documentToken = Number(window.__kasetDocumentToken || 0); + const documentID = Number(window.__kasetDocumentID || 0); if (typeof window.__kasetUseNextPrev !== 'boolean') { try { window.__kasetUseNextPrev = @@ -156,11 +156,11 @@ extension SingletonPlayerWebView { ms.setActionHandler('seekbackward', null); ms.setActionHandler('nexttrack', function() { window.webkit.messageHandlers.singletonPlayer - .postMessage({ type: 'REMOTE_NEXT', observerEpoch: observerEpoch, documentToken: documentToken }); + .postMessage({ type: 'REMOTE_NEXT', observerEpoch: observerEpoch, documentID: documentID }); }); ms.setActionHandler('previoustrack', function() { window.webkit.messageHandlers.singletonPlayer - .postMessage({ type: 'REMOTE_PREVIOUS', observerEpoch: observerEpoch, documentToken: documentToken }); + .postMessage({ type: 'REMOTE_PREVIOUS', observerEpoch: observerEpoch, documentID: documentID }); }); }); } catch (e) {} diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift b/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift index 697e161a6..e6f413ec7 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift @@ -72,7 +72,7 @@ extension SingletonPlayerWebView { const bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.singletonPlayer; const injectionAttemptId = \(attemptGeneration); - const documentToken = Number(window.__kasetDocumentToken || 0); + const documentID = Number(window.__kasetDocumentID || 0); const startedAt = Date.now(); let menuObserver = null; let menuWaitTimerId = null; @@ -128,7 +128,7 @@ extension SingletonPlayerWebView { type: 'QUEUE_INJECTION_RESULT', videoId: reportedVideoId || targetVideoId, attemptGeneration: injectionAttemptId, - documentToken: documentToken, + documentID: documentID, success: !!success, reason: reason || '' }); diff --git a/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift b/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift index 086fe2b09..9469d9272 100644 --- a/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift +++ b/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift @@ -4,7 +4,7 @@ import Testing // MARK: - PlaybackMediaGenerationCase -struct PlaybackMediaGenerationCase: Sendable { +struct PlaybackMediaGenerationCase { let queueEntryChanged: Bool let epoch: Double let lastEpoch: Double? From f81529a59af66be7c35be0c93eba2e8b8a31bf3f Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 21:02:58 -0700 Subject: [PATCH 18/73] fix(player): address queue handoff review Signed-off-by: Sertac Ozercan --- .../PlayerService+PlaybackControls.swift | 11 ++- .../Player/PlayerService+WebQueueSync.swift | 12 +++- Sources/Kaset/Views/MiniPlayerWebView.swift | 69 ++++++++++++++++++- ...PlayerServiceNativeQueueAdvanceTests.swift | 23 +++++++ ...ayerServiceWebQueueSyncFollowUpTests.swift | 16 +++++ .../adr/0026-gapless-playback-native-queue.md | 4 +- 6 files changed, 129 insertions(+), 6 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index e9ee44482..191de0335 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -389,7 +389,8 @@ extension PlayerService { } /// Skips to next track. - func next() async { + func next() async { // swiftlint:disable:this cyclomatic_complexity + guard !Task.isCancelled else { return } self.logger.debug("Skipping to next track") self.clearRestoredPlaybackSessionState() SingletonPlayerWebView.shared.setAutoplayBlocked(false) @@ -403,6 +404,7 @@ extension PlayerService { } else if self.mixContinuationToken != nil { let previousCount = self.queue.count await self.fetchMoreMixSongsIfNeeded() + guard !Task.isCancelled else { return } if self.queue.count > previousCount { targetIndex = self.currentIndex + 1 } @@ -411,8 +413,11 @@ extension PlayerService { guard let targetIndex else { return } self.pushForwardSkipStackIfLeavingIndex(for: targetIndex) await self.loadQueueSongForNavigation(at: targetIndex) + guard !Task.isCancelled else { return } await self.fetchMoreMixSongsIfNeeded() + guard !Task.isCancelled else { return } await self.fillSmartShuffleWindow() + guard !Task.isCancelled else { return } self.saveQueueForPersistence(syncWebQueue: false) return } @@ -426,11 +431,15 @@ extension PlayerService { if let currentTrack = self.currentTrack { await self.fetchAndApplyRadioQueue(for: currentTrack.videoId) + guard !Task.isCancelled else { return } if self.queue.indices.contains(self.currentIndex + 1) { self.pushForwardSkipStackIfLeavingIndex(for: self.currentIndex + 1) await self.loadQueueSongForNavigation(at: self.currentIndex + 1) + guard !Task.isCancelled else { return } await self.fetchMoreMixSongsIfNeeded() + guard !Task.isCancelled else { return } await self.fillSmartShuffleWindow() + guard !Task.isCancelled else { return } self.saveQueueForPersistence(syncWebQueue: false) } else { self.logger.debug("Ignoring next without a Kaset queue") diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index 098bc73fa..c5a7163d7 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -252,7 +252,6 @@ extension PlayerService { if self.repeatMode == .one { return self.currentIndex } - guard !self.shuffleEnabled else { return nil } if self.currentIndex < self.queue.count - 1 { return self.currentIndex + 1 } @@ -671,10 +670,16 @@ extension PlayerService { } } + // swiftlint:disable cyclomatic_complexity /// Handles a natural track completion reported directly by the WebView. - func handleTrackEnded(observedVideoId: String?) async { + func handleTrackEnded( + observedVideoId: String?, + shouldContinue: @MainActor () -> Bool = { true } + ) async { + guard shouldContinue() else { return } self.logger.debug("Track ended reported by WebView: \(observedVideoId ?? "unknown")") await self.awaitNativeQueueMaintenanceIfNeeded() + guard shouldContinue() else { return } self.songNearingEnd = false guard !self.queue.isEmpty else { if self.repeatMode == .one, self.currentTrack != nil || self.pendingPlayVideoId != nil { @@ -760,9 +765,12 @@ extension PlayerService { } self.logger.info("Track ended in WebView, advancing native queue immediately") + guard shouldContinue() else { return } await self.next() } + // swiftlint:enable cyclomatic_complexity + /// Updates track metadata and enforces Kaset's queue when YouTube tries to diverge. func updateTrackMetadata(title: String, artist: String, thumbnailUrl: String, videoId observedVideoId: String?) { self.logger.debug("Track metadata updated: \(title) - \(artist)") diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index 91fda0af6..c2a31baa3 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -298,6 +298,12 @@ struct MiniPlayerWebView: NSViewRepresentable { /// - Observer script (SingletonPlayerWebView+ObserverScript.swift) @MainActor final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length + private struct PendingRouterNavigation { + let videoId: String + let fallbackURL: URL + let generation: Int + } + static let shared = SingletonPlayerWebView() private(set) var webView: WKWebView? @@ -308,6 +314,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length var coordinator: Coordinator? let logger = DiagnosticsLogger.player private var loadGeneration = 0 + private var pendingRouterNavigation: PendingRouterNavigation? private var documentIDGeneration = 0 var pendingDocumentID: Int? var activeDocumentNavigation: WKNavigation? @@ -467,6 +474,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length 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.isDocumentNavigationInProgress = false @@ -544,6 +552,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length self.currentVideoId = videoId self.loadGeneration &+= 1 let generation = self.loadGeneration + self.pendingRouterNavigation = nil // Get current volume from PlayerService via coordinator let currentVolume = self.coordinator?.playerService.volume ?? 1.0 @@ -600,6 +609,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length let host = webView.url?.host ?? "" guard host == "music.youtube.com" || host == "www.music.youtube.com" else { self.logger.debug("Router unavailable (host: \(host, privacy: .public)); falling back to full load") + self.pendingRouterNavigation = nil webView.load(URLRequest(url: fallbackURL)) return } @@ -624,13 +634,62 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length let didNavigate = result as? Bool ?? false if didNavigate { self.logger.info("Router navigation started for video: \(videoId)") + self.pendingRouterNavigation = PendingRouterNavigation( + videoId: videoId, + fallbackURL: fallbackURL, + generation: generation + ) + self.scheduleRouterNavigationFallback( + videoId: videoId, + fallbackURL: fallbackURL, + generation: generation + ) } else { self.logger.info("Router navigation failed for video: \(videoId), using full load") + self.pendingRouterNavigation = nil webView.load(URLRequest(url: fallbackURL)) } } } + func confirmRouterNavigationIfNeeded(videoId: String?) { + guard let videoId, + let pendingRouterNavigation = self.pendingRouterNavigation, + pendingRouterNavigation.videoId == videoId, + pendingRouterNavigation.generation == self.loadGeneration + else { + return + } + + self.pendingRouterNavigation = nil + self.logger.debug("Router navigation confirmed for video: \(videoId)") + } + + private func scheduleRouterNavigationFallback( + videoId: String, + fallbackURL: URL, + generation: Int + ) { + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(3)) + guard let self, + let pendingRouterNavigation = self.pendingRouterNavigation, + pendingRouterNavigation.videoId == videoId, + pendingRouterNavigation.fallbackURL == fallbackURL, + pendingRouterNavigation.generation == generation, + self.loadGeneration == generation, + self.currentVideoId == videoId, + let webView = self.webView + else { + return + } + + self.pendingRouterNavigation = nil + self.logger.warning("Router navigation to \(videoId) was not media-confirmed; using full load") + webView.load(URLRequest(url: fallbackURL)) + } + } + /// Returns the JS snippet that hands the autoplay intent to the freshly loaded /// page's window. Restored sessions suppress autoplay so the reconcile path /// resumes at the saved seek rather than at 0s. @@ -776,7 +835,12 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length case "TRACK_ENDED": guard (body["mediaIdentityUncertain"] as? Bool) != true else { return } self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in - await coordinator.playerService.handleTrackEnded(observedVideoId: observedVideoId) + await coordinator.playerService.handleTrackEnded( + observedVideoId: observedVideoId, + shouldContinue: { + !Task.isCancelled && coordinator.isCurrentDocument(messageGeneration) + } + ) } case "REMOTE_NEXT": self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in @@ -1247,6 +1311,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length } private extension SingletonPlayerWebView.Coordinator { + // swiftlint:disable:next function_body_length private func handleStateUpdate( body: [String: Any], observedVideoId: String?, @@ -1275,6 +1340,8 @@ private extension SingletonPlayerWebView.Coordinator { return } + SingletonPlayerWebView.shared.confirmRouterNavigationIfNeeded(videoId: mediaVideoId) + let shouldContinuePendingAdvance = await self.playerService .reconcilePendingNativeQueueAdvanceObservation(videoId: mediaVideoId) guard self.isCurrentDocument(messageGeneration), diff --git a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift index b64d39f12..df7b4a6b5 100644 --- a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift +++ b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift @@ -3,6 +3,29 @@ import Testing @testable import Kaset extension PlayerServiceWebQueueSyncTests { + @Test("Track-ended processing stops when its document generation is invalidated") + func staleTrackEndedGenerationStopsBeforeQueueMutation() async { + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + var validationCount = 0 + + await self.playerService.handleTrackEnded( + observedVideoId: "v1", + shouldContinue: { + validationCount += 1 + return validationCount == 1 + } + ) + + #expect(validationCount >= 2) + #expect(self.playerService.currentIndex == 0) + #expect(self.playerService.currentTrack?.videoId == "v1") + } + @Test("Injected track end keeps the outgoing song visible until expected media confirmation") func injectedTrackEndWaitsForExpectedMediaConfirmation() async { let songs = [ diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift index a1f81de1d..874a43110 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift @@ -3,6 +3,22 @@ import Testing @testable import Kaset extension PlayerServiceWebQueueSyncTests { + @Test("Plain shuffle exposes the next materialized queue entry for native injection") + func plainShuffleHasDeterministicNextEntry() async { + await self.playerService.playQueue(TestFixtures.makeSongs(count: 4), startingAt: 0) + self.playerService.setShuffleMode(.on) + + #expect(self.playerService.expectedQueueIndexAfterCurrentTrack() == 1) + } + + @Test("Smart shuffle exposes the next materialized queue entry for native injection") + func smartShuffleHasDeterministicNextEntry() async { + await self.playerService.playQueue(TestFixtures.makeSongs(count: 4), startingAt: 0) + self.playerService.setShuffleMode(.smart) + + #expect(self.playerService.expectedQueueIndexAfterCurrentTrack() == 1) + } + @Test("Radio queue replacement preserves the current playback occurrence ID") func radioQueueReplacementPreservesCurrentEntryID() async { let seed = Song(id: "seed", title: "Seed", artists: [], duration: 180, videoId: "seed-video") diff --git a/docs/adr/0026-gapless-playback-native-queue.md b/docs/adr/0026-gapless-playback-native-queue.md index fc5011eab..dc8c0e9f1 100644 --- a/docs/adr/0026-gapless-playback-native-queue.md +++ b/docs/adr/0026-gapless-playback-native-queue.md @@ -47,8 +47,8 @@ track into YouTube Music's native **Up Next** queue ahead of time. - Before dispatch, the menu endpoint's source video ID must still match Kaset's current queue song. The page preserves rendered queue-row occurrences, accepts an already aligned queue without clicking again, otherwise requires a post-click queue change, and reports success only when the expected target becomes - the actual next row (whether or not the current row is rendered). Stale, duplicate, - malformed, or unconfirmed clicks fail closed. + the row immediately after the rendered selected source row. If that current row is + absent, stale, duplicated, malformed, or otherwise unconfirmed, the attempt fails closed. - Swift tracks queue injection as two separate states: - `pendingWebQueueInjectionVideoId`: an injection attempt has started but the WebView has not confirmed it; From 07d9f2a606b499c45f04d0bbff92adcbf13aece7 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 21:13:39 -0700 Subject: [PATCH 19/73] fix(player): preserve deferred restore state Signed-off-by: Sertac Ozercan --- .../Player/PlayerService+PlaybackRestoration.swift | 8 ++++++-- .../Views/SingletonPlayerWebView+ObserverScript.swift | 2 +- Tests/KasetTests/PlaybackObserverIdentityTests.swift | 1 + .../PlayerServiceWebQueueSyncFollowUpTests.swift | 4 +++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift index 55b1a1324..a18d52631 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift @@ -44,8 +44,12 @@ extension PlayerService { let previousProgress = self.progress if self.isPendingRestoredLoadDeferred { - self.progress = progress - self.duration = duration + if self.pendingRestoredSeek == nil { + self.progress = progress + } + if duration > 0 { + self.duration = duration + } self.state = .paused if isPlaying, !self.hasIssuedAutoplayPauseDuringDeferredRestore { self.hasIssuedAutoplayPauseDuringDeferredRestore = true diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift index 12c1eed5f..25f844cbb 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift @@ -282,7 +282,7 @@ extension SingletonPlayerWebView { mediaVideoId = videoId; mediaSource = source; lastMediaCurrentTime = currentTime; - mediaIdentityIsInitialBinding = !previousMediaVideoId; + mediaIdentityIsInitialBinding = !previousMediaVideoId && !videoId; mediaIdentityTransitionFromVideoId = previousMediaVideoId || videoId; mediaIdentityUncertain = !videoId || mediaIdentityIsInitialBinding diff --git a/Tests/KasetTests/PlaybackObserverIdentityTests.swift b/Tests/KasetTests/PlaybackObserverIdentityTests.swift index 7fa0f4c7d..42359274f 100644 --- a/Tests/KasetTests/PlaybackObserverIdentityTests.swift +++ b/Tests/KasetTests/PlaybackObserverIdentityTests.swift @@ -21,6 +21,7 @@ struct PlaybackObserverIdentityTests { #expect(script.contains("confirmMediaIdentityOnPlaying")) #expect(script.contains("identityCorrectionEvidence")) #expect(script.contains("initialEmptyIdentityResolved")) + #expect(script.contains("mediaIdentityIsInitialBinding = !previousMediaVideoId && !videoId")) #expect(script.contains("observerEpoch: observerEpoch")) } diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift index 874a43110..5fffd9181 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift @@ -458,11 +458,13 @@ extension PlayerServiceWebQueueSyncTests { ) self.playerService.isAwaitingWebRestoredTrack = false - self.playerService.updatePlaybackState(isPlaying: true, progress: 61, duration: 180) + self.playerService.updatePlaybackState(isPlaying: true, progress: 61, duration: 0) #expect(self.playerService.isPendingRestoredLoadDeferred == true) #expect(self.playerService.state == .paused) #expect(self.playerService.pendingRestoredSeek == 60) + #expect(self.playerService.progress == 60) + #expect(self.playerService.duration == 180) #expect(self.playerService.currentTrack?.videoId == "v1") #expect(self.playerService.hasIssuedAutoplayPauseDuringDeferredRestore == true) } From b8824f0a4921769fb21e74bd8a62e27279f80a80 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 21:58:13 -0700 Subject: [PATCH 20/73] fix(player): stabilize manual queue transitions Signed-off-by: Sertac Ozercan --- .../Player/PlayerService+ManualSeek.swift | 42 +++++++++++++ .../Player/PlayerService+WebQueueSync.swift | 59 +++++++------------ Sources/Kaset/Views/MiniPlayerWebView.swift | 39 ++++++++++-- ...ngletonPlayerWebView+NavigationState.swift | 2 + ...ayerServiceWebQueueSyncFollowUpTests.swift | 19 ++++++ .../PlayerServiceWebQueueSyncTests.swift | 2 + 6 files changed, 120 insertions(+), 43 deletions(-) create mode 100644 Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift diff --git a/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift b/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift new file mode 100644 index 000000000..41604ceab --- /dev/null +++ b/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift @@ -0,0 +1,42 @@ +import Foundation + +@MainActor +extension PlayerService { + /// Distance from `duration` at which a manual seek is treated as the end of the track. + /// `video.currentTime = duration` does not reliably fire `ended` in WebKit, and a subsequent + /// play call would restart the same song from 0 instead of advancing. + static let seekToEndThreshold: TimeInterval = 0.5 + + /// Routes a manual seek that landed at the end of the track through the track-ended path so + /// repeat / queue / autoplay-suppression rules apply consistently with a natural end. + func handleManualSeekToEnd() async { + self.logger.info("Manual seek reached end of track; routing through track-ended path") + self.clearRestoredPlaybackSessionState() + self.progress = self.duration + + if !self.queue.isEmpty, + self.repeatMode != .one, + self.canAdvanceNativeQueueAfterTrackEnd + { + SingletonPlayerWebView.shared.seekAndPause(to: self.duration) + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() + await self.next() + return + } + + if self.shouldSynchronizeWebViewForTerminalManualSeekToEnd { + SingletonPlayerWebView.shared.seekAndPause(to: self.duration) + } + + await self.handleTrackEnded(observedVideoId: self.currentTrack?.videoId) + } + + private var shouldSynchronizeWebViewForTerminalManualSeekToEnd: Bool { + if self.queue.isEmpty { + return !(self.repeatMode == .one && (self.currentTrack != nil || self.pendingPlayVideoId != nil)) + } + + return !self.canAdvanceNativeQueueAfterTrackEnd + } +} diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index c5a7163d7..5be6ea7f4 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -80,33 +80,6 @@ extension PlayerService { } } - /// Distance from `duration` at which a manual seek is treated as the end of the track. - /// `video.currentTime = duration` does not reliably fire `ended` in WebKit, and a subsequent - /// play call would restart the same song from 0 instead of advancing. - static let seekToEndThreshold: TimeInterval = 0.5 - - /// Routes a manual seek that landed at the end of the track through the track-ended path so - /// repeat / queue / autoplay-suppression rules apply consistently with a natural end. - func handleManualSeekToEnd() async { - self.logger.info("Manual seek reached end of track; routing through track-ended path") - self.clearRestoredPlaybackSessionState() - self.progress = self.duration - - if self.shouldSynchronizeWebViewForTerminalManualSeekToEnd { - SingletonPlayerWebView.shared.seekAndPause(to: self.duration) - } - - await self.handleTrackEnded(observedVideoId: self.currentTrack?.videoId) - } - - private var shouldSynchronizeWebViewForTerminalManualSeekToEnd: Bool { - if self.queue.isEmpty { - return !(self.repeatMode == .one && (self.currentTrack != nil || self.pendingPlayVideoId != nil)) - } - - return !self.canAdvanceNativeQueueAfterTrackEnd - } - private func normalizedObservedVideoId(_ videoId: String?) -> String? { guard let videoId, !videoId.isEmpty else { return nil } return videoId @@ -156,18 +129,18 @@ extension PlayerService { return } - guard self.injectedWebQueueVideoId != nextSong.videoId, - self.pendingWebQueueInjectionVideoId != nextSong.videoId - else { return } - // Duplicate video IDs need an explicit in-place restart so the media - // generation advances with the queue entry. Do not trust native Up Next - // for a same-ID transition. + // 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 } + guard self.injectedWebQueueVideoId != nextSong.videoId, + self.pendingWebQueueInjectionVideoId != nextSong.videoId + else { return } + self.pendingWebQueueInjectionVideoId = nextSong.videoId self.webQueueInjectionGeneration &+= 1 let injectionGeneration = self.webQueueInjectionGeneration @@ -239,7 +212,7 @@ extension PlayerService { self.logger.info("Synced web queue: confirmed \(videoId) to play next natively") } - private var canAdvanceNativeQueueAfterTrackEnd: Bool { + var canAdvanceNativeQueueAfterTrackEnd: Bool { self.shuffleEnabled || self.repeatMode == .one || self.currentIndex < self.queue.count - 1 @@ -443,6 +416,15 @@ extension PlayerService { return true } + private func commitObservedQueueTrack(to index: Int, videoId: String) { + self.clearWebQueueInjectionState() + self.clearPendingNativeQueueAdvance() + self.pushForwardSkipStackIfLeavingIndex(for: index) + self.advanceQueueStateForNativeNavigation(to: index) + SingletonPlayerWebView.shared.currentVideoId = videoId + self.saveQueueForPersistence(syncWebQueue: false) + } + private func handleNearEndTrackChangeIfNeeded( observedVideoId: String?, title: String, @@ -481,9 +463,11 @@ extension PlayerService { return true } - self.currentIndex = expectedNextIndex + self.commitObservedQueueTrack( + to: expectedNextIndex, + videoId: expectedNextTrack.videoId + ) self.logger.info("Track advanced to queue index \(expectedNextIndex)") - self.saveQueueForPersistence() if self.shouldKeepQueueMetadata(title: title, artist: artist, song: expectedNextTrack) { self.logger.debug( @@ -610,9 +594,8 @@ extension PlayerService { { let queueIndexChanged = matchingIndex != self.currentIndex if queueIndexChanged { - self.currentIndex = matchingIndex + self.commitObservedQueueTrack(to: matchingIndex, videoId: observedVideoId) self.logger.info("Observed playback moved to queue index \(matchingIndex), realigning native queue") - self.saveQueueForPersistence() } if queueIndexChanged || self.shouldKeepQueueMetadata(title: title, artist: artist, song: matchingSong) { diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index c2a31baa3..bd0e95ff3 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -318,6 +318,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length private var documentIDGeneration = 0 var pendingDocumentID: Int? var activeDocumentNavigation: WKNavigation? + var activeDocumentNavigationID: Int? var isDocumentNavigationInProgress = false /// Current display mode for the WebView. @@ -477,6 +478,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length self.pendingRouterNavigation = nil self.pendingDocumentID = nil self.activeDocumentNavigation = nil + self.activeDocumentNavigationID = nil self.isDocumentNavigationInProgress = false self.currentVideoId = nil webView.evaluateJavaScript("document.querySelector('video')?.pause()", completionHandler: nil) @@ -484,6 +486,7 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length webView.removeFromSuperview() self.webKitManager?.extensionHostWebViewDidDeactivate(role: .musicPlayer) self.webView = nil + self.coordinator?.cancelPlaybackBridgeTasks() self.coordinator = nil self.currentContainer = nil self.usesCookieFreeDataStore = nil @@ -808,6 +811,9 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length private var documentGeneration = 0 private var activeDocumentID: Int? private var playbackBridgeTask: Task? + private var playbackBridgeTaskID: UInt64 = 0 + private var playbackBridgeTailTaskID: UInt64? + private var playbackBridgeTasks: [UInt64: Task] = [:] init(playerService: PlayerService) { self.playerService = playerService @@ -881,7 +887,16 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length operation: @escaping @MainActor (Coordinator) async -> Void ) { let previousTask = self.playbackBridgeTask - self.playbackBridgeTask = Task { @MainActor [weak self] in + self.playbackBridgeTaskID &+= 1 + let taskID = self.playbackBridgeTaskID + let task = Task { @MainActor [weak self] in + defer { + self?.playbackBridgeTasks[taskID] = nil + if self?.playbackBridgeTailTaskID == taskID { + self?.playbackBridgeTask = nil + self?.playbackBridgeTailTaskID = nil + } + } _ = await previousTask?.value guard let self, !Task.isCancelled, @@ -891,6 +906,18 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length } await operation(self) } + self.playbackBridgeTask = task + self.playbackBridgeTailTaskID = taskID + self.playbackBridgeTasks[taskID] = task + } + + func cancelPlaybackBridgeTasks() { + for task in self.playbackBridgeTasks.values { + task.cancel() + } + self.playbackBridgeTasks.removeAll() + self.playbackBridgeTask = nil + self.playbackBridgeTailTaskID = nil } private static func isDocumentScopedMessage(_ type: String) -> Bool { @@ -1180,9 +1207,8 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length guard SingletonPlayerWebView.shared.commitDocumentNavigation(navigation, in: webView) else { return } - self.playbackBridgeTask?.cancel() - self.playbackBridgeTask = nil - self.activeDocumentID = SingletonPlayerWebView.shared.pendingDocumentID + self.cancelPlaybackBridgeTasks() + self.activeDocumentID = SingletonPlayerWebView.shared.activeDocumentNavigationID self.documentGeneration &+= 1 } @@ -1388,8 +1414,11 @@ private extension SingletonPlayerWebView.Coordinator { lastAcceptedMediaGeneration: self.lastAcceptedMediaGeneration ) + let mediaMatches = self.playerService.observedPlaybackMatchesCurrentTarget( + videoId: mediaVideoId + ) guard shouldApplyPlaybackState, - self.playerService.observedPlaybackMatchesCurrentTarget(videoId: mediaVideoId), + mediaMatches, shouldAcceptMediaState else { return diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift b/Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift index 76c8fd3cb..36d187b24 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift @@ -6,6 +6,7 @@ extension SingletonPlayerWebView { func beginDocumentNavigation(_ navigation: WKNavigation?, in webView: WKWebView) -> Bool { guard webView === self.webView else { return false } self.activeDocumentNavigation = navigation + self.activeDocumentNavigationID = self.pendingDocumentID self.isDocumentNavigationInProgress = true return true } @@ -35,6 +36,7 @@ extension SingletonPlayerWebView { return false } self.activeDocumentNavigation = nil + self.activeDocumentNavigationID = nil self.isDocumentNavigationInProgress = false return true } diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift index 5fffd9181..942a8323e 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift @@ -54,6 +54,23 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) } + @Test("Consumed injection marker is cleared before a same-video next occurrence") + func consumedMarkerClearsBeforeSameVideoNextOccurrence() async { + let songs = [ + Song(id: "a", title: "A", artists: [], duration: 180, videoId: "va"), + Song(id: "b1", title: "B 1", artists: [], duration: 180, videoId: "vb"), + Song(id: "b2", title: "B 2", artists: [], duration: 180, videoId: "vb"), + ] + await self.playerService.playQueue(songs, startingAt: 1) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "vb" + + self.playerService.syncWebQueue() + + #expect(self.playerService.injectedWebQueueVideoId == nil) + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + } + @Test("Native queue injection skips duplicate consecutive video IDs") func nativeQueueInjectionSkipsDuplicateVideoIDs() async { let duplicate = Song( @@ -327,11 +344,13 @@ extension PlayerServiceWebQueueSyncTests { await self.playerService.playQueue(songs, startingAt: 0) self.playerService.duration = 180 + self.playerService.injectedWebQueueVideoId = "v2" await self.playerService.seek(to: 180) #expect(self.playerService.currentIndex == 1) #expect(self.playerService.pendingPlayVideoId == "v2") + #expect(self.playerService.pendingNativeQueueAdvanceVideoId == nil) } @Test("Manual seek within end-threshold still advances queue") diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift index 6f22b432d..f6b8eaf22 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift @@ -739,6 +739,8 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.currentIndex == 2) #expect(self.playerService.currentTrack?.videoId == "v3") #expect(self.playerService.currentTrack?.title == "Song 3") + #expect(self.playerService.pendingPlayVideoId == "v3") + #expect(self.playerService.progress == 0) } @Test("Track end wraps to the first queue song when repeat all is enabled") From 053ab8b32d11dcb49abbb2318baae8be5e9168a7 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 22:19:27 -0700 Subject: [PATCH 21/73] fix(player): invalidate stale queue sync state Signed-off-by: Sertac Ozercan --- .../Player/PlayerService+WebQueueSync.swift | 8 +++++++ ...ayerServiceWebQueueSyncFollowUpTests.swift | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index 5be6ea7f4..f1972a6cc 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -28,6 +28,8 @@ extension PlayerService { 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 } // Sync the web view's current video ID so Kaset knows the player is already on this track @@ -137,6 +139,12 @@ extension PlayerService { 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 } diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift index 942a8323e..a559180fc 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift @@ -71,6 +71,26 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) } + @Test("Changing the expected next target invalidates stale confirmed and pending injections") + func changedNextTargetInvalidatesStaleInjectionState() async { + let songs = [ + Song(id: "a", title: "A", artists: [], duration: 180, videoId: "va"), + Song(id: "b", title: "B", artists: [], duration: 180, videoId: "vb"), + Song(id: "c", title: "C", artists: [], duration: 180, videoId: "vc"), + ] + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.state = .playing + self.playerService.injectedWebQueueVideoId = "vb" + self.playerService.pendingWebQueueInjectionVideoId = "vc" + let generation = self.playerService.webQueueInjectionGeneration + + self.playerService.syncWebQueue() + + #expect(self.playerService.webQueueInjectionGeneration > generation) + #expect(self.playerService.injectedWebQueueVideoId == nil) + #expect(self.playerService.pendingWebQueueInjectionVideoId == nil) + } + @Test("Native queue injection skips duplicate consecutive video IDs") func nativeQueueInjectionSkipsDuplicateVideoIDs() async { let duplicate = Song( @@ -553,6 +573,8 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.currentTrack?.videoId == "server-v2") #expect(self.playerService.pendingPlayVideoId == "server-v2") #expect(self.playerService.pendingRestoredSeek == nil) + #expect(self.playerService.progress == 0) + #expect(self.playerService.duration == 0) #expect(self.playerService.isPendingRestoredLoadDeferred == true) } From bb2406513af1b7a101ab6f76a74635b1356a5644 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 22:28:55 -0700 Subject: [PATCH 22/73] fix(player): mount guest playback webview Signed-off-by: Sertac Ozercan --- Sources/Kaset/Views/MainWindow.swift | 11 +++++++++-- Tests/KasetTests/MainWindowLayoutTests.swift | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index 6ddbf7c25..58391d5dd 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -19,6 +19,10 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length static let commandBarTopPadding: CGFloat = 72 } + static func shouldMountPersistentPlayer(isLoggedIn: Bool, pendingVideoId: String?) -> Bool { + isLoggedIn || pendingVideoId != nil + } + @Environment(AuthService.self) private var authService @Environment(PlayerService.self) private var playerService @Environment(YouTubePlayerService.self) private var youtubePlayerService @@ -155,10 +159,13 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length DiagnosticsLogger.app.info("MainWindow: Login check complete") } - // Persistent WebView - always present once logged in. + // 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 self.authService.state.isLoggedIn { + if Self.shouldMountPersistentPlayer( + isLoggedIn: self.authService.state.isLoggedIn, + pendingVideoId: self.playerService.pendingPlayVideoId + ) { PersistentPlayerView(videoId: self.playerService.pendingPlayVideoId, isExpanded: false) .frame(width: 1, height: 1) .opacity(0) diff --git a/Tests/KasetTests/MainWindowLayoutTests.swift b/Tests/KasetTests/MainWindowLayoutTests.swift index d502fcd0e..68fa0ac6b 100644 --- a/Tests/KasetTests/MainWindowLayoutTests.swift +++ b/Tests/KasetTests/MainWindowLayoutTests.swift @@ -31,4 +31,11 @@ struct MainWindowLayoutTests { #expect(MainWindowLayout.isPrimaryWindowIdentity(title: "Settings", frameAutosaveName: MainWindowLayout.autosaveName)) #expect(!MainWindowLayout.isPrimaryWindowIdentity(title: "Settings", frameAutosaveName: "")) } + + @Test("Persistent player mounts for authenticated preload or guest playback") + func persistentPlayerMountingPolicy() { + #expect(MainWindow.shouldMountPersistentPlayer(isLoggedIn: true, pendingVideoId: nil)) + #expect(MainWindow.shouldMountPersistentPlayer(isLoggedIn: false, pendingVideoId: "public-video")) + #expect(!MainWindow.shouldMountPersistentPlayer(isLoggedIn: false, pendingVideoId: nil)) + } } From 1fc3f96ba711a60eebe207280753fa54f81bd020 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 22:53:18 -0700 Subject: [PATCH 23/73] fix(player): handle shuffled queue terminal state Signed-off-by: Sertac Ozercan --- .../Player/PlayerService+WebQueueSync.swift | 3 +-- .../PlayerServiceWebQueueSyncFollowUpTests.swift | 14 ++++++++++++++ Tests/KasetTests/QueueInjectionScriptTests.swift | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index f1972a6cc..8d63f4469 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -221,8 +221,7 @@ extension PlayerService { } var canAdvanceNativeQueueAfterTrackEnd: Bool { - self.shuffleEnabled - || self.repeatMode == .one + self.repeatMode == .one || self.currentIndex < self.queue.count - 1 || self.repeatMode == .all || self.mixContinuationToken != nil diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift index a559180fc..4d89d0512 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift @@ -19,6 +19,20 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.expectedQueueIndexAfterCurrentTrack() == 1) } + @Test("Materialized shuffle ends at its last entry when repeat is off") + func materializedShuffleEndsAtLastEntry() async { + let songs = TestFixtures.makeSongs(count: 3) + await self.playerService.playQueue(songs, startingAt: 2) + self.playerService.shuffleMode = .on + self.playerService.state = .playing + + await self.playerService.handleTrackEnded(observedVideoId: songs[2].videoId) + + #expect(self.playerService.currentIndex == 2) + #expect(self.playerService.state == .ended) + #expect(self.playerService.shouldSuppressAutoplayAfterQueueEnd) + } + @Test("Radio queue replacement preserves the current playback occurrence ID") func radioQueueReplacementPreservesCurrentEntryID() async { let seed = Song(id: "seed", title: "Seed", artists: [], duration: 180, videoId: "seed-video") diff --git a/Tests/KasetTests/QueueInjectionScriptTests.swift b/Tests/KasetTests/QueueInjectionScriptTests.swift index fef331fa7..2dc8c4978 100644 --- a/Tests/KasetTests/QueueInjectionScriptTests.swift +++ b/Tests/KasetTests/QueueInjectionScriptTests.swift @@ -200,7 +200,7 @@ struct QueueInjectionScriptTests { let context = try #require(self.makeContext( menuAvailableAfterLookup: 1, clickMode: "noInsert", - initialQueueVideoIds: ["wrong-next", "source-video", "target-video"] + initialQueueVideoIds: ["source-video", "wrong-next", "source-video", "target-video"] )) self.evaluate( @@ -214,7 +214,7 @@ struct QueueInjectionScriptTests { #expect(context.evaluateScript("lastMessage.success")?.toBool() == false) #expect(context.evaluateScript("lastMessage.reason")?.toString() == "queue-readback-timeout") - #expect(context.evaluateScript("queueVideoIds[0]")?.toString() == "wrong-next") + #expect(context.evaluateScript("queueVideoIds[1]")?.toString() == "wrong-next") } @Test("Queue injection reports a native Play Next click exception") From f520fdca7f409afd589e2b4d66e86da870a65512 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 23:03:34 -0700 Subject: [PATCH 24/73] fix(player): bound autoplay blocker polling Signed-off-by: Sertac Ozercan --- .../Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift index ebf1ed4f8..cc76cb715 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift @@ -15,7 +15,7 @@ extension SingletonPlayerWebView { } if (!window.__kasetBlockAutoplay) return 'autoplay-allowed'; window.__kasetAutoplayPending = false; - let ticks = 0; + var ticks = 0; const timer = setInterval(function() { if (!window.__kasetBlockAutoplay) { clearInterval(timer); From 2c2b10fdf4b9cc9f321976e0b8ffa4ca057421d3 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 23:15:03 -0700 Subject: [PATCH 25/73] fix(player): end exhausted mix continuations Signed-off-by: Sertac Ozercan --- .../Player/PlayerService+WebQueueSync.swift | 13 +++++++++++++ ...layerServiceWebQueueSyncFollowUpTests.swift | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index 8d63f4469..41a9cc0cd 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -756,7 +756,20 @@ extension PlayerService { self.logger.info("Track ended in WebView, advancing native queue immediately") guard shouldContinue() else { return } + let previousEntryID = self.currentQueueEntryID + let previousIndex = self.currentIndex await self.next() + guard shouldContinue() else { return } + if self.currentQueueEntryID == previousEntryID, + self.currentIndex == previousIndex + { + self.mixContinuationToken = nil + self.mixContinuationRequiresAuth = false + self.shouldSuppressAutoplayAfterQueueEnd = true + self.markPlaybackEnded() + self.logger.info("Continuation produced no next queue entry; ending playback") + await self.pause() + } } // swiftlint:enable cyclomatic_complexity diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift index 4d89d0512..71dd5a8ea 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift @@ -33,6 +33,24 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.shouldSuppressAutoplayAfterQueueEnd) } + @Test("Empty mix continuation ends playback at the queue boundary") + func emptyMixContinuationEndsPlayback() async { + let mockClient = MockYTMusicClient() + mockClient.mixQueueContinuationResult = RadioQueueResult(songs: [], continuationToken: nil) + self.playerService.setYTMusicClient(mockClient) + let song = TestFixtures.makeSong(id: "last-song") + await self.playerService.playQueue([song], startingAt: 0) + self.playerService.mixContinuationToken = "empty-continuation" + self.playerService.state = .playing + + await self.playerService.handleTrackEnded(observedVideoId: song.videoId) + + #expect(mockClient.getMixQueueContinuationCallCount == 1) + #expect(self.playerService.mixContinuationToken == nil) + #expect(self.playerService.state == .ended) + #expect(self.playerService.shouldSuppressAutoplayAfterQueueEnd) + } + @Test("Radio queue replacement preserves the current playback occurrence ID") func radioQueueReplacementPreservesCurrentEntryID() async { let seed = Song(id: "seed", title: "Seed", artists: [], duration: 180, videoId: "seed-video") From 6929a82c02d11b1e10612d1f0ba974d544455cdf Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 23:36:03 -0700 Subject: [PATCH 26/73] fix(player): end manual seek after empty continuation Signed-off-by: Sertac Ozercan --- .../Player/PlayerService+ManualSeek.swift | 9 +++++++++ .../Player/PlayerService+WebQueueSync.swift | 18 ++++++++++++------ ...ayerServiceWebQueueSyncFollowUpTests.swift | 19 +++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift b/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift index 41604ceab..39a0f3c42 100644 --- a/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift +++ b/Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift @@ -21,7 +21,16 @@ extension PlayerService { SingletonPlayerWebView.shared.seekAndPause(to: self.duration) self.clearWebQueueInjectionState() self.clearPendingNativeQueueAdvance() + let previousEntryID = self.currentQueueEntryID + let previousIndex = self.currentIndex await self.next() + if self.currentQueueEntryID == previousEntryID, + self.currentIndex == previousIndex + { + await self.finishPlaybackAfterFailedQueueAdvance( + reason: "manual seek continuation produced no next queue entry" + ) + } return } diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index 41a9cc0cd..cac43122a 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -763,15 +763,21 @@ extension PlayerService { if self.currentQueueEntryID == previousEntryID, self.currentIndex == previousIndex { - self.mixContinuationToken = nil - self.mixContinuationRequiresAuth = false - self.shouldSuppressAutoplayAfterQueueEnd = true - self.markPlaybackEnded() - self.logger.info("Continuation produced no next queue entry; ending playback") - await self.pause() + await self.finishPlaybackAfterFailedQueueAdvance( + reason: "continuation produced no next queue entry" + ) } } + func finishPlaybackAfterFailedQueueAdvance(reason: String) async { + self.mixContinuationToken = nil + self.mixContinuationRequiresAuth = false + self.shouldSuppressAutoplayAfterQueueEnd = true + self.markPlaybackEnded() + self.logger.info("Ending playback after failed queue advance: \(reason)") + await self.pause() + } + // swiftlint:enable cyclomatic_complexity /// Updates track metadata and enforces Kaset's queue when YouTube tries to diverge. diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift index 71dd5a8ea..6080a8509 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncFollowUpTests.swift @@ -405,6 +405,25 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.pendingNativeQueueAdvanceVideoId == nil) } + @Test("Manual seek ends playback when an empty continuation produces no successor") + func manualSeekWithEmptyContinuationEndsPlayback() async { + let mockClient = MockYTMusicClient() + mockClient.mixQueueContinuationResult = RadioQueueResult(songs: [], continuationToken: nil) + self.playerService.setYTMusicClient(mockClient) + let song = TestFixtures.makeSong(id: "last-song") + await self.playerService.playQueue([song], startingAt: 0) + self.playerService.mixContinuationToken = "empty-continuation" + self.playerService.state = .playing + self.playerService.duration = song.duration ?? 180 + + await self.playerService.seek(to: self.playerService.duration) + + #expect(mockClient.getMixQueueContinuationCallCount == 1) + #expect(self.playerService.mixContinuationToken == nil) + #expect(self.playerService.state == .ended) + #expect(self.playerService.shouldSuppressAutoplayAfterQueueEnd) + } + @Test("Manual seek within end-threshold still advances queue") func manualSeekWithinEndThresholdAdvancesQueue() async { let songs = [ From f37fad175c116d5a66578c2631b03001411c4e47 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Fri, 10 Jul 2026 23:51:14 -0700 Subject: [PATCH 27/73] fix(player): coalesce stale navigation recovery Signed-off-by: Sertac Ozercan --- .../PlayerService+PlaybackControls.swift | 7 ++- ...layerService+QueueNavigationRecovery.swift | 52 +++++++++++++++++++ .../Player/PlayerService+WebQueueSync.swift | 17 ++---- .../Kaset/Services/Player/PlayerService.swift | 6 +++ .../PlayerServiceWebQueueSyncTests.swift | 5 ++ 5 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift diff --git a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift index 191de0335..e6ce822f9 100644 --- a/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift +++ b/Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift @@ -84,6 +84,7 @@ extension PlayerService { self.logger.debug("play() called with videoId: \(videoId)") self.logger.info("Playing video: \(videoId)") self.clearRestoredPlaybackSessionState() + self.clearQueueNavigationRecovery() self.clearWebQueueInjectionState() // An explicit Kaset load supersedes (rather than participates in) a // native queue advance. The native-only path never calls `play`. @@ -131,11 +132,15 @@ extension PlayerService { func play( song: Song, webLoadStrategy: SingletonPlayerWebView.VideoLoadStrategy, - episode: ArtistEpisode? = nil + episode: ArtistEpisode? = nil, + isQueueNavigationRecovery: Bool = false ) async { self.logger.info("Playing song: \(song.title)") self.logger.debug("Web load strategy: \(String(describing: webLoadStrategy))") self.clearRestoredPlaybackSessionState() + if !isQueueNavigationRecovery { + self.clearQueueNavigationRecovery() + } self.clearWebQueueInjectionState() // An explicit Kaset load supersedes (rather than participates in) a // native queue advance. `advanceQueueStateForNativeNavigation` does not diff --git a/Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift b/Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift new file mode 100644 index 000000000..3f3e1057c --- /dev/null +++ b/Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift @@ -0,0 +1,52 @@ +import Foundation + +@MainActor +extension PlayerService { + func finishPlaybackAfterFailedQueueAdvance(reason: String) async { + self.mixContinuationToken = nil + self.mixContinuationRequiresAuth = false + self.shouldSuppressAutoplayAfterQueueEnd = true + self.markPlaybackEnded() + self.logger.info("Ending playback after failed queue advance: \(reason)") + await self.pause() + } + + func clearQueueNavigationRecovery() { + self.queueNavigationRecoveryGeneration &+= 1 + self.queueNavigationRecoveryTask?.cancel() + self.queueNavigationRecoveryTask = nil + self.queueNavigationRecoveryVideoId = nil + } + + func scheduleQueueNavigationRecovery(for song: Song) { + guard self.queueNavigationRecoveryVideoId != song.videoId + || self.queueNavigationRecoveryTask == nil + else { + self.logger.debug("Coalescing stale metadata recovery for \(song.videoId)") + return + } + + self.clearQueueNavigationRecovery() + let generation = self.queueNavigationRecoveryGeneration + self.queueNavigationRecoveryVideoId = song.videoId + self.queueNavigationRecoveryTask = Task { @MainActor [weak self] in + guard let self, + !Task.isCancelled, + self.queueNavigationRecoveryGeneration == generation, + self.queueNavigationRecoveryVideoId == song.videoId + else { + return + } + + await self.play( + song: song, + webLoadStrategy: .forceFullPageWhenSameVideoId, + isQueueNavigationRecovery: true + ) + + guard self.queueNavigationRecoveryGeneration == generation else { return } + self.queueNavigationRecoveryTask = nil + self.queueNavigationRecoveryVideoId = nil + } + } +} diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index cac43122a..30fb549c9 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -370,6 +370,7 @@ extension PlayerService { ) -> Bool { if self.clearExpiredQueueNavigationProtectionIfNeeded() { self.isKasetInitiatedPlayback = false + self.clearQueueNavigationRecovery() } guard self.isKasetInitiatedPlayback, !self.queue.isEmpty else { @@ -378,6 +379,7 @@ extension PlayerService { guard let intendedSong = self.queue[safe: self.currentIndex] else { self.isKasetInitiatedPlayback = false + self.clearQueueNavigationRecovery() return false } @@ -385,6 +387,7 @@ extension PlayerService { 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" ) @@ -400,6 +403,7 @@ extension PlayerService { ) { self.confirmQueueNavigationTarget(intendedSong.videoId) self.isKasetInitiatedPlayback = false + self.clearQueueNavigationRecovery() self.logger.debug("Confirmed Kaset-initiated playback for '\(intendedSong.title)'") return false } @@ -417,9 +421,7 @@ extension PlayerService { // 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`. - Task { - await self.play(song: intendedSong, webLoadStrategy: .forceFullPageWhenSameVideoId) - } + self.scheduleQueueNavigationRecovery(for: intendedSong) return true } @@ -769,15 +771,6 @@ extension PlayerService { } } - func finishPlaybackAfterFailedQueueAdvance(reason: String) async { - self.mixContinuationToken = nil - self.mixContinuationRequiresAuth = false - self.shouldSuppressAutoplayAfterQueueEnd = true - self.markPlaybackEnded() - self.logger.info("Ending playback after failed queue advance: \(reason)") - await self.pause() - } - // swiftlint:enable cyclomatic_complexity /// Updates track metadata and enforces Kaset's queue when YouTube tries to diverge. diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 6f013abe9..9f3e18b6b 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -684,6 +684,12 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// 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 queueNavigationRecoveryTask: Task? + /// Grace period instant - don't auto-close video window shortly after opening (uses monotonic clock) var videoWindowOpenedAt: ContinuousClock.Instant? diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift index f6b8eaf22..d39b3df02 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift @@ -115,12 +115,14 @@ struct PlayerServiceWebQueueSyncTests { // YouTube can emit stale metadata for the previous video while Kaset's // manual navigation load is still in flight. Multiple stale frames must // not be allowed to realign the native queue back to the old song. + let recoveryGeneration = self.playerService.queueNavigationRecoveryGeneration self.playerService.updateTrackMetadata( title: "Song 1", artist: "", thumbnailUrl: "", videoId: "v1" ) + self.playerService.updateTrackMetadata( title: "Song 1", artist: "", @@ -128,9 +130,12 @@ struct PlayerServiceWebQueueSyncTests { videoId: "v1" ) + #expect(self.playerService.queueNavigationRecoveryVideoId == "v2") + #expect(self.playerService.queueNavigationRecoveryGeneration == recoveryGeneration + 1) #expect(self.playerService.currentIndex == 1) #expect(self.playerService.currentTrack?.videoId == "v2") #expect(self.playerService.pendingPlayVideoId == "v2") + self.playerService.clearQueueNavigationRecovery() } @Test("Stale metadata after manual next confirmation cannot realign backward") From 6f1ff65d7d2388faceef06577f0d5e8d78c05288 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sat, 11 Jul 2026 00:24:00 -0700 Subject: [PATCH 28/73] fix(player): guard canceled mix maintenance Signed-off-by: Sertac Ozercan --- .../Services/Player/PlayerService+Queue.swift | 15 ++++-- .../PlayerService+WebPlaybackIdentity.swift | 4 +- Sources/Kaset/Views/MainWindow.swift | 2 +- Sources/Kaset/Views/MiniPlayerWebView.swift | 6 ++- .../Helpers/MockYTMusicClient.swift | 8 +++ ...PlayerServiceNativeQueueAdvanceTests.swift | 52 +++++++++++++++++++ .../PlayerServiceQueueEnrichmentTests.swift | 7 +-- 7 files changed, 80 insertions(+), 14 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+Queue.swift b/Sources/Kaset/Services/Player/PlayerService+Queue.swift index bc992eb67..b2a278313 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Queue.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Queue.swift @@ -148,12 +148,14 @@ extension PlayerService { /// Fetches more songs for the current mix when approaching the end of the queue. /// This enables "infinite mix" behavior like YouTube Music web. - func fetchMoreMixSongsIfNeeded() async { + func fetchMoreMixSongsIfNeeded( + shouldApplyResult: @MainActor () -> Bool = { true } + ) async { let songsRemaining = self.queue.count - self.currentIndex - 1 self.logger.debug("Infinite mix check: \(songsRemaining) songs remaining, hasContinuation: \(self.mixContinuationToken != nil)") // Only fetch if we have a continuation token and we're near the end - guard let token = mixContinuationToken, + guard let continuation = mixContinuationToken, !isFetchingMoreMixSongs, !(self.mixContinuationRequiresAuth && self.authService?.hasPersonalAccount != true), let client = ytMusicClient @@ -171,9 +173,12 @@ extension PlayerService { let requestGeneration = self.playbackRequestGeneration do { - let result = try await client.getMixQueueContinuation(continuationToken: token) - guard requestGeneration == self.playbackRequestGeneration else { - self.logger.info("Discarding stale mix continuation after privacy boundary") + let result = try await client.getMixQueueContinuation(continuationToken: continuation) + guard !Task.isCancelled, + requestGeneration == self.playbackRequestGeneration, + shouldApplyResult() + else { + self.logger.info("Discarding stale or cancelled mix continuation") self.isFetchingMoreMixSongs = false return } diff --git a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift index 0bd078c44..94894a9fb 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift @@ -251,7 +251,9 @@ extension PlayerService { self.nativeQueueMaintenanceTask?.cancel() self.nativeQueueMaintenanceTask = Task { @MainActor [weak self] in guard let self else { return } - await self.fetchMoreMixSongsIfNeeded() + await self.fetchMoreMixSongsIfNeeded { + !Task.isCancelled && self.nativeQueueMaintenanceGeneration == generation + } guard !Task.isCancelled, self.nativeQueueMaintenanceGeneration == generation else { return } diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index 58391d5dd..03a884e5d 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -19,7 +19,7 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length static let commandBarTopPadding: CGFloat = 72 } - static func shouldMountPersistentPlayer(isLoggedIn: Bool, pendingVideoId: String?) -> Bool { + nonisolated static func shouldMountPersistentPlayer(isLoggedIn: Bool, pendingVideoId: String?) -> Bool { isLoggedIn || pendingVideoId != nil } diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index bd0e95ff3..abb531cee 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -424,10 +424,14 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length guard !self.hasStartedHomePreload else { return } guard self.currentVideoId == nil else { return } guard let webView else { return } + guard let homeURL = URL(string: "https://music.youtube.com/") else { + self.logger.error("Unable to construct YT Music home URL") + return + } self.hasStartedHomePreload = true self.logger.info("Preloading YT Music home page") - webView.load(URLRequest(url: URL(string: "https://music.youtube.com/")!)) + webView.load(URLRequest(url: homeURL)) } /// Ensures the WebView is in the given container's view hierarchy. diff --git a/Tests/KasetTests/Helpers/MockYTMusicClient.swift b/Tests/KasetTests/Helpers/MockYTMusicClient.swift index 5acd62f58..9ed200ad2 100644 --- a/Tests/KasetTests/Helpers/MockYTMusicClient.swift +++ b/Tests/KasetTests/Helpers/MockYTMusicClient.swift @@ -77,11 +77,13 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this var unsubscribeFromArtistDelay: Duration? var rateSongDelay: Duration? var getSongDelay: Duration? + var getSongErrors: [Error] = [] var getHistoryDelay: Duration? var getPodcastsDelay: Duration? var getPlaylistDelay: Duration? var playlistContinuationDelay: Duration? var mixQueueDelay: Duration? + var mixQueueContinuationGate: AsyncGate? var getRadioQueueDelay: Duration? var mixQueueResult = RadioQueueResult(songs: [], continuationToken: nil) var mixQueueContinuationResult = RadioQueueResult(songs: [], continuationToken: nil) @@ -1073,6 +1075,9 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this if let getSongDelay = self.getSongDelay { try? await Task.sleep(for: getSongDelay) } + if !self.getSongErrors.isEmpty { + throw self.getSongErrors.removeFirst() + } if let error = shouldThrowError { throw error } @@ -1111,6 +1116,7 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this func getMixQueueContinuation(continuationToken _: String) async throws -> RadioQueueResult { self.getMixQueueContinuationCallCount += 1 + await self.mixQueueContinuationGate?.wait() if let error = shouldThrowError { throw error } @@ -1222,8 +1228,10 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this self.unsubscribeFromArtistDelay = nil self.rateSongDelay = nil self.getSongDelay = nil + self.getSongErrors = [] self.getHistoryDelay = nil self.mixQueueDelay = nil + self.mixQueueContinuationGate = nil self.getRadioQueueDelay = nil self.mixQueueResult = RadioQueueResult(songs: [], continuationToken: nil) self.mixQueueContinuationResult = RadioQueueResult(songs: [], continuationToken: nil) diff --git a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift index df7b4a6b5..08bb16fcc 100644 --- a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift +++ b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift @@ -256,4 +256,56 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.pendingNativeQueueAdvanceVideoId == nil) #expect(self.playerService.state == .loading) } + + @Test("Cancelled native queue maintenance cannot append a stale mix continuation") + func cancelledNativeQueueMaintenanceCannotMutateQueue() async { + let mockClient = MockYTMusicClient() + let continuationGate = AsyncGate() + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + let staleSong = Song( + id: "stale", + title: "Stale continuation", + artists: [], + duration: 220, + videoId: "stale-video" + ) + mockClient.mixQueueContinuationGate = continuationGate + mockClient.mixQueueContinuationResult = RadioQueueResult( + songs: [staleSong], + continuationToken: nil + ) + let previousWebVideoId = SingletonPlayerWebView.shared.currentVideoId + defer { SingletonPlayerWebView.shared.currentVideoId = previousWebVideoId } + self.playerService.setYTMusicClient(mockClient) + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService[keyPath: \.mixContinuationToken] = "test-continuation" + self.playerService.beginPendingNativeQueueAdvance(to: 1) + + let confirmed = await self.playerService.reconcilePendingNativeQueueAdvanceObservation(videoId: "v2") + #expect(confirmed) + await Self.waitUntilNativeQueueMaintenanceStarts(mockClient: mockClient) + #expect(mockClient.getMixQueueContinuationCallCount == 1) + let cancelledMaintenance = self.playerService.nativeQueueMaintenanceTask + + await self.playerService.loadQueueSongForNavigation(at: 0) + await continuationGate.open() + await cancelledMaintenance?.value + + #expect(self.playerService.queue.map(\.videoId) == ["v1", "v2"]) + #expect(self.playerService.mixContinuationToken == "test-continuation") + } + + private static func waitUntilNativeQueueMaintenanceStarts(mockClient: MockYTMusicClient) async { + let clock = ContinuousClock() + let deadline = clock.now + .seconds(1) + while clock.now < deadline { + if mockClient.getMixQueueContinuationCallCount == 1 { + return + } + try? await Task.sleep(for: .milliseconds(10)) + } + } } diff --git a/Tests/KasetTests/PlayerServiceQueueEnrichmentTests.swift b/Tests/KasetTests/PlayerServiceQueueEnrichmentTests.swift index a69c46433..e13613468 100644 --- a/Tests/KasetTests/PlayerServiceQueueEnrichmentTests.swift +++ b/Tests/KasetTests/PlayerServiceQueueEnrichmentTests.swift @@ -65,7 +65,7 @@ struct PlayerServiceQueueEnrichmentTests { let mockClient = MockYTMusicClient() playerService.queueEnrichmentInitialDelay = .milliseconds(5) playerService.queueEnrichmentRetryDelay = .milliseconds(50) - mockClient.shouldThrowError = NSError(domain: "Transient", code: 500) + mockClient.getSongErrors = [NSError(domain: "Transient", code: 500)] mockClient.songResponses["retry-me"] = TestFixtures.makeSong( id: "retry-me", title: "Recovered Song", @@ -75,11 +75,6 @@ struct PlayerServiceQueueEnrichmentTests { playerService.setYTMusicClient(mockClient) playerService.setQueue([Self.incompleteSong(videoId: "retry-me")]) - await Self.waitUntil { - mockClient.getSongVideoIds.count == 1 && playerService.enrichmentTask != nil - } - mockClient.shouldThrowError = nil - await Self.waitUntil { playerService.enrichmentTask == nil && playerService.queue.first?.title == "Recovered Song" } From f777106eddce48f59d0a11b52c31149e24ca2fd2 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sat, 11 Jul 2026 00:43:53 -0700 Subject: [PATCH 29/73] fix(player): preserve replacement mix fetch Signed-off-by: Sertac Ozercan --- .../Services/Player/PlayerService+Queue.swift | 30 +++++++++++-- .../Kaset/Services/Player/PlayerService.swift | 3 ++ .../Helpers/MockYTMusicClient.swift | 27 ++++++++---- ...PlayerServiceNativeQueueAdvanceTests.swift | 42 +++++++++++++++---- 4 files changed, 83 insertions(+), 19 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+Queue.swift b/Sources/Kaset/Services/Player/PlayerService+Queue.swift index b2a278313..63c5599c6 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Queue.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Queue.swift @@ -154,11 +154,28 @@ extension PlayerService { let songsRemaining = self.queue.count - self.currentIndex - 1 self.logger.debug("Infinite mix check: \(songsRemaining) songs remaining, hasContinuation: \(self.mixContinuationToken != nil)") + if self.isFetchingMoreMixSongs { + let queueCountBeforeWait = self.queue.count + let continuationBeforeWait = self.mixContinuationToken + await withCheckedContinuation { continuation in + self.mixContinuationFetchWaiters.append(continuation) + } + guard !Task.isCancelled, + shouldApplyResult(), + self.queue.count == queueCountBeforeWait, + self.mixContinuationToken == continuationBeforeWait + else { + return + } + await self.fetchMoreMixSongsIfNeeded(shouldApplyResult: shouldApplyResult) + return + } + // Only fetch if we have a continuation token and we're near the end guard let continuation = mixContinuationToken, - !isFetchingMoreMixSongs, !(self.mixContinuationRequiresAuth && self.authService?.hasPersonalAccount != true), - let client = ytMusicClient + let client = ytMusicClient, + shouldApplyResult() else { return } @@ -170,6 +187,7 @@ extension PlayerService { self.logger.info("Fetching more mix songs, \(songsRemaining) remaining in queue") self.isFetchingMoreMixSongs = true + defer { self.finishMixContinuationFetch() } let requestGeneration = self.playbackRequestGeneration do { @@ -179,7 +197,6 @@ extension PlayerService { shouldApplyResult() else { self.logger.info("Discarding stale or cancelled mix continuation") - self.isFetchingMoreMixSongs = false return } self.logger.debug("Continuation returned \(result.songs.count) songs, hasNextToken: \(result.continuationToken != nil)") @@ -200,8 +217,15 @@ extension PlayerService { } catch { self.logger.warning("Failed to fetch more mix songs: \(error.localizedDescription)") } + } + private func finishMixContinuationFetch() { self.isFetchingMoreMixSongs = false + let waiters = self.mixContinuationFetchWaiters + self.mixContinuationFetchWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } } /// Fetches radio queue and applies it, keeping the current song at the front. diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 9f3e18b6b..b5234eb85 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -285,6 +285,9 @@ final class PlayerService: NSObject, PlayerServiceProtocol { /// Whether we're currently fetching more mix songs. var isFetchingMoreMixSongs: Bool = false + /// Callers waiting for the current mix continuation request to release its single-flight slot. + @ObservationIgnored var mixContinuationFetchWaiters: [CheckedContinuation] = [] + /// Smart Shuffle: videoIds suggested this session, for dedup across fills. var smartShuffleSeenSuggestionIds: Set = [] diff --git a/Tests/KasetTests/Helpers/MockYTMusicClient.swift b/Tests/KasetTests/Helpers/MockYTMusicClient.swift index 9ed200ad2..751d9a923 100644 --- a/Tests/KasetTests/Helpers/MockYTMusicClient.swift +++ b/Tests/KasetTests/Helpers/MockYTMusicClient.swift @@ -87,6 +87,7 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this var getRadioQueueDelay: Duration? var mixQueueResult = RadioQueueResult(songs: [], continuationToken: nil) var mixQueueContinuationResult = RadioQueueResult(songs: [], continuationToken: nil) + var mixQueueContinuationResults: [RadioQueueResult] = [] private(set) var getMixQueueContinuationCallCount = 0 var shouldAutoUpdatePlaylistLibraryOnMutation = true var shouldAutoUpdatePodcastLibraryOnMutation = true @@ -1116,11 +1117,16 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this func getMixQueueContinuation(continuationToken _: String) async throws -> RadioQueueResult { self.getMixQueueContinuationCallCount += 1 + let result = if self.mixQueueContinuationResults.isEmpty { + self.mixQueueContinuationResult + } else { + self.mixQueueContinuationResults.removeFirst() + } await self.mixQueueContinuationGate?.wait() if let error = shouldThrowError { throw error } - return self.mixQueueContinuationResult + return result } func getMoodCategory(browseId _: String, params _: String?) async throws -> HomeResponse { @@ -1227,20 +1233,25 @@ final class MockYTMusicClient: YTMusicClientProtocol { // swiftlint:disable:this self.unsubscribeFromArtistIds = [] self.unsubscribeFromArtistDelay = nil self.rateSongDelay = nil + self.getHistoryDelay = nil + self.resetQueueFetchState() + self.getLyricsCalled = false + self.getLyricsVideoIds = [] + self.getRadioQueueCalled = false + self.getRadioQueueVideoIds = [] + self.moodCategoryCalled = false + self.shouldThrowError = nil + } + + private func resetQueueFetchState() { self.getSongDelay = nil self.getSongErrors = [] - self.getHistoryDelay = nil self.mixQueueDelay = nil self.mixQueueContinuationGate = nil self.getRadioQueueDelay = nil self.mixQueueResult = RadioQueueResult(songs: [], continuationToken: nil) self.mixQueueContinuationResult = RadioQueueResult(songs: [], continuationToken: nil) + self.mixQueueContinuationResults = [] self.getMixQueueContinuationCallCount = 0 - self.getLyricsCalled = false - self.getLyricsVideoIds = [] - self.getRadioQueueCalled = false - self.getRadioQueueVideoIds = [] - self.moodCategoryCalled = false - self.shouldThrowError = nil } } diff --git a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift index 08bb16fcc..f732ce7dc 100644 --- a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift +++ b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift @@ -257,8 +257,8 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.state == .loading) } - @Test("Cancelled native queue maintenance cannot append a stale mix continuation") - func cancelledNativeQueueMaintenanceCannotMutateQueue() async { + @Test("Cancelled native queue maintenance yields to a valid replacement fetch") + func cancelledNativeQueueMaintenanceYieldsToReplacementFetch() async { let mockClient = MockYTMusicClient() let continuationGate = AsyncGate() let songs = [ @@ -272,11 +272,18 @@ extension PlayerServiceWebQueueSyncTests { duration: 220, videoId: "stale-video" ) - mockClient.mixQueueContinuationGate = continuationGate - mockClient.mixQueueContinuationResult = RadioQueueResult( - songs: [staleSong], - continuationToken: nil + let freshSong = Song( + id: "fresh", + title: "Fresh continuation", + artists: [], + duration: 220, + videoId: "fresh-video" ) + mockClient.mixQueueContinuationGate = continuationGate + mockClient.mixQueueContinuationResults = [ + RadioQueueResult(songs: [staleSong], continuationToken: "next-page"), + RadioQueueResult(songs: [freshSong], continuationToken: nil), + ] let previousWebVideoId = SingletonPlayerWebView.shared.currentVideoId defer { SingletonPlayerWebView.shared.currentVideoId = previousWebVideoId } self.playerService.setYTMusicClient(mockClient) @@ -291,11 +298,19 @@ extension PlayerServiceWebQueueSyncTests { let cancelledMaintenance = self.playerService.nativeQueueMaintenanceTask await self.playerService.loadQueueSongForNavigation(at: 0) + let replacementFetch = Task { @MainActor in + await self.playerService.fetchMoreMixSongsIfNeeded() + } + await Self.waitUntilMixContinuationWaiterIsRegistered(playerService: self.playerService) + #expect(self.playerService.mixContinuationFetchWaiters.count == 1) await continuationGate.open() await cancelledMaintenance?.value + await replacementFetch.value - #expect(self.playerService.queue.map(\.videoId) == ["v1", "v2"]) - #expect(self.playerService.mixContinuationToken == "test-continuation") + #expect(self.playerService.queue.map(\.videoId) == ["v1", "v2", "fresh-video"]) + #expect(!self.playerService.queue.contains { $0.videoId == "stale-video" }) + #expect(mockClient.getMixQueueContinuationCallCount == 2) + #expect(self.playerService.mixContinuationFetchWaiters.isEmpty) } private static func waitUntilNativeQueueMaintenanceStarts(mockClient: MockYTMusicClient) async { @@ -308,4 +323,15 @@ extension PlayerServiceWebQueueSyncTests { try? await Task.sleep(for: .milliseconds(10)) } } + + private static func waitUntilMixContinuationWaiterIsRegistered(playerService: PlayerService) async { + let clock = ContinuousClock() + let deadline = clock.now + .seconds(1) + while clock.now < deadline { + if playerService.mixContinuationFetchWaiters.count == 1 { + return + } + try? await Task.sleep(for: .milliseconds(10)) + } + } } From 066b8ed5f73d280792f1fa1a77d01f9ac9dab6f3 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sat, 11 Jul 2026 00:48:05 -0700 Subject: [PATCH 30/73] fix(player): invalidate native maintenance on queue replacement Signed-off-by: Sertac Ozercan --- .../PlayerService+WebPlaybackIdentity.swift | 30 ++++++++------- .../Kaset/Services/Player/PlayerService.swift | 4 ++ ...PlayerServiceNativeQueueAdvanceTests.swift | 38 +++++++++++++++++++ 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift index 94894a9fb..0ff50511e 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift @@ -251,24 +251,26 @@ extension PlayerService { self.nativeQueueMaintenanceTask?.cancel() self.nativeQueueMaintenanceTask = Task { @MainActor [weak self] in guard let self else { return } - 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) - if self.nativeQueueMaintenanceGeneration == generation { - self.nativeQueueMaintenanceTask = nil + 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) + if self.nativeQueueMaintenanceGeneration == generation { + self.nativeQueueMaintenanceTask = nil + } } } } - private func clearNativeQueueMaintenance() { + func clearNativeQueueMaintenance() { self.nativeQueueMaintenanceGeneration &+= 1 self.nativeQueueMaintenanceTask?.cancel() self.nativeQueueMaintenanceTask = nil diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index b5234eb85..3aac5f9a8 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -191,6 +191,7 @@ final class PlayerService: NSObject, PlayerServiceProtocol { var pendingNativeQueueAdvanceGeneration: Int = 0 var nativeQueueMaintenanceGeneration: Int = 0 var nativeQueueMaintenanceTask: Task? + var isApplyingNativeQueueMaintenanceQueueMutation = false var pendingNativeQueueAdvanceVideoId: String? { self.pendingNativeQueueAdvance?.targetVideoId @@ -547,6 +548,9 @@ final class PlayerService: NSObject, PlayerServiceProtocol { } func setQueue(entries: [QueueEntry]) { + if !self.isApplyingNativeQueueMaintenanceQueueMutation { + self.clearNativeQueueMaintenance() + } let pendingGeneration = self.pendingNativeQueueAdvance?.generation self.queueStorage = entries self.synchronizeCurrentQueueEntryID() diff --git a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift index f732ce7dc..b597eeebb 100644 --- a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift +++ b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift @@ -313,6 +313,44 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.mixContinuationFetchWaiters.isEmpty) } + @Test("Clearing the queue invalidates in-flight native continuation maintenance") + func clearQueueInvalidatesNativeContinuationMaintenance() async { + let mockClient = MockYTMusicClient() + let continuationGate = AsyncGate() + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2"), + ] + let staleSong = Song( + id: "stale", + title: "Stale continuation", + artists: [], + duration: 220, + videoId: "stale-video" + ) + mockClient.mixQueueContinuationGate = continuationGate + mockClient.mixQueueContinuationResults = [ + RadioQueueResult(songs: [staleSong], continuationToken: nil), + ] + self.playerService.setYTMusicClient(mockClient) + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService.mixContinuationToken = "test-continuation" + self.playerService.beginPendingNativeQueueAdvance(to: 1) + + let confirmed = await self.playerService.reconcilePendingNativeQueueAdvanceObservation(videoId: "v2") + #expect(confirmed) + await Self.waitUntilNativeQueueMaintenanceStarts(mockClient: mockClient) + let cancelledMaintenance = self.playerService.nativeQueueMaintenanceTask + + self.playerService.clearQueueEntirely() + await continuationGate.open() + await cancelledMaintenance?.value + + #expect(self.playerService.queue.isEmpty) + #expect(!self.playerService.queue.contains { $0.videoId == "stale-video" }) + #expect(self.playerService.nativeQueueMaintenanceTask == nil) + } + private static func waitUntilNativeQueueMaintenanceStarts(mockClient: MockYTMusicClient) async { let clock = ContinuousClock() let deadline = clock.now + .seconds(1) From ce95e287d7b86557d03b6006e184f9b0d8b0c555 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sat, 11 Jul 2026 01:06:51 -0700 Subject: [PATCH 31/73] fix(player): bind events to media occurrence Signed-off-by: Sertac Ozercan --- .../PlayerService+WebPlaybackIdentity.swift | 40 +++++-- .../Player/PlayerService+WebQueueSync.swift | 10 +- .../Kaset/Services/Player/PlayerService.swift | 10 +- Sources/Kaset/Views/MiniPlayerWebView.swift | 52 ++++++++- ...ingletonPlayerWebView+ObserverScript.swift | 24 ++++- .../PlaybackObserverIdentityTests.swift | 25 +++++ ...PlayerServiceNativeQueueAdvanceTests.swift | 100 +++++++++++++++++- ...layerServiceWebPlaybackIdentityTests.swift | 30 ++++++ 8 files changed, 274 insertions(+), 17 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift index 0ff50511e..e2eb28320 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift @@ -240,17 +240,32 @@ extension PlayerService { ) } - func awaitNativeQueueMaintenanceIfNeeded() async { - let task = self.nativeQueueMaintenanceTask - await task?.value + 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 @@ -263,17 +278,30 @@ extension PlayerService { self.nativeQueueMaintenanceGeneration == generation else { return } self.saveQueueForPersistence(syncWebQueue: false) - if self.nativeQueueMaintenanceGeneration == generation { - self.nativeQueueMaintenanceTask = nil - } } } } 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() + } } private func normalizedWebPlaybackVideoId(_ videoId: String?) -> String? { diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index 30fb549c9..e904da133 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -670,8 +670,14 @@ extension PlayerService { ) async { guard shouldContinue() else { return } self.logger.debug("Track ended reported by WebView: \(observedVideoId ?? "unknown")") - await self.awaitNativeQueueMaintenanceIfNeeded() - guard shouldContinue() else { return } + while !self.queue.isEmpty, + self.expectedQueueIndexAfterCurrentTrack() == nil, + self.nativeQueueMaintenanceTask != nil + { + let maintenanceGeneration = self.nativeQueueMaintenanceGeneration + await self.awaitNativeQueueMaintenanceIfNeeded(generation: maintenanceGeneration) + guard shouldContinue() else { return } + } self.songNearingEnd = false guard !self.queue.isEmpty else { if self.repeatMode == .one, self.currentTrack != nil || self.pendingPlayVideoId != nil { diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 3aac5f9a8..5813e958b 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -2,6 +2,12 @@ import Foundation import Observation import os +// MARK: - NativeQueueMaintenanceContext + +enum NativeQueueMaintenanceContext { + @TaskLocal static var isApplyingQueueMutation = false +} + // MARK: - PlayerService /// Controls music playback via a hidden WKWebView. @@ -191,7 +197,7 @@ final class PlayerService: NSObject, PlayerServiceProtocol { var pendingNativeQueueAdvanceGeneration: Int = 0 var nativeQueueMaintenanceGeneration: Int = 0 var nativeQueueMaintenanceTask: Task? - var isApplyingNativeQueueMaintenanceQueueMutation = false + @ObservationIgnored var nativeQueueMaintenanceWaiters: [Int: [CheckedContinuation]] = [:] var pendingNativeQueueAdvanceVideoId: String? { self.pendingNativeQueueAdvance?.targetVideoId @@ -548,7 +554,7 @@ final class PlayerService: NSObject, PlayerServiceProtocol { } func setQueue(entries: [QueueEntry]) { - if !self.isApplyingNativeQueueMaintenanceQueueMutation { + if !NativeQueueMaintenanceContext.isApplyingQueueMutation { self.clearNativeQueueMaintenance() } let pendingGeneration = self.pendingNativeQueueAdvance?.generation diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index abb531cee..1eef89646 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -62,6 +62,23 @@ enum WebPlaybackIdentityTransition { return mediaGeneration >= lastAcceptedMediaGeneration } + static func shouldAcceptEndedOccurrence( + observerEpoch: Double, + lastHandledObserverEpoch: Double?, + mediaGeneration: Int, + lastHandledMediaGeneration: Int? + ) -> Bool { + guard let lastHandledObserverEpoch else { return true } + if observerEpoch < lastHandledObserverEpoch { + return false + } + if observerEpoch > lastHandledObserverEpoch { + return true + } + guard let lastHandledMediaGeneration else { return true } + return mediaGeneration > lastHandledMediaGeneration + } + static func shouldHandleDeferredIdentitylessObservation( isDeferred: Bool, observedVideoId: String?, @@ -810,6 +827,8 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length private var lastAcceptedObservedVideoId: String? private var lastAcceptedObserverEpoch: Double? private var lastAcceptedMediaGeneration: Int? + private var lastHandledEndedObserverEpoch: Double? + private var lastHandledEndedMediaGeneration: Int? private var lastAcceptedQueueEntryID: UUID? private var hasAcceptedQueueEntryBaseline = false private var documentGeneration = 0 @@ -844,7 +863,15 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length switch type { case "TRACK_ENDED": guard (body["mediaIdentityUncertain"] as? Bool) != true else { return } + let observerEpoch = body["observerEpoch"] as? Double ?? 0 + let mediaGeneration = body["mediaGeneration"] as? Int ?? 0 self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in + guard coordinator.consumeTrackEndedOccurrence( + observerEpoch: observerEpoch, + mediaGeneration: mediaGeneration + ) else { + return + } await coordinator.playerService.handleTrackEnded( observedVideoId: observedVideoId, shouldContinue: { @@ -938,6 +965,23 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length && generation == self.documentGeneration } + 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 static func observedVideoId(from body: [String: Any]) -> String? { guard let videoId = body["videoId"] as? String, !videoId.isEmpty else { return nil } return videoId @@ -1348,8 +1392,8 @@ private extension SingletonPlayerWebView.Coordinator { messageGeneration: Int ) async { let isPlaying = body["isPlaying"] as? Bool ?? false - let progress = body["progress"] as? Int ?? 0 - let duration = body["duration"] as? Int ?? 0 + let progress = (body["progress"] as? NSNumber)?.doubleValue ?? 0 + let duration = (body["duration"] as? NSNumber)?.doubleValue ?? 0 let title = body["title"] as? String ?? "" let artist = body["artist"] as? String ?? "" let thumbnailUrl = body["thumbnailUrl"] as? String ?? "" @@ -1443,8 +1487,8 @@ private extension SingletonPlayerWebView.Coordinator { self.playerService.updatePlaybackState( isPlaying: isPlaying, - progress: Double(progress), - duration: Double(duration), + progress: progress, + duration: duration, observedVideoId: mediaVideoId ) self.lastAcceptedObserverEpoch = observerEpoch diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift index 25f844cbb..02702cdbc 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift @@ -30,6 +30,24 @@ extension SingletonPlayerWebView { """ } + nonisolated static var mediaTimingFunctionJS: String { + """ + function __kasetMediaTiming(video, progressBar) { + const rawDOMProgress = progressBar + ? Number(progressBar.getAttribute('value') || 0) : 0; + const rawDOMDuration = progressBar + ? Number(progressBar.getAttribute('aria-valuemax') || 0) : 0; + const domProgress = Number.isFinite(rawDOMProgress) ? rawDOMProgress : 0; + const domDuration = Number.isFinite(rawDOMDuration) ? rawDOMDuration : 0; + const progress = video && Number.isFinite(video.currentTime) + ? video.currentTime : domProgress; + const duration = video && Number.isFinite(video.duration) && video.duration > 0 + ? video.duration : domDuration; + return { progress, duration }; + } + """ + } + /// Observer script for playback state. nonisolated static var observerScript: String { """ @@ -41,6 +59,7 @@ extension SingletonPlayerWebView { const documentID = Number(window.__kasetDocumentID || 0); \(autoplayRecoveryFunctionJS) \(mediaIdentityBindingDecisionFunctionJS) + \(mediaTimingFunctionJS) let lastTitle = ''; let lastArtist = ''; let lastVideoId = ''; @@ -499,6 +518,7 @@ extension SingletonPlayerWebView { const isPlaying = video ? !video.paused : false; const progressBar = document.querySelector('#progress-bar'); + const mediaTiming = __kasetMediaTiming(video, progressBar); // Extract track metadata const titleEl = document.querySelector('.ytmusic-player-bar.title'); @@ -603,8 +623,8 @@ extension SingletonPlayerWebView { bridge.postMessage({ type: 'STATE_UPDATE', isPlaying: isPlaying, - progress: progressBar ? parseInt(progressBar.getAttribute('value') || '0') : 0, - duration: progressBar ? parseInt(progressBar.getAttribute('aria-valuemax') || '0') : 0, + progress: mediaTiming.progress, + duration: mediaTiming.duration, title: title, artist: artist, videoId: videoId, diff --git a/Tests/KasetTests/PlaybackObserverIdentityTests.swift b/Tests/KasetTests/PlaybackObserverIdentityTests.swift index 42359274f..264e0fbb9 100644 --- a/Tests/KasetTests/PlaybackObserverIdentityTests.swift +++ b/Tests/KasetTests/PlaybackObserverIdentityTests.swift @@ -38,6 +38,31 @@ struct PlaybackObserverIdentityTests { )?.toBool() == false) } + @Test("Playback timing stays bound to the media element with DOM fallback") + func mediaTimingPrefersVideoElement() throws { + let context = try #require(JSContext()) + context.evaluateScript(SingletonPlayerWebView.mediaTimingFunctionJS) + context.evaluateScript( + """ + const laggingProgressBar = { + getAttribute: (name) => name === 'value' ? '179' : '180' + }; + """ + ) + + let mediaTiming = context.evaluateScript( + "__kasetMediaTiming({ currentTime: 1.25, duration: 200 }, laggingProgressBar)" + ) + #expect(mediaTiming?.objectForKeyedSubscript("progress")?.toDouble() == 1.25) + #expect(mediaTiming?.objectForKeyedSubscript("duration")?.toDouble() == 200) + + let fallbackTiming = context.evaluateScript( + "__kasetMediaTiming({ currentTime: NaN, duration: Infinity }, laggingProgressBar)" + ) + #expect(fallbackTiming?.objectForKeyedSubscript("progress")?.toDouble() == 179) + #expect(fallbackTiming?.objectForKeyedSubscript("duration")?.toDouble() == 180) + } + @Test("A stale WebView cannot start the current document navigation gate") func staleWebViewNavigationStartIsIgnored() { let staleWebView = WKWebView() diff --git a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift index b597eeebb..a559b4309 100644 --- a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift +++ b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift @@ -332,9 +332,11 @@ extension PlayerServiceWebQueueSyncTests { mockClient.mixQueueContinuationResults = [ RadioQueueResult(songs: [staleSong], continuationToken: nil), ] + let previousWebVideoId = SingletonPlayerWebView.shared.currentVideoId + defer { SingletonPlayerWebView.shared.currentVideoId = previousWebVideoId } self.playerService.setYTMusicClient(mockClient) await self.playerService.playQueue(songs, startingAt: 0) - self.playerService.mixContinuationToken = "test-continuation" + self.playerService[keyPath: \.mixContinuationToken] = "test-continuation" self.playerService.beginPendingNativeQueueAdvance(to: 1) let confirmed = await self.playerService.reconcilePendingNativeQueueAdvanceObservation(videoId: "v2") @@ -351,6 +353,77 @@ extension PlayerServiceWebQueueSyncTests { #expect(self.playerService.nativeQueueMaintenanceTask == nil) } + @Test("A materialized successor advances before native maintenance completes") + func materializedSuccessorDoesNotWaitForNativeMaintenance() async { + let mockClient = MockYTMusicClient() + let continuationGate = AsyncGate() + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 1, videoId: "v2"), + Song(id: "3", title: "Song 3", artists: [], duration: 220, videoId: "v3"), + ] + mockClient.mixQueueContinuationGate = continuationGate + let previousWebVideoId = SingletonPlayerWebView.shared.currentVideoId + defer { SingletonPlayerWebView.shared.currentVideoId = previousWebVideoId } + self.playerService.setYTMusicClient(mockClient) + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService[keyPath: \.mixContinuationToken] = "test-continuation" + self.playerService.beginPendingNativeQueueAdvance(to: 1) + _ = await self.playerService.reconcilePendingNativeQueueAdvanceObservation(videoId: "v2") + await Self.waitUntilNativeQueueMaintenanceStarts(mockClient: mockClient) + #expect(mockClient.getMixQueueContinuationCallCount == 1) + #expect(self.playerService.nativeQueueMaintenanceTask != nil) + + let endedTask = Task { @MainActor in + await self.playerService.handleTrackEnded(observedVideoId: "v2") + } + await Self.waitUntilCurrentIndex(2, playerService: self.playerService) + + #expect(self.playerService.currentIndex == 2) + #expect(self.playerService.currentTrack?.videoId == "v3") + + await continuationGate.open() + await endedTask.value + } + + @Test("A successor inserted while waiting cancels the maintenance wait") + func insertedSuccessorUnblocksTrackEndMaintenanceWait() async { + let mockClient = MockYTMusicClient() + let continuationGate = AsyncGate() + let songs = [ + Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1"), + Song(id: "2", title: "Song 2", artists: [], duration: 1, videoId: "v2"), + ] + let successor = Song(id: "3", title: "Song 3", artists: [], duration: 220, videoId: "v3") + mockClient.mixQueueContinuationGate = continuationGate + let previousWebVideoId = SingletonPlayerWebView.shared.currentVideoId + defer { SingletonPlayerWebView.shared.currentVideoId = previousWebVideoId } + self.playerService.setYTMusicClient(mockClient) + await self.playerService.playQueue(songs, startingAt: 0) + self.playerService[keyPath: \.mixContinuationToken] = "test-continuation" + self.playerService.beginPendingNativeQueueAdvance(to: 1) + _ = await self.playerService.reconcilePendingNativeQueueAdvanceObservation(videoId: "v2") + await Self.waitUntilNativeQueueMaintenanceStarts(mockClient: mockClient) + #expect(mockClient.getMixQueueContinuationCallCount == 1) + + let endedTask = Task { @MainActor in + await self.playerService.handleTrackEnded(observedVideoId: "v2") + } + let maintenanceGeneration = self.playerService.nativeQueueMaintenanceGeneration + await Self.waitUntilNativeQueueMaintenanceWaiterIsRegistered( + generation: maintenanceGeneration, + playerService: self.playerService + ) + #expect(self.playerService.nativeQueueMaintenanceWaiters[maintenanceGeneration]?.count == 1) + + self.playerService.appendToQueue([successor]) + await Self.waitUntilCurrentIndex(2, playerService: self.playerService) + + #expect(self.playerService.currentTrack?.videoId == "v3") + await continuationGate.open() + await endedTask.value + } + private static func waitUntilNativeQueueMaintenanceStarts(mockClient: MockYTMusicClient) async { let clock = ContinuousClock() let deadline = clock.now + .seconds(1) @@ -372,4 +445,29 @@ extension PlayerServiceWebQueueSyncTests { try? await Task.sleep(for: .milliseconds(10)) } } + + private static func waitUntilCurrentIndex(_ index: Int, playerService: PlayerService) async { + let clock = ContinuousClock() + let deadline = clock.now + .seconds(1) + while clock.now < deadline { + if playerService.currentIndex == index { + return + } + try? await Task.sleep(for: .milliseconds(10)) + } + } + + private static func waitUntilNativeQueueMaintenanceWaiterIsRegistered( + generation: Int, + playerService: PlayerService + ) async { + let clock = ContinuousClock() + let deadline = clock.now + .seconds(1) + while clock.now < deadline { + if playerService.nativeQueueMaintenanceWaiters[generation]?.count == 1 { + return + } + try? await Task.sleep(for: .milliseconds(10)) + } + } } diff --git a/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift b/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift index 9469d9272..f70b07626 100644 --- a/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift +++ b/Tests/KasetTests/PlayerServiceWebPlaybackIdentityTests.swift @@ -13,6 +13,16 @@ struct PlaybackMediaGenerationCase { let expected: Bool } +// MARK: - PlaybackEndedOccurrenceCase + +struct PlaybackEndedOccurrenceCase { + let epoch: Double + let lastEpoch: Double? + let generation: Int + let lastGeneration: Int? + let expected: Bool +} + extension PlayerServiceWebQueueSyncTests { // MARK: - Web Playback Identity Reconciliation @@ -311,6 +321,26 @@ extension PlayerServiceWebQueueSyncTests { ) == testCase.expected) } + @Test( + "Ended occurrences are consumed once per observer epoch and media generation", + arguments: [ + PlaybackEndedOccurrenceCase(epoch: 10, lastEpoch: nil, generation: 4, lastGeneration: nil, expected: true), + PlaybackEndedOccurrenceCase(epoch: 10, lastEpoch: 10, generation: 4, lastGeneration: 4, expected: false), + PlaybackEndedOccurrenceCase(epoch: 10, lastEpoch: 10, generation: 3, lastGeneration: 4, expected: false), + PlaybackEndedOccurrenceCase(epoch: 10, lastEpoch: 10, generation: 5, lastGeneration: 4, expected: true), + PlaybackEndedOccurrenceCase(epoch: 11, lastEpoch: 10, generation: 0, lastGeneration: 4, expected: true), + PlaybackEndedOccurrenceCase(epoch: 9, lastEpoch: 10, generation: 8, lastGeneration: 4, expected: false), + ] + ) + func endedOccurrenceGate(testCase: PlaybackEndedOccurrenceCase) { + #expect(WebPlaybackIdentityTransition.shouldAcceptEndedOccurrence( + observerEpoch: testCase.epoch, + lastHandledObserverEpoch: testCase.lastEpoch, + mediaGeneration: testCase.generation, + lastHandledMediaGeneration: testCase.lastGeneration + ) == testCase.expected) + } + @Test( "Deferred restore handles only fully identityless observations", arguments: [ From 67a57219615317f42ffd6db5fc187e41660e6b5e Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sat, 11 Jul 2026 01:19:57 -0700 Subject: [PATCH 32/73] refactor(player): remove redundant feedback aliases Signed-off-by: Sertac Ozercan --- .../Player/PlayerService+WebQueueSync.swift | 3 +-- .../Kaset/Services/Player/PlayerService.swift | 4 +--- .../PlayerServiceWebQueueSyncTests.swift | 20 +------------------ 3 files changed, 3 insertions(+), 24 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index e904da133..dd4b2d37f 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -315,7 +315,6 @@ extension PlayerService { private func keepQueueSongVisible(_ song: Song, thumbnailUrl: String) { let intendedThumbnailURL = URL(string: thumbnailUrl) ?? song.thumbnailURL - let fb = song.feedbackTokens self.currentTrack = Song( id: song.id, title: song.title, @@ -328,7 +327,7 @@ extension PlayerService { musicVideoType: song.musicVideoType, likeStatus: song.likeStatus, isInLibrary: song.isInLibrary, - feedbackTokens: fb + feedbackTokens: song.feedbackTokens ) } diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 5813e958b..4cbd17ded 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -14,8 +14,6 @@ enum NativeQueueMaintenanceContext { @MainActor @Observable final class PlayerService: NSObject, PlayerServiceProtocol { - typealias TrackFB = FeedbackTokens - /// Shared instance for AppleScript access. /// /// **Safety Invariant:** This property is set exactly once during app initialization @@ -239,7 +237,7 @@ final class PlayerService: NSObject, PlayerServiceProtocol { var currentTrackInLibrary: Bool = false /// Feedback tokens for the current track (used for library add/remove). - var currentTrackFeedbackTokens: TrackFB? + var currentTrackFeedbackTokens: FeedbackTokens? /// Whether the lyrics panel is visible. var showLyrics: Bool = false { diff --git a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift index d39b3df02..336dc501c 100644 --- a/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift +++ b/Tests/KasetTests/PlayerServiceWebQueueSyncTests.swift @@ -2,24 +2,6 @@ import Foundation import Testing @testable import Kaset -@MainActor -private extension PlayerService { - var expectedQueueIndexForTesting: 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 - } - if self.repeatMode == .all { - return 0 - } - return nil - } -} - // MARK: - PlayerServiceWebQueueSyncTests /// Web queue sync, next/previous stack, repeat-one, metadata drift, and radio-related PlayerService tests. @@ -253,7 +235,7 @@ struct PlayerServiceWebQueueSyncTests { #expect(self.playerService.currentIndex == 1) #expect(self.playerService.injectedWebQueueVideoId == nil) - #expect(self.playerService.expectedQueueIndexForTesting == 2) + #expect(self.playerService.expectedQueueIndexAfterCurrentTrack() == 2) } @Test("Saving an empty queue clears web queue injection state") From 67614f1fc7849a099377ec69ea44a48e5973e7e3 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sat, 11 Jul 2026 01:54:35 -0700 Subject: [PATCH 33/73] fix(player): close playback transition races Signed-off-by: Sertac Ozercan --- .../PlayerService+WebPlaybackIdentity.swift | 5 +++ .../Kaset/Services/Player/PlayerService.swift | 3 ++ Sources/Kaset/Views/MiniPlayerWebView.swift | 7 +-- ...ingletonPlayerWebView+ObserverScript.swift | 36 +++++++++++++++- .../PlaybackObserverIdentityTests.swift | 20 +++++++++ ...PlayerServiceNativeQueueAdvanceTests.swift | 43 +++++++++++++++++++ 6 files changed, 109 insertions(+), 5 deletions(-) diff --git a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift index e2eb28320..16500cbc1 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift @@ -304,6 +304,11 @@ extension PlayerService { } } + func resumeNativeQueueMaintenanceWaitersIfSuccessorMaterialized() { + guard self.expectedQueueIndexAfterCurrentTrack() != nil else { return } + self.resumeNativeQueueMaintenanceWaiters(generation: self.nativeQueueMaintenanceGeneration) + } + private func normalizedWebPlaybackVideoId(_ videoId: String?) -> String? { guard let videoId, !videoId.isEmpty else { return nil } return videoId diff --git a/Sources/Kaset/Services/Player/PlayerService.swift b/Sources/Kaset/Services/Player/PlayerService.swift index 4cbd17ded..d97ed4f30 100644 --- a/Sources/Kaset/Services/Player/PlayerService.swift +++ b/Sources/Kaset/Services/Player/PlayerService.swift @@ -558,6 +558,9 @@ final class PlayerService: NSObject, PlayerServiceProtocol { let pendingGeneration = self.pendingNativeQueueAdvance?.generation self.queueStorage = entries self.synchronizeCurrentQueueEntryID() + if NativeQueueMaintenanceContext.isApplyingQueueMutation { + self.resumeNativeQueueMaintenanceWaitersIfSuccessorMaterialized() + } self.queueDidChangeForEnrichment() if let pendingGeneration, diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index 1eef89646..84dc48507 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -901,10 +901,13 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length ) } case "STATE_UPDATE": + let mediaVideoId = Self.nonEmptyVideoId(body["mediaVideoId"]) + SingletonPlayerWebView.shared.confirmRouterNavigationIfNeeded(videoId: mediaVideoId) self.enqueuePlaybackBridgeMessage(generation: messageGeneration) { coordinator in await coordinator.handleStateUpdate( body: body, observedVideoId: observedVideoId, + mediaVideoId: mediaVideoId, messageGeneration: messageGeneration ) } @@ -1389,6 +1392,7 @@ private extension SingletonPlayerWebView.Coordinator { private func handleStateUpdate( body: [String: Any], observedVideoId: String?, + mediaVideoId: String?, messageGeneration: Int ) async { let isPlaying = body["isPlaying"] as? Bool ?? false @@ -1400,7 +1404,6 @@ private extension SingletonPlayerWebView.Coordinator { let trackChanged = body["trackChanged"] as? Bool ?? false let likeStatus = Self.likeStatus(from: body["likeStatus"] as? String) let hasVideo = body["hasVideo"] as? Bool ?? false - let mediaVideoId = Self.nonEmptyVideoId(body["mediaVideoId"]) let mediaGeneration = body["mediaGeneration"] as? Int ?? 0 let observerEpoch = body["observerEpoch"] as? Double ?? 0 @@ -1414,8 +1417,6 @@ private extension SingletonPlayerWebView.Coordinator { return } - SingletonPlayerWebView.shared.confirmRouterNavigationIfNeeded(videoId: mediaVideoId) - let shouldContinuePendingAdvance = await self.playerService .reconcilePendingNativeQueueAdvanceObservation(videoId: mediaVideoId) guard self.isCurrentDocument(messageGeneration), diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift index 02702cdbc..7ffdeaaa2 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift @@ -48,6 +48,24 @@ extension SingletonPlayerWebView { """ } + nonisolated static var endedReplayGenerationFunctionJS: String { + """ + function __kasetShouldAdvanceEndedReplay(endedMediaGeneration, mediaGeneration) { + return endedMediaGeneration !== null + && endedMediaGeneration === mediaGeneration; + } + """ + } + + nonisolated static var mediaOccurrenceAdvanceFunctionJS: String { + """ + window.__kasetAdvanceMediaOccurrenceGeneration = function() { + mediaGeneration += 1; + return true; + }; + """ + } + /// Observer script for playback state. nonisolated static var observerScript: String { """ @@ -60,6 +78,8 @@ extension SingletonPlayerWebView { \(autoplayRecoveryFunctionJS) \(mediaIdentityBindingDecisionFunctionJS) \(mediaTimingFunctionJS) + \(endedReplayGenerationFunctionJS) + \(mediaOccurrenceAdvanceFunctionJS) let lastTitle = ''; let lastArtist = ''; let lastVideoId = ''; @@ -70,6 +90,7 @@ extension SingletonPlayerWebView { let mediaIdentityUncertain = false; let mediaIdentityTransitionFromVideoId = ''; let mediaIdentityIsInitialBinding = false; + let endedMediaGeneration = null; let isPollingActive = false; let pollIntervalId = null; let lastUpdateTime = 0; @@ -125,8 +146,18 @@ extension SingletonPlayerWebView { bindMediaIdentity(video, true, false); } - video.addEventListener('play', startPolling); - video.addEventListener('playing', startPolling); + function handlePlaybackStarted() { + if (__kasetShouldAdvanceEndedReplay( + endedMediaGeneration, + mediaGeneration + )) { + window.__kasetAdvanceMediaOccurrenceGeneration(); + } + endedMediaGeneration = null; + startPolling(); + } + video.addEventListener('play', handlePlaybackStarted); + video.addEventListener('playing', handlePlaybackStarted); // Enforce volume on playing event to catch all track changes // (auto-advance, SPA navigation, button clicks) video.addEventListener('playing', () => { @@ -142,6 +173,7 @@ extension SingletonPlayerWebView { }); video.addEventListener('pause', stopPolling); video.addEventListener('ended', () => { + endedMediaGeneration = mediaGeneration; sendTrackEnded(); stopPolling(); }); diff --git a/Tests/KasetTests/PlaybackObserverIdentityTests.swift b/Tests/KasetTests/PlaybackObserverIdentityTests.swift index 264e0fbb9..b388ead34 100644 --- a/Tests/KasetTests/PlaybackObserverIdentityTests.swift +++ b/Tests/KasetTests/PlaybackObserverIdentityTests.swift @@ -63,6 +63,26 @@ struct PlaybackObserverIdentityTests { #expect(fallbackTiming?.objectForKeyedSubscript("duration")?.toDouble() == 180) } + @Test("A same-element replay advances the consumed ended generation") + func endedReplayGenerationGate() throws { + let context = try #require(JSContext()) + context.evaluateScript(SingletonPlayerWebView.endedReplayGenerationFunctionJS) + + #expect(context.evaluateScript("__kasetShouldAdvanceEndedReplay(4, 4)")?.toBool() == true) + #expect(context.evaluateScript("__kasetShouldAdvanceEndedReplay(4, 5)")?.toBool() == false) + #expect(context.evaluateScript("__kasetShouldAdvanceEndedReplay(null, 4)")?.toBool() == false) + + let observerScript = SingletonPlayerWebView.observerScript + #expect(observerScript.contains("function handlePlaybackStarted()")) + #expect(observerScript.contains("video.addEventListener('play', handlePlaybackStarted)")) + #expect(observerScript.contains("video.addEventListener('playing', handlePlaybackStarted)")) + + let occurrenceAdvance = SingletonPlayerWebView.mediaOccurrenceAdvanceFunctionJS + #expect(occurrenceAdvance.contains("mediaGeneration += 1")) + #expect(!occurrenceAdvance.contains("mediaVideoId")) + #expect(!occurrenceAdvance.contains("sendUpdate")) + } + @Test("A stale WebView cannot start the current document navigation gate") func staleWebViewNavigationStartIsIgnored() { let staleWebView = WKWebView() diff --git a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift index a559b4309..0d6ce4d54 100644 --- a/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift +++ b/Tests/KasetTests/PlayerServiceNativeQueueAdvanceTests.swift @@ -424,6 +424,49 @@ extension PlayerServiceWebQueueSyncTests { await endedTask.value } + @Test("Maintenance-owned successor insertion wakes track end before maintenance completes") + func maintenanceSuccessorInsertionWakesTrackEndImmediately() async { + let mutationGate = AsyncGate() + let completionGate = AsyncGate() + let current = Song(id: "1", title: "Song 1", artists: [], duration: 180, videoId: "v1") + let successor = Song(id: "2", title: "Song 2", artists: [], duration: 200, videoId: "v2") + let previousWebVideoId = SingletonPlayerWebView.shared.currentVideoId + defer { SingletonPlayerWebView.shared.currentVideoId = previousWebVideoId } + await self.playerService.playQueue([current], startingAt: 0) + self.playerService.nativeQueueMaintenanceGeneration &+= 1 + let maintenanceGeneration = self.playerService.nativeQueueMaintenanceGeneration + var maintenanceCompleted = false + let maintenanceTask = Task { @MainActor in + await mutationGate.wait() + await NativeQueueMaintenanceContext.$isApplyingQueueMutation.withValue(true) { + self.playerService.setQueue([current, successor]) + await completionGate.wait() + } + maintenanceCompleted = true + } + self.playerService.nativeQueueMaintenanceTask = maintenanceTask + + let endedTask = Task { @MainActor in + await self.playerService.handleTrackEnded(observedVideoId: "v1") + } + await Self.waitUntilNativeQueueMaintenanceWaiterIsRegistered( + generation: maintenanceGeneration, + playerService: self.playerService + ) + #expect(self.playerService.nativeQueueMaintenanceWaiters[maintenanceGeneration]?.count == 1) + + await mutationGate.open() + await Self.waitUntilCurrentIndex(1, playerService: self.playerService) + + #expect(self.playerService.currentTrack?.videoId == "v2") + #expect(!maintenanceCompleted) + + await completionGate.open() + await maintenanceTask.value + self.playerService.clearNativeQueueMaintenance() + await endedTask.value + } + private static func waitUntilNativeQueueMaintenanceStarts(mockClient: MockYTMusicClient) async { let clock = ContinuousClock() let deadline = clock.now + .seconds(1) From 7795266c29d466a603cdbc30d3160376ea003159 Mon Sep 17 00:00:00 2001 From: Sertac Ozercan Date: Sat, 11 Jul 2026 02:14:04 -0700 Subject: [PATCH 34/73] fix(player): address queue handoff follow-ups Signed-off-by: Sertac Ozercan --- .../Services/Player/PlayerService+Queue.swift | 12 ++++---- .../Player/PlayerService+WebQueueSync.swift | 5 +--- Sources/Kaset/Views/MiniPlayerWebView.swift | 15 +++++++++- .../PlaybackObserverIdentityTests.swift | 13 ++++++++ ...ayerServiceQueuePersistenceSyncTests.swift | 28 +++++++++++++++++ .../PlayerServiceWebQueueSyncTests.swift | 30 +++++++++++++++++++ 6 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 Tests/KasetTests/PlayerServiceQueuePersistenceSyncTests.swift diff --git a/Sources/Kaset/Services/Player/PlayerService+Queue.swift b/Sources/Kaset/Services/Player/PlayerService+Queue.swift index 63c5599c6..1bb9963c5 100644 --- a/Sources/Kaset/Services/Player/PlayerService+Queue.swift +++ b/Sources/Kaset/Services/Player/PlayerService+Queue.swift @@ -809,6 +809,13 @@ extension PlayerService { self.removeSavedPlaybackSession() return } + defer { + if shouldSyncWebQueue { + // Queue synchronization follows the live queue, including ephemeral + // Smart Shuffle entries, even when the persisted payload is unchanged. + self.syncWebQueue() + } + } do { let encoder = JSONEncoder() @@ -855,11 +862,6 @@ extension PlayerService { self.queuePersistenceWriteCountForTesting += 1 self.restoredPlaybackSessionOwnerScope = ownerScope self.logger.info("Saved playback session with \(persistedQueue.count) songs at index \(safeIndex)") - - if shouldSyncWebQueue { - // Re-sync the web queue in case the queue order or next song changed - self.syncWebQueue() - } } catch { self.logger.error("Failed to save playback session: \(error.localizedDescription)") } diff --git a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift index dd4b2d37f..13f30045a 100644 --- a/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift +++ b/Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift @@ -288,9 +288,7 @@ extension PlayerService { "Ignoring stale in-queue metadata for \(normalizedObservedVideoId); keeping protected queue target \(protectedVideoId)" ) self.keepQueueSongVisible(currentQueueSong, thumbnailUrl: thumbnailUrl) - Task { - await self.play(song: currentQueueSong, webLoadStrategy: .forceFullPageWhenSameVideoId) - } + self.scheduleQueueNavigationRecovery(for: currentQueueSong) return true } @@ -299,7 +297,6 @@ extension PlayerService { expectedCurrentVideoId: String ) -> Bool { guard self.repeatMode == .all, - !self.shuffleEnabled, self.expectedQueueIndexAfterCurrentTrack() == 0, let currentQueueSong = self.queue[safe: self.currentIndex], let firstQueueSong = self.queue.first diff --git a/Sources/Kaset/Views/MiniPlayerWebView.swift b/Sources/Kaset/Views/MiniPlayerWebView.swift index 84dc48507..8becdf133 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView.swift @@ -568,6 +568,11 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length } } + guard let urlToLoad = Self.youtubeMusicWatchURL(videoId: videoId) else { + self.logger.error("Unable to construct YouTube Music watch URL") + return + } + if videoId != previousVideoId { self.logger.info("Loading video: \(videoId) (was: \(previousVideoId ?? "none"))") } @@ -594,7 +599,6 @@ final class SingletonPlayerWebView { // swiftlint:disable:this type_body_length // OLD