Selecting a subtitle on Android did nothing. The Kotlin side has been
complete for a long time — JellyTauPlayer.load() parses a subtitles JSON
array into MediaItem.SubtitleConfigurations and setSubtitleTrack() drives a
TrackSelectionOverride — but nothing ever reached it.
VideoPlayer built the list and then threw it away: it resolved every
subtitle stream's URL into a subtitleTracks array and the
commands.playerPlayItem({...}) call two lines below passed only streamUrl,
title, id, videoCodec and needsTranscoding. PlayItemRequest had no subtitle
field to put them in, so create_media_item hardcoded subtitles: vec![],
android/mod.rs serialized "[]" across JNI, and every MediaItem reached
ExoPlayer with zero SubtitleConfigurations. A later set_subtitle_track then
found no text track groups and logged "Invalid subtitle track index".
PlayItemRequest now carries the tracks (defaulted, so the background-audio
handoff and next-episode callers are unchanged) and create_media_item
threads them onto the MediaItem.
Serialization: SubtitleTrack is reused verbatim rather than given an
IPC-specific twin, and deliberately keeps snake_case. The same struct feeds
two consumers that both spell mime_type — the JNI JSON that
JellyTauPlayer.load() reads with optString("mime_type"), and the generated
binding the frontend types against. camelCasing it would not fail the build
or the IPC; Kotlin would silently fall back to its default MIME type for
every track. UT-146 asserts the exact serialized keys so a future
rename_all cannot pass unnoticed.
The index mapping was NOT already correct. setSubtitleTrack(n) indexes
ExoPlayer's filtered text track groups, i.e. the position of the sideloaded
configuration — but the menu passed its own {#each} row number, which counts
every subtitle *stream*, including ones whose URL failed to resolve and were
therefore never sideloaded. One failed URL and every track below it selected
the wrong subtitle. The position is now looked up in the exact array that
was sent (nativeSubtitleArrayIndex), and a stream that was never sent maps
to "off" rather than to a guessed position.
The resolution loop also reuses resolveSubtitleTracks() from the Linux fix
instead of duplicating it, which fans the URL requests out in parallel
rather than awaiting them one per stream before playback can start. The
awaits are safe where they sit: the native-mode pitfall is about Svelte
lifecycle calls after an await, and nothing is registered here — the
background-audio subscriptions above still run synchronously.
No Kotlin change was needed.
Tests (UT-145, UT-146, UT-147) were written first and failed: PlayItemRequest
had no subtitles field to compile against, nativeSubtitleTracks and
nativeSubtitleArrayIndex did not exist, and the playerPlayItem call carried
no subtitles key.
TRACES: UR-020 | IR-016, JA-008 | UT-145, UT-146, UT-147
228 lines
9.2 KiB
TypeScript
228 lines
9.2 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;
|
|
}
|
|
|
|
/** 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, in stream order. */
|
|
export function subtitleStreamsOf(
|
|
streams: readonly SubtitleStreamLike[] | null | undefined,
|
|
): SubtitleStreamLike[] {
|
|
if (!streams) return [];
|
|
return streams.filter((s) => s.kind === "subtitle");
|
|
}
|
|
|
|
/** 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;
|
|
}
|