Files
jellytau/src/lib/components/player/backgroundAudioHandoff.test.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

254 lines
9.5 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);
});
// An episode that ends while backgrounded advances in the backend. Reloading
// the video the player was mounted with brought back the PREVIOUS episode,
// at the new episode's timestamp.
//
// TRACES: UR-040, UR-023 | DR-296 | UT-265
it("switches to the item the backend advanced to", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 95,
wasPlaying: true,
nativeStateKind: "playing",
mountedItemId: "ep1",
resumeItemId: "ep2",
});
expect(plan.target).toBe("other-item");
expect(plan.itemId).toBe("ep2");
expect(plan.position).toBe(95);
});
it("reloads in place when the backend is still on the mounted item", () => {
const plan = planHandoffReturn({
useHtml5Element: true,
position: 95,
wasPlaying: true,
nativeStateKind: "playing",
mountedItemId: "ep1",
resumeItemId: "ep1",
});
expect(plan.target).toBe("html5-element");
});
it("reloads in place when the backend reports no item", () => {
// Queue emptied while backgrounded (e.g. the sleep timer): there is no
// other item to go to, so the mounted one is the best we have.
const plan = planHandoffReturn({
useHtml5Element: false,
position: 95,
wasPlaying: false,
nativeStateKind: undefined,
mountedItemId: "ep1",
resumeItemId: null,
});
expect(plan.target).toBe("native-backend");
});
});
});
/**
* 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);
});
});
});