fix(player): never let the server burn a subtitle in, and never offer one we cannot draw

Reported as "subtitles are shown even when off", and no toggle in the app
cleared them — because they were not the app's subtitles at all. The server was
painting them into the video.

`PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none": the
server then honours the source's own default/forced flag. On the reported
episode that default is a PGS track — a bitmap, which cannot go out as a
sidecar — so the server fell back to `SubtitleMethod=Encode` and composited it
onto every frame. Confirmed against the live server, which answered the same
PlaybackInfo request two ways: with the index omitted it returned
`SubtitleStreamIndex=2` + `SubtitleMethod=Encode` and a
`SubtitleCodecNotSupported` transcode reason, and its ffmpeg command carried
`[0:2]…[sub];[main][sub]overlay_qsv=…`; with `-1` it selected no subtitle stream
at all. The cost landed on the video, not the subtitle: burn-in rules out
remuxing, so a stream that only needed its audio transcoded was re-encoded frame
by frame.

Three parts:

- The negotiation asks for `SubtitleStreamIndex=-1` and advertises every text
  format we can render (srt/subrip/ass/ssa/vtt) as `External`.
- The stream URL says the same thing, because the negotiation is not what opens
  most streams: a quality switch, a transcoded seek and an audio-track switch
  each rebuild the URL on their own, and an omitted index there lets the server
  pick the default track back up out of whatever session state it still holds.
- The picker offers only subtitles the app can actually draw. Each subtitle
  stream now crosses the boundary carrying `supports_external_delivery`, decided
  in Rust where the codec vocabulary belongs, and `None` for anything that is
  not a subtitle so a `false` cannot be misread as a verdict.
  `subtitleStreamsOf()` drops the rejected ones — and since that one function
  feeds the menu, the `<track>` children and the native play request alike, a
  bitmap track disappears from all three without its URL ever being fetched.
  Only an explicit "no" hides a track; a stream carrying no verdict behaves
  exactly as before.

Nothing is lost by refusing burn-in: the app already fetches the text tracks and
draws them itself (UR-020), so the server's composited copy was always
redundant. Image-based tracks are consequently not offered, which is honest
rather than a regression — the renderer cannot composite a bitmap, and the old
behaviour paid for them by making the whole stream unwatchable.

Tests were written first and observed failing: the Rust one would not compile
against a field that did not exist, and the frontend one resolved a URL for the
PGS track it was supposed to drop.

Carries with it the in-flight per-stream `PlaySessionId` work in online.rs,
whose hunks sit inside the same request builder and could not be separated from
these.

TRACES: UR-020, UR-004 | DR-176 | UT-168
This commit is contained in:
2026-08-16 09:47:09 +02:00
parent 041969f446
commit 13264e225b
9 changed files with 5690 additions and 4421 deletions
+14 -1
View File
@@ -2235,7 +2235,20 @@ type: string;
/**
* Provider-neutral stream classification — replaces `stream_type`.
*/
kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean }
kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean;
/**
* Whether this stream can reach the app as a sidecar it renders itself.
*
* `None` for anything that is not a subtitle — the question does not apply,
* and `false` there would read like a verdict. For a subtitle it is the
* difference between a track the app can draw and one only the server could
* have shown, by burning it into the picture (DR-176) — which this app never
* asks it to do. The vocabulary of *which formats those are* stays in Rust;
* the frontend only reads the answer.
*
* TRACES: UR-020 | DR-176 | UT-168
*/
supportsExternalDelivery?: boolean | null }
export type MediaType = "audio" | "video"
/**
* Lightweight media item for merged playback state
+8 -2
View File
@@ -15,6 +15,7 @@
import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit";
import {
subtitleStreamsOf,
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
@@ -332,13 +333,18 @@
}
}
// Get available subtitle tracks from media streams
// The subtitle streams the menu offers — the same list the <track> children
// and the native play request are built from, so the menu can never name a
// track the player was never given. subtitleStreamsOf() also drops the ones
// the backend says it cannot deliver as a sidecar (image-based PGS/DVD/DVB,
// which only server burn-in could show and we never ask for — DR-176).
// TRACES: UR-020 | DR-176 | UT-168
const subtitleTracks = $derived(() => {
if (!media || !media.mediaStreams) {
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
return [];
}
const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle");
const tracks = subtitleStreamsOf(media.mediaStreams);
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
return tracks;
});
@@ -53,6 +53,68 @@ describe("subtitleStreamsOf", () => {
expect(subtitleStreamsOf(null)).toEqual([]);
expect(subtitleStreamsOf(undefined)).toEqual([]);
});
/**
* A subtitle the app cannot draw must not reach the picker. Image-based
* tracks (PGS/DVD/DVB) are bitmaps: the only way to show one is for the server
* to composite it into the video, which this app deliberately never asks for
* (DR-176). Offering it anyway produced the reported symptom's twin a menu
* entry that selects, ticks, and shows nothing.
*
* The verdict is the backend's (`supportsExternalDelivery`); the codec
* vocabulary behind it stays in Rust.
*
* TRACES: UR-020 | DR-176 | UT-168
*/
it("drops subtitles the backend says it cannot deliver as a sidecar", () => {
const streams: SubtitleStreamLike[] = [
{ index: 2, kind: "subtitle", displayTitle: "English PGS SDH", supportsExternalDelivery: false },
{ index: 3, kind: "subtitle", displayTitle: "English Text SDH", supportsExternalDelivery: true },
];
expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([3]);
});
/**
* Only an explicit "no" hides a track. A stream that carries no verdict at all
* predates the field (or came from somewhere that does not set it), and
* hiding those would silently empty the menu for sources that work today.
*
* TRACES: UR-020 | DR-176 | UT-168
*/
it("keeps subtitles that carry no verdict", () => {
const streams: SubtitleStreamLike[] = [
{ index: 2, kind: "subtitle", displayTitle: "English" },
{ index: 3, kind: "subtitle", displayTitle: "French", supportsExternalDelivery: null },
];
expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([2, 3]);
});
/**
* The same list feeds the `<track>` children and the native play request, so
* an undeliverable track must not even have its URL fetched that request is
* the one that 404s, and the sideloaded track it would produce is the dead
* entry all over again.
*
* TRACES: UR-020 | DR-176 | UT-168
*/
it("never resolves a URL for a subtitle it dropped", async () => {
const asked: number[] = [];
const tracks = await resolveSubtitleTracks(
[
{ index: 2, kind: "subtitle", displayTitle: "PGS", supportsExternalDelivery: false },
{ index: 3, kind: "subtitle", displayTitle: "SRT", supportsExternalDelivery: true },
],
async (index) => {
asked.push(index);
return url(index);
},
);
expect(asked).toEqual([3]);
expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
});
});
describe("subtitleTrackLabel", () => {
+32 -5
View File
@@ -32,6 +32,13 @@ export interface SubtitleStreamLike {
displayTitle?: string | null;
isDefault?: boolean;
isForced?: boolean;
/**
* The backend's verdict on whether this track can arrive as a sidecar the app
* renders itself. `false` means only the server could have shown it, by
* burning it into the picture which the app never asks for. Absent means no
* verdict was given, which is not the same as "no".
*/
supportsExternalDelivery?: boolean | null;
}
/** A subtitle stream whose URL resolved — i.e. one we can actually render. */
@@ -46,12 +53,32 @@ export interface RenderableSubtitleTrack {
isDefault: boolean;
}
/** Subtitle streams of a media item, in stream order. */
export function subtitleStreamsOf(
streams: readonly SubtitleStreamLike[] | null | undefined,
): SubtitleStreamLike[] {
/**
* Subtitle streams of a media item that the app can actually show, in stream
* order. This is the one list behind everything: the picker, the `<track>`
* children, and the array sent to the native backend.
*
* Image-based subtitles (PGS/DVD/DVB) are filtered out here rather than at each
* consumer. They are bitmaps a client can only display one if the server
* composites it into the video, and the app deliberately asks for no burn-in at
* all (DR-176), so such a track is one it can never draw. Leaving it in the
* picker produced a control that ticked and showed nothing.
*
* The judgement is the backend's: `supportsExternalDelivery` arrives already
* decided, because *which formats are bitmaps* is domain vocabulary and belongs
* in Rust. Only an explicit `false` drops a stream; a stream carrying no verdict
* is kept, so a source that never sets the field behaves exactly as before.
*
* Generic in the stream type so callers keep their own richer fields (the menu
* reads `codec` off the result).
*
* TRACES: UR-020 | DR-176 | UT-168
*/
export function subtitleStreamsOf<T extends SubtitleStreamLike>(
streams: readonly T[] | null | undefined,
): T[] {
if (!streams) return [];
return streams.filter((s) => s.kind === "subtitle");
return streams.filter((s) => s.kind === "subtitle" && s.supportsExternalDelivery !== false);
}
/** Human label for a subtitle stream, matching the menu's own fallback chain. */