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
+23 -3
View File
@@ -120,6 +120,12 @@
onNext?: () => void; // Called when user clicks next episode button
hasNext?: boolean; // Whether there is a next episode available
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
/**
* Returning from background audio found the backend on a different item
* (the episode advanced while backgrounded). The page switches to it; this
* player must not reload the one it was mounted with. (DR-296)
*/
onResumeOtherItem?: (itemId: string, positionSeconds: number) => void;
}
let {
@@ -137,6 +143,7 @@
onNext,
hasNext = false,
isLive = false,
onResumeOtherItem,
}: Props = $props();
// The id this player instance reports progress against. Snapshotted from the
@@ -2035,9 +2042,12 @@
const wasPlaying = shouldResumeOnForeground(handoffState.wasPlaying, get(playerState).kind);
handoffState = { ...initialHandoffState };
try {
// Absolute position the native audio reached (base offset applied in Rust).
const pos = await commands.playerExitBackgroundAudio();
log.debug("Returning from background audio at:", pos.toFixed(1));
// The item the native audio is on and the absolute position it reached
// (base offset applied in Rust). The item may not be `media`: an episode
// that ended while backgrounded has already advanced in the backend.
const resume = await commands.playerExitBackgroundAudio();
const pos = resume.positionSeconds;
log.debug("Returning from background audio at:", pos.toFixed(1), "item:", resume.itemId);
isMediaReady = false;
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
@@ -2056,8 +2066,18 @@
position: pos,
wasPlaying,
nativeStateKind: get(playerState).kind,
mountedItemId: media?.id ?? null,
resumeItemId: resume.itemId,
});
// Reloading `media` here would bring back the previous episode at the new
// one's timestamp. Hand the switch to the page instead.
// TRACES: UR-040, UR-023 | DR-296
if (plan.target === "other-item" && plan.itemId) {
onResumeOtherItem?.(plan.itemId, plan.position);
return;
}
pendingForegroundPlay = plan.shouldPlay;
// Determine the target stream + how the element/offset should be
@@ -138,6 +138,51 @@ describe("backgroundAudioHandoff", () => {
});
expect(plan.position).toBe(0);
});
// An episode that ends while backgrounded advances in the backend. Reloading
// the video the player was mounted with brought back the PREVIOUS episode,
// at the new episode's timestamp.
//
// TRACES: UR-040, UR-023 | DR-296 | UT-265
it("switches to the item the backend advanced to", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 95,
wasPlaying: true,
nativeStateKind: "playing",
mountedItemId: "ep1",
resumeItemId: "ep2",
});
expect(plan.target).toBe("other-item");
expect(plan.itemId).toBe("ep2");
expect(plan.position).toBe(95);
});
it("reloads in place when the backend is still on the mounted item", () => {
const plan = planHandoffReturn({
useHtml5Element: true,
position: 95,
wasPlaying: true,
nativeStateKind: "playing",
mountedItemId: "ep1",
resumeItemId: "ep1",
});
expect(plan.target).toBe("html5-element");
});
it("reloads in place when the backend reports no item", () => {
// Queue emptied while backgrounded (e.g. the sleep timer): there is no
// other item to go to, so the mounted one is the best we have.
const plan = planHandoffReturn({
useHtml5Element: false,
position: 95,
wasPlaying: false,
nativeStateKind: undefined,
mountedItemId: "ep1",
resumeItemId: null,
});
expect(plan.target).toBe("native-backend");
});
});
});
@@ -77,8 +77,14 @@ export function shouldResumeOnForeground(
/** What has to be restarted to put picture back on screen, and how. */
export interface HandoffReturn {
/** Which renderer must be brought back. */
target: "html5-element" | "native-backend";
/**
* Which renderer must be brought back — or `other-item` when the backend is
* no longer on the item this player was mounted with, so the player must
* switch to `itemId` instead of reloading itself.
*/
target: "html5-element" | "native-backend" | "other-item";
/** The item to switch to; set only for `other-item`. */
itemId?: string;
/** Absolute position the background audio reached. */
position: number;
/** Whether playback should be running once it is back. */
@@ -107,18 +113,31 @@ export interface HandoffReturn {
* `shouldPlay` folds in [shouldResumeOnForeground], so a lockscreen pause during
* the handoff still wins over the snapshot taken on the way out.
*
* TRACES: UR-040, UR-003 | DR-196 | UT-060
* An episode that ends while backgrounded advances in the backend, so the item
* the native player returns on (`resumeItemId`) can differ from the one this
* player was mounted with. Reloading the mounted one brought back the previous
* episode at the new one's timestamp; in that case the plan is `other-item`.
* A missing `resumeItemId` (queue emptied) keeps the in-place reload.
*
* TRACES: UR-040, UR-003, UR-023 | DR-196, DR-296 | UT-060, UT-265
*/
export function planHandoffReturn(opts: {
useHtml5Element: boolean;
position: number;
wasPlaying: boolean;
nativeStateKind: string | undefined;
mountedItemId?: string | null;
resumeItemId?: string | null;
}): HandoffReturn {
const position = opts.position > 0 ? opts.position : 0;
const shouldPlay = shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind);
if (opts.resumeItemId && opts.resumeItemId !== opts.mountedItemId) {
return { target: "other-item", itemId: opts.resumeItemId, position, shouldPlay };
}
return {
target: opts.useHtml5Element ? "html5-element" : "native-backend",
position: opts.position > 0 ? opts.position : 0,
shouldPlay: shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind),
position,
shouldPlay,
};
}