fix(player): make lockscreen transport reach background audio (DR-097)

Pausing from the lockscreen did nothing while a video's audio played in
the background. The handoff starts native ExoPlayer audio and only then
tears the WebView <video> down, and that teardown fires a DOM `pause`
the frontend reports like any other — leaving html5_playing = Some(false).
Transport therefore stayed aimed at the element: the lockscreen pause
emitted a ControlCommand into a <video> that no longer existed while the
native player carried on.

The controller now tracks a background-audio handoff explicitly. Entering
one hands transport authority to the native backend and drops the dying
element's state/position/media-loaded reports, which also stop flipping
the UI to paused and dragging the position backwards. Exiting restores
the element as the player.

A lockscreen pause also has to survive the return to the foreground: the
video used to resume from a snapshot taken at handoff time, undoing the
pause on the way back in. shouldResumeOnForeground() lets an explicit
`paused` from the player override that snapshot.

TRACES: UR-040, UR-005 | DR-052, DR-097
This commit is contained in:
2026-08-05 12:26:25 +02:00
parent 6aaa80ff92
commit 878ac5fa59
5 changed files with 196 additions and 4 deletions
@@ -4,6 +4,7 @@ import {
initialHandoffState,
shouldEnterBackgroundAudio,
shouldExitBackgroundAudio,
shouldResumeOnForeground,
type BackgroundAudioState,
} from "./backgroundAudioHandoff";
@@ -58,4 +59,27 @@ describe("backgroundAudioHandoff", () => {
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);
});
});
});