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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user