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
+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.