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
201 lines
7.9 KiB
TypeScript
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;
|
|
}
|