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
+8 -2
View File
@@ -1,6 +1,7 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
<script lang="ts">
import { onMount, onDestroy, untrack } from "svelte";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings";
import type { JRayActor } from "$lib/api/bindings";
@@ -14,7 +15,7 @@
import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition } from "$lib/stores/player";
import { playbackPosition, playerState } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
import { playerController } from "$lib/player";
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
@@ -42,6 +43,7 @@
initialHandoffState,
shouldEnterBackgroundAudio,
shouldExitBackgroundAudio,
shouldResumeOnForeground,
type BackgroundAudioState,
} from "./backgroundAudioHandoff";
@@ -1333,7 +1335,11 @@
// the position native reached, and restore play/pause.
async function exitBackgroundAudioHandoff() {
if (!shouldExitBackgroundAudio(handoffState)) return;
const wasPlaying = handoffState.wasPlaying;
// Read the native player's state BEFORE exiting — the exit stops it. If the
// user hit pause on the lockscreen while backgrounded, that pause must
// survive the return to video rather than being overwritten by whatever the
// <video> was doing when we handed off.
const wasPlaying = shouldResumeOnForeground(handoffState.wasPlaying, get(playerState).kind);
handoffState = { ...initialHandoffState };
try {
// Absolute position the native audio reached (base offset applied in Rust).
@@ -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);
});
});
});
@@ -55,3 +55,22 @@ export function shouldEnterBackgroundAudio(
export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean {
return state.active;
}
/**
* Whether the `<video>` should start playing again once it reloads on foreground.
*
* `wasPlaying` is what the video was doing when we handed off, but the native
* audio player kept going after that and the lockscreen/notification can pause
* it while backgrounded. The player is the authoritative source of play/pause,
* so an explicit `paused` from it overrides the handoff snapshot; anything less
* definite (loading, seeking, already-stopped, no state at all) falls back to
* the snapshot.
*
* TRACES: UR-040, UR-005 | DR-052 | UT-060
*/
export function shouldResumeOnForeground(
wasPlaying: boolean,
nativeStateKind: string | undefined
): boolean {
return wasPlaying && nativeStateKind !== "paused";
}