Skip to content

Implement queues, native router#248

Open
tui2019 wants to merge 7 commits into
sozercan:mainfrom
tui2019:feature/implement-queues
Open

Implement queues, native router#248
tui2019 wants to merge 7 commits into
sozercan:mainfrom
tui2019:feature/implement-queues

Conversation

@tui2019

@tui2019 tui2019 commented May 9, 2026

Copy link
Copy Markdown

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
Implement gapless playback by intercepting YouTube Music's native "Up Next" queue and injecting Kaset's local queue items via JavaScript. Update the track navigation to use the web player's SPA router (`navigateEndpoint`) instead of fully reloading the page.

AI Tool: Gemini CLI

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to change)
  • 📚 Documentation update
  • 🎨 UI/UX improvement
  • ♻️ Refactoring (no functional changes)
  • 🧪 Test update
  • 🔧 Build/CI configuration

Related Issues

Fixes #241

Changes Made

  • Gapless Playback: Added SingletonPlayerWebView+QueueInjection.swift to intercept JSON.stringify and seamlessly inject Kaset's local queue into the native YouTube Music player.
  • SPA Routing: Replaced hard page loads with navigateEndpoint router calls for instant, flicker-free track transitions.
  • Track Navigation: Refactored next() and previous() in PlayerService+PlaybackControls to fully delegate to the native web queue navigation.
  • Preload Stabilization: Modified PersistentPlayerView initialization to unconditionally render instead of waiting for a track, ensuring the background web player preloads early for a quicker load of the first track.
  • Cloud Queue Sync: Updated updateTrackMetadata to correctly restore and build the local queue around cloud-synced states on first launch.
  • Autoplay Blocking: Injected window.__kasetBlockAutoplay = true during background preloading and explicitly unblocked it on manual user interaction to prevent unintended autoplay.

Testing

  • Unit tests pass (xcodebuild test -only-testing:KasetTests)
  • Manual testing performed
  • UI tested on macOS 26+

Checklist

  • My code follows the project's style guidelines
  • I have run swiftlint --strict && swiftformat .
  • I have added tests that prove my fix/feature works
  • New and existing unit tests pass locally
  • I have updated documentation if needed
  • I have checked for any performance implications
  • My changes generate no new warnings

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() and previous() 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 resulting STATE_UPDATE metadata event from the web player. The test suite needs to be refactored in a future PR to simulate these async web callbacks.

Copilot AI review requested due to automatic review settings May 9, 2026 18:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
}
})();
"""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Seamless switching between songs

2 participants