fix(player): re-entering a video no longer opens the audio player (DR-100)

Leaving a video and returning to it rendered the movie/episode in
AudioPlayer. Closing a webview-rendered video deliberately emits no
"stopped" state (that would break the autoplay handoff), and the
direct-play path does not stop the backend on unmount, so the Rust
controller still reported that item as its loaded media. Re-entering the
route therefore took the "already playing, just show the UI" shortcut,
which returns before a stream URL is fetched, and the render fell
through to the audio surface. Mostly visible on Android, where video
direct-plays; Linux transcodes and stops the backend on unmount.

Both decisions move into playerSurface.ts as pure functions:
shouldReuseActivePlayback excludes video, so video always takes the full
load path and gets its stream URL and resume position;
resolvePlayerSurface maps video-without-a-stream-URL to "pending"
(spinner) rather than falling through to audio.
This commit is contained in:
2026-08-03 18:12:37 +02:00
parent a26a853f01
commit a818fee297
6 changed files with 505 additions and 288 deletions
@@ -0,0 +1,107 @@
/**
* Regression tests for the `/player/[id]` surface decision.
*
* The bug these pin down: a video that was left and re-entered rendered in the
* AUDIO player. Exiting a webview-rendered video does not stop the Rust
* controller (`onReportStop` deliberately emits no `stopped` state, so the
* autoplay handoff survives), so the backend still reports that episode/movie as
* the loaded media. Re-entering the route therefore took the "already playing,
* just show the UI" shortcut, which returns *before* a stream URL is fetched —
* and the render then fell through to `<AudioPlayer>` because it treated
* "video without a stream URL" as audio.
*
* TRACES: UR-005 | DR-100 | UT-092, UT-093
*/
import { describe, it, expect } from "vitest";
import { shouldReuseActivePlayback, resolvePlayerSurface } from "./playerSurface";
describe("shouldReuseActivePlayback", () => {
it("reuses playback when the same audio track is already loaded", () => {
expect(
shouldReuseActivePlayback({
requestedId: "track-1",
activeMediaId: "track-1",
isVideo: false,
forceRestart: false,
})
).toBe(true);
});
it("does NOT reuse playback for video, even when the backend reports it loaded", () => {
// Video needs a full load: the shortcut skips fetching the stream URL, and
// <VideoPlayer> cannot render without one.
expect(
shouldReuseActivePlayback({
requestedId: "episode-1",
activeMediaId: "episode-1",
isVideo: true,
forceRestart: false,
})
).toBe(false);
});
it("does not reuse playback for a different item", () => {
expect(
shouldReuseActivePlayback({
requestedId: "track-2",
activeMediaId: "track-1",
isVideo: false,
forceRestart: false,
})
).toBe(false);
});
it("does not reuse playback when nothing is loaded", () => {
expect(
shouldReuseActivePlayback({
requestedId: "track-1",
activeMediaId: null,
isVideo: false,
forceRestart: false,
})
).toBe(false);
});
it("does not reuse playback when an explicit start position is requested", () => {
expect(
shouldReuseActivePlayback({
requestedId: "track-1",
activeMediaId: "track-1",
isVideo: false,
startPosition: 42,
forceRestart: false,
})
).toBe(false);
});
it("does not reuse playback when restarting (next-episode advance)", () => {
expect(
shouldReuseActivePlayback({
requestedId: "episode-2",
activeMediaId: "episode-2",
isVideo: true,
forceRestart: true,
})
).toBe(false);
});
});
describe("resolvePlayerSurface", () => {
it("renders the video surface for video with a stream URL", () => {
expect(resolvePlayerSurface({ isVideo: true, streamUrl: "http://s/master.m3u8" })).toBe(
"video"
);
});
it("renders the audio surface for audio content", () => {
expect(resolvePlayerSurface({ isVideo: false, streamUrl: null })).toBe("audio");
});
it("never renders video content in the audio surface when the stream URL is missing", () => {
// A video whose stream URL has not resolved yet is pending, not audio —
// otherwise the movie/episode shows up in the audio player.
expect(resolvePlayerSurface({ isVideo: true, streamUrl: null })).toBe("pending");
expect(resolvePlayerSurface({ isVideo: true, streamUrl: "" })).toBe("pending");
});
});
@@ -0,0 +1,63 @@
/**
* Pure decisions for the `/player/[id]` route: which player surface to render,
* and whether a load can be skipped because the backend is already playing the
* requested item.
*
* Kept free of Svelte so both can be unit-tested without mounting the route.
*
* TRACES: UR-005 | DR-100 | UT-092, UT-093
*/
/** Which player component the route should render. */
export type PlayerSurface = "video" | "audio" | "pending";
export interface ReuseActivePlaybackInput {
/** Item id the route was asked to play. */
requestedId: string;
/** Id of the media the backend currently reports as loaded, if any. */
activeMediaId: string | null | undefined;
/** Whether the requested item is video content. */
isVideo: boolean;
/** Explicit start position, if the caller asked for one. */
startPosition?: number;
/** Advancing to a next episode always restarts from the beginning. */
forceRestart: boolean;
}
/**
* Whether the route can show its UI over the backend's existing playback
* instead of reloading the item (e.g. expanding the audio mini player).
*
* Never for video. The shortcut returns before a stream URL is fetched, which
* is fine for audio (the backend owns the stream and the UI only mirrors it)
* but leaves `<VideoPlayer>` with nothing to render. Leaving a webview-rendered
* video does not clear the Rust controller's media — closing the route emits no
* `stopped` state by design — so re-entering the same movie/episode hit this
* shortcut and rendered the audio player instead.
*/
export function shouldReuseActivePlayback(input: ReuseActivePlaybackInput): boolean {
return (
!input.isVideo &&
input.activeMediaId === input.requestedId &&
!input.startPosition &&
!input.forceRestart
);
}
export interface PlayerSurfaceInput {
isVideo: boolean;
streamUrl: string | null;
}
/**
* Which surface to render for the loaded item.
*
* Video without a stream URL is `pending`, never `audio` — falling through to
* the audio player is how a movie/episode ended up in it.
*/
export function resolvePlayerSurface(input: PlayerSurfaceInput): PlayerSurface {
if (input.isVideo) {
return input.streamUrl ? "video" : "pending";
}
return "audio";
}