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:
+23
-2
@@ -66,9 +66,14 @@ async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number)
|
||||
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
|
||||
},
|
||||
/**
|
||||
* TRACES: UR-040 | DR-052 | UT-061, IT-013
|
||||
* Returns the item the native player is on and its absolute position. The
|
||||
* item matters: an episode that ended while backgrounded has already advanced
|
||||
* in the backend, so reloading the video the webview was mounted with would
|
||||
* bring back the previous episode. (DR-296)
|
||||
*
|
||||
* TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
|
||||
*/
|
||||
async playerExitBackgroundAudio() : Promise<number> {
|
||||
async playerExitBackgroundAudio() : Promise<BackgroundAudioResume> {
|
||||
return await TAURI_INVOKE("player_exit_background_audio");
|
||||
},
|
||||
/**
|
||||
@@ -2190,6 +2195,22 @@ export type BackgroundAction =
|
||||
* Stop making sound. The user did not ask for background playback.
|
||||
*/
|
||||
"pause"
|
||||
/**
|
||||
* Where playback stands when a background-audio handoff returns to the
|
||||
* foreground. See [`PlayerController::background_audio_resume`].
|
||||
*
|
||||
* TRACES: UR-040, UR-023 | DR-296
|
||||
*/
|
||||
export type BackgroundAudioResume = {
|
||||
/**
|
||||
* Item the native audio player is on — `None` if the queue emptied (e.g.
|
||||
* the sleep timer stopped playback while backgrounded).
|
||||
*/
|
||||
itemId: string | null;
|
||||
/**
|
||||
* Absolute position in that item, in seconds.
|
||||
*/
|
||||
positionSeconds: number }
|
||||
/**
|
||||
* Smart caching configuration
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,12 +32,12 @@ describe("background-audio player commands (param naming)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("player_exit_background_audio takes no params and returns a position", async () => {
|
||||
(invoke as any).mockResolvedValueOnce(193.5);
|
||||
it("player_exit_background_audio takes no params and returns the resume point", async () => {
|
||||
(invoke as any).mockResolvedValueOnce({ itemId: "ep2", positionSeconds: 193.5 });
|
||||
|
||||
const pos = await commands.playerExitBackgroundAudio();
|
||||
const resume = await commands.playerExitBackgroundAudio();
|
||||
|
||||
expect(pos).toBe(193.5);
|
||||
expect(resume).toEqual({ itemId: "ep2", positionSeconds: 193.5 });
|
||||
expect(invoke).toHaveBeenCalledWith("player_exit_background_audio");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user