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