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
+3
View File
@@ -544,6 +544,9 @@ Internal architecture, components, and application logic.
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done | | UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done | | UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done | | UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
### Integration Tests ### Integration Tests
+1089 -873
View File
File diff suppressed because it is too large Load Diff
+160 -1
View File
@@ -202,6 +202,27 @@ pub struct PlayItemRequest {
/// look up the next episode when a background-audio track ends. /// look up the next episode when a background-audio track ends.
#[serde(default)] #[serde(default)]
pub series_id: Option<String>, pub series_id: Option<String>,
/// Subtitle tracks to sideload, with URLs the frontend has already resolved.
///
/// Only the native backends use these: on Android they become the
/// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
/// builds its own `<track>` children instead and ignores this list.
///
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
/// *text track groups* — i.e. the position of the sideloaded configuration,
/// not the Jellyfin stream index (which is kept on each entry for the UI's
/// benefit). So `n` must be a position in this very array, and the array
/// must not be reordered or filtered between building it and sending it.
/// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
/// list that is sent here, for exactly this reason.
///
/// Defaulted so the background-audio handoff and the autoplay/next-episode
/// callers, which have no subtitles to offer, need not send the field.
///
/// TRACES: UR-020 | IR-016, JA-008 | UT-145
#[serde(default)]
pub subtitles: Vec<crate::player::SubtitleTrack>,
} }
/// Queue context for remote transfer - what type of queue is this? /// Queue context for remote transfer - what type of queue is this?
@@ -373,7 +394,10 @@ pub(super) async fn create_media_item(
needs_transcoding: req.needs_transcoding, needs_transcoding: req.needs_transcoding,
video_width: None, // Not available from video-only request video_width: None, // Not available from video-only request
video_height: None, // Not available from video-only request video_height: None, // Not available from video-only request
subtitles: vec![], // Sideloaded subtitles, in the order the frontend sent them — that order
// is what `player_set_subtitle_track(n)` indexes into on Android.
// TRACES: UR-020 | IR-016 | UT-145
subtitles: req.subtitles,
series_id: None, // Not available from video-only request series_id: None, // Not available from video-only request
server_id: None, // Not available from video-only request server_id: None, // Not available from video-only request
}) })
@@ -2459,6 +2483,141 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// The subtitle list the frontend resolved must survive the IPC hop and end
/// up on the `MediaItem` the native backend loads.
///
/// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
/// then dropped it on the floor — `PlayItemRequest` had no field to put it
/// in — so `create_media_item` always produced `subtitles: vec![]`,
/// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
/// `MediaItem` with zero `SubtitleConfiguration`s. Every later
/// `setSubtitleTrack(n)` then found no text track groups and logged
/// "Invalid subtitle track index".
///
/// The payload below is exactly what the frontend sends: camelCase for the
/// top-level command params (Tauri v2 converts them), and the subtitle
/// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
///
/// TRACES: UR-020 | IR-016 | UT-145
#[tokio::test]
async fn test_play_item_request_carries_subtitles_into_media_item() {
use super::{create_media_item, PlayItemRequest};
let payload = serde_json::json!({
"id": "ep-1",
"title": "Pilot",
"streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
"videoCodec": "h264",
"needsTranscoding": false,
"subtitles": [
{
"index": 2,
"url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
"language": "eng",
"label": "English (SRT)",
"mime_type": "text/vtt"
},
{
"index": 3,
"url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
"language": null,
"label": null,
"mime_type": "text/vtt"
}
]
});
let req: PlayItemRequest =
serde_json::from_value(payload).expect("frontend payload must deserialize");
assert_eq!(
req.subtitles.len(),
2,
"PlayItemRequest must carry the subtitle tracks, not silently ignore them"
);
let media = create_media_item(req, None).await.unwrap();
assert_eq!(
media.subtitles.len(),
2,
"create_media_item must thread the tracks onto the MediaItem the backend loads"
);
assert_eq!(media.subtitles[0].index, 2);
assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
assert_eq!(media.subtitles[0].mime_type, "text/vtt");
// Order is the contract: `player_set_subtitle_track(n)` is a position in
// this list (see the note on `PlayItemRequest::subtitles`).
assert_eq!(media.subtitles[1].index, 3);
assert!(media.subtitles[1].language.is_none());
}
/// A request without subtitles must still deserialize — the field is
/// defaulted so the background-audio handoff and the autoplay/next-episode
/// callers keep compiling and sending what they always sent.
///
/// TRACES: UR-020 | IR-016 | UT-145
#[tokio::test]
async fn test_play_item_request_without_subtitles_defaults_to_empty() {
use super::{create_media_item, PlayItemRequest};
let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
"id": "movie-1",
"title": "Movie",
"streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
"videoCodec": "h264",
"needsTranscoding": false
}))
.expect("a subtitle-less payload must still deserialize");
assert!(req.subtitles.is_empty());
assert!(create_media_item(req, None)
.await
.unwrap()
.subtitles
.is_empty());
}
/// The JSON handed to Kotlin over JNI must use the keys
/// `JellyTauPlayer.load()` actually reads.
///
/// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
/// house IPC rule) is to camelCase nested structs too — but
/// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
/// the field would not fail to compile or fail the IPC; it would silently
/// fall back to the default MIME type for every track, so this is asserted
/// on the exact bytes `android/mod.rs` sends.
///
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
#[test]
fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
use crate::player::media::SubtitleTrack;
let subtitles = vec![SubtitleTrack {
index: 2,
url: "https://jelly.example/subs.vtt".to_string(),
language: Some("eng".to_string()),
label: Some("English".to_string()),
mime_type: "text/vtt".to_string(),
}];
// Exactly what player/android/mod.rs passes to loadWithMetadata.
let json = serde_json::to_string(&subtitles).unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
let obj = parsed[0].as_object().unwrap();
for key in ["url", "language", "label", "mime_type"] {
assert!(
obj.contains_key(key),
"JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
obj.keys().collect::<Vec<_>>()
);
}
assert!(
!obj.contains_key("mimeType"),
"camelCasing mime_type silently drops every track's MIME type on Android"
);
}
/// The audio-only handoff must play a downloaded file when there is one, /// The audio-only handoff must play a downloaded file when there is one,
/// rather than fetching an audio-only stream for media already on disk. /// rather than fetching an audio-only stream for media already on disk.
/// ///
+19 -1
View File
@@ -23,6 +23,23 @@ pub enum QueueContext {
} }
/// Represents a subtitle track /// Represents a subtitle track
///
/// 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
/// struct in the player that deliberately keeps snake_case on the wire, because
/// the *same* serialization feeds two consumers that both spell `mime_type`:
///
/// * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
/// with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
/// whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
/// * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
/// from the frontend, and the generated binding (`SubtitleTrack` in
/// `bindings.ts`) therefore also declares `mime_type`.
///
/// Renaming would not break the build and would not fail the IPC: Kotlin's
/// `optString` would just fall back to its default MIME type for every track, so
/// the failure would be silent. UT-146 asserts the serialized keys.
///
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SubtitleTrack { pub struct SubtitleTrack {
/// Stream index in the media source /// Stream index in the media source
@@ -33,7 +50,8 @@ pub struct SubtitleTrack {
pub language: Option<String>, pub language: Option<String>,
/// Display title /// Display title
pub label: Option<String>, pub label: Option<String>,
/// MIME type (e.g., "text/vtt", "application/x-subrip") /// MIME type (e.g., "text/vtt", "application/x-subrip").
/// Snake_case on purpose — see the note on the struct.
pub mime_type: String, pub mime_type: String,
} }
+1 -1
View File
@@ -32,7 +32,7 @@ pub mod webview_audio_backend;
pub use autoplay::{AutoplayDecision, AutoplaySettings}; pub use autoplay::{AutoplayDecision, AutoplaySettings};
pub use backend::{NullBackend, PlayerBackend, PlayerError}; pub use backend::{NullBackend, PlayerBackend, PlayerError};
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter}; pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
pub use media::{MediaItem, MediaSource, MediaType, QueueContext}; pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
pub use queue::{QueueManager, RepeatMode}; pub use queue::{QueueManager, RepeatMode};
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy}; pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
pub use session::{MediaSessionManager, MediaSessionType}; pub use session::{MediaSessionManager, MediaSessionType};
+42 -2
View File
@@ -2231,7 +2231,29 @@ itemType?: string | null;
* Series ID for TV episodes. Needed alongside `item_type` so the backend can * Series ID for TV episodes. Needed alongside `item_type` so the backend can
* look up the next episode when a background-audio track ends. * look up the next episode when a background-audio track ends.
*/ */
seriesId?: string | null } seriesId?: string | null;
/**
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
*
* Only the native backends use these: on Android they become the
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
* builds its own `<track>` children instead and ignores this list.
*
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
* *text track groups* i.e. the position of the sideloaded configuration,
* not the Jellyfin stream index (which is kept on each entry for the UI's
* benefit). So `n` must be a position in this very array, and the array
* must not be reordered or filtered between building it and sending it.
* `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
* list that is sent here, for exactly this reason.
*
* Defaulted so the background-audio handoff and the autoplay/next-episode
* callers, which have no subtitles to offer, need not send the field.
*
* TRACES: UR-020 | IR-016, JA-008 | UT-145
*/
subtitles?: SubtitleTrack[] }
/** /**
* Queue context for remote transfer - what type of queue is this? * Queue context for remote transfer - what type of queue is this?
*/ */
@@ -2769,6 +2791,23 @@ export type StreamKind = "audio" | "video" | "subtitle" |
"other" "other"
/** /**
* Represents a subtitle track * Represents a subtitle track
*
* 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
* struct in the player that deliberately keeps snake_case on the wire, because
* the *same* serialization feeds two consumers that both spell `mime_type`:
*
* * the JNI boundary `player/android/mod.rs` serializes `MediaItem::subtitles`
* with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
* whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
* * the IPC boundary `PlayItemRequest::subtitles` deserializes this same type
* from the frontend, and the generated binding (`SubtitleTrack` in
* `bindings.ts`) therefore also declares `mime_type`.
*
* Renaming would not break the build and would not fail the IPC: Kotlin's
* `optString` would just fall back to its default MIME type for every track, so
* the failure would be silent. UT-146 asserts the serialized keys.
*
* TRACES: UR-020 | IR-016, JA-008 | UT-146
*/ */
export type SubtitleTrack = { export type SubtitleTrack = {
/** /**
@@ -2788,7 +2827,8 @@ language: string | null;
*/ */
label: string | null; label: string | null;
/** /**
* MIME type (e.g., "text/vtt", "application/x-subrip") * MIME type (e.g., "text/vtt", "application/x-subrip").
* Snake_case on purpose see the note on the struct.
*/ */
mime_type: string } mime_type: string }
/** /**
+52 -30
View File
@@ -18,6 +18,8 @@
resolveSubtitleTracks, resolveSubtitleTracks,
reconcileSelectedSubtitle, reconcileSelectedSubtitle,
videoCrossOriginMode, videoCrossOriginMode,
nativeSubtitleTracks,
nativeSubtitleArrayIndex,
type RenderableSubtitleTrack, type RenderableSubtitleTrack,
} from "./subtitleTracks"; } from "./subtitleTracks";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer"; import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
@@ -291,6 +293,15 @@
// TRACES: UR-020 | DR-023 | UT-143, UT-144 // TRACES: UR-020 | DR-023 | UT-143, UT-144
let renderedSubtitleTracks = $state<RenderableSubtitleTrack[]>([]); let renderedSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
// The subtitle list actually handed to the native backend at load time
// (Android/ExoPlayer). Kept because `player_set_subtitle_track` takes a
// *position in this list*, not a Jellyfin stream index — see
// nativeSubtitleArrayIndex. It is written once, from onMount, before the
// play request; it is not derived, because the request is what fixed the
// backend's idea of the track order.
// TRACES: UR-020 | IR-016 | UT-147
let sentSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
// Cross-origin <track> fetches use the media element's CORS setting; see // Cross-origin <track> fetches use the media element's CORS setting; see
// videoCrossOriginMode for why this is opt-in and same-origin-only. // videoCrossOriginMode for why this is opt-in and same-origin-only.
const videoCrossOrigin = $derived( const videoCrossOrigin = $derived(
@@ -596,28 +607,23 @@
console.log("[VideoPlayer] Initializing player for:", media.name); console.log("[VideoPlayer] Initializing player for:", media.name);
console.log("[VideoPlayer] Stream URL:", currentStreamUrl); console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
// Build subtitle tracks for native player // Resolve subtitle URLs for the native (ExoPlayer) path. These must be
const subtitleTracks = []; // in hand *before* the play request: ExoPlayer sideloads subtitles as
if (media.mediaStreams && mediaSourceId) { // MediaItem.SubtitleConfigurations, which have to exist before
const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle"); // prepare() — there is no way to add one to a loaded item afterwards.
for (const sub of subtitles) { //
try { // Awaiting here is safe despite the native-mode pitfall: that rule is
const url = await getSubtitleUrl(sub.index); // about Svelte *lifecycle* calls (onMount/onDestroy) after an await,
if (url) { // which throw lifecycle_outside_component and used to be misread as an
subtitleTracks.push({ // init failure. Nothing is registered here, and the background-audio
index: sub.index, // subscriptions above already ran synchronously. resolveSubtitleTracks
url: url, // fans the requests out in parallel, so this costs one round trip, not
language: sub.language || null, // one per subtitle stream as the old serial loop did.
label: sub.displayTitle || sub.language || `Track ${sub.index}`, // TRACES: UR-020 | IR-016, JA-008 | UT-147
mime_type: "text/vtt" // Jellyfin converts to WebVTT sentSubtitleTracks = mediaSourceId
}); ? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
} : [];
} catch (err) { console.log(`[VideoPlayer] Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
console.warn(`[VideoPlayer] Failed to build subtitle URL for track ${sub.index}:`, err);
}
}
console.log(`[VideoPlayer] Built ${subtitleTracks.length} subtitle tracks for native player`);
}
// Call Rust backend to start playback // Call Rust backend to start playback
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5 // Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
@@ -628,6 +634,10 @@
id: media.id, id: media.id,
videoCodec: needsTranscoding ? "hevc" : "h264", videoCodec: needsTranscoding ? "hevc" : "h264",
needsTranscoding: needsTranscoding, needsTranscoding: needsTranscoding,
// Order matters: player_set_subtitle_track(n) is a position in this
// array. Previously this array was built and then dropped, so
// ExoPlayer got a MediaItem with no subtitles at all.
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
}); });
// Rust tells us which backend it's using // Rust tells us which backend it's using
@@ -1747,8 +1757,22 @@
}); });
} }
async function selectSubtitle(streamIndex: number | null, arrayIndex?: number) { /**
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex, "arrayIndex:", arrayIndex); * Apply the menu's choice. `streamIndex` is always the Jellyfin media-stream
* index (or `null` for "Off") — the UI speaks stream indices throughout.
*
* The native backend does not: `player_set_subtitle_track(n)` reaches
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes ExoPlayer's text track
* groups, i.e. the position of the sideloaded subtitle configuration. That
* position is derived from `sentSubtitleTracks` — the exact array sent with
* the play request — and not from the menu's row number, which counts every
* subtitle *stream* including ones whose URL never resolved and so were never
* sideloaded.
*
* TRACES: UR-020 | DR-023, IR-016 | UT-147
*/
async function selectSubtitle(streamIndex: number | null) {
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex);
selectedSubtitleIndex = streamIndex; selectedSubtitleIndex = streamIndex;
showSubtitleMenu = false; showSubtitleMenu = false;
@@ -1758,11 +1782,9 @@
} else { } else {
// For native backend (Android), send command to change subtitle track // For native backend (Android), send command to change subtitle track
try { try {
// Use array index for ExoPlayer (0-based position in subtitle tracks array) const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
// If streamIndex is null (disable), pass null; otherwise use arrayIndex
const indexToUse = streamIndex === null ? null : (arrayIndex !== undefined ? arrayIndex : streamIndex);
await commands.playerSetSubtitleTrack(indexToUse); await commands.playerSetSubtitleTrack(indexToUse);
console.log("[VideoPlayer] Native backend subtitle track changed - arrayIndex:", arrayIndex, "used:", indexToUse); console.log("[VideoPlayer] Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
} catch (error) { } catch (error) {
console.error("[VideoPlayer] Failed to set subtitle track:", error); console.error("[VideoPlayer] Failed to set subtitle track:", error);
} }
@@ -2135,9 +2157,9 @@
{/if} {/if}
</button> </button>
<!-- Subtitle tracks --> <!-- Subtitle tracks -->
{#each subtitleTracks() as track, i} {#each subtitleTracks() as track}
<button <button
onclick={() => selectSubtitle(track.index, i)} onclick={() => selectSubtitle(track.index)}
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}" class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
> >
<div class="flex flex-col"> <div class="flex flex-col">
@@ -7,6 +7,8 @@ import {
resolveSubtitleTracks, resolveSubtitleTracks,
reconcileSelectedSubtitle, reconcileSelectedSubtitle,
videoCrossOriginMode, videoCrossOriginMode,
nativeSubtitleTracks,
nativeSubtitleArrayIndex,
type SubtitleStreamLike, type SubtitleStreamLike,
} from "./subtitleTracks"; } from "./subtitleTracks";
@@ -151,6 +153,99 @@ describe("videoCrossOriginMode", () => {
}); });
}); });
/**
* Subtitles on the Android / ExoPlayer native path.
*
* TRACES: UR-020 | IR-016, JA-008 | UT-147
*
* The bug this guards: VideoPlayer built a fully-resolved subtitle array in
* onMount and then never sent it `commands.playerPlayItem({...})` passed only
* streamUrl/title/id/videoCodec/needsTranscoding so every MediaItem reached
* ExoPlayer with zero SubtitleConfigurations and `setSubtitleTrack(n)` logged
* "Invalid subtitle track index".
*
* And the second half: `setSubtitleTrack(n)` indexes ExoPlayer's *text track
* groups*, i.e. the position of the sideloaded configuration not the Jellyfin
* stream index. The menu used to pass its own row position, which is a position
* in the *unresolved* stream list; the moment one subtitle URL failed to
* resolve, the two lists diverged and every track below the gap selected the
* wrong subtitle.
*/
describe("nativeSubtitleTracks", () => {
it("maps to the wire shape Rust deserializes and Kotlin parses", async () => {
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
const payload = nativeSubtitleTracks(resolved);
expect(payload).toHaveLength(2);
// Kotlin reads url/language/label/mime_type; Rust's SubtitleTrack keeps
// snake_case for exactly that reason, and so does the generated binding.
for (const track of payload) {
expect(Object.keys(track).sort()).toEqual(
["index", "label", "language", "mime_type", "url"].sort(),
);
expect(track).not.toHaveProperty("mimeType");
expect(track.mime_type).toBe("text/vtt");
}
// Jellyfin serves every subtitle stream as WebVTT here, and the stream index
// rides along so the UI can keep talking in stream indices.
expect(payload.map((t) => t.index)).toEqual([2, 3]);
expect(payload[0].url).toContain("subtitles.vtt");
expect(payload[0].language).toBe("eng");
expect(payload[0].label).toBe("English (SRT)");
});
it("preserves stream order, because that order is the selection index", async () => {
const resolved = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
expect(nativeSubtitleTracks(resolved).map((t) => t.index)).toEqual(
resolved.map((t) => t.streamIndex),
);
});
it("has nothing to send when no subtitle URL resolved", async () => {
expect(nativeSubtitleTracks(await resolveSubtitleTracks(SUBS, async () => ""))).toEqual([]);
expect(nativeSubtitleTracks([])).toEqual([]);
});
it("carries a null language/label through rather than inventing one", () => {
const payload = nativeSubtitleTracks([
{ streamIndex: 5, url: "u.vtt", srclang: "und", label: "Track 5", isDefault: false },
]);
expect(payload[0].language).toBeNull();
expect(payload[0].label).toBe("Track 5");
});
});
describe("nativeSubtitleArrayIndex", () => {
it("returns the position in the list that was actually sent, not the stream index", async () => {
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(nativeSubtitleArrayIndex(resolved, 2)).toBe(0);
expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(1);
});
it("stays aligned when a subtitle URL failed to resolve (the mis-selection bug)", async () => {
// Stream 2 has no URL, so it is not among the sideloaded configurations.
// The menu's own row for stream 3 is position 1, but ExoPlayer only has one
// text track group — position 0. Sending 1 would select nothing.
const resolved = await resolveSubtitleTracks(SUBS, async (i) => {
if (i === 2) throw new Error("no repository");
return url(i);
});
expect(resolved).toHaveLength(1);
expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(0);
});
it("maps 'Off' to null so the backend disables text instead of selecting track 0", async () => {
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(nativeSubtitleArrayIndex(resolved, null)).toBeNull();
});
it("maps a track that was never sent to null rather than to a wrong position", async () => {
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(nativeSubtitleArrayIndex(resolved, 99)).toBeNull();
expect(nativeSubtitleArrayIndex([], 3)).toBeNull();
});
});
describe("VideoPlayer markup (the regression that made the menu inert)", () => { describe("VideoPlayer markup (the regression that made the menu inert)", () => {
const source = readFileSync( const source = readFileSync(
resolve(__dirname, "VideoPlayer.svelte"), resolve(__dirname, "VideoPlayer.svelte"),
@@ -178,3 +273,31 @@ describe("VideoPlayer markup (the regression that made the menu inert)", () => {
expect(trackElement).not.toMatch(/\bdefault=/); expect(trackElement).not.toMatch(/\bdefault=/);
}); });
}); });
/**
* The half of the Android fix that lives in the component: the resolved list has
* to actually be handed to `playerPlayItem`, and the index sent to the backend
* has to be computed from that same list.
*
* TRACES: UR-020 | IR-016 | UT-147
*/
describe("VideoPlayer -> playerPlayItem (the tracks that were built and thrown away)", () => {
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
/** The playerPlayItem({...}) argument object. */
const playItemCall = (() => {
const start = source.indexOf("commands.playerPlayItem(");
expect(start).toBeGreaterThan(-1);
return source.slice(start, source.indexOf("});", start) + 3);
})();
it("sends the subtitle tracks it resolved", () => {
expect(playItemCall).toMatch(/\bsubtitles:/);
});
it("selects by position in the sent list, not by the menu's row number", () => {
expect(source).toContain("nativeSubtitleArrayIndex");
// The old code forwarded the `{#each}` index straight to the backend.
expect(source).not.toMatch(/playerSetSubtitleTrack\(\s*arrayIndex\s*\)/);
});
});
+77 -1
View File
@@ -12,7 +12,14 @@
// the render path, and only tracks that actually resolved are handed to the // the render path, and only tracks that actually resolved are handed to the
// markup. // 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. * The subset of `MediaStream` (from the generated bindings) this module needs.
@@ -149,3 +156,72 @@ export function videoCrossOriginMode(
if (subtitleStreamCount <= 0) return undefined; if (subtitleStreamCount <= 0) return undefined;
return originOf(streamUrl) ? "anonymous" : 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;
}