Files
jellytau/src/lib/components/player/subtitleTracks.ts
T
dtourolle 5096c01960 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
2026-08-16 10:20:22 +02:00

255 lines
11 KiB
TypeScript

// Subtitle plumbing for the Linux / WebKitGTK HTML5 `<video>` playback path.
//
// Extracted from VideoPlayer.svelte so it is unit-testable, and because the
// original inline version hid a fatal mistake in plain sight: `getSubtitleUrl()`
// is async, so `src={getSubtitleUrl(track.index)}` bound a *Promise* to the
// attribute and every `<track>` pointed at "[object Promise]". The whole block
// was commented out rather than fixed, which left `<video>` with no text tracks
// at all — `Html5PlayerAdapter.selectSubtitle()` then iterated an empty
// `textTracks` list and the subtitle menu silently did nothing.
//
// The rule this module enforces: URLs are resolved to plain strings *here*, off
// the render path, and only tracks that actually resolved are handed to the
// markup.
//
// The Android / ExoPlayer native path shares this module (see
// nativeSubtitleTracks / nativeSubtitleArrayIndex at the bottom): it needs the
// exact same "resolve the URLs first, keep only what resolved" list, just handed
// to Rust instead of to `<track>` elements.
//
// TRACES: UR-020 | DR-023, IR-016 | UT-143, UT-144, UT-147
import type { SubtitleTrack } from "$lib/api/bindings";
/**
* The subset of `MediaStream` (from the generated bindings) this module needs.
* Kept structural so tests do not have to build full binding objects.
*/
export interface SubtitleStreamLike {
index: number;
kind?: string | null;
language?: string | null;
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. */
export interface RenderableSubtitleTrack {
/** Jellyfin media-stream index; the adapter matches `data-stream-index`. */
streamIndex: number;
/** Fully resolved WebVTT URL. Always a string, never a Promise. */
url: string;
srclang: string;
label: string;
/** Server's "default" flag — shown in the menu, never auto-enabled. */
isDefault: boolean;
}
/**
* 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" && s.supportsExternalDelivery !== false);
}
/** Human label for a subtitle stream, matching the menu's own fallback chain. */
export function subtitleTrackLabel(stream: SubtitleStreamLike): string {
return stream.displayTitle || stream.language || `Track ${stream.index}`;
}
/** A src we are willing to put on a `<track>`: a non-blank plain string. */
function isRenderableUrl(url: unknown): url is string {
return typeof url === "string" && url.trim().length > 0;
}
/**
* Resolve every subtitle stream's URL and return only the tracks that can be
* rendered. `resolveUrl` failures are swallowed per track: one unavailable
* subtitle must not cost the user the others, and a dead `src` on a media
* element is exactly what made this block get disabled in the first place.
*/
export async function resolveSubtitleTracks(
streams: readonly SubtitleStreamLike[] | null | undefined,
resolveUrl: (streamIndex: number) => Promise<string>,
): Promise<RenderableSubtitleTrack[]> {
const subtitles = subtitleStreamsOf(streams);
if (subtitles.length === 0) return [];
const resolved = await Promise.all(
subtitles.map(async (stream) => {
try {
const url = await resolveUrl(stream.index);
if (!isRenderableUrl(url)) return null;
return {
streamIndex: stream.index,
url,
srclang: stream.language || "und",
label: subtitleTrackLabel(stream),
isDefault: stream.isDefault === true,
} satisfies RenderableSubtitleTrack;
} catch {
return null;
}
}),
);
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
}
/**
* The selection to keep once the rendered track list changes.
*
* Subtitles are OFF unless the user turns them on: `null` in, `null` out. The
* server's `isDefault` flag is deliberately NOT promoted to a selection (and the
* markup deliberately omits the `default` attribute, which would auto-show the
* track) — the menu opens on "Off", so auto-enabling would make the UI lie about
* what is on screen, and it would change behaviour for every user who has never
* asked for subtitles.
*
* A selection that is no longer renderable (new item, or a URL that failed to
* resolve) collapses to off, so the menu's checkmark can never point at a track
* that does not exist on the element.
*/
export function reconcileSelectedSubtitle(
tracks: readonly RenderableSubtitleTrack[],
selected: number | null,
): number | null {
if (selected === null) return null;
return tracks.some((t) => t.streamIndex === selected) ? selected : null;
}
function originOf(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return parsed.origin;
} catch {
return null;
}
}
/**
* The `crossorigin` value for the `<video>` element, or undefined for none.
*
* Text-track fetches are CORS-enabled per the HTML spec and use the *media
* element's* CORS setting, so a cross-origin `<track>` never loads unless the
* element opts in. The webview page's origin is `tauri://localhost`, so every
* subtitle served by Jellyfin is cross-origin.
*
* Opting in is only safe when the media itself comes from an http(s) server —
* the same Jellyfin that already answers hls.js' cross-origin XHRs, so we know
* it sends the headers. For a local/offline source (`file:`/`asset:`) we leave
* the attribute off: subtitles staying dark there is the status quo, whereas
* forcing CORS onto the video fetch could break playback outright.
*
* Deliberately keyed on the *count of subtitle streams* rather than on the
* resolved tracks: both inputs are known at first render, so the attribute is
* decided before the element starts loading and never flips underneath an
* in-flight media fetch.
*/
export function videoCrossOriginMode(
streamUrl: string,
subtitleStreamCount: number,
): "anonymous" | undefined {
if (subtitleStreamCount <= 0) return undefined;
return originOf(streamUrl) ? "anonymous" : undefined;
}
// ===== Native (Android / ExoPlayer) path ====================================
//
// The HTML5 element gets `<track>` children; the native backend instead gets the
// list *up front*, as part of the play request, because ExoPlayer sideloads
// subtitles as `MediaItem.SubtitleConfiguration`s that must exist before
// `prepare()`. There is no "add a subtitle later" — a track absent from the
// MediaItem simply does not exist as far as the player is concerned.
/**
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
*
* The element type is the *generated* `SubtitleTrack` binding on purpose, so
* `bun run check` fails if the Rust struct's field names ever move. In
* particular `mime_type` is snake_case and must stay that way: the very same
* bytes are re-serialized across JNI in `player/android/mod.rs`, and
* `JellyTauPlayer.load()` reads `optString("mime_type")`. Renaming it to
* `mimeType` would not error anywhere — Kotlin would just silently fall back to
* its default MIME type for every track.
*
* Jellyfin is asked for every subtitle stream as WebVTT (see
* `getSubtitleUrl(..., "vtt")`), so the MIME type is fixed rather than derived
* from the source subtitle codec.
*
* TRACES: UR-020 | IR-016, JA-008 | UT-147
*/
export function nativeSubtitleTracks(
tracks: readonly RenderableSubtitleTrack[],
): SubtitleTrack[] {
return tracks.map((track) => ({
index: track.streamIndex,
url: track.url,
// `srclang` carries "und" for a stream with no language, which is the right
// value for a `<track>` but is not a language the native side should claim.
language: track.srclang === "und" ? null : track.srclang,
label: track.label,
mime_type: "text/vtt",
}));
}
/**
* The argument for `player_set_subtitle_track` on the native backend.
*
* 🔴 This is **not** the Jellyfin stream index.
* `JellyTauPlayer.setSubtitleTrack(n)` filters ExoPlayer's track groups down to
* `C.TRACK_TYPE_TEXT` and indexes that list with `n`, so `n` is the *position of
* the sideloaded subtitle configuration* — which is the position in the array
* that `nativeSubtitleTracks()` produced and `playerPlayItem` sent.
*
* The menu's own row number is not that position: the menu lists every subtitle
* *stream*, while only the streams whose URL resolved are sent. One failed URL
* and everything below it selects the wrong subtitle. So the index is looked up
* in the sent list instead of being passed down from the `{#each}`.
*
* `null` (the menu's "Off") stays `null`, which the backend turns into -1 and
* Kotlin turns into "disable text tracks". A stream that was never sent also
* maps to `null`: disabling subtitles is a truthful outcome, whereas guessing a
* position would show the user a different language than the one they clicked.
*
* TRACES: UR-020 | IR-016 | UT-147
*/
export function nativeSubtitleArrayIndex(
tracks: readonly RenderableSubtitleTrack[],
streamIndex: number | null,
): number | null {
if (streamIndex === null) return null;
const position = tracks.findIndex((t) => t.streamIndex === streamIndex);
return position === -1 ? null : position;
}