Watching in a picture-in-picture window would occasionally drop to audio-only, and the audio would resume from wherever the video had been when PiP was entered while the picture had carried on past it. Two independent faults, both needed to produce that. The position froze (DR-265). VideoPlayer tracks the absolute position in its own `currentTime` rather than reading `videoElement.currentTime` at the point of use, because transcoded HLS resets the element to 0 on every segment rebuild. While playing, that variable had exactly one writer: a requestAnimationFrame loop. RAF is driven by the document being rendered, and an Android activity behind a PiP window is paused, so the loop stops while the element plays on. The `timeupdate` handler that would have covered the gap was written as a fallback "for when RAF isn't running" and gated itself on `!isPlaying` -- switching itself off at precisely the moment it was the only source left. Everything downstream froze with it: the seek bar, the ten-second progress reports, the position mirrored into Rust, and the handoff. The gate is now `shouldApplyTimeUpdate` and turns only on things that genuinely own the position -- an in-flight seek, a seek-bar drag, an element below HAVE_CURRENT_DATA. Both writers producing the same derived value costs nothing. The handoff fired at all (DR-266). PiP and the background-audio handoff are alternatives -- one keeps the picture, the other throws it away -- but exclusivity was enforced from one side only: arming the toggle suppressed *auto*-PiP, while the PiP button stayed ungated, so pressing it left both armed. What then stood between them was `isInPictureInPictureMode`, sampled once inside MainActivity.onStop(). That sample is not reliable: the keyguard dismissing the window, the window being stashed, or OEM variance in when onPictureInPictureModeChanged(false) lands can all leave the activity stopped with a window still on screen and the flag reading false. Now entering PiP disarms background audio, both directions go through one BackgroundBehaviour pair, and the PiP question accepts either witness -- the native sample or the frontend's latch over jellytau-pip-entered/exited. The latch cannot report a window that has closed: both events reach the WebView through the same message queue in dispatch order. The decision itself stays in Rust; the frontend only supplies a fact it can establish more reliably than the activity can. Red first, both: the existing behaviour was extracted into pure helpers, the tests written against the correct behaviour, and both watched to fail before either was changed.
182 lines
7.0 KiB
TypeScript
182 lines
7.0 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),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|