fix(player): restore the subtitle sidecar work dropped by the previous commit

The previous commit was assembled from a tree read before 13264e22 landed,
so committing it reverted that commit's changes: the image-based subtitle
filtering in device_profile/types, subtitleTracks and its tests, the
regenerated bindings, and the VideoPlayer menu wiring.

Nothing was lost — the working tree held both changes throughout. This
restores those files to the merged state, leaving both the subtitle fix and
the play-session fix in place.

TRACES: UR-020, UR-004 | DR-176 | UT-168
This commit is contained in:
2026-08-16 10:20:22 +02:00
parent 2d67b0e4f5
commit 5096c01960
9 changed files with 5442 additions and 4416 deletions
+8 -2
View File
@@ -16,6 +16,7 @@
import { videoFitClass } from "./videoFit";
import { fatalNetworkErrorAction } from "./hlsRecovery";
import {
subtitleStreamsOf,
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
@@ -333,13 +334,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. */