fix(player): change the audio track, and load subtitles at all

Two faults, both present since v0.0.1, both found and confirmed on a device.

Audio track (DR-258). Jellyfin builds a transcode around one AudioStreamIndex,
so the alternate tracks are not in the stream that arrives — but the native
path only ever called setAudioTrack(n), which indexes ExoPlayer's audio track
*groups*. On Android that is the common case, since any source whose default
audio codec the device cannot decode is transcoded: logcat showed ExoPlayer
holding `Audio tracks: 1` while the menu listed every track in the file, so
each selection warned `Invalid audio track index` and was dropped, leaving the
default track playing with nothing in the UI saying so.

determine_audio_track_switch_strategy now decides by whether the stream in
front of the engine carries the track at all — a direct play still selects in
place, a transcode is re-negotiated at the chosen index and resumed. Where it
resumes is the player's answer rather than the UI's: the native path has no
<video> element to read, so it sends no position, and defaulting that to zero
re-opened the film at the beginning (caught on device before it shipped).

Subtitles (DR-259). The URL was missing its `Stream.` route segment, so every
sideloaded subtitle 404ed; since media3 1.5 a sideloaded text track only
becomes a track group once its file is parsed, so 42 failed fetches left
ExoPlayer with no text tracks and selection warned `available: 0`. Verified
against a live server: the built URL answers 404, the corrected one 200. The
tests that should have caught this asserted the shape of a mock helper that
restated the format string instead of the URL the app requests — so the new
test drives the repository itself, and failed red on the old URL.
This commit is contained in:
2026-08-23 19:15:05 +02:00
parent 231ffae626
commit 64de22bd51
8 changed files with 397 additions and 61 deletions
+141 -49
View File
@@ -25,8 +25,9 @@ use super::DatabaseWrapper;
use crate::download::cache::{CacheConfig, SmartCache};
use crate::jellyfin::{JellyfinClient, JellyfinConfig};
use crate::player::{
determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
determine_audio_track_switch_strategy, determine_video_seek_strategy, AudioTrackSwitchStrategy,
MediaItem, MediaSessionManager, MediaSource, MediaType, PlayerController, PlayerState,
PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
};
use crate::repository::{
types::{GetItemsOptions, ImageOptions, ImageType},
@@ -1595,18 +1596,37 @@ pub async fn player_seek_video(
}
}
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
/// Switch audio track.
/// Note: Frontend should handle saving series preferences after this command succeeds
///
/// The split is the requirement: an HTML5 `<video>` element cannot be told to
/// change audio track, so the stream is re-opened at the chosen
/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
/// `position`; a native backend (ExoPlayer) switches in place by track-group
/// index. libmpv implements neither — it is the audio-only backend here and
/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
/// which is why IR-019 is met by these two paths rather than by MPV.
/// What decides the route is **whether the stream in front of the engine
/// carries the requested track at all** — see
/// [`determine_audio_track_switch_strategy`]:
///
/// TRACES: UR-021 | IR-019, DR-024
/// - An HTML5 `<video>` element has no track-selection API, so the stream is
/// always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
/// the reloaded element back to `position`.
/// - A native backend playing a **direct play** holds the source file with
/// every track in it, so ExoPlayer selects in place by track-group index.
/// - A native backend playing a **transcode** does not. Jellyfin builds a
/// transcode around one `AudioStreamIndex`, so the alternate tracks are not
/// in the stream; the switch has to re-open it, which this command does
/// itself and resumes at `current_position`.
///
/// That last case is a bug fix, and it was the common case on Android: any
/// source whose default audio codec the device cannot decode is transcoded, so
/// ExoPlayer saw `Audio tracks: 1` while the menu listed every track in the
/// file. The old code called `setAudioTrack(n)` regardless, which indexes
/// ExoPlayer's audio track *groups*, found nothing at `n`, warned `Invalid
/// audio track index` and dropped the request — the default track just kept
/// playing, with nothing in the UI saying so.
///
/// libmpv implements neither selection nor reload here — it is the audio-only
/// backend and leaves `PlayerBackend::set_audio_track` at its
/// `not_implemented()` default, which is why IR-019 is met by these paths
/// rather than by MPV.
///
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
#[tauri::command]
#[specta::specta]
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
@@ -1626,56 +1646,128 @@ pub async fn player_switch_audio_track(
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
stream_index, array_index, use_html5);
if use_html5 {
// HTML5 backend needs stream reload
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Read what the engine is playing before deciding anything — including
// where it is, which has to be captured before the stop below wipes it.
// Locks are dropped at the end of this block so none is held across an
// await.
let (jellyfin_item_id, needs_transcoding, engine_position) = {
let controller = player.0.lock().await;
let engine_position = controller.absolute_position();
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
// Get current item to find Jellyfin ID
let jellyfin_item_id = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let current_item = queue.current().ok_or("No item currently playing")?;
let current_item = queue.current().ok_or("No item currently playing")?;
(
current_item
.jellyfin_id()
.ok_or("Current item has no Jellyfin ID")?
.to_string()
};
.to_string(),
current_item.needs_transcoding,
engine_position,
)
};
// Select a stream carrying the chosen audio track. It starts at zero —
// an HLS playlist cannot carry a position (DR-181) — and `position`
// below tells the frontend where to seek the reloaded element back to.
//
// Pinning a track is itself a reason the source cannot be direct-played:
// the file has one default track and the viewer asked for another, so
// the negotiation returns a transcode. That decision lives in
// `decide_playback_kind`, not here.
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
Some(stream_index),
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
Ok(AudioTrackSwitchResponse::ReloadStream {
selection,
position: current_position.unwrap_or(0.0),
})
} else {
// Native backend (Android ExoPlayer) - use array index
info!(
"[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
needs_transcoding, use_html5, strategy
);
if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
// A direct play: the engine holds the source file, every track included.
let controller = player.0.lock().await;
controller
.set_audio_track(array_index)
.map_err(|e| e.to_string())?;
Ok(AudioTrackSwitchResponse::Native { success: true })
return Ok(AudioTrackSwitchResponse::Native { success: true });
}
// Both reload strategies need a stream built around the chosen track.
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Select a stream carrying the chosen audio track. It starts at zero —
// an HLS playlist cannot carry a position (DR-181) — so the position is
// restored by seeking afterwards, here or in the frontend.
//
// Pinning a track is itself a reason the source cannot be direct-played:
// the file has one default track and the viewer asked for another, so
// the negotiation returns a transcode. That decision lives in
// `decide_playback_kind`, not here.
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
Some(stream_index),
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
// The caller's position if it has one, the engine's otherwise. The native
// path has no `<video>` element to read, so it sends none — and defaulting
// that to zero re-opened the stream at the start of the film.
let position = crate::player::track_switch::resume_position(current_position, engine_position);
match strategy {
AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
selection,
position,
}),
AudioTrackSwitchStrategy::BackendReloadStream => {
// The native backend re-opens its own stream, the same sequence the
// transcoded seek and quality change use: stop, repoint the queue
// entry at the new URL, load, then seek back to where the viewer
// was. Nothing is left for the frontend to do.
let new_url = selection.url.clone();
{
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
}
{
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
if !queue.update_current_stream_url(new_url) {
return Err("Failed to update stream URL in queue".to_string());
}
}
{
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let updated_item = queue
.current()
.ok_or("No current item after URL update")?
.clone();
drop(queue);
controller
.load_and_play(&updated_item)
.map_err(|e| e.to_string())?;
controller.seek(position).map_err(|e| e.to_string())?;
}
info!(
"[player_switch_audio_track] Re-opened the stream on audio stream {} and resumed at {}",
stream_index, position
);
Ok(AudioTrackSwitchResponse::Native { success: true })
}
// Handled above, before the stream was negotiated.
AudioTrackSwitchStrategy::BackendSelectInPlace => {
Ok(AudioTrackSwitchResponse::Native { success: true })
}
}
}