Files
jellytau/src/lib/components/player/backgroundAudioHandoff.ts
T
dtourolle 1fb5f070c8 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
2026-09-24 03:45:59 +02:00

201 lines
7.9 KiB
TypeScript

/**
* Pure helpers for the video → background-audio handoff (UR-040).
*
* TRACES: UR-040 | DR-052 | UT-060
*
* Kept free of Svelte/DOM so the handoff arithmetic and state transitions are
* unit-testable without mounting the player. The component
* (VideoPlayer.svelte) owns the actual `<video>` teardown and IPC calls.
*/
/**
* Absolute playback position to resume the audio stream at.
*
* Transcoded HLS playback tracks time as `videoElement.currentTime + seekOffset`
* (the element resets to 0 after each transcode reload; `seekOffset` carries the
* cumulative offset). Background audio must resume at that ABSOLUTE position, so
* both terms are summed here — mirroring the `effectiveTime` used elsewhere in
* the player.
*/
export function computeHandoffPosition(elementCurrentTime: number, seekOffset: number): number {
const pos = elementCurrentTime + seekOffset;
return pos > 0 ? pos : 0;
}
/**
* The handoff state. `wasPlaying` is captured on the way out so play/pause is
* restored when the app returns to the foreground.
*/
export interface BackgroundAudioState {
active: boolean;
wasPlaying: boolean;
}
export const initialHandoffState: BackgroundAudioState = {
active: false,
wasPlaying: false,
};
/**
* Whether a background signal should trigger the audio handoff right now.
* Only when the toggle is on and we're not already handed off.
*/
export function shouldEnterBackgroundAudio(
toggleOn: boolean,
state: BackgroundAudioState,
): boolean {
return toggleOn && !state.active;
}
/**
* Whether a foreground signal should trigger the return to WebView video.
* Only when we actually handed off (regardless of the current toggle value, so
* turning the toggle off while backgrounded still returns cleanly).
*/
export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean {
return state.active;
}
/**
* Whether the `<video>` should start playing again once it reloads on foreground.
*
* `wasPlaying` is what the video was doing when we handed off, but the native
* audio player kept going after that — and the lockscreen/notification can pause
* it while backgrounded. The player is the authoritative source of play/pause,
* so an explicit `paused` from it overrides the handoff snapshot; anything less
* definite (loading, seeking, already-stopped, no state at all) falls back to
* the snapshot.
*
* TRACES: UR-040, UR-005 | DR-052 | UT-060
*/
export function shouldResumeOnForeground(
wasPlaying: boolean,
nativeStateKind: string | undefined,
): boolean {
return wasPlaying && nativeStateKind !== "paused";
}
/** What has to be restarted to put picture back on screen, and how. */
export interface HandoffReturn {
/**
* 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. */
shouldPlay: boolean;
}
/**
* How to come back when the app returns to the foreground.
*
* The two render paths resume by completely different means, and conflating
* them is what broke the native one:
*
* - **html5-element** — assigning the stream URL is enough. An `$effect` in the
* component watches it, (re)initialises HLS or sets `videoElement.src`, and
* `canplay` then drives the seek and play.
* - **native-backend** — ExoPlayer owns no element, and nothing reacts to the
* stream URL on its behalf. Native playback is only ever started by an
* explicit backend load, which the component issues once, from `onMount`. So
* the return has to re-issue it; reassigning the URL restarts nothing.
*
* The component previously did only the URL assignment, for both paths. On the
* native path that left the backend holding no item at all: a black screen with
* a play overlay, a play button that did nothing, and the position pinned at
* 0:00 — the handoff's own audio player having been stopped on the way out.
*
* `shouldPlay` folds in [shouldResumeOnForeground], so a lockscreen pause during
* the handoff still wins over the snapshot taken on the way out.
*
* 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,
shouldPlay,
};
}
/**
* Which of the two mutually exclusive background behaviours is armed.
*
* TRACES: UR-040, UR-041 | DR-266 | UT-246
*
* Backgrounding the app can either shrink the video into a picture-in-picture
* window (UR-041) or hand its audio off to the native player and drop the
* picture (UR-040). They are alternatives — the first keeps the video on
* screen, the second throws it away — so at most one may ever be armed.
*/
export interface BackgroundBehaviour {
/** The per-player background-audio toggle (UR-040). */
backgroundAudioArmed: boolean;
/** Whether leaving the app auto-enters PiP (UR-041). */
autoPipEnabled: boolean;
}
/** Arming/disarming the background-audio toggle flips auto-PiP the other way. */
export function setBackgroundAudioArmed(armed: boolean): BackgroundBehaviour {
return { backgroundAudioArmed: armed, autoPipEnabled: !armed };
}
/**
* The user has asked for a PiP window, by pressing the button rather than by
* leaving the app.
*
* Exclusivity used to be enforced from one side only — arming the toggle
* suppressed auto-PiP — while the PiP button stayed live and ungated. Pressing
* it left both behaviours armed, and the video was then one stray background
* signal away from being handed off to audio-only while the user was watching
* it in the window. Pressing PiP is an unambiguous request to keep the picture,
* so it disarms the behaviour that throws the picture away.
*/
export function enteringPictureInPicture(_current: BackgroundBehaviour): BackgroundBehaviour {
return setBackgroundAudioArmed(false);
}
/**
* Whether the app is in a picture-in-picture window, for the purpose of
* deciding what backgrounding means.
*
* TRACES: UR-040, UR-041 | DR-266 | UT-246
*
* @param nativeFlag the Activity's `isInPictureInPictureMode`, sampled inside
* `onStop()`
* @param sawPipEntered whether the frontend has seen `jellytau-pip-entered`
* without a matching `jellytau-pip-exited`
*/
export function inPictureInPicture(nativeFlag: boolean, sawPipEntered: boolean): boolean {
// Either witness is enough. The native flag is a single sample taken inside
// onStop(); the frontend's is a latch, set by `jellytau-pip-entered` and
// cleared by `jellytau-pip-exited`. Both events reach the WebView through the
// same message queue in dispatch order, so a genuine exit is always known
// before the background signal that follows it — the latch can report a
// window that is still open, never one that has closed.
return nativeFlag || sawPipEntered;
}