fix(player): return from background audio onto the episode it advanced to

An episode that ends while backgrounded in audio-only mode advances in the
backend, but player_exit_background_audio returned only a position, so the
video page reloaded the episode it was mounted with -- the previous one, at
the new episode's timestamp.

The command now returns BackgroundAudioResume { itemId, positionSeconds }.
planHandoffReturn yields "other-item" when the id differs from the mounted
one, and the player page navigates to that episode with resumeAt=<seconds>,
marking the outgoing episode watched and suppressing its stale stop report.

TRACES: UR-040, UR-023 | DR-296 | UT-265, UT-266
This commit is contained in:
2026-09-24 03:45:59 +02:00
parent f6efa7208a
commit 1fb5f070c8
9 changed files with 240 additions and 24 deletions
+27 -1
View File
@@ -50,6 +50,9 @@
// When advancing to a next episode we always start from the beginning,
// even if the episode was previously started or watched.
const restartParam = $derived($page.url.searchParams.get("restart") === "true");
// Explicit start position in seconds — set when returning from background
// audio onto an episode the backend advanced to (DR-296).
const resumeAtParam = $derived(Number($page.url.searchParams.get("resumeAt")) || 0);
// Derive playback context from URL query params
const playbackContext = $derived.by(() => {
@@ -121,6 +124,7 @@
$effect(() => {
const id = itemId;
const restart = restartParam;
const resumeAt = resumeAtParam;
if (id && id !== loadedItemId) {
autoPlayLog.debug(
"$effect triggered: loading new item",
@@ -132,7 +136,11 @@
);
// restart=true (advancing to next episode) forces start-from-beginning,
// bypassing the resume-progress check.
loadAndPlay(id, restart ? 0 : undefined, restart);
if (resumeAt > 0) {
loadAndPlay(id, resumeAt);
} else {
loadAndPlay(id, restart ? 0 : undefined, restart);
}
}
});
@@ -797,6 +805,23 @@
}
}
/**
* Background audio advanced to another episode; show that one where the audio
* left off, rather than the episode this page was opened on.
*
* The outgoing episode was played out (the backend advances only past its
* end), so it is recorded as watched and the VideoPlayer's unmount stop
* report — which would carry the stale handoff position — is suppressed, as
* for a manual skip.
*
* TRACES: UR-040, UR-023 | DR-296
*/
function handleResumeOtherItem(nextId: string, positionSeconds: number) {
void reportSkippedEpisode(currentMedia?.id ?? itemId ?? null);
const start = positionSeconds > 0 ? `resumeAt=${Math.floor(positionSeconds)}` : "restart=true";
goto(`/player/${nextId}?${start}`, { replaceState: true });
}
function handleSkipToNextEpisode() {
if (nextEpisode) {
// Skipping means "I'm done with this one" — record the outgoing episode as
@@ -889,6 +914,7 @@
onEnded={handleVideoEnded}
hasNext={nextEpisode !== null}
onNext={handleSkipToNextEpisode}
onResumeOtherItem={handleResumeOtherItem}
/>
<NextEpisodePopup />
{:else}