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
254 lines
9.5 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|