fix(player): restart the native renderer when returning from background audio
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.
This commit is contained in:
@@ -72,6 +72,7 @@
|
||||
shouldEnterBackgroundAudio,
|
||||
shouldExitBackgroundAudio,
|
||||
shouldResumeOnForeground,
|
||||
planHandoffReturn,
|
||||
type BackgroundAudioState,
|
||||
} from "./backgroundAudioHandoff";
|
||||
|
||||
@@ -1736,7 +1737,17 @@
|
||||
hasPerformedInitialSeek = true;
|
||||
lastAppliedInitialPosition = initialPosition;
|
||||
|
||||
pendingForegroundPlay = wasPlaying;
|
||||
// How to come back depends on which renderer is actually on screen. See
|
||||
// planHandoffReturn: the webview element resumes off its stream URL, the
|
||||
// native backend only ever resumes off an explicit load.
|
||||
const plan = planHandoffReturn({
|
||||
useHtml5Element,
|
||||
position: pos,
|
||||
wasPlaying,
|
||||
nativeStateKind: get(playerState).kind,
|
||||
});
|
||||
|
||||
pendingForegroundPlay = plan.shouldPlay;
|
||||
|
||||
// Determine the target URL + how the element/offset should be positioned.
|
||||
let targetUrl: string;
|
||||
@@ -1761,6 +1772,55 @@
|
||||
pendingForegroundSeek = pos;
|
||||
}
|
||||
|
||||
if (plan.target === "native-backend" && media) {
|
||||
// ExoPlayer has no element and nothing watches the stream URL for it, so
|
||||
// the URL dance below would restart precisely nothing — which is exactly
|
||||
// what shipped: the backend came back from the handoff holding no item,
|
||||
// leaving a black screen with a play overlay stuck at 0:00 and a play
|
||||
// button that did nothing (there was nothing loaded to play).
|
||||
//
|
||||
// Re-issue the same pair the initial load does, in the same order:
|
||||
// player_play_item hands ExoPlayer the item and its sideloaded subtitle
|
||||
// configurations (which cannot be added after prepare()), then the
|
||||
// adapter load carries the resume position. `sentSubtitleTracks` was
|
||||
// resolved during onMount for this same item, so it is reused rather
|
||||
// than re-fetched.
|
||||
//
|
||||
// TRACES: UR-040, UR-003 | DR-196
|
||||
currentStreamUrl = targetUrl;
|
||||
await commands.playerPlayItem({
|
||||
streamUrl: targetUrl,
|
||||
title: media.name,
|
||||
id: media.id,
|
||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||
needsTranscoding,
|
||||
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
|
||||
});
|
||||
didStartNativePlayback = true;
|
||||
await playerAdapter?.load(targetUrl, {
|
||||
mediaId: media.id,
|
||||
mediaSourceId: mediaSourceId ?? null,
|
||||
needsTranscoding,
|
||||
initialPosition: plan.position,
|
||||
isLive,
|
||||
audioTrackIndex: selectedAudioTrackIndex ?? null,
|
||||
knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
|
||||
subtitleTracks: sentSubtitleTracks.map((t) => ({
|
||||
index: t.streamIndex,
|
||||
url: t.url,
|
||||
language: t.srclang,
|
||||
label: t.label,
|
||||
mimeType: "text/vtt",
|
||||
})),
|
||||
});
|
||||
currentTime = plan.position;
|
||||
// The load starts playing; honour a pause taken on the lockscreen.
|
||||
if (!plan.shouldPlay) {
|
||||
await playerController.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Force the HLS-init $effect to re-run even if the URL string is unchanged:
|
||||
// blank it first, then set it on the next microtask so Svelte sees a real
|
||||
// transition. Without this, assigning the same value is a no-op and the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeHandoffPosition,
|
||||
planHandoffReturn,
|
||||
initialHandoffState,
|
||||
shouldEnterBackgroundAudio,
|
||||
shouldExitBackgroundAudio,
|
||||
@@ -82,4 +83,57 @@ describe("backgroundAudioHandoff", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,3 +74,50 @@ export function shouldResumeOnForeground(
|
||||
): boolean {
|
||||
return wasPlaying && nativeStateKind !== "paused";
|
||||
}
|
||||
|
||||
/** What has to be restarted to put picture back on screen, and how. */
|
||||
export interface HandoffReturn {
|
||||
/** Which renderer must be brought back. */
|
||||
target: "html5-element" | "native-backend";
|
||||
/** Absolute position the background audio reached. */
|
||||
position: number;
|
||||
/** Whether playback should be running once it is back. */
|
||||
shouldPlay: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* How to come back when the app returns to the foreground.
|
||||
*
|
||||
* The two render paths resume by completely different means, and conflating
|
||||
* them is what broke the native one:
|
||||
*
|
||||
* - **html5-element** — assigning the stream URL is enough. An `$effect` in the
|
||||
* component watches it, (re)initialises HLS or sets `videoElement.src`, and
|
||||
* `canplay` then drives the seek and play.
|
||||
* - **native-backend** — ExoPlayer owns no element, and nothing reacts to the
|
||||
* stream URL on its behalf. Native playback is only ever started by an
|
||||
* explicit backend load, which the component issues once, from `onMount`. So
|
||||
* the return has to re-issue it; reassigning the URL restarts nothing.
|
||||
*
|
||||
* The component previously did only the URL assignment, for both paths. On the
|
||||
* native path that left the backend holding no item at all: a black screen with
|
||||
* a play overlay, a play button that did nothing, and the position pinned at
|
||||
* 0:00 — the handoff's own audio player having been stopped on the way out.
|
||||
*
|
||||
* `shouldPlay` folds in [shouldResumeOnForeground], so a lockscreen pause during
|
||||
* the handoff still wins over the snapshot taken on the way out.
|
||||
*
|
||||
* TRACES: UR-040, UR-003 | DR-196 | UT-060
|
||||
*/
|
||||
export function planHandoffReturn(opts: {
|
||||
useHtml5Element: boolean;
|
||||
position: number;
|
||||
wasPlaying: boolean;
|
||||
nativeStateKind: string | undefined;
|
||||
}): HandoffReturn {
|
||||
return {
|
||||
target: opts.useHtml5Element ? "html5-element" : "native-backend",
|
||||
position: opts.position > 0 ? opts.position : 0,
|
||||
shouldPlay: shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user