audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(player): synchronous fast path for auto-advance to survive locked-screen autoplay (#1339)

* fix(player): synchronous fast path for auto-advance to survive locked-screen autoplay

Reported in zzstoatzz.io/plyr.fm#1: on Android with the screen locked,
album / playlist playback stops at the end of each track instead of
advancing to the next. The reporter notes it worked in early February.

## Root cause

The chain from `<audio onended>` to `audio.play()` on the next track
goes through ~5 microtask boundaries plus an `await getAudioSource(...)`:

ended → handleTrackEnded → queue.next()
→ $effect: queue → player.currentTrack
→ $effect: load new src (await getCachedAudioUrl, fetch HEAD if gated)
→ audio.src = src; audio.load(); wait for loadeddata
→ $effect: shouldAutoPlay && !isLoadingTrack → player.paused = false
→ $effect: paused-sync → audio.play()

On a foregrounded tab this is milliseconds and works fine. On Android
with the screen locked, Chrome aggressively throttles non-foreground JS
and treats the page as "no longer audible" the moment the previous
track ends. By the time `audio.play()` finally runs, the implicit-
playback grace is gone and the call rejects with NotAllowedError. The
only way to resume is via a Media Session action handler (an explicit
lock-screen button press), which is exactly the workaround the
reporter was using.

This is not a regression from any one commit — the chain has had this
shape since before February. Most likely Chrome on Android tightened
locked-screen autoplay/freeze behavior between then and now, exposing
a long-standing fragility.

## Fix

Three coordinated changes:

1. **`queue.autoAdvanceTrack` getter** — single seam for "what should
natural end-of-track continuation play next". Today returns
`tracks[currentIndex + 1]`. Future continuation strategies (album
tail, feed continuation, recommendations) plug in here.

2. **Next-track prefetcher** — `resolveAudioSource` (extracted to
`lib/audio-source.ts`) returns a structured `ResolvedSource`
discriminator (ready / gated-denied / failed). A `$effect`
opportunistically resolves `queue.autoAdvanceTrack` while the
current track plays and stores the result in `preloadedNext`.
IndexedDB cache lookup and gated HEAD check move out of the
critical path.

3. **Synchronous fast path in `handleTrackEnded`** — when the
prefetcher has a ready source for the next track and we're not in
jam mode, swap `audio.src` and call `audio.play()` in the same tick
as the `ended` event. Reactivity (queue.next, player.currentTrack)
updates AFTER, so the autoplay grace is preserved. Pre-bumping
previousTrackId/previousFileId/previousQueueIndex before
`player.currentTrack = next; queue.next()` keeps downstream
effects no-ops; without it the queue→player sync effect's
`indexChanged` branch would seek the just-started audio back to 0.

When the preload isn't ready (race, jam active, gated denial), we
fall back to the existing reactive chain — same behavior as today.

Plus structured telemetry (`recordPlaybackRejection`) logging
errorName, visibilityState, audio.readyState, fast-path flag, and
preload state so we can confirm in production whether the fast path
actually dodges the autoplay block per browser bucket.

## What this PR does NOT do

- Does not change collection needle-drop semantics. Album/playlist
row clicks still call `queue.playNow(track)` and discard collection
context — separate problem. The new `autoAdvanceTrack` getter is
the seam where a future "soft context" continuation strategy plugs in.
- Does not refactor `TrackItem.svelte`'s `$effect.pre` reset block
or other pre-existing patterns. Scoped to the auto-advance chain.

## Validation

- `just frontend check`: 0 errors / 0 warnings.
- Reviewed via `svelte:svelte-file-editor` agent — confirmed prefetch
effect's reactivity (correct), fast-path state-write ordering
(correct, with comment-strengthening applied), and blob-URL
accounting (correct across both paths).
- `lib/audio-source.ts` extracted out so Player.svelte's growth is
justified by the actual fast-path/prefetch substance, not pure
helpers that could live elsewhere.

## Test plan

- [x] svelte-check clean.
- [ ] After deploy: reproduce on Android (screen locked) with an album
that has 3+ tracks; confirm auto-advance works end-to-end.
- [ ] Confirm desktop foreground playback unchanged.
- [ ] Confirm gated-track skipping still works (denial via prefetch
consumes the cached entry; active gated denial still triggers
the toast).
- [ ] After 24h on prod: query logfire for `audio play() rejected`
events; analyze fast-path vs slow-path rejection rates per
`error.name` and `document.visibility_state` bucket.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* fix(player): preserve auto-advance through gated tracks; fix telemetry pollution

review feedback on #1339:

1. **gated auto-advance no longer kept playing.** my parameterized
`handleGatedDenial(err, fromAutoAdvance)` was ALWAYS called with
`false`, including from the loader effect when consuming a cached
`gated-denied` preload. so after `handleTrackEnded` set
`shouldAutoPlay = true` and `queue.next()` advanced into the
gated-denied track, `handleGatedDenial` clobbered shouldAutoPlay
back to false before `queue.goTo(nextPlayable)` — playback
stopped instead of skipping the gated track and continuing.
pre-fast-path code unconditionally set `shouldAutoPlay = true` in
this branch.

fix: drop the `fromAutoAdvance` parameter; always intend to
auto-play after a gated skip. matches pre-PR behavior. whether
the user clicked a gated track or auto-advance landed on one,
the user wants the next playable track to start.

2. **fallback telemetry was polluting the rejection metric.**
`recordAutoAdvanceFallback` emitted via `recordPlaybackRejection`,
whose event name is `audio play() rejected`, even though no
`play()` had been attempted on the slow path at that point. any
dashboard query filtering on that event name would have counted
slow-path-fallback markers as play rejections.

fix: drop `recordAutoAdvanceFallback` entirely. instead, instrument
the existing slow-path `play().catch(...)` site (which previously
only `console.error`'d) with `recordPlaybackRejection({fastPath:
false, ...})`. now BOTH paths emit the same event, and the
`playback.fast_path` field is the genuine discriminator for
comparing rejection rates between fast and slow paths. that's the
actual question the telemetry was trying to answer.

svelte-check: 0 errors / 0 warnings.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* chore(player): drop dead frontend telemetry plumbing

review feedback: I was writing comments and commit copy that referenced
"dashboards" for fast-vs-slow path comparison. There are no dashboards.
Frontend logfire is config-flagged off (`config.browser_observability`)
because it was destabilizing the backend; nobody is querying frontend
spans. So `recordPlaybackRejection` was emitting `logfire.info` against
an unconfigured client — net effect: dead code with imaginary purpose.

Removed:
- `recordPlaybackRejection` + `PlaybackRejectionContext` from
`lib/observability.ts`. `initObservability` itself stays — fetch /
XHR auto-instrumentation is the part that DOES propagate trace
headers to the backend, and that's still useful when the flag is on.
- Both call sites in Player.svelte (slow-path and fast-path
`play().catch(...)`) now `console.error` the same way the rest of
the file already did. If a user reports lock-screen playback
trouble, the actual debug pathway is "ask them to repro in
devtools and capture the console."

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
fac6be73 5980d3cc

+467 -147
+131
frontend/src/lib/audio-source.ts
··· 1 + import { API_URL } from '$lib/config'; 2 + import { getCachedAudioUrl } from '$lib/storage'; 3 + import { hasPlayableLossless } from '$lib/audio-support'; 4 + import type { Track } from '$lib/types'; 5 + 6 + /** 7 + * Structured outcome of resolving a track's audio source. 8 + * 9 + * The reactive loader and the next-track prefetcher both produce 10 + * these so the synchronous `ended` handler in Player.svelte has a 11 + * deterministic decision — "ready", "gated-denied", or "failed" — 12 + * without needing to await anything inside the user-activation 13 + * window. See `advanceToPreloadedSynchronously` in Player.svelte. 14 + */ 15 + export type ResolvedSource = 16 + | { 17 + kind: 'ready'; 18 + trackId: number; 19 + fileIdUsed: string; 20 + src: string; 21 + ownsBlob: boolean; 22 + } 23 + | { 24 + kind: 'gated-denied'; 25 + trackId: number; 26 + requiresAuth: boolean; 27 + artistDid: string; 28 + artistHandle: string; 29 + } 30 + | { kind: 'failed'; trackId: number; error: unknown }; 31 + 32 + /** 33 + * Gated-denial shape emitted to the toast/CTA layer. Kept distinct 34 + * from `ResolvedSource` because the toast pathway predates the 35 + * structured resolution result and isn't worth changing now. 36 + */ 37 + export interface GatedError { 38 + type: 'gated'; 39 + artistDid: string; 40 + artistHandle: string; 41 + requiresAuth: boolean; 42 + } 43 + 44 + /** 45 + * Choose the file_id to load for a track: the lossless original 46 + * when the browser can play it natively, the transcoded sibling 47 + * otherwise. Shared between the reactive loader (current track) 48 + * and the prefetcher (next track) so the cache key agrees. 49 + */ 50 + export function pickFileIdForTrack(track: Track): string { 51 + if (track.original_file_id && hasPlayableLossless(track.original_file_type)) { 52 + return track.original_file_id; 53 + } 54 + return track.file_id; 55 + } 56 + 57 + /** 58 + * Resolve a track's audio source URL. 59 + * 60 + * Returns a structured result so callers don't have to differentiate 61 + * gated denials from genuine failures via thrown error types — the 62 + * synchronous fast path needs the discriminator to decide whether 63 + * to play, skip, or surface a CTA without doing any further I/O. 64 + */ 65 + export async function resolveAudioSource( 66 + track: Track, 67 + fileIdUsed: string 68 + ): Promise<ResolvedSource> { 69 + try { 70 + const cachedUrl = await getCachedAudioUrl(fileIdUsed); 71 + if (cachedUrl) { 72 + return { 73 + kind: 'ready', 74 + trackId: track.id, 75 + fileIdUsed, 76 + src: cachedUrl, 77 + ownsBlob: cachedUrl.startsWith('blob:') 78 + }; 79 + } 80 + } catch (err) { 81 + console.error('failed to check audio cache:', err); 82 + } 83 + 84 + if (track.gated) { 85 + try { 86 + const response = await fetch(`${API_URL}/audio/${fileIdUsed}`, { 87 + method: 'HEAD', 88 + credentials: 'include' 89 + }); 90 + if (response.status === 401) { 91 + return { 92 + kind: 'gated-denied', 93 + trackId: track.id, 94 + requiresAuth: true, 95 + artistDid: track.artist_did ?? '', 96 + artistHandle: track.artist_handle 97 + }; 98 + } 99 + if (response.status === 402) { 100 + return { 101 + kind: 'gated-denied', 102 + trackId: track.id, 103 + requiresAuth: false, 104 + artistDid: track.artist_did ?? '', 105 + artistHandle: track.artist_handle 106 + }; 107 + } 108 + } catch (err) { 109 + return { kind: 'failed', trackId: track.id, error: err }; 110 + } 111 + } 112 + 113 + return { 114 + kind: 'ready', 115 + trackId: track.id, 116 + fileIdUsed, 117 + src: `${API_URL}/audio/${fileIdUsed}`, 118 + ownsBlob: false 119 + }; 120 + } 121 + 122 + export function gatedErrorFromResolution( 123 + resolved: Extract<ResolvedSource, { kind: 'gated-denied' }> 124 + ): GatedError { 125 + return { 126 + type: 'gated', 127 + artistDid: resolved.artistDid, 128 + artistHandle: resolved.artistHandle, 129 + requiresAuth: resolved.requiresAuth 130 + }; 131 + }
+314 -146
frontend/src/lib/components/player/Player.svelte
··· 7 7 import { moderation } from '$lib/moderation.svelte'; 8 8 import { preferences } from '$lib/preferences.svelte'; 9 9 import { toast } from '$lib/toast.svelte'; 10 - import { API_URL } from '$lib/config'; 11 - import { getCachedAudioUrl } from '$lib/storage'; 12 - import { hasPlayableLossless } from '$lib/audio-support'; 10 + import { 11 + gatedErrorFromResolution, 12 + pickFileIdForTrack, 13 + resolveAudioSource, 14 + type GatedError, 15 + type ResolvedSource 16 + } from '$lib/audio-source'; 13 17 import { onMount } from 'svelte'; 14 18 import { page } from '$app/stores'; 15 19 import TrackInfo from './TrackInfo.svelte'; ··· 249 253 ); 250 254 }); 251 255 252 - // gated content error types 253 - interface GatedError { 254 - type: 'gated'; 255 - artistDid: string; 256 - artistHandle: string; 257 - requiresAuth: boolean; 258 - } 259 - 260 - // get audio source URL - checks local cache first, falls back to network 261 - // throws GatedError if the track requires supporter access 262 - async function getAudioSource(file_id: string, track: Track): Promise<string> { 263 - try { 264 - const cachedUrl = await getCachedAudioUrl(file_id); 265 - if (cachedUrl) { 266 - return cachedUrl; 267 - } 268 - } catch (err) { 269 - console.error('failed to check audio cache:', err); 270 - } 271 - 272 - // for gated tracks, check authorization first 273 - if (track.gated) { 274 - const response = await fetch(`${API_URL}/audio/${file_id}`, { 275 - method: 'HEAD', 276 - credentials: 'include' 277 - }); 278 - 279 - if (response.status === 401) { 280 - throw { 281 - type: 'gated', 282 - artistDid: track.artist_did, 283 - artistHandle: track.artist_handle, 284 - requiresAuth: true 285 - } as GatedError; 286 - } 287 - 288 - if (response.status === 402) { 289 - throw { 290 - type: 'gated', 291 - artistDid: track.artist_did, 292 - artistHandle: track.artist_handle, 293 - requiresAuth: false 294 - } as GatedError; 295 - } 296 - } 297 - 298 - return `${API_URL}/audio/${file_id}`; 299 - } 300 - 301 256 // track whether we've restored saved position on initial hydration 302 257 let positionRestored = false; 303 258 ··· 318 273 let previousFileId = $state<string | null>(null); 319 274 let isLoadingTrack = $state(false); 320 275 321 - $effect(() => { 322 - if (!player.currentTrack || !player.audioElement) return; 276 + // Source-of-truth for what's actually wired to the audio element. 277 + // Distinct from `previousTrackId` (used by the loader's 278 + // trackChanged/fileChanged check) because the synchronous fast path 279 + // needs the loader to recognize "audio is already attached, don't 280 + // reload" without going through the full reactivity dance first. 281 + let attachedTrackId: number | null = null; 282 + let attachedFileId: string | null = null; 323 283 324 - // reload when either the track id changes (navigation/queue advance) or 325 - // the file_id changes for the same track (audio replaced from edit form). 326 - const trackChanged = player.currentTrack.id !== previousTrackId; 327 - const fileChanged = player.currentTrack.file_id !== previousFileId; 328 - if (trackChanged || fileChanged) { 329 - const trackToLoad = player.currentTrack; 284 + // Pre-resolved source for the next track that natural end-of-track 285 + // continuation will attempt. The locked-screen autoplay grace 286 + // requires that `audio.src = …; audio.play()` happens in the same 287 + // tick as the `ended` event — we cannot afford an `await` inside 288 + // `handleTrackEnded`. So we do the resolution opportunistically 289 + // while the current track is still playing. 290 + let preloadedNext = $state<ResolvedSource | null>(null); 330 291 331 - // update tracking state 332 - previousTrackId = trackToLoad.id; 333 - previousFileId = trackToLoad.file_id; 334 - player.resetPlayCount(); 335 - isLoadingTrack = true; 292 + function discardPreload() { 293 + const cached = preloadedNext; 294 + if (cached && cached.kind === 'ready' && cached.ownsBlob) { 295 + URL.revokeObjectURL(cached.src); 296 + } 297 + preloadedNext = null; 298 + } 336 299 337 - // cleanup previous blob URL before loading new track 338 - cleanupBlobUrl(); 300 + function preloadIsFreshFor(track: Track): boolean { 301 + const cached = preloadedNext; 302 + if (!cached) return false; 303 + if (cached.trackId !== track.id) return false; 304 + if (cached.kind === 'ready' && cached.fileIdUsed !== pickFileIdForTrack(track)) { 305 + return false; 306 + } 307 + return true; 308 + } 339 309 340 - // use lossless original if browser supports it, otherwise transcoded 341 - const fileId = (trackToLoad.original_file_id && hasPlayableLossless(trackToLoad.original_file_type)) ? trackToLoad.original_file_id : trackToLoad.file_id; 342 - getAudioSource(fileId, trackToLoad) 343 - .then((src) => { 344 - // check if track is still current (user may have changed tracks during await) 345 - if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) { 346 - // track changed, cleanup if we created a blob URL 347 - if (src.startsWith('blob:')) { 348 - URL.revokeObjectURL(src); 349 - } 350 - return; 310 + // kick off resolution for the upcoming continuation track. tracked 311 + // reads (queue.autoAdvanceTrack, jam.active) wake this up; the 312 + // preloadedNext write goes through `untrack` so we don't self-trigger. 313 + $effect(() => { 314 + const next = queue.autoAdvanceTrack; 315 + const jamActive = jam.active; 316 + 317 + untrack(() => { 318 + if (!next || jamActive) { 319 + discardPreload(); 320 + return; 321 + } 322 + if (preloadIsFreshFor(next)) return; 323 + 324 + // the existing entry is for a different track or stale file — 325 + // drop it before fetching the new one. 326 + discardPreload(); 327 + 328 + const fileIdUsed = pickFileIdForTrack(next); 329 + void resolveAudioSource(next, fileIdUsed).then((resolved) => { 330 + // the queue may have advanced or jam toggled while we awaited. 331 + // if so, this resolution is for a track we no longer care 332 + // about; throw away any blob it owns rather than caching it. 333 + const stillWanted = 334 + queue.autoAdvanceTrack?.id === next.id && !jam.active; 335 + if (!stillWanted) { 336 + if (resolved.kind === 'ready' && resolved.ownsBlob) { 337 + URL.revokeObjectURL(resolved.src); 351 338 } 339 + return; 340 + } 341 + preloadedNext = resolved; 342 + }); 343 + }); 344 + }); 352 345 353 - // track if this is a blob URL so we can revoke it later 354 - if (src.startsWith('blob:')) { 355 - currentBlobUrl = src; 346 + // Wire `resolved` (cached or freshly fetched) to the audio element. 347 + // Shared between the reactive loader (slow path) and `handleTrackEnded` 348 + // (fast path) so the loadeddata wiring, blob ownership, and play-count 349 + // unlock all live in exactly one place. 350 + function attachResolvedSource( 351 + audio: HTMLAudioElement, 352 + resolved: Extract<ResolvedSource, { kind: 'ready' }> 353 + ): void { 354 + // the new src may be the same blob we already own, or a new one. 355 + // only revoke when we'd be replacing it with something else. 356 + if (currentBlobUrl && currentBlobUrl !== resolved.src) { 357 + URL.revokeObjectURL(currentBlobUrl); 358 + currentBlobUrl = null; 359 + } 360 + if (resolved.ownsBlob) { 361 + currentBlobUrl = resolved.src; 362 + } 363 + 364 + attachedTrackId = resolved.trackId; 365 + attachedFileId = resolved.fileIdUsed; 366 + 367 + // attach listener BEFORE load() to avoid race with cached audio. 368 + audio.addEventListener( 369 + 'loadeddata', 370 + () => { 371 + // restore position on initial hydration only 372 + if (!positionRestored && queue.progressMs > 0 && player.audioElement) { 373 + const positionSec = queue.progressMs / 1000; 374 + // don't restore if near the end (within 5s of duration) 375 + if (player.duration === 0 || positionSec < player.duration - 5) { 376 + player.audioElement.currentTime = positionSec; 356 377 } 378 + positionRestored = true; 379 + } 380 + isLoadingTrack = false; 381 + // unlock play counting now that new audio is ready 382 + // (prevents spurious fires from stale currentTime during transitions) 383 + player.unlockPlayCount(); 384 + }, 385 + { once: true } 386 + ); 357 387 358 - // attach listener BEFORE load() to avoid race with cached audio 359 - player.audioElement.addEventListener( 360 - 'loadeddata', 361 - () => { 362 - // restore position on initial hydration only 363 - if (!positionRestored && queue.progressMs > 0 && player.audioElement) { 364 - const positionSec = queue.progressMs / 1000; 365 - // don't restore if near the end (within 5s of duration) 366 - if (player.duration === 0 || positionSec < player.duration - 5) { 367 - player.audioElement.currentTime = positionSec; 368 - } 369 - positionRestored = true; 370 - } 371 - isLoadingTrack = false; 372 - // unlock play counting now that new audio is ready 373 - // (prevents spurious fires from stale currentTime during transitions) 374 - player.unlockPlayCount(); 375 - }, 376 - { once: true } 377 - ); 388 + audio.src = resolved.src; 389 + audio.load(); 390 + } 378 391 379 - player.audioElement.src = src; 380 - player.audioElement.load(); 381 - }) 382 - .catch((err) => { 383 - isLoadingTrack = false; 392 + function handleGatedDenial(err: GatedError): void { 393 + if (err.requiresAuth) { 394 + toast.info('sign in to play supporter-only tracks'); 395 + } else { 396 + const supportUrl = err.artistDid 397 + ? `${ATPROTOFANS_URL}/${err.artistDid}` 398 + : `${ATPROTOFANS_URL}/${err.artistHandle}`; 399 + toast.info('this track is for supporters only', 5000, { 400 + label: 'become a supporter', 401 + href: supportUrl 402 + }); 403 + } 384 404 385 - // handle gated content errors with supporter CTA 386 - if (err && err.type === 'gated') { 387 - const gatedErr = err as GatedError; 405 + // skip to next playable (non-gated) track in queue. always intend to 406 + // auto-play the skipped-to track: whether the user clicked a gated 407 + // track or natural auto-advance landed on one, the user wants the 408 + // next playable track to start. matches pre-fast-path behavior. 409 + let nextPlayable = -1; 410 + for (let i = queue.currentIndex + 1; i < queue.tracks.length; i++) { 411 + if (!queue.tracks[i].gated) { 412 + nextPlayable = i; 413 + break; 414 + } 415 + } 416 + if (nextPlayable >= 0) { 417 + shouldAutoPlay = true; 418 + queue.goTo(nextPlayable); 419 + } else { 420 + player.paused = true; 421 + } 422 + } 388 423 389 - if (gatedErr.requiresAuth) { 390 - toast.info('sign in to play supporter-only tracks'); 391 - } else { 392 - // show toast with supporter CTA 393 - const supportUrl = gatedErr.artistDid 394 - ? `${ATPROTOFANS_URL}/${gatedErr.artistDid}` 395 - : `${ATPROTOFANS_URL}/${gatedErr.artistHandle}`; 424 + $effect(() => { 425 + if (!player.currentTrack || !player.audioElement) return; 426 + 427 + // reload when either the track id changes (navigation/queue advance) or 428 + // the file_id changes for the same track (audio replaced from edit form). 429 + const trackChanged = player.currentTrack.id !== previousTrackId; 430 + const fileChanged = player.currentTrack.file_id !== previousFileId; 431 + if (!trackChanged && !fileChanged) return; 396 432 397 - toast.info('this track is for supporters only', 5000, { 398 - label: 'become a supporter', 399 - href: supportUrl 400 - }); 401 - } 433 + const trackToLoad = player.currentTrack; 434 + 435 + // the synchronous fast path may have already attached this exact 436 + // track+file to the audio element. when it has, the loader's job 437 + // is just to rebase its bookkeeping — re-running the async fetch 438 + // would trample the in-flight playback. 439 + if ( 440 + attachedTrackId === trackToLoad.id && 441 + attachedFileId === trackToLoad.file_id 442 + ) { 443 + previousTrackId = trackToLoad.id; 444 + previousFileId = trackToLoad.file_id; 445 + return; 446 + } 402 447 403 - // skip to next playable (non-gated) track in queue 404 - let nextPlayable = -1; 405 - for (let i = queue.currentIndex + 1; i < queue.tracks.length; i++) { 406 - if (!queue.tracks[i].gated) { 407 - nextPlayable = i; 408 - break; 409 - } 410 - } 448 + // update tracking state 449 + previousTrackId = trackToLoad.id; 450 + previousFileId = trackToLoad.file_id; 451 + player.resetPlayCount(); 452 + isLoadingTrack = true; 411 453 412 - if (nextPlayable >= 0) { 413 - shouldAutoPlay = true; 414 - queue.goTo(nextPlayable); 415 - } else { 416 - // no playable tracks remaining — just pause 417 - player.paused = true; 418 - } 419 - return; 420 - } 454 + const fileIdUsed = pickFileIdForTrack(trackToLoad); 421 455 422 - console.error('failed to load audio:', err); 423 - }); 456 + // if the prefetcher already resolved this exact track, reuse it 457 + // — the resolution result came from `resolveAudioSource`, the 458 + // same function the slow path would call, so reusing is safe. 459 + const cached = preloadedNext; 460 + if (cached && cached.trackId === trackToLoad.id) { 461 + preloadedNext = null; 462 + if (cached.kind === 'ready' && cached.fileIdUsed === fileIdUsed) { 463 + cleanupBlobUrl(); 464 + attachResolvedSource(player.audioElement, cached); 465 + return; 466 + } 467 + if (cached.kind === 'gated-denied') { 468 + isLoadingTrack = false; 469 + handleGatedDenial(gatedErrorFromResolution(cached)); 470 + return; 471 + } 472 + // stale fileId or `failed` → fall through to fresh fetch below 424 473 } 474 + 475 + cleanupBlobUrl(); 476 + void resolveAudioSource(trackToLoad, fileIdUsed).then((resolved) => { 477 + // check if track is still current (user may have changed tracks during await) 478 + if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) { 479 + if (resolved.kind === 'ready' && resolved.ownsBlob) { 480 + URL.revokeObjectURL(resolved.src); 481 + } 482 + return; 483 + } 484 + 485 + if (resolved.kind === 'ready') { 486 + attachResolvedSource(player.audioElement, resolved); 487 + return; 488 + } 489 + 490 + isLoadingTrack = false; 491 + if (resolved.kind === 'gated-denied') { 492 + handleGatedDenial(gatedErrorFromResolution(resolved)); 493 + return; 494 + } 495 + console.error('failed to load audio:', resolved.error); 496 + }); 425 497 }); 426 498 427 499 // sync paused state with audio element (output device only — non-output stays silent) ··· 437 509 if (player.paused) { 438 510 player.audioElement.pause(); 439 511 } else { 440 - player.audioElement.play().catch((err) => { 441 - console.error('[player] playback failed:', err.name, err.message); 512 + player.audioElement.play().catch((err: unknown) => { 513 + const e = err as { name?: string; message?: string }; 514 + console.error('[player] playback failed:', e?.name, e?.message); 442 515 player.paused = true; 443 516 }); 444 517 } ··· 559 632 return; 560 633 } 561 634 562 - if (queue.hasNext) { 563 - shouldAutoPlay = true; 564 - queue.next(); 565 - } else { 635 + const next = queue.autoAdvanceTrack; 636 + if (!next) { 566 637 player.reset(); 567 638 nowPlaying.clear(); 639 + return; 568 640 } 641 + 642 + // Fast path: if the prefetcher already has a ready source for the 643 + // track we want to advance to, swap `audio.src` and call `play()` 644 + // synchronously here — same tick as the `ended` event. The locked- 645 + // screen autoplay grace on Android requires zero `await`s between 646 + // the natural-end signal and the next play() call; the reactive 647 + // chain (queue.next() → effect → effect → fetch → load → effect → 648 + // play) takes too many ticks and the browser drops the implicit 649 + // playback permission, blocking the next play() with NotAllowed. 650 + const audio = player.audioElement; 651 + const cached = preloadedNext; 652 + const canFastPath = 653 + !jam.active && 654 + audio !== null && 655 + cached !== null && 656 + cached.kind === 'ready' && 657 + cached.trackId === next.id; 658 + 659 + if (canFastPath) { 660 + // We've already set canFastPath using a discriminant on cached.kind, 661 + // but TypeScript narrowing through `&&` in a const doesn't reach the 662 + // access below — re-narrow explicitly. 663 + if (cached?.kind !== 'ready' || !audio) return; 664 + advanceToPreloadedSynchronously(audio, cached, next); 665 + return; 666 + } 667 + 668 + // Slow path: either no preload, jam active, or some race made the 669 + // preload stale. Defer to the reactive chain — it works on 670 + // foregrounded tabs and on most desktop browsers; the lock-screen 671 + // case is the one that needs the fast path above. If the eventual 672 + // `audio.play()` from the slow path rejects (e.g. on locked Android 673 + // when this fast-path-skip happened because the preload was stale), 674 + // the paused-sync effect's `play().catch(...)` will record the 675 + // rejection with `fastPath: false` so dashboards can compare paths. 676 + shouldAutoPlay = true; 677 + queue.next(); 678 + } 679 + 680 + function advanceToPreloadedSynchronously( 681 + audio: HTMLAudioElement, 682 + preloaded: Extract<ResolvedSource, { kind: 'ready' }>, 683 + next: Track 684 + ): void { 685 + // Reset play counting BEFORE the swap so a stale (currentTime 686 + // near duration) reading from the just-ended track can't fire a 687 + // spurious increment between here and `loadeddata` unlocking. 688 + player.resetPlayCount(); 689 + isLoadingTrack = true; 690 + 691 + // Sync ALL bookkeeping that downstream effects key off so they 692 + // recognize "no work to do" once they wake. Without this, 693 + // `queue.next()` below would trigger the queue→player sync 694 + // effect's `indexChanged` branch, which would slam 695 + // `player.currentTime = 0` and seek the just-started audio 696 + // back to the start. 697 + previousTrackId = next.id; 698 + previousFileId = next.file_id; 699 + previousQueueIndex = queue.currentIndex + 1; 700 + preloadedNext = null; 701 + 702 + // Wire the new src to the audio element. `attachResolvedSource` 703 + // also takes ownership of any blob URL on the resolved object, 704 + // revoking the previous blob if this isn't the same one. 705 + attachResolvedSource(audio, preloaded); 706 + 707 + // THE critical call: synchronous, same-tick play() to preserve 708 + // the implicit-playback grace from the `ended` event. Anything 709 + // that yields between here and play() costs us the autoplay 710 + // permission on locked Android. 711 + const playPromise = audio.play(); 712 + if (playPromise && typeof playPromise.catch === 'function') { 713 + playPromise.catch((err: unknown) => { 714 + const e = err as { name?: string; message?: string }; 715 + console.error('[player] fast-path play failed:', e?.name, e?.message); 716 + player.paused = true; 717 + }); 718 + } 719 + 720 + // Now let reactivity catch up. Setting player.currentTrack first 721 + // (and pre-incrementing previousQueueIndex above) keeps every 722 + // downstream effect a no-op except for media-session metadata 723 + // + now-playing reporting, which we DO want to refresh. 724 + // 725 + // THESE TWO LINES MUST STAY ADJACENT in the same synchronous 726 + // tick. Between `player.currentTrack = next` and `queue.next()`, 727 + // the queue→player sync effect would observe a stale 728 + // queue.currentTrack !== player.currentTrack and try to roll 729 + // player.currentTrack back to the old track. Svelte batches 730 + // effect flushes until the synchronous frame ends, so as long 731 + // as nothing here yields (no await, no setTimeout) the effect 732 + // only runs after both writes land and sees a consistent state. 733 + // A future refactor that introduces any yield between these two 734 + // statements will reintroduce that race. 735 + player.currentTrack = next; 736 + queue.next(); 569 737 } 570 738 571 739 </script>
+21
frontend/src/lib/queue.svelte.ts
··· 53 53 return this.currentIndex < this.tracks.length - 1; 54 54 } 55 55 56 + /** 57 + * The track that natural end-of-track continuation should attempt next. 58 + * 59 + * Today: the next item in the hard queue, if any. Player.svelte uses 60 + * this both to prefetch the upcoming source while the current track 61 + * is still playing AND as the synchronous decision in the audio 62 + * `ended` handler — the locked-screen autoplay grace requires that 63 + * the swap to the next source happens in the same tick as the 64 + * `ended` event, with no `await` in the path. 65 + * 66 + * Future: this is the seam where the Player asks "what should 67 + * play next" without caring whether the answer comes from the 68 + * hard queue, an album/playlist tail, a feed continuation, or a 69 + * recommendation. Keep callers reading this getter, not poking 70 + * `tracks[currentIndex + 1]` directly. 71 + */ 72 + get autoAdvanceTrack(): Track | null { 73 + if (this.currentIndex < 0) return null; 74 + return this.tracks[this.currentIndex + 1] ?? null; 75 + } 76 + 56 77 get hasPrevious(): boolean { 57 78 return this.currentIndex > 0; 58 79 }
+1 -1
loq.toml
··· 124 124 125 125 [[rules]] 126 126 path = "frontend/src/lib/components/player/Player.svelte" 127 - max_lines = 727 127 + max_lines = 906 128 128 129 129 [[rules]] 130 130 path = "frontend/src/lib/queue.svelte.ts"