With native video on, coming back from background audio left a black screen: a play overlay pinned at 0:00, a seek bar at zero, and a play button that did nothing. Nothing crashed — the process stayed up and the frontend kept logging — the transition was simply dropped. The two render paths resume by different means, and exitBackgroundAudioHandoff only ever performed one of them. The webview <video> reloads off its stream URL: an $effect watches it, reinitialises HLS or sets element.src, and canplay drives the seek and play. ExoPlayer owns no element and nothing watches the URL on its behalf — native playback is only ever started by an explicit player_play_item plus adapter load, which the component issues once, from onMount. So reassigning the URL restarted precisely nothing, and since player_exit_background_audio had already stopped the handoff's audio player, the backend came back holding no item at all. That is why the play button was inert: there was nothing loaded to play. The return now re-issues that pair on the native path, in the same order as the initial load, carrying the position the audio reached. Subtitle configurations are reused from the ones resolved at mount — ExoPlayer sideloads them as MediaItem.SubtitleConfigurations and cannot accept one after prepare(). Which path to take is decided by planHandoffReturn, a pure helper in backgroundAudioHandoff.ts, so the branch is unit-testable without mounting the player. It also folds in shouldResumeOnForeground, so a pause taken on the lockscreen during the handoff still wins over the snapshot captured on the way out. Verified on device (HONOR ROD2-W09, Android 16): handoff to audio-only at 69:54, return restored native video playing at 70:18. Previously the same sequence left the player idle and black. The requirements count pin in extract-traces.test.ts moves with the new DR-196.
140 lines
5.1 KiB
TypeScript
140 lines
5.1 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
computeHandoffPosition,
|
|
planHandoffReturn,
|
|
initialHandoffState,
|
|
shouldEnterBackgroundAudio,
|
|
shouldExitBackgroundAudio,
|
|
shouldResumeOnForeground,
|
|
type BackgroundAudioState,
|
|
} 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);
|
|
});
|
|
});
|
|
});
|