Files
jellytau/src/lib/components/player/backgroundAudioHandoff.test.ts
T
dtourolle c0399e4ebd fix(player): stop PiP dropping the video and restarting the audio behind it
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.
2026-08-27 17:56:52 +02:00

209 lines
8.0 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
computeHandoffPosition,
planHandoffReturn,
initialHandoffState,
shouldEnterBackgroundAudio,
shouldExitBackgroundAudio,
shouldResumeOnForeground,
type BackgroundAudioState,
setBackgroundAudioArmed,
enteringPictureInPicture,
inPictureInPicture,
} from "./backgroundAudioHandoff";
// TRACES: UR-040 | DR-052 | UT-060
describe("backgroundAudioHandoff", () => {
describe("computeHandoffPosition", () => {
it("sums element time and transcode seekOffset (absolute position)", () => {
// Transcoded HLS resets element time to 0 after a reload; seekOffset carries
// the cumulative offset. The audio stream must resume at the absolute pos.
expect(computeHandoffPosition(12, 180)).toBe(192);
});
it("handles a direct stream with no offset", () => {
expect(computeHandoffPosition(45, 0)).toBe(45);
});
it("never returns a negative position", () => {
expect(computeHandoffPosition(-5, 0)).toBe(0);
});
});
describe("shouldEnterBackgroundAudio", () => {
it("enters when toggle is on and not already handed off", () => {
expect(shouldEnterBackgroundAudio(true, initialHandoffState)).toBe(true);
});
it("does not enter when the toggle is off", () => {
expect(shouldEnterBackgroundAudio(false, initialHandoffState)).toBe(false);
});
it("does not double-enter when already active", () => {
const active: BackgroundAudioState = { active: true, wasPlaying: true };
expect(shouldEnterBackgroundAudio(true, active)).toBe(false);
});
});
describe("shouldExitBackgroundAudio", () => {
it("exits when a handoff is active", () => {
const active: BackgroundAudioState = { active: true, wasPlaying: false };
expect(shouldExitBackgroundAudio(active)).toBe(true);
});
it("does not exit when no handoff happened", () => {
expect(shouldExitBackgroundAudio(initialHandoffState)).toBe(false);
});
it("exits even if the toggle was turned off while backgrounded", () => {
// shouldExit ignores the toggle by design, so turning it off mid-background
// still returns cleanly to video on foreground.
const active: BackgroundAudioState = { active: true, wasPlaying: true };
expect(shouldExitBackgroundAudio(active)).toBe(true);
});
});
describe("shouldResumeOnForeground", () => {
it("resumes when it was playing and the native audio still is", () => {
expect(shouldResumeOnForeground(true, "playing")).toBe(true);
});
it("stays paused when the lockscreen paused the native audio", () => {
// The whole point of the lockscreen pause: coming back to the app must not
// undo it just because the video was playing when we handed off.
expect(shouldResumeOnForeground(true, "paused")).toBe(false);
});
it("stays paused when the video was already paused at handoff", () => {
expect(shouldResumeOnForeground(false, "playing")).toBe(false);
});
it("falls back to the captured state when native state is unknown", () => {
// Loading/seeking/idle say nothing about intent — the handoff snapshot is
// the best evidence we have, so a playing video still resumes.
expect(shouldResumeOnForeground(true, "loading")).toBe(true);
expect(shouldResumeOnForeground(true, undefined)).toBe(true);
});
});
// Returning from background audio has to restart whatever is actually
// rendering. The webview <video> reloads off its stream URL, but the native
// (ExoPlayer) path owns no element and no URL-driven effect — its playback is
// only ever started by an explicit backend load. The component used to just
// reassign the stream URL and call it done, which on the native path restarted
// nothing: the player sat IDLE on a black screen with a play overlay, and the
// play button did nothing because the backend held no item.
//
// TRACES: UR-040, UR-003 | DR-196
describe("planHandoffReturn", () => {
it("restarts the native backend when the native path is rendering", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 4214,
wasPlaying: true,
nativeStateKind: "playing",
});
expect(plan.target).toBe("native-backend");
expect(plan.position).toBe(4214);
expect(plan.shouldPlay).toBe(true);
});
it("reloads the webview element when HTML5 is rendering", () => {
const plan = planHandoffReturn({
useHtml5Element: true,
position: 120,
wasPlaying: true,
nativeStateKind: "playing",
});
expect(plan.target).toBe("html5-element");
});
it("honours a lockscreen pause over the handoff snapshot", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 300,
wasPlaying: true,
nativeStateKind: "paused",
});
expect(plan.shouldPlay).toBe(false);
});
it("never returns a negative resume position", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: -3,
wasPlaying: false,
nativeStateKind: undefined,
});
expect(plan.position).toBe(0);
});
});
});
/**
* TRACES: UT-246 | DR-266
*/
describe("background behaviour exclusivity", () => {
describe("setBackgroundAudioArmed", () => {
it("disables auto-PiP when background audio is armed", () => {
expect(setBackgroundAudioArmed(true)).toEqual({
backgroundAudioArmed: true,
autoPipEnabled: false,
});
});
it("restores auto-PiP when background audio is disarmed", () => {
expect(setBackgroundAudioArmed(false)).toEqual({
backgroundAudioArmed: false,
autoPipEnabled: true,
});
});
});
describe("enteringPictureInPicture", () => {
it("disarms background audio when the user opens a PiP window", () => {
// THE REPORTED BUG, half one. Exclusivity was enforced in one direction
// only: arming the toggle suppressed auto-PiP, but the PiP *button* was
// still offered and still worked, leaving both behaviours live at once.
// A single stray background signal then handed a video the user was
// watching in a PiP window off to audio-only.
expect(
enteringPictureInPicture({ backgroundAudioArmed: true, autoPipEnabled: false }),
).toEqual({ backgroundAudioArmed: false, autoPipEnabled: true });
});
it("leaves an already-exclusive state alone", () => {
const state = { backgroundAudioArmed: false, autoPipEnabled: true };
expect(enteringPictureInPicture(state)).toEqual(state);
});
});
describe("inPictureInPicture", () => {
it("trusts the native flag when the two agree", () => {
expect(inPictureInPicture(true, true)).toBe(true);
expect(inPictureInPicture(false, false)).toBe(false);
});
it("treats a live PiP window as PiP even when the native flag says otherwise", () => {
// THE REPORTED BUG, half two. `isInPictureInPictureMode` is sampled once,
// inside onStop(). There are orderings -- the keyguard dismissing the
// window, the window being stashed, OEM variance in whether
// onPictureInPictureModeChanged(false) lands first -- where the activity
// is stopped with a PiP window still on screen and that single boolean
// reads false. Backgrounding then means "the app is gone" and the video
// the user is watching is handed off to audio.
expect(inPictureInPicture(false, true)).toBe(true);
});
it("does not resurrect a window the frontend has already seen close", () => {
// jellytau-pip-exited and jellytau-background are both posted to the same
// WebView message queue, in that order, so a genuine exit is always known
// by the time the background signal is handled. Leaving playback running
// here would be the opposite defect: audio continuing after the user
// closed the window and left the app.
expect(inPictureInPicture(false, false)).toBe(false);
});
});
});