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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user