fix(player): send subtitle tracks to ExoPlayer on Android (UR-020)

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
This commit is contained in:
2026-08-11 20:03:19 +02:00
parent 211792947d
commit 6a712c46cb
9 changed files with 1566 additions and 909 deletions
+77 -1
View File
@@ -12,7 +12,14 @@
// the render path, and only tracks that actually resolved are handed to the
// markup.
//
// TRACES: UR-020 | DR-023 | UT-143, UT-144
// 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.
@@ -149,3 +156,72 @@ export function videoCrossOriginMode(
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;
}