Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
06f9250
Port gapless playback and SPA navigation from fork
tui2019 May 7, 2026
40a5c98
Restore pre-loading home page for faster SPA routing
tui2019 May 7, 2026
764b016
Refactor next() and previous() to use native YouTube Music built-in q…
tui2019 May 9, 2026
e60078e
Fix web page preload on first launch by conditionally rendering Persi…
tui2019 May 9, 2026
0fa771d
Fix cloud-synced queue restoration on first launch
tui2019 May 9, 2026
8744f0a
Block autoplay during initial home page preload and unblock on manual…
tui2019 May 9, 2026
13a221e
Apply swiftformat
May 9, 2026
a274c1b
Merge remote-tracking branch 'origin/main' into feature/implement-queues
sozercan Jul 7, 2026
8c4fef0
chore: clarify feedback token local name
sozercan Jul 7, 2026
f49ee0b
fix: confirm web queue injection before trusting it
sozercan Jul 7, 2026
b4cbccf
fix: make manual next deterministic
sozercan Jul 8, 2026
b2f23e3
docs: document gapless playback architecture
sozercan Jul 8, 2026
e5d087f
fix: route player bar next through Kaset queue
sozercan Jul 8, 2026
1788859
fix(player): materialize standalone next queue
sozercan Jul 8, 2026
f1253ec
fix(player): keep manual next target guarded
sozercan Jul 8, 2026
80591e4
fix(player): protect manual queue skips from stale metadata
sozercan Jul 8, 2026
45cd44a
chore: merge main into feature/implement-queues
sozercan Jul 10, 2026
a81cf4d
fix(player): harden native queue handoff
sozercan Jul 11, 2026
6777d5e
fix(player): serialize playback bridge events
sozercan Jul 11, 2026
f81529a
fix(player): address queue handoff review
sozercan Jul 11, 2026
07d9f2a
fix(player): preserve deferred restore state
sozercan Jul 11, 2026
b8824f0
fix(player): stabilize manual queue transitions
sozercan Jul 11, 2026
053ab8b
fix(player): invalidate stale queue sync state
sozercan Jul 11, 2026
bb24065
fix(player): mount guest playback webview
sozercan Jul 11, 2026
1fc3f96
fix(player): handle shuffled queue terminal state
sozercan Jul 11, 2026
f520fdc
fix(player): bound autoplay blocker polling
sozercan Jul 11, 2026
2c2b10f
fix(player): end exhausted mix continuations
sozercan Jul 11, 2026
6929a82
fix(player): end manual seek after empty continuation
sozercan Jul 11, 2026
f37fad1
fix(player): coalesce stale navigation recovery
sozercan Jul 11, 2026
6f1ff65
fix(player): guard canceled mix maintenance
sozercan Jul 11, 2026
f777106
fix(player): preserve replacement mix fetch
sozercan Jul 11, 2026
066b8ed
fix(player): invalidate native maintenance on queue replacement
sozercan Jul 11, 2026
ce95e28
fix(player): bind events to media occurrence
sozercan Jul 11, 2026
67a5721
refactor(player): remove redundant feedback aliases
sozercan Jul 11, 2026
67614f1
fix(player): close playback transition races
sozercan Jul 11, 2026
7795266
fix(player): address queue handoff follow-ups
sozercan Jul 11, 2026
f0076aa
fix(player): bound queue navigation recovery
sozercan Jul 11, 2026
35a0c86
fix(player): discard canceled radio queues
sozercan Jul 11, 2026
e73fe08
test(equalizer): await debounced persistence
sozercan Jul 11, 2026
f72c4de
docs(player): document native queue handoff
sozercan Jul 12, 2026
8fa6a0b
fix(player): handle queue transition edge cases
sozercan Jul 12, 2026
1aa8027
test: replace timing-based async waits
sozercan Jul 12, 2026
5c50eae
fix(player): preserve maintenance during enrichment
sozercan Jul 12, 2026
68cb81c
test(equalizer): await round-trip persistence
sozercan Jul 12, 2026
51784f6
test(player): cover stale navigation safeguards
sozercan Jul 12, 2026
e629ae8
fix(player): bind async navigation to playback context
sozercan Jul 12, 2026
c3e9811
chore: merge main into feature/implement-queues
sozercan Jul 12, 2026
4a784f7
fix(scrobbling): retain playback observation provenance
sozercan Jul 12, 2026
53f569c
fix(player): preserve manual intent and media identity
sozercan Jul 12, 2026
4e18c91
fix(player): prevent post-navigation playback rollback
sozercan Jul 12, 2026
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
4 changes: 4 additions & 0 deletions .agents/skills/playback-webview-debugging/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ Use this skill when playback, auth recovery, queue sync, or hidden WebView state
- `docs/playback.md`
- `Sources/Kaset/Views/MiniPlayerWebView.swift`
- `Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift`
- `Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift`
- `Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift`
- `Sources/Kaset/Services/Player/PlayerService.swift`
- `Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift`
- `Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift`
- `Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift`
- `Sources/Kaset/Services/WebKit/WebKitManager.swift`
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }})
Expand Down
163 changes: 163 additions & 0 deletions Sources/APIExplorer/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,156 @@ 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 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 = joinedRunsText(videoRenderer["title"] as? [String: Any]) ?? "Unknown"
let artists = joinedRunsText(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
Expand Down Expand Up @@ -2102,6 +2252,8 @@ func showHelp() {
browse <browseId> [params] Explore a browse endpoint
action <endpoint> <body> Explore an action endpoint (body as JSON)
continuation <token> [ep] Explore a continuation (ep: 'browse' or 'next')
queue-probe <videoId> [playlistId]
Summarize the Music next/radio queue shape
list List all known endpoints
auth Check authentication status
accounts Discover available accounts (via authuser)
Expand Down Expand Up @@ -2154,6 +2306,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 <token> # browse endpoint (default)
Expand Down Expand Up @@ -2278,6 +2431,16 @@ func runMain() async {
token, endpoint: endpoint, verbose: verbose, outputFile: outputFile
)

case "queue-probe":
guard filteredArgs.count >= 2 else {
print("❌ Usage: queue-probe <videoId> [playlistId]")
print(" playlistId defaults to RDAMVM<videoId>, 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()

Expand Down
5 changes: 5 additions & 0 deletions Sources/Kaset/Services/Audio/EqualizerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,11 @@ final class EqualizerService {
}
}

func awaitPendingPersistence() async {
let task = self.persistTask
await task?.value
}

private func persist() {
do {
let data = try Self.encoder.encode(self.settings)
Expand Down
5 changes: 5 additions & 0 deletions Sources/Kaset/Services/Player/AlbumPlaybackActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,14 @@ enum AlbumPlaybackActions {
client: any YTMusicClientProtocol,
playerService: PlayerService
) {
let requestGeneration = playerService.beginPendingPlaybackSelectionRequest()
Task {
do {
let response = try await client.getPlaylist(id: album.id)
guard playerService.isCurrentPendingPlaybackSelectionRequest(requestGeneration) else {
DiagnosticsLogger.ui.info("Discarding stale album playback request")
return
}
let songs = QueueSongMetadata.albumSongs(
response.detail.tracks,
album: album,
Expand Down
36 changes: 36 additions & 0 deletions Sources/Kaset/Services/Player/PlayerQueueModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,42 @@ struct QueueEntry: Identifiable, Hashable {
var source: Source = .queued
}

// MARK: - QueuePlaybackContext

/// Identifies the queue occurrence and explicit playback generation that owns async navigation work.
struct QueuePlaybackContext: Equatable {
let entryID: UUID?
let index: Int
let requestGeneration: Int
let navigationGeneration: Int
}

// MARK: - PlaybackNavigationContext

struct PlaybackNavigationContext: Equatable {
let requestGeneration: Int
let navigationGeneration: Int
}

// MARK: - RadioQueueFetchOutcome

enum RadioQueueFetchOutcome: Equatable {
case applied
case unavailable
case queueMutated
case superseded
}

// MARK: - PendingNativeQueueAdvance

struct PendingNativeQueueAdvance: Equatable {
let sourceEntryID: UUID?
let sourceVideoId: String
let targetEntryID: UUID
let targetVideoId: String
let generation: Int
}

// MARK: - QueueState

struct QueueState {
Expand Down
1 change: 1 addition & 0 deletions Sources/Kaset/Services/Player/PlayerService+Episodes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ extension PlayerService {
/// thumbnail, then installs `currentEpisode` through the player so the UI
/// can gate live behavior before metadata loading suspends.
func playEpisode(_ episode: ArtistEpisode) async {
self.beginPlaybackRequest()
self.logger.info("Playing artist episode: \(episode.title) (live=\(episode.isLive))")

// A standalone episode is a new playback context that replaces the queue: supersede any
Expand Down
55 changes: 55 additions & 0 deletions Sources/Kaset/Services/Player/PlayerService+ManualSeek.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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()
let previousNavigationContext = self.playbackNavigationContext
let previousEntryID = self.currentQueueEntryID
let didAdvance = await self.performNextNavigation()
if !didAdvance,
!Task.isCancelled,
self.playbackNavigationContext == previousNavigationContext
{
if await self.advanceToMaterializedNextQueueSongIfAvailable(after: previousEntryID) {
return
}
await self.finishPlaybackAfterFailedQueueAdvance(
reason: "manual seek continuation produced no next queue entry"
)
}
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
}
}
Loading