Files
jellytau/src/lib/components/player/backgroundAudioHandoff.ts
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

124 lines
4.5 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. */
target: "html5-element" | "native-backend";
/** 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.
*
* TRACES: UR-040, UR-003 | DR-196 | UT-060
*/
export function planHandoffReturn(opts: {
useHtml5Element: boolean;
position: number;
wasPlaying: boolean;
nativeStateKind: string | undefined;
}): HandoffReturn {
return {
target: opts.useHtml5Element ? "html5-element" : "native-backend",
position: opts.position > 0 ? opts.position : 0,
shouldPlay: shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind),
};
}