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:
2026-08-16 22:10:14 +02:00
parent 1285908733
commit 5e8efa252e
11 changed files with 792 additions and 416 deletions
+61 -1
View File
@@ -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),
};
}
+48 -1
View File
@@ -11,7 +11,10 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
import {
filterSupersededResumeItems,
filterInProgressNextUpItems,
} from "./continueWatchingFilter";
function episode(
id: string,
@@ -122,3 +125,47 @@ describe("filterSupersededResumeItems", () => {
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
});
describe("filterInProgressNextUpItems", () => {
it("drops the episode the viewer is mid-way through", () => {
// The same episode in both lists is the duplicate-row bug: an in-progress
// episode belongs to Continue Watching, never to Next Up.
const resume = [episode("s1e4", "series-a", 1, 4)];
const nextUp = [episode("s1e4", "series-a", 1, 4)];
expect(filterInProgressNextUpItems(nextUp, resume)).toEqual([]);
});
it("keeps the genuinely unstarted next episode", () => {
const resume = [episode("s1e4", "series-a", 1, 4)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterInProgressNextUpItems(nextUp, resume).map(i => i.id)).toEqual(["s1e5"]);
});
it("only suppresses the started episode, not the rest of the row", () => {
const resume = [episode("a-s1e4", "series-a", 1, 4)];
const nextUp = [
episode("a-s1e4", "series-a", 1, 4),
episode("b-s1e1", "series-b", 1, 1),
episode("c-s2e3", "series-c", 2, 3),
];
const result = filterInProgressNextUpItems(nextUp, resume);
expect(result.map(i => i.id)).toEqual(["b-s1e1", "c-s2e3"]);
});
it("is a no-op when nothing is in progress", () => {
const nextUp = [episode("s1e1", "series-a", 1, 1)];
expect(filterInProgressNextUpItems(nextUp, [])).toHaveLength(1);
});
it("ignores resume entries for other media", () => {
const resume = [movie("movie-1")];
const nextUp = [episode("s1e1", "series-a", 1, 1)];
expect(filterInProgressNextUpItems(nextUp, resume)).toHaveLength(1);
});
});
+27 -1
View File
@@ -9,7 +9,10 @@
// This is presentation-layer de-duplication over two lists the frontend already
// holds — no Jellyfin taxonomy involved, so it stays in `src/`.
//
// TRACES: UR-059 | DR-089
// The mirror image lives here too: an episode that is *in progress* belongs to
// Continue Watching and must not also headline Next Up.
//
// TRACES: UR-059 | DR-089, DR-196
import type { MediaItem } from "$lib/api/types";
/**
@@ -73,3 +76,26 @@ export function filterSupersededResumeItems(
return !isAheadOf(ahead, item);
});
}
/**
* Drop Next Up entries the viewer has already started.
*
* Jellyfin's `/Shows/NextUp` treats a partially-watched episode as its series'
* next up, so the same episode arrives in both lists and the two rows render
* identical cards. The backend asks the server to exclude those
* (`EnableResumable=false`), but servers predating that parameter ignore it
* so an episode present in the resume list is removed here as well. The split
* is then clean: Continue Watching offers unfinished episodes, Next Up offers
* unstarted ones.
*
* TRACES: UR-059 | DR-196
*/
export function filterInProgressNextUpItems(
nextUpItems: MediaItem[],
resumeItems: MediaItem[]
): MediaItem[] {
if (resumeItems.length === 0) return nextUpItems;
const inProgress = new Set(resumeItems.map(item => item.id));
return nextUpItems.filter(item => !inProgress.has(item.id));
}
+12 -5
View File
@@ -1,9 +1,12 @@
// Home screen data store - featured items, continue watching, recently added
// TRACES: UR-023, UR-024, UR-034, UR-059, UR-067 | DR-026, DR-027, DR-038, DR-039, DR-089, DR-118
// TRACES: UR-023, UR-024, UR-034, UR-059, UR-067 | DR-026, DR-027, DR-038, DR-039, DR-089, DR-118, DR-196
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
import {
filterSupersededResumeItems,
filterInProgressNextUpItems,
} from "./continueWatchingFilter";
interface HomeState {
heroItems: MediaItem[];
@@ -64,11 +67,15 @@ function createHomeStore() {
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
const rawResume = valueOr(0, [] as typeof initialState.resumeItems);
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
const rawNextUp = valueOr(1, [] as typeof initialState.nextUpItems);
// Drop episodes the user has already moved past (their series' Next Up
// points further ahead) so Continue Watching isn't cluttered with stale
// partial positions left behind by skipping.
const resume = filterSupersededResumeItems(rawResume, nextUp);
// partial positions left behind by skipping. The frontier is read from the
// unfiltered Next Up list, before in-progress entries are removed from it.
const resume = filterSupersededResumeItems(rawResume, rawNextUp);
// ...and the other way round: an episode already under way is Continue
// Watching's, so Next Episode only offers unstarted ones.
const nextUp = filterInProgressNextUpItems(rawNextUp, rawResume);
const latest = valueOr(2, [] as typeof initialState.latestItems);
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
+10 -4
View File
@@ -1,11 +1,14 @@
// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089, DR-196
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
import {
filterSupersededResumeItems,
filterInProgressNextUpItems,
} from "./continueWatchingFilter";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
@@ -63,7 +66,7 @@ function createTvStore() {
try {
const repo = auth.getRepository();
const [resume, nextUp, latest, surprise] = await Promise.all([
const [resume, rawNextUp, latest, surprise] = await Promise.all([
repo.getResumeItems(libraryId, SECTION_LIMIT),
repo.getNextUpEpisodes(undefined, SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
@@ -86,8 +89,11 @@ function createTvStore() {
// behind the series' Next Up entry isn't something to continue.
const continueWatching = filterSupersededResumeItems(
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
nextUp
rawNextUp
);
// And drop from Next Up the episodes that are already under way — those
// are Continue Watching's, or the two rows show the same cards.
const nextUp = filterInProgressNextUpItems(rawNextUp, resume);
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.