fix(player): render subtitle tracks on the Linux HTML5 path (UR-020)

Selecting a subtitle on Linux did nothing. VideoPlayer rendered no <track>
children at all — the block was commented out as "temporarily disabled to
debug playback issues" (it has been that way since the POC) — so
Html5PlayerAdapter.selectSubtitle() walked an empty textTracks list and the
menu, which is built from media.mediaStreams, was purely decorative.

The reason it had to be disabled is still visible in the dead markup:
getSubtitleUrl() is async, so src={getSubtitleUrl(track.index)} bound a
Promise to the attribute and every track pointed at "[object Promise]" — an
unloadable resource hanging off the media element.

Subtitle URLs are now resolved off the render path into component state
(subtitleTracks.ts), and only streams whose URL actually resolved are
rendered; a per-track failure drops that track instead of emitting a dead
src. data-stream-index is kept, since that is what the adapter matches on.

Subtitles stay OFF unless the user asks for them: the server's isDefault flag
is shown in the menu but is never promoted to a selection, and the `default`
attribute is deliberately not emitted. A <track default> auto-shows, so the
menu would open on "Off" while subtitles were burned over the picture, and
every user who never wanted subtitles would suddenly get them. That matches
the existing initial state (selectedSubtitleIndex = null).

Selection and rendered tracks are reconciled whenever the list changes: a
selection that no longer resolves collapses to "Off", and a surviving one is
re-applied after the new <track> elements exist. "Off" disables every text
track, as before.

Cross-origin text-track fetches use the media element's CORS setting, so the
element opts in with crossorigin="anonymous" — but only for an http(s)
stream, never for a local/offline file:/asset: source, where forcing CORS
onto the video fetch could break playback. It is keyed on the subtitle stream
count, known at first render, so the attribute cannot flip under an in-flight
media load.

Android/native is untouched: the ExoPlayer branch still goes through
player_set_subtitle_track.

Tests (UT-143, UT-144) were written first and failed against the old markup:
the commented-out block, the Promise bound to src, and the default attribute.

TRACES: UR-020 | DR-023 | UT-143, UT-144
This commit is contained in:
2026-08-11 19:25:39 +02:00
parent 2c3955914e
commit 211792947d
5 changed files with 755 additions and 307 deletions
@@ -0,0 +1,180 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
subtitleStreamsOf,
subtitleTrackLabel,
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
type SubtitleStreamLike,
} from "./subtitleTracks";
/**
* Subtitles on the Linux / WebKitGTK HTML5 `<video>` path.
*
* TRACES: UR-020 | DR-023 | UT-143, UT-144
*
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the
* block was commented out "to debug playback issues"), so
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
* the subtitle menu was inert on Linux. The reason it had to be disabled is
* visible in the original markup — `src={getSubtitleUrl(track.index)}` bound the
* *Promise* returned by an async function to the attribute, so every track's src
* stringified to "[object Promise]", an unloadable resource hanging off the
* media element.
*
* So the fix has two halves and both are tested here: URLs must be resolved into
* plain strings *before* they reach the markup, and the markup must actually
* render the tracks (with the `data-stream-index` the adapter matches on).
*/
const SUBS: SubtitleStreamLike[] = [
{ index: 2, kind: "subtitle", language: "eng", displayTitle: "English (SRT)", isDefault: true },
{ index: 3, kind: "subtitle", language: "fre", displayTitle: "French", isDefault: false },
];
const STREAMS: SubtitleStreamLike[] = [
{ index: 0, kind: "video", language: null, displayTitle: "1080p" },
{ index: 1, kind: "audio", language: "eng", displayTitle: "English AAC" },
...SUBS,
];
const url = (i: number) => `http://jelly.example/Videos/x/Subtitles/${i}/0/subtitles.vtt?api_key=k`;
describe("subtitleStreamsOf", () => {
it("keeps only subtitle streams, in stream order", () => {
expect(subtitleStreamsOf(STREAMS).map((s) => s.index)).toEqual([2, 3]);
});
it("tolerates missing media streams", () => {
expect(subtitleStreamsOf(null)).toEqual([]);
expect(subtitleStreamsOf(undefined)).toEqual([]);
});
});
describe("subtitleTrackLabel", () => {
it("prefers the display title, then language, then the index", () => {
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe("English (SRT)");
expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng");
expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
});
});
describe("resolveSubtitleTracks", () => {
it("resolves real string URLs — never a Promise — for every subtitle stream", async () => {
const tracks = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
expect(tracks).toHaveLength(2);
for (const track of tracks) {
expect(typeof track.url).toBe("string");
// The exact regression: a Promise bound to src stringifies to this.
expect(String(track.url)).not.toContain("[object Promise]");
expect(track.url).toContain("subtitles.vtt");
}
// The adapter matches <track> elements by data-stream-index, so the stream
// index has to survive resolution.
expect(tracks.map((t) => t.streamIndex)).toEqual([2, 3]);
expect(tracks.map((t) => t.label)).toEqual(["English (SRT)", "French"]);
expect(tracks.map((t) => t.srclang)).toEqual(["eng", "fre"]);
expect(tracks[0].isDefault).toBe(true);
});
it("drops tracks whose URL cannot be built instead of rendering a dead src", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => {
if (i === 2) throw new Error("no repository");
return url(i);
});
expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
});
it("drops empty and non-string URLs", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) =>
i === 2 ? " " : (undefined as unknown as string),
);
expect(tracks).toEqual([]);
});
it("returns nothing when there are no subtitle streams", async () => {
expect(await resolveSubtitleTracks([STREAMS[0]], async (i) => url(i))).toEqual([]);
expect(await resolveSubtitleTracks(null, async (i) => url(i))).toEqual([]);
});
});
describe("reconcileSelectedSubtitle", () => {
it("starts off (null) and keeps 'off' selectable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
it("keeps a selection that is still renderable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
});
it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
expect(reconcileSelectedSubtitle([], 3)).toBeNull();
});
it("never auto-selects the server's default track", async () => {
// The menu opens on "Off" and a <track default> would auto-show, so the UI
// would claim subtitles are off while they are burned over the picture.
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(tracks[0].isDefault).toBe(true);
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
});
describe("videoCrossOriginMode", () => {
it("opts into CORS for a server stream that has subtitles", () => {
expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
});
it("leaves a local/offline source alone so playback cannot regress", () => {
expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
});
it("stays out of the way when there is nothing to load", () => {
expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
expect(videoCrossOriginMode("", 0)).toBeUndefined();
});
it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
// Same answer before and after the async URL resolution completes.
const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
expect(before).toBe(after);
});
});
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
const source = readFileSync(
resolve(__dirname, "VideoPlayer.svelte"),
"utf-8",
);
it("renders <track> elements instead of leaving them commented out", () => {
expect(source).not.toContain("Temporarily disabled to debug playback issues");
expect(source).toMatch(/<track\b/);
expect(source).toContain('kind="subtitles"');
});
/** The rendered element, not a `<track>` mentioned in prose. */
const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);
it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
expect(trackElement).toContain("data-stream-index");
});
it("never binds the async getSubtitleUrl() Promise to src", () => {
expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
});
it("does not mark any track default (a default track auto-shows)", () => {
expect(trackElement).not.toMatch(/\bdefault=/);
});
});