Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 21 additions & 59 deletions Sources/Kaset/Services/Player/PlayerService+PlaybackControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ extension PlayerService {

if didStartPlayback {
self.logger.info("Playback confirmed started")
self.syncWebQueue()
}
}

Expand Down Expand Up @@ -224,6 +225,23 @@ extension PlayerService {
func resume() async {
self.logger.debug("Resuming playback")

SingletonPlayerWebView.shared.setAutoplayBlocked(false)

if self.isPendingRestoredLoadDeferred {
self.clearRestoredPlaybackSessionState()

self.showMiniPlayer = false
self.state = .loading
self.isKasetInitiatedPlayback = true

if SingletonPlayerWebView.shared.webView != nil {
SingletonPlayerWebView.shared.play()
} else {
await self.evaluatePlayerCommand("play")
}
return
}

guard let pendingPlayVideoId = self.pendingPlayVideoId else {
self.clearRestoredPlaybackSessionState()
await self.evaluatePlayerCommand("play")
Expand Down Expand Up @@ -257,37 +275,7 @@ extension PlayerService {
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)
Comment on lines 275 to +278

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.


// Standalone artist episodes are intentionally not in the local queue.
// Do not let them fall through to YouTube Music's ambient next button.
Expand All @@ -305,33 +293,7 @@ extension PlayerService {
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 293 to +296

// Standalone artist episodes are intentionally not in the local queue.
// Do not restart them or fall through to YouTube Music's ambient previous button.
Expand All @@ -342,7 +304,7 @@ extension PlayerService {

if self.progress > 3 {
await self.seek(to: 0)
} else {
} else if self.pendingPlayVideoId != nil {
SingletonPlayerWebView.shared.previous()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +11 to +25

guard !self.isRestoringPlaybackSession else {
self.reconcileRestoredPlaybackState(
isPlaying: isPlaying,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions Sources/Kaset/Services/Player/PlayerService+Queue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
127 changes: 127 additions & 0 deletions Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
}
Comment on lines +143 to +147
}

private var canAdvanceNativeQueueAfterTrackEnd: Bool {
self.shuffleEnabled
|| self.repeatMode == .one
Expand Down Expand Up @@ -471,13 +558,50 @@ 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()
}

/// 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)")

let isRestoringFromCloud = self.queue.isEmpty && !self.isKasetInitiatedPlayback && observedVideoId != nil

if self.isPendingRestoredLoadDeferred || isRestoringFromCloud {
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)
Expand Down Expand Up @@ -557,6 +681,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()
}
}
}
8 changes: 8 additions & 0 deletions Sources/Kaset/Services/Player/PlayerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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?

Expand Down
6 changes: 3 additions & 3 deletions Sources/Kaset/Views/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: self.playerService.pendingPlayVideoId, isExpanded: false)
.frame(width: 1, height: 1)
.opacity(0)
.allowsHitTesting(false)
Expand Down
Loading
Loading