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
+24
View File
@@ -9,6 +9,30 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md). [docs/defect-windows.md](docs/defect-windows.md).
## v0.11.1
Two faults that had been present since the first release, both found on a
device: changing the audio track did nothing, and no subtitle would load.
### 🐛 Fixes
- **Changing the audio track changes the audio.** Picking a different language
did nothing on Android — the menu closed, the tick moved, and the original
track kept playing, with nothing saying otherwise. When the server is
converting a film it builds that conversion around *one* audio track, so the
others are not in the stream that arrives; the app was asking the player to
select from tracks it had never been sent. It now asks the server for the
track you picked and resumes where you were. A film playing in its original
form still switches instantly, because there every track really is present.
(UR-021 → DR-258)
- **Subtitles load.** Every subtitle in the list was inert: the address the app
fetched them from was missing a segment, so each request came back "not
found", and a subtitle that never arrives is a subtitle the player cannot
offer. All of them had been failing this way since the first release — the
tests that were supposed to cover the address were checking a copy of it kept
inside the tests, not the one being requested. (UR-020 → DR-259)
## v0.11.0 ## v0.11.0
Video can play through the native renderer on Linux, and the machinery every Video can play through the native renderer on Linux, and the machinery every
+5 -2
View File
@@ -17,8 +17,8 @@ row can be re-checked or disputed:
## Present since the first release ## Present since the first release
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped Fifteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
for between two weeks and seven weeks short of two months before anyone hit them. shipped for between two weeks and two months before anyone hit them.
That is the dominant pattern here: not regressions, but original assumptions that That is the dominant pattern here: not regressions, but original assumptions that
went unexercised until a later feature leaned on them. went unexercised until a later feature leaned on them.
@@ -34,9 +34,12 @@ went unexercised until a later feature leaned on them.
| No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe | | No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe | | `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe | | `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence | | Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe | | Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
| Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe | | Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe |
| Audio-track change asked the player to select a track the transcode never carried (DR-258) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
| Subtitle URL missing its `Stream.` route segment, so every fetch 404ed (DR-259) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
### Why they took so long to surface ### Why they took so long to surface
+5
View File
@@ -451,6 +451,8 @@ Internal architecture, components, and application logic.
| DR-255 | One helper answers "what URL should an engine open". `playback_url` was gated to Android because only ExoPlayer needed it, and that gate is why a byte-identical copy was later added for the cross-platform open path — the original is invisible in a Linux build, so nothing warned. Two matches over `MediaSource` meant a new variant could be handled in one and forgotten in the other | Player | UR-081 | Done | | DR-255 | One helper answers "what URL should an engine open". `playback_url` was gated to Android because only ExoPlayer needed it, and that gate is why a byte-identical copy was later added for the cross-platform open path — the original is invisible in a Linux build, so nothing warned. Two matches over `MediaSource` meant a new variant could be handled in one and forgotten in the other | Player | UR-081 | Done |
| DR-256 | The video control bar opens **at most one menu at a time**, and opens it where it can be read. Audio track, quality and subtitles each owned a `show…` boolean that no other toggle cleared, so a second menu opened stacked over the first — two panels in the same corner, the newer one covering rows of the older, both still taking clicks. A single `openMenu` value replaces them, which makes "one menu" a property of the state rather than something every handler must remember; the desktop volume popup joins the same group through `VolumeControl`'s optional controlled-open props. Placement was the second half of the same defect: every panel was `absolute right-0` against **its own icon button**, and those icons sit mid-row, so a 220 px panel hung off the left edge of a portrait phone and half the tracks could not be read or tapped. One shared panel now anchors to the control ROW's right edge, clamped to `min(20rem, 100vw 2rem)` wide and `min(300px, 45vh)` tall, with a full-screen dismiss layer inside the controls subtree so a tap elsewhere closes it without reaching the container's tap gestures (DR-098). The icon row itself wraps instead of overflowing — in portrait the transport controls plus nine icons are wider than the screen, which put fullscreen and close past the edge | UI | UR-020, UR-021, UR-066, UR-074 | Done | | DR-256 | The video control bar opens **at most one menu at a time**, and opens it where it can be read. Audio track, quality and subtitles each owned a `show…` boolean that no other toggle cleared, so a second menu opened stacked over the first — two panels in the same corner, the newer one covering rows of the older, both still taking clicks. A single `openMenu` value replaces them, which makes "one menu" a property of the state rather than something every handler must remember; the desktop volume popup joins the same group through `VolumeControl`'s optional controlled-open props. Placement was the second half of the same defect: every panel was `absolute right-0` against **its own icon button**, and those icons sit mid-row, so a 220 px panel hung off the left edge of a portrait phone and half the tracks could not be read or tapped. One shared panel now anchors to the control ROW's right edge, clamped to `min(20rem, 100vw 2rem)` wide and `min(300px, 45vh)` tall, with a full-screen dismiss layer inside the controls subtree so a tap elsewhere closes it without reaching the container's tap gestures (DR-098). The icon row itself wraps instead of overflowing — in portrait the transport controls plus nine icons are wider than the screen, which put fullscreen and close past the edge | UI | UR-020, UR-021, UR-066, UR-074 | Done |
| DR-257 | A container's children are ordered by **what the container is**, decided in Rust. The frontend pinned `SortBy=SortName` onto every drill-down, so a Jellypod podcast — a Jellyfin channel folder whose plugin returns episodes newest-first and prefixes played ones with "[Played]" — listed alphabetically, which both discarded the release order and clumped every heard episode at the top. `ChannelFolderItem` with `is_folder` now maps to its own `MediaKind::ChannelFolder` rather than collapsing into `Folder`, which is what makes the two distinguishable at all; `default_listing_sort` maps that kind to `PremiereDate` descending and every other container to `SortName` ascending, and a caller that names no container still gets no `SortBy`, so paths relying on the server's own order (a playlist's stored order) keep it. An explicit sort always wins. The offline leg of the cache/server race applies the same order, so the cached list does not flash in name order before the server's arrives. The store now names the container and never a sort field — the ordering rule is domain vocabulary, the same division as `SearchScope` | Repository | UR-007 | Done | | DR-257 | A container's children are ordered by **what the container is**, decided in Rust. The frontend pinned `SortBy=SortName` onto every drill-down, so a Jellypod podcast — a Jellyfin channel folder whose plugin returns episodes newest-first and prefixes played ones with "[Played]" — listed alphabetically, which both discarded the release order and clumped every heard episode at the top. `ChannelFolderItem` with `is_folder` now maps to its own `MediaKind::ChannelFolder` rather than collapsing into `Folder`, which is what makes the two distinguishable at all; `default_listing_sort` maps that kind to `PremiereDate` descending and every other container to `SortName` ascending, and a caller that names no container still gets no `SortBy`, so paths relying on the server's own order (a playlist's stored order) keep it. An explicit sort always wins. The offline leg of the cache/server race applies the same order, so the cached list does not flash in name order before the server's arrives. The store now names the container and never a sort field — the ordering rule is domain vocabulary, the same division as `SearchScope` | Repository | UR-007 | Done |
| DR-258 | An audio-track change is honoured by **re-opening the stream** when the stream cannot carry the track. Jellyfin builds a transcode around one `AudioStreamIndex`, so the alternate tracks are not in it — 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: ExoPlayer held one audio track while the menu listed every track in the file, so every 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, not 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 | Player | UR-021, UR-005 | Done |
| DR-259 | Subtitle URLs address Jellyfin's route, `/Videos/{item}/{source}/Subtitles/{index}/Stream.{format}`. The `Stream.` segment was missing, which matches no route and 404s, so every sideloaded subtitle failed to fetch. 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 at all and subtitle selection warned `available: 0` and did nothing. The URL tests that existed asserted the shape of a *mock helper* duplicating the format string rather than the URL the app requests, which is why a route error survived from the first release | Repository | UR-020 | Done |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done | | DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
--- ---
@@ -774,6 +776,9 @@ Internal architecture, components, and application logic.
| UT-229 | A channel folder's children are requested by release date, newest first, while every other container keeps name order; an explicit sort still wins, and a caller naming no container gets no `SortBy` at all | DR-257 | Done | | UT-229 | A channel folder's children are requested by release date, newest first, while every other container keeps name order; an explicit sort still wins, and a caller naming no container gets no `SortBy` at all | DR-257 | Done |
| UT-230 | A `ChannelFolderItem` that is a folder maps to `ChannelFolder`, not to the generic `Folder` it was indistinguishable from | DR-257 | Done | | UT-230 | A `ChannelFolderItem` that is a folder maps to `ChannelFolder`, not to the generic `Folder` it was indistinguishable from | DR-257 | Done |
| UT-231 | The library store sends the container's kind and no sort field, defaulting to a plain folder when the caller names none | DR-257 | Done | | UT-231 | The library store sends the container's kind and no sort field, defaulting to a plain folder when the caller names none | DR-257 | Done |
| UT-232 | A transcode's audio-track change re-opens the stream, a direct play selects in place, and an HTML5 element reloads either way — the engine is only asked to select a track the stream actually carries | DR-258 | Done |
| UT-233 | The position a re-opened stream resumes at comes from the engine when the caller has none, and a non-finite or negative position is treated as absent rather than passed to a backend that rejects it | DR-258 | Done |
| UT-234 | A subtitle URL targets Jellyfin's `Stream.{format}` route, asserted against the repository that builds it rather than a mock that restates it | DR-259 | Done |
### Integration Tests ### Integration Tests
+141 -49
View File
@@ -25,8 +25,9 @@ use super::DatabaseWrapper;
use crate::download::cache::{CacheConfig, SmartCache}; use crate::download::cache::{CacheConfig, SmartCache};
use crate::jellyfin::{JellyfinClient, JellyfinConfig}; use crate::jellyfin::{JellyfinClient, JellyfinConfig};
use crate::player::{ use crate::player::{
determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType, determine_audio_track_switch_strategy, determine_video_seek_strategy, AudioTrackSwitchStrategy,
PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy, MediaItem, MediaSessionManager, MediaSource, MediaType, PlayerController, PlayerState,
PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
}; };
use crate::repository::{ use crate::repository::{
types::{GetItemsOptions, ImageOptions, ImageType}, 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 /// Note: Frontend should handle saving series preferences after this command succeeds
/// ///
/// The split is the requirement: an HTML5 `<video>` element cannot be told to /// What decides the route is **whether the stream in front of the engine
/// change audio track, so the stream is re-opened at the chosen /// carries the requested track at all** — see
/// `AudioStreamIndex` and the frontend seeks the reloaded element back to /// [`determine_audio_track_switch_strategy`]:
/// `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.
/// ///
/// 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] #[tauri::command]
#[specta::specta] #[specta::specta]
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller // 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: {}", info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
stream_index, array_index, use_html5); stream_index, array_index, use_html5);
if use_html5 { // Read what the engine is playing before deciding anything — including
// HTML5 backend needs stream reload // where it is, which has to be captured before the stop below wipes it.
let repository = repository_manager // Locks are dropped at the end of this block so none is held across an
.0 // await.
.get(&repository_handle) let (jellyfin_item_id, needs_transcoding, engine_position) = {
.ok_or("Repository not found - user may need to log in")?; 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 current_item = queue.current().ok_or("No item currently playing")?;
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")?;
(
current_item current_item
.jellyfin_id() .jellyfin_id()
.ok_or("Current item has no 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 — let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
// 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))?;
Ok(AudioTrackSwitchResponse::ReloadStream { info!(
selection, "[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
position: current_position.unwrap_or(0.0), needs_transcoding, use_html5, strategy
}) );
} else {
// Native backend (Android ExoPlayer) - use array index if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
// A direct play: the engine holds the source file, every track included.
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller controller
.set_audio_track(array_index) .set_audio_track(array_index)
.map_err(|e| e.to_string())?; .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 })
}
} }
} }
+2
View File
@@ -23,6 +23,7 @@ pub mod session;
pub mod sleep_timer; pub mod sleep_timer;
pub mod state; pub mod state;
pub mod stream_end; pub mod stream_end;
pub mod track_switch;
#[cfg(test)] #[cfg(test)]
mod mpv_backend_test; mod mpv_backend_test;
@@ -71,6 +72,7 @@ pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
pub use session::{MediaSessionManager, MediaSessionType}; pub use session::{MediaSessionManager, MediaSessionType};
pub use sleep_timer::{SleepTimerMode, SleepTimerState}; pub use sleep_timer::{SleepTimerMode, SleepTimerState};
pub use state::{EndReason, PlayerState}; pub use state::{EndReason, PlayerState};
pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchStrategy};
// Re-export platform-specific backends // Re-export platform-specific backends
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
+155
View File
@@ -0,0 +1,155 @@
//! Audio-track switch strategy decision logic.
//!
//! Pure logic, extracted from the command layer so it can be unit-tested in the
//! player core — the sibling of [`super::seek`]. `player_switch_audio_track`
//! turns the resulting [`AudioTrackSwitchStrategy`] into a concrete action.
//!
//! The rule this module exists to state: **an engine can only select a track
//! the stream in front of it actually carries.** A Jellyfin transcode is built
//! around one `AudioStreamIndex`, so the alternate tracks are not in the stream
//! at all — the switch has to re-open it. Only a direct play/stream hands the
//! engine the source file with every track present.
/// How a request to change audio track has to be carried out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioTrackSwitchStrategy {
/// Re-open the stream pinned to the chosen track; the frontend reloads its
/// `<video>` element. An HTML5 element cannot select an audio track at all,
/// so this holds whether or not the current stream is a transcode.
Html5ReloadStream,
/// Re-open the stream pinned to the chosen track; the backend reloads
/// itself and restores the position.
BackendReloadStream,
/// The engine already holds every track — select in place, no reload.
BackendSelectInPlace,
}
/// Decide how to honour an audio-track change.
///
/// # Arguments
/// * `needs_transcoding` - Whether the stream now playing is a server-side
/// transcode, which carries exactly the one audio track it was built around.
/// * `use_html5` - Whether the frontend `<video>` element is rendering.
///
/// TRACES: UR-021 | IR-019, DR-024, DR-258 | UT-232
pub fn determine_audio_track_switch_strategy(
needs_transcoding: bool,
use_html5: bool,
) -> AudioTrackSwitchStrategy {
if use_html5 {
return AudioTrackSwitchStrategy::Html5ReloadStream;
}
if needs_transcoding {
AudioTrackSwitchStrategy::BackendReloadStream
} else {
AudioTrackSwitchStrategy::BackendSelectInPlace
}
}
/// Where to resume after re-opening the stream for a track change.
///
/// `requested` is what the caller supplied; `engine_position` is where the
/// engine itself says it is. The caller wins when it has something real to say,
/// and the engine answers otherwise — which is the whole point: **position is
/// the player's to know**, not the UI's to remember.
///
/// The native path proved why. It has no `<video>` element, so the frontend
/// sent `null`, the command defaulted to `0.0`, and switching audio track
/// re-opened the stream at the beginning of the film — the track changed and
/// the viewer lost their place. A non-finite or negative value is treated the
/// same as absent rather than passed through to a backend that would reject it.
///
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258 | UT-233
pub fn resume_position(requested: Option<f64>, engine_position: f64) -> f64 {
let usable = requested.filter(|p| p.is_finite() && *p > 0.0);
let fallback = if engine_position.is_finite() && engine_position > 0.0 {
engine_position
} else {
0.0
};
usable.unwrap_or(fallback)
}
#[cfg(test)]
mod tests {
use super::*;
/// The reported bug, seen on a device: switching audio track changed the
/// track but "restarts from zero". The native path has no `<video>`
/// element, so the frontend passed `null` and the re-opened stream began at
/// the start of the film — logcat: `Re-opened the stream on audio stream 2
/// and resumed at 0` while playback was 22 minutes in.
#[test]
fn a_caller_with_no_position_resumes_where_the_engine_is() {
assert_eq!(resume_position(None, 1337.5), 1337.5);
}
/// The HTML5 path does have an element and its clock is the honest answer
/// there, so what the caller supplies wins.
#[test]
fn a_caller_that_knows_its_position_is_believed() {
assert_eq!(resume_position(Some(42.0), 1337.5), 42.0);
}
/// A position that is not a position — NaN from an element with no
/// metadata, or a negative from a clock read mid-teardown — is treated as
/// absent. Passing it through re-opens at a place no backend accepts.
#[test]
fn a_nonsense_position_falls_back_to_the_engine() {
assert_eq!(resume_position(Some(f64::NAN), 90.0), 90.0);
assert_eq!(resume_position(Some(-5.0), 90.0), 90.0);
assert_eq!(resume_position(None, f64::NAN), 0.0);
}
/// Switching track in the first moments of playback resumes at the start,
/// which is where the viewer actually is.
#[test]
fn the_very_beginning_stays_the_very_beginning() {
assert_eq!(resume_position(None, 0.0), 0.0);
}
/// The reported bug: on Android the audio-track menu did nothing and the
/// default track kept playing.
///
/// Jellyfin had negotiated a transcode (`TranscodeReasons=AudioCodecNot
/// Supported`) whose URL pins `AudioStreamIndex=1`, so ExoPlayer was handed
/// a stream with exactly one audio track — logcat: `Audio tracks: 1`. The
/// native path nonetheless only ever called `setAudioTrack(n)`, which
/// indexes ExoPlayer's audio track *groups* and so found nothing to select:
/// `Invalid audio track index: 1 (available: 1)`, warned and dropped. The
/// track the viewer asked for is not in the stream; it has to be re-opened.
#[test]
fn a_transcode_is_re_opened_because_it_carries_only_one_track() {
assert_eq!(
determine_audio_track_switch_strategy(true, false),
AudioTrackSwitchStrategy::BackendReloadStream
);
}
/// A direct play hands the engine the source file, every track included, so
/// ExoPlayer selects in place — no reload, no re-buffer, no lost position.
#[test]
fn a_direct_play_switches_in_place() {
assert_eq!(
determine_audio_track_switch_strategy(false, false),
AudioTrackSwitchStrategy::BackendSelectInPlace
);
}
/// An HTML5 `<video>` element has no track-selection API, so it reloads
/// either way. This is the path that already worked, and it must keep
/// working: the fix is about the native side only.
#[test]
fn html5_always_reloads_because_the_element_cannot_select() {
assert_eq!(
determine_audio_track_switch_strategy(true, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
assert_eq!(
determine_audio_track_switch_strategy(false, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
}
}
+37 -1
View File
@@ -2469,8 +2469,15 @@ impl MediaRepository for OnlineRepository {
stream_index: i32, stream_index: i32,
format: &str, format: &str,
) -> String { ) -> String {
// `Stream.{format}` is the route, not a filename we get to choose:
// Jellyfin exposes the subtitle as
// `/Videos/{item}/{source}/Subtitles/{index}/Stream.{format}`, and
// stopping at the format alone matches no route and 404s. Every
// sideloaded subtitle failed to load on Android because of it, leaving
// ExoPlayer with no text tracks to select.
// TRACES: UR-020 | JA-008, DR-259 | UT-234
format!( format!(
"{}/Videos/{}/{}/Subtitles/{}/{}", "{}/Videos/{}/{}/Subtitles/{}/Stream.{}",
self.server_url, item_id, media_source_id, stream_index, format self.server_url, item_id, media_source_id, stream_index, format
) )
} }
@@ -3019,6 +3026,35 @@ mod tests {
) )
} }
/// The reported bug: on Android every subtitle track was inert — the menu
/// listed 42 languages and picking one changed nothing.
///
/// The cause is here rather than in the player. ExoPlayer sideloads each
/// subtitle as its own media source, and since media3 1.5 a sideloaded text
/// track only becomes a *track group* once its file has been fetched and
/// parsed. Every fetch 404ed, so `Tracks` carried no text group at all and
/// `setSubtitleTrack(1)` warned `available: 0` and dropped the request.
///
/// Jellyfin's route is `/Videos/{item}/{source}/Subtitles/{index}/Stream.{fmt}`
/// (verified against a live server: this shape answers 200, the one built
/// here answered 404). The `Stream.` segment is not decoration — without it
/// the path matches no route.
///
/// The old mock-based URL tests could not catch this: they asserted the
/// shape of a *test helper* that duplicated the format string, not of the
/// URL the app actually requests.
///
/// TRACES: UR-020 | JA-008, DR-259 | UT-234
#[test]
fn subtitle_url_uses_jellyfins_stream_route() {
let repo = create_test_repository();
assert_eq!(
repo.get_subtitle_url("item123", "source456", 2, "vtt"),
"https://test.server.com/Videos/item123/source456/Subtitles/2/Stream.vtt"
);
}
/// Build a repository wired to a real ConnectivityReporter so we can assert /// Build a repository wired to a real ConnectivityReporter so we can assert
/// how `report_outcome` classifies each `RepoError` into reachability. /// how `report_outcome` classifies each `RepoError` into reachability.
/// (No app handle → event emission is a harmless no-op.) /// (No app handle → event emission is a harmless no-op.)
+28 -9
View File
@@ -145,18 +145,37 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_audio_track", { streamIndex }); return await TAURI_INVOKE("player_set_audio_track", { streamIndex });
}, },
/** /**
* 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 * Note: Frontend should handle saving series preferences after this command succeeds
* *
* The split is the requirement: an HTML5 `<video>` element cannot be told to * What decides the route is **whether the stream in front of the engine
* change audio track, so the stream is re-opened at the chosen * carries the requested track at all** see
* `AudioStreamIndex` and the frontend seeks the reloaded element back to * [`determine_audio_track_switch_strategy`]:
* `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.
* *
* 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
*/ */
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> { async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId }); return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId });