Skip to content

Commit db07ab2

Browse files
committed
fix: confirm web queue injection before trusting it
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
1 parent 8c4fef0 commit db07ab2

9 files changed

Lines changed: 366 additions & 38 deletions

Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ extension PlayerService {
8484
self.logger.debug("play() called with videoId: \(videoId)")
8585
self.logger.info("Playing video: \(videoId)")
8686
self.clearRestoredPlaybackSessionState()
87+
self.clearWebQueueInjectionState()
8788
self.currentEpisode = nil
8889
self.state = .loading
8990
self.songNearingEnd = false
@@ -132,6 +133,7 @@ extension PlayerService {
132133
self.logger.info("Playing song: \(song.title)")
133134
self.logger.debug("Web load strategy: \(String(describing: webLoadStrategy))")
134135
self.clearRestoredPlaybackSessionState()
136+
self.clearWebQueueInjectionState()
135137
self.currentEpisode = episode
136138
// Brief `.loading` until the observer reports playback; in-place restarts may flash loading briefly.
137139
self.state = .loading
@@ -305,8 +307,22 @@ extension PlayerService {
305307
SingletonPlayerWebView.shared.setAutoplayBlocked(false)
306308

307309
if self.isPendingRestoredLoadDeferred {
308-
self.clearRestoredPlaybackSessionState()
310+
if let pendingPlayVideoId = self.pendingPlayVideoId,
311+
self.shouldLoadPendingVideoBeforePlayback
312+
{
313+
let strategy: SingletonPlayerWebView.VideoLoadStrategy = self.shouldForcePendingRestoredLoad ? .forceFullPageWhenSameVideoId : .standard
314+
self.beginRestoredPlaybackLoad(autoResumeAfterSeek: true)
315+
self.showMiniPlayer = false
316+
self.state = .loading
317+
self.isKasetInitiatedPlayback = true
318+
if SingletonPlayerWebView.shared.webView != nil {
319+
SingletonPlayerWebView.shared.loadVideo(videoId: pendingPlayVideoId, strategy: strategy)
320+
self.shouldForcePendingRestoredLoad = false
321+
}
322+
return
323+
}
309324

325+
self.clearRestoredPlaybackSessionState()
310326
self.showMiniPlayer = false
311327
self.state = .loading
312328
self.isKasetInitiatedPlayback = true
@@ -370,11 +386,18 @@ extension PlayerService {
370386
}
371387
}
372388

373-
guard let targetIndex else { return }
389+
guard let targetIndex, let targetSong = self.queue[safe: targetIndex] else { return }
374390
self.pushForwardSkipStackIfLeavingIndex(for: targetIndex)
375-
self.advanceQueueStateForNativeNavigation(to: targetIndex)
391+
if self.injectedWebQueueVideoId == targetSong.videoId {
392+
self.advanceQueueStateForNativeNavigation(to: targetIndex)
393+
SingletonPlayerWebView.shared.next()
394+
} else {
395+
self.injectedWebQueueVideoId = nil
396+
await self.loadQueueSongForNavigation(at: targetIndex)
397+
}
398+
await self.fetchMoreMixSongsIfNeeded()
376399
await self.fillSmartShuffleWindow()
377-
SingletonPlayerWebView.shared.next()
400+
self.saveQueueForPersistence(syncWebQueue: false)
378401
return
379402
}
380403

@@ -403,14 +426,12 @@ extension PlayerService {
403426
}
404427

405428
if let priorIndex = self.popForwardSkipIndex(), self.queue.indices.contains(priorIndex) {
406-
self.advanceQueueStateForNativeNavigation(to: priorIndex)
407-
SingletonPlayerWebView.shared.previous()
429+
await self.loadQueueSongForNavigation(at: priorIndex)
408430
return
409431
}
410432

411433
if self.currentIndex > 0 {
412-
self.advanceQueueStateForNativeNavigation(to: self.currentIndex - 1)
413-
SingletonPlayerWebView.shared.previous()
434+
await self.loadQueueSongForNavigation(at: self.currentIndex - 1)
414435
} else {
415436
await self.seek(to: 0)
416437
}
@@ -431,6 +452,14 @@ extension PlayerService {
431452
}
432453
}
433454

455+
/// Navigates to a queue song through Kaset's deterministic load path.
456+
private func loadQueueSongForNavigation(at index: Int) async {
457+
guard let song = self.queue[safe: index] else { return }
458+
self.currentIndex = index
459+
await self.play(song: song)
460+
self.saveQueueForPersistence()
461+
}
462+
434463
/// Updates Kaset's local queue pointer for native WebView queue navigation without forcing a page load.
435464
private func advanceQueueStateForNativeNavigation(to index: Int) {
436465
guard let song = self.queue[safe: index] else { return }
@@ -440,6 +469,7 @@ extension PlayerService {
440469
self.currentTrack = song
441470
self.currentEpisode = nil
442471
self.pendingPlayVideoId = song.videoId
472+
self.state = .loading
443473
self.isKasetInitiatedPlayback = true
444474
self.songNearingEnd = false
445475
self.shouldSuppressAutoplayAfterQueueEnd = false
@@ -458,7 +488,7 @@ extension PlayerService {
458488
self.currentTrackLikeStatus = song.likeStatus ?? self.currentTrackLikeStatus
459489
}
460490

461-
self.saveQueueForPersistence()
491+
self.saveQueueForPersistence(syncWebQueue: false)
462492
}
463493

464494
/// Seeks to a specific time.

Sources/Kaset/Services/Player/PlayerService+Queue.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,7 @@ extension PlayerService {
721721
private static let savedPlaybackSessionKey = "kaset.saved.playbackSession"
722722

723723
/// Saves the current queue to UserDefaults for restoration on next launch.
724-
func saveQueueForPersistence() {
724+
func saveQueueForPersistence(syncWebQueue shouldSyncWebQueue: Bool = true) {
725725
let queue = self.queue
726726
guard !queue.isEmpty else {
727727
if self.suppressNextEmptyQueuePersistence {
@@ -784,8 +784,10 @@ extension PlayerService {
784784
self.restoredPlaybackSessionOwnerScope = ownerScope
785785
self.logger.info("Saved playback session with \(persistedQueue.count) songs at index \(safeIndex)")
786786

787-
// Re-sync the web queue in case the queue order or next song changed
788-
self.syncWebQueue()
787+
if shouldSyncWebQueue {
788+
// Re-sync the web queue in case the queue order or next song changed
789+
self.syncWebQueue()
790+
}
789791
} catch {
790792
self.logger.error("Failed to save playback session: \(error.localizedDescription)")
791793
}

Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ extension PlayerService {
2525
// Sync the web view's current video ID so Kaset knows the player is already on this track
2626
SingletonPlayerWebView.shared.currentVideoId = observedVideoId
2727
if observedVideoId == previousVideoId, !self.queue.isEmpty {
28+
Task {
29+
await self.fetchSongMetadata(videoId: observedVideoId)
30+
}
2831
return
2932
}
3033
self.mixContinuationToken = nil
@@ -140,13 +143,46 @@ extension PlayerService {
140143
guard let nextIndex = self.expectedQueueIndexAfterCurrentTrack(),
141144
let nextSong = self.queue[safe: nextIndex] else { return }
142145

143-
if self.injectedWebQueueVideoId != nextSong.videoId {
144-
self.injectedWebQueueVideoId = nextSong.videoId
145-
SingletonPlayerWebView.shared.injectNextSong(videoId: nextSong.videoId)
146-
self.logger.info("Synced web queue: injected \(nextSong.videoId) to play next natively")
146+
guard self.injectedWebQueueVideoId != nextSong.videoId,
147+
self.pendingWebQueueInjectionVideoId != nextSong.videoId
148+
else { return }
149+
150+
self.pendingWebQueueInjectionVideoId = nextSong.videoId
151+
if SingletonPlayerWebView.shared.injectNextSong(videoId: nextSong.videoId) {
152+
self.logger.info("Syncing web queue: requested injection of \(nextSong.videoId) to play next natively")
153+
} else {
154+
self.pendingWebQueueInjectionVideoId = nil
147155
}
148156
}
149157

158+
/// Records the WebView result for an attempted native queue injection.
159+
func handleWebQueueInjectionResult(videoId: String, success: Bool, reason: String?) {
160+
if self.pendingWebQueueInjectionVideoId == videoId {
161+
self.pendingWebQueueInjectionVideoId = nil
162+
}
163+
164+
guard success else {
165+
if self.injectedWebQueueVideoId == videoId {
166+
self.injectedWebQueueVideoId = nil
167+
}
168+
self.logger.warning("Web queue injection failed for \(videoId): \(reason ?? "unknown")")
169+
return
170+
}
171+
172+
guard let nextIndex = self.expectedQueueIndexAfterCurrentTrack(),
173+
self.queue[safe: nextIndex]?.videoId == videoId
174+
else {
175+
if self.injectedWebQueueVideoId == videoId {
176+
self.injectedWebQueueVideoId = nil
177+
}
178+
self.logger.debug("Ignoring stale web queue injection confirmation for \(videoId)")
179+
return
180+
}
181+
182+
self.injectedWebQueueVideoId = videoId
183+
self.logger.info("Synced web queue: confirmed \(videoId) to play next natively")
184+
}
185+
150186
private var canAdvanceNativeQueueAfterTrackEnd: Bool {
151187
self.shuffleEnabled
152188
|| self.repeatMode == .one
@@ -571,14 +607,25 @@ extension PlayerService {
571607
self.pushForwardSkipStackIfLeavingIndex(for: expectedIndex)
572608
self.currentIndex = expectedIndex
573609
self.currentTrack = expectedSong
610+
self.pendingPlayVideoId = expectedSong.videoId
611+
self.state = .loading
612+
self.songNearingEnd = false
613+
self.shouldSuppressAutoplayAfterQueueEnd = false
574614
self.isKasetInitiatedPlayback = true
615+
self.currentTrackHasVideo = expectedSong.musicVideoType?.hasVideoContent ?? expectedSong.hasVideo ?? false
575616
self.resetTrackStatus()
576617
if let cachedStatus = SongLikeStatusManager.shared.status(for: expectedSong.videoId) {
577618
self.currentTrackLikeStatus = cachedStatus
578619
}
579-
self.saveQueueForPersistence()
580-
// Pre-inject the *next* next track for the following transition
581-
self.syncWebQueue()
620+
if let details = expectedSong.feedbackTokens {
621+
self.currentTrackFeedbackTokens = details
622+
self.currentTrackInLibrary = expectedSong.isInLibrary ?? false
623+
self.currentTrackLikeStatus = expectedSong.likeStatus ?? self.currentTrackLikeStatus
624+
}
625+
self.saveQueueForPersistence(syncWebQueue: false)
626+
await self.fetchMoreMixSongsIfNeeded()
627+
await self.fillSmartShuffleWindow()
628+
self.saveQueueForPersistence(syncWebQueue: false)
582629
return
583630
}
584631

@@ -590,9 +637,22 @@ extension PlayerService {
590637
func updateTrackMetadata(title: String, artist: String, thumbnailUrl: String, videoId observedVideoId: String?) {
591638
self.logger.debug("Track metadata updated: \(title) - \(artist)")
592639

593-
let isRestoringFromCloud = self.queue.isEmpty && !self.isKasetInitiatedPlayback && observedVideoId != nil
640+
let isRestoringFromCloud = self.isAwaitingWebRestoredTrack
641+
&& !self.isKasetInitiatedPlayback
642+
&& observedVideoId != nil
643+
644+
if self.isPendingRestoredLoadDeferred {
645+
guard self.isAwaitingWebRestoredTrack else { return }
646+
self.applyDeferredRestoredMetadata(
647+
title: title,
648+
artist: artist,
649+
thumbnailUrl: thumbnailUrl,
650+
videoId: observedVideoId
651+
)
652+
return
653+
}
594654

595-
if self.isPendingRestoredLoadDeferred || isRestoringFromCloud {
655+
if isRestoringFromCloud {
596656
self.applyDeferredRestoredMetadata(
597657
title: title,
598658
artist: artist,
@@ -676,6 +736,7 @@ extension PlayerService {
676736
)
677737

678738
if trackChanged {
739+
self.clearWebQueueInjectionState()
679740
self.resetTrackStatus()
680741
// Immediately restore like status from SongLikeStatusManager cache
681742
if let cachedStatus = SongLikeStatusManager.shared.status(for: resolvedVideoId) {

Sources/Kaset/Services/Player/PlayerService.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,11 @@ final class PlayerService: NSObject, PlayerServiceProtocol {
490490
self.currentQueueEntryID = self.queueStorage[safe: self.currentIndex]?.id
491491
}
492492

493+
func clearWebQueueInjectionState() {
494+
self.injectedWebQueueVideoId = nil
495+
self.pendingWebQueueInjectionVideoId = nil
496+
}
497+
493498
/// Records the current index before `next()` moves to `newIndex` (no-op if unchanged).
494499
func pushForwardSkipStackIfLeavingIndex(for newIndex: Int) {
495500
let from = self.currentIndex
@@ -573,11 +578,16 @@ final class PlayerService: NSObject, PlayerServiceProtocol {
573578
/// Flag to suppress YouTube autoplay after the native queue has finished.
574579
var shouldSuppressAutoplayAfterQueueEnd: Bool = false
575580

576-
/// Video ID of the song last injected into YouTube Music's native "Up Next" queue.
581+
/// Video ID of the song last confirmed as injected into YouTube Music's native "Up Next" queue.
577582
/// Used to avoid duplicate injections and to detect when YouTube has auto-advanced
578583
/// to the injected track (enabling gapless transition without calling `loadVideo`).
579584
var injectedWebQueueVideoId: String?
580585

586+
/// Video ID currently being injected into YouTube Music's native "Up Next" queue.
587+
/// This is intentionally separate from ``injectedWebQueueVideoId`` so track-end logic
588+
/// only trusts injections after the WebView script confirms the queue payload was swapped.
589+
var pendingWebQueueInjectionVideoId: String?
590+
581591
/// Grace period instant - don't auto-close video window shortly after opening (uses monotonic clock)
582592
var videoWindowOpenedAt: ContinuousClock.Instant?
583593

Sources/Kaset/Views/MiniPlayerWebView.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,9 @@ final class SingletonPlayerWebView {
299299
// Dynamic startup state is refreshed before each full page load so the
300300
// next document gets current volume/autoplay flags at document start.
301301

302-
let shouldBlockAutoplay = playerService.isRestoringPlaybackSession || playerService.pendingPlayVideoId == nil
302+
let shouldBlockAutoplay = playerService.isRestoringPlaybackSession
303+
|| playerService.isPendingRestoredLoadDeferred
304+
|| playerService.pendingPlayVideoId == nil
303305

304306
self.installUserScripts(
305307
on: configuration.userContentController,
@@ -658,6 +660,8 @@ final class SingletonPlayerWebView {
658660
self.handleLyricsTimeUpdate(body: body)
659661
case "PLAYBACK_AUDIO_QUALITY_STATS":
660662
Self.logAudioQualityStats(body: body, observedVideoId: observedVideoId)
663+
case "QUEUE_INJECTION_RESULT":
664+
self.handleQueueInjectionResult(body: body, observedVideoId: observedVideoId)
661665
case "STATE_UPDATE":
662666
self.handleStateUpdate(body: body, observedVideoId: observedVideoId)
663667
default:
@@ -690,6 +694,20 @@ final class SingletonPlayerWebView {
690694
}
691695
}
692696

697+
private func handleQueueInjectionResult(body: [String: Any], observedVideoId: String?) {
698+
guard let observedVideoId else { return }
699+
let success = body["success"] as? Bool ?? false
700+
let reason = body["reason"] as? String
701+
702+
Task { @MainActor in
703+
self.playerService.handleWebQueueInjectionResult(
704+
videoId: observedVideoId,
705+
success: success,
706+
reason: reason
707+
)
708+
}
709+
}
710+
693711
private func handleStateUpdate(body: [String: Any], observedVideoId: String?) {
694712
let isPlaying = body["isPlaying"] as? Bool ?? false
695713
let progress = body["progress"] as? Int ?? 0
@@ -1011,7 +1029,10 @@ final class SingletonPlayerWebView {
10111029
}
10121030
})();
10131031
"""
1014-
SingletonPlayerWebView.shared.setAutoplayBlocked(self.playerService.isPendingRestoredLoadDeferred)
1032+
let shouldBlockAutoplay = self.playerService.isRestoringPlaybackSession
1033+
|| self.playerService.isPendingRestoredLoadDeferred
1034+
|| self.playerService.pendingPlayVideoId == nil
1035+
SingletonPlayerWebView.shared.setAutoplayBlocked(shouldBlockAutoplay)
10151036

10161037
webView.evaluateJavaScript(applyVolumeScript) { result, error in
10171038
if let error {

Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,30 @@ extension SingletonPlayerWebView {
99
let script = """
1010
(function() {
1111
window.__kasetBlockAutoplay = \(blocked ? "true" : "false");
12+
if (window.__kasetAutoplayBlockTimer) {
13+
clearInterval(window.__kasetAutoplayBlockTimer);
14+
window.__kasetAutoplayBlockTimer = null;
15+
}
1216
if (!window.__kasetBlockAutoplay) return 'autoplay-allowed';
1317
window.__kasetAutoplayPending = false;
1418
let ticks = 0;
1519
const timer = setInterval(function() {
20+
if (!window.__kasetBlockAutoplay) {
21+
clearInterval(timer);
22+
if (window.__kasetAutoplayBlockTimer === timer) window.__kasetAutoplayBlockTimer = null;
23+
return;
24+
}
1625
const video = document.querySelector('video');
1726
if (video && !video.paused) {
1827
try { video.pause(); } catch (_) {}
1928
}
2029
ticks += 1;
21-
if (ticks >= 20) clearInterval(timer);
30+
if (ticks >= 20) {
31+
clearInterval(timer);
32+
if (window.__kasetAutoplayBlockTimer === timer) window.__kasetAutoplayBlockTimer = null;
33+
}
2234
}, 150);
35+
window.__kasetAutoplayBlockTimer = timer;
2336
return 'autoplay-blocked';
2437
})();
2538
"""
@@ -147,6 +160,11 @@ extension SingletonPlayerWebView {
147160

148161
let script = """
149162
(function() {
163+
window.__kasetBlockAutoplay = false;
164+
if (window.__kasetAutoplayBlockTimer) {
165+
clearInterval(window.__kasetAutoplayBlockTimer);
166+
window.__kasetAutoplayBlockTimer = null;
167+
}
150168
const video = document.querySelector('video');
151169
if (video && video.paused) { video.play(); return 'played'; }
152170
return 'already-playing';

0 commit comments

Comments
 (0)