Implement queues, native router#248
Open
tui2019 wants to merge 7 commits into
Open
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds tighter WebView/PlayerService coordination to improve playback continuity: native YouTube Music queue injection for gapless transitions, router-based navigation to reduce full reloads, and startup autoplay suppression/restoration handling.
Changes:
- Inject the expected next track into YouTube Music’s native “Up Next” queue and advance locally when YTM auto-advances.
- Add startup autoplay blocking knobs and restoration flow that waits for a server-restored track before falling back.
- Prefer in-page router navigation and preload the YTM home shell once logged in.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift | Adds JS-based “Play next” injection via DOM + JSON.stringify interception. |
| Sources/Kaset/Views/SingletonPlayerWebView+PlaybackControls.swift | Adds a JS toggle to block/unblock autoplay at runtime. |
| Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift | Honors the autoplay-block flag in playback observers. |
| Sources/Kaset/Views/MiniPlayerWebView.swift | Adds home preload, router navigation, updated autoplay bootstrap flags. |
| Sources/Kaset/Views/MiniPlayerViews.swift | Allows the persistent player view to exist without an immediate videoId. |
| Sources/Kaset/Views/MainWindow.swift | Keeps the persistent web view alive while logged in (even without pending video). |
| Sources/Kaset/Services/Player/PlayerService.swift | Tracks restoration wait state and last injected web-queue videoId. |
| Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift | Implements web-queue sync/injection and restoration metadata application. |
| Sources/Kaset/Services/Player/PlayerService+Queue.swift | Re-syncs web queue when persisting queue/session changes. |
| Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift | Defers restored playback until server-restored track is observed (or times out). |
| Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift | Syncs web queue after playback start; changes resume/next/previous autoplay behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
275
to
+278
| func next() async { | ||
| 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 | ||
| } | ||
| SingletonPlayerWebView.shared.setAutoplayBlocked(false) |
Author
There was a problem hiding this comment.
This (and the second advice) just missing the point. Instead of relying on the in-app queue we now let the website itself handle switching the song.
Comment on lines
293
to
+296
| func previous() async { | ||
| 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 | ||
| } | ||
| SingletonPlayerWebView.shared.setAutoplayBlocked(false) |
Comment on lines
+143
to
+147
| 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") | ||
| } |
Comment on lines
+279
to
285
| let shouldBlockAutoplay = playerService.isRestoringPlaybackSession || playerService.pendingPlayVideoId == nil | ||
|
|
||
| self.installUserScripts( | ||
| on: configuration.userContentController, | ||
| isRestoringPlaybackSession: playerService.isRestoringPlaybackSession, | ||
| isRestoringPlaybackSession: shouldBlockAutoplay, | ||
| targetVolume: playerService.volume | ||
| ) |
Comment on lines
+11
to
+25
| 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 | ||
| } |
Comment on lines
+15
to
+22
| 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); |
| } | ||
| })(); | ||
| """ | ||
| SingletonPlayerWebView.shared.setAutoplayBlocked(self.playerService.isPendingRestoredLoadDeferred) |
Comment on lines
+420
to
+431
| 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; | ||
| } | ||
| })(); | ||
| """ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Note: This is an experimental feature and I don't expect it to be merged right away. Feedback and testing would be greatly appreciated.
This PR implements gapless playback by injecting Kaset's local queue items directly into YouTube Music's native "Up Next" queue. It also updates track navigation to use the web player's internal SPA router (
navigateEndpoint), removing the need for full page reloads when changing tracks.AI Prompt (Optional)
🤖 AI Prompt Used
AI Tool: Gemini CLI
Type of Change
Related Issues
Fixes #241
Changes Made
SingletonPlayerWebView+QueueInjection.swiftto interceptJSON.stringifyand seamlessly inject Kaset's local queue into the native YouTube Music player.navigateEndpointrouter calls for instant, flicker-free track transitions.next()andprevious()inPlayerService+PlaybackControlsto fully delegate to the native web queue navigation.PersistentPlayerViewinitialization to unconditionally render instead of waiting for a track, ensuring the background web player preloads early for a quicker load of the first track.updateTrackMetadatato correctly restore and build the local queue around cloud-synced states on first launch.window.__kasetBlockAutoplay = trueduring background preloading and explicitly unblocked it on manual user interaction to prevent unintended autoplay.Testing
xcodebuild test -only-testing:KasetTests)Checklist
swiftlint --strict && swiftformat .Screenshots
Bildschirmaufnahme.2026-05-09.um.8.35.21.PM.mov
Additional Notes
Note on Automated Testing: Several unit tests related to queue traversal (e.g., in
PlayerServiceWebQueueSyncTests) are currently failing. This is an expected side-effect of the new architecture. Previously,next()andprevious()advanced Kaset's local queue synchronously. With this PR, Kaset delegates track navigation to the built-in YouTube Music web buttons and only updates its internal index asynchronously when it receives the resultingSTATE_UPDATEmetadata event from the web player. The test suite needs to be refactored in a future PR to simulate these async web callbacks.