Compare commits

..
4 Commits
Author SHA1 Message Date
dtourolle a94632461b chore(release): bump version to v0.11.1
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m47s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 15m4s
Build & Release / Build Linux (push) Successful in 20m48s
Build & Release / Build Windows (push) Successful in 16m14s
Build & Release / Build Android (push) Successful in 31m22s
Build & Release / Create Release (push) Successful in 33s
2026-08-23 19:20:15 +02:00
dtourolle 64de22bd51 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.
2026-08-23 19:15:05 +02:00
dtourolle 231ffae626 fix(library): podcasts list newest episode first
A Jellypod podcast listed its episodes alphabetically. The store pinned
SortBy=SortName onto every drill-down, which overrode the order the
channel plugin returns — and since Jellypod prefixes played episodes with
"[Played]", the name sort also clumped every heard episode at the top.

Which order a container's children take is domain knowledge, so it moves
to Rust: the caller names the container (GetItemsOptions.parentKind) and
default_listing_sort answers with the sort. A channel folder is
PremiereDate descending, every other container keeps SortName ascending,
and a caller naming no container still gets no SortBy, so the paths that
rely on the server's own order keep it. An explicit sort always wins.

ChannelFolderItem with is_folder now maps to MediaKind::ChannelFolder
instead of collapsing into Folder — while both were Folder there was
nothing to key the rule on. The offline leg of the cache/server race
applies the same order, so the cached list no longer flashes in name
order before the server's arrives.

TRACES: UR-007 | DR-257 | UT-229, UT-230, UT-231
2026-08-23 18:38:24 +02:00
dtourolle 2ff07bfa49 fix(player): one menu at a time, and inside the screen it opens on
Two defects in the video control bar, reported together because they present
together: the menus cover each other, and in portrait they cover the edge of
the screen instead of the video.

DR-256 (a) — audio track, quality and subtitles each owned a `show…` boolean
and no toggle cleared the others. Opening a second menu stacked it over the
first in the same corner: the newer panel hid rows of the older, both stayed
live, and both kept taking clicks. A single `openMenu` value replaces the three
booleans, which makes "at most one menu is open" a property of the state rather
than something every handler has to remember to enforce. The desktop volume
popup was a fourth uncoordinated menu in the same row, so `VolumeControl` grew
optional controlled-open props and joined the group; without them it still
manages itself, which is how MiniPlayer and AudioPlayer keep it.

DR-256 (b) — every panel was `absolute right-0` against *its own icon button*.
Those icons sit mid-row, so a 200-220 px panel extended left from a point well
inside the bar and hung off the left edge of a phone in portrait: half the
tracks could not be read, let alone 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. A full-screen dismiss layer inside the controls subtree
closes it on a tap elsewhere — inside, so the tap never reaches the container's
gesture layer and cannot toggle playback (DR-098).

The volume popup had the same placement bug from the other side: `left-full`
opened it rightward from an icon near the right end of every bar it appears in.
It opens upward, right-aligned, now. And the icon row wraps rather than
overflowing — in portrait the transport controls plus nine icons are wider than
the screen, which pushed fullscreen and close past the edge.

The test renders the real component and drives the toggles, because neither
fault is visible from a helper: both are properties of the composition. It was
written first and failed on both counts — `["Audio Track", "Subtitles"]` open at
once, and no shared panel to anchor.
2026-08-23 17:53:16 +02:00
27 changed files with 1195 additions and 301 deletions
+49
View File
@@ -9,6 +9,45 @@ 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
[docs/defect-windows.md](docs/defect-windows.md).
## v0.11.1
Four fixes. Two had been present since the first release and were 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)
- **The player's menus stop covering each other, and stay on screen.** Opening
the quality menu on top of the audio menu left both open in the same corner,
the newer hiding rows of the older and both still taking taps. Only one opens
now. They were also positioned against the icon that opened them, which sits
mid-row — so on a phone held upright a panel hung off the left edge and half
its rows could not be read or reached. (UR-020, UR-021, UR-066, UR-074 →
DR-256)
- **Podcast episodes list newest first.** Every list was sorted by name
regardless of what it contained, which for a podcast discards the running
order — and because played episodes are labelled as such by the server,
sorting by name also gathered everything already heard at the top. What order
a container's children take is now decided by what the container is.
(UR-007 → DR-257)
## v0.11.0
Video can play through the native renderer on Linux, and the machinery every
@@ -93,6 +132,16 @@ fact about the platform rather than asking the thing that would know.
for was not discarded if you skipped onward first — so the next item began
wherever you had dragged to in the previous one. (DR-253)
- **The player's menus no longer cover each other, or the edge of the screen.**
Audio track, quality and subtitles could all be open at once, stacked in the
same corner with the newest panel hiding rows of the one underneath, and each
one was anchored to its own icon — which sits mid-row, so on a phone in
portrait the panel hung off the left edge and half the tracks could not be
read or tapped. One menu is open at a time now (the desktop volume slider
included), it opens against the edge of the control bar clamped to the screen
it is on, and tapping anywhere else dismisses it. The row of icons wraps
instead of pushing fullscreen and close past the edge. (DR-256)
### 🧹 Under the hood
- The conformance suite can be run on its own: `bun run test:player` for the
+27
View File
@@ -49,6 +49,33 @@ sequenceDiagram
- Background cache updates (planned)
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
### Listing order is decided in Rust
**TRACES**: UR-007 | DR-257
A browse call names the **container** (`GetItemsOptions.parentKind`, the neutral
`MediaKind` the caller already holds) and not a sort field.
`default_listing_sort` in `repository/types.rs` turns that kind into the order:
| Container kind | Order |
|---|---|
| `channelFolder` — one podcast inside a plugin channel | `PremiereDate` descending |
| any other container | `SortName` ascending |
| none given | no `SortBy` — the server's own order stands |
Both legs of the race apply it, so the cached list does not flash in name order
before the server's arrives. An explicit `sortBy` from the caller always wins;
the default only fills the gap.
This is a domain rule, not a display preference, which is why it is not in the
frontend: the store that asks for a podcast's episodes has no business knowing
that podcasts are read newest-first. `MediaKind::ChannelFolder` exists for the
same reason — Jellyfin gives a channel container and an ordinary folder the same
item type (`ChannelFolderItem`), and while both mapped to `Folder` there was
nothing to key the rule on. The defect this prevents: every Jellypod podcast
listed alphabetically, which discarded the release order *and* clumped every
`[Played] …` episode at the top of the list.
## Search Flow (Locally Indexed)
**TRACES**: UR-065 | DR-108 … DR-111, IR-030
+5 -2
View File
@@ -17,8 +17,8 @@ row can be re-checked or disputed:
## Present since the first release
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped
for between two weeks and seven weeks short of two months before anyone hit them.
Fifteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
shipped for between two weeks and two months before anyone hit them.
That is the dominant pattern here: not regressions, but original assumptions that
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 |
| `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 |
| 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 |
| 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
+13 -1
View File
@@ -449,6 +449,10 @@ Internal architecture, components, and application logic.
| DR-253 | A deferred seek is discarded when the file it was issued against stops being the one loading. `seek` holds a position while MPV has nothing loaded and the `FileLoaded` handler applies it (DR-241), but neither `load` nor `stop` cleared it — so scrubbing near the end of a transcoded item, which re-opens the stream, and then skipping to the next item before the reload completed applied the old position to the new item. It started wherever the previous one had been scrubbed to, silently | Player | UR-040, UR-005 | Done |
| DR-254 | Advancing to the next episode drops a per-playback quality override. The override is process-wide and describes one playback: a viewer who drops to 720p for a struggling episode has said nothing about the next. Every advance the frontend drives clears it via `player_play_item`; the background audio-only advance loads the next episode in Rust and skipped all three clearing sites, so every later episode stayed capped with nothing in the UI saying why | Repository | UR-074 | 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-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 |
---
@@ -465,7 +469,7 @@ Internal architecture, components, and application logic.
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
@@ -767,6 +771,14 @@ Internal architecture, components, and application logic.
| UT-224 | Stopping clears an active background-audio handoff, both the flag and the base offset, so a later position read cannot be interpreted against a handoff that no longer exists. Previously verified only by listening to a device | DR-250 | Done |
| UT-225 | Both `load` and `stop` discard a deferred seek, so a position held for a file that is no longer loading cannot be applied to whatever loads next | DR-253 | Done |
| UT-226 | The background episode advance clears the per-playback quality override, so a ceiling chosen for one episode does not cap every episode after it | DR-254 | Done |
| UT-227 | Opening any one of the control bar's menus closes whichever was open — track, quality, subtitle and the desktop volume popup are one group, never two panels at once — and a second click on the open menu's own toggle closes it | DR-256 | Done |
| UT-228 | The open menu panel is anchored to the control row rather than to the icon that opened it, and carries a viewport-clamped width, so it cannot hang off the edge of a portrait screen | DR-256 | 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-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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.11.0",
"version": "0.11.1",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
+1 -1
View File
@@ -2181,7 +2181,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.11.0"
version = "0.11.1"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -4,7 +4,7 @@ name = "jellytau"
# `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous.
default-run = "jellytau"
version = "0.11.0"
version = "0.11.1"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
+121 -29
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,30 +1646,54 @@ 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")?;
// Get current item to find Jellyfin ID
let jellyfin_item_id = {
// 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())?;
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,
)
};
let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
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())?;
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) — and `position`
// below tells the frontend where to seek the reloaded element back to.
// 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
@@ -1664,19 +1708,67 @@ pub async fn player_switch_audio_track(
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
Ok(AudioTrackSwitchResponse::ReloadStream {
// 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: current_position.unwrap_or(0.0),
})
} else {
// Native backend (Android ExoPlayer) - use array index
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
.set_audio_track(array_index)
.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 })
}
}
}
/// Change the bandwidth ceiling of the video that is playing *right now*.
+9 -2
View File
@@ -72,7 +72,7 @@ pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
// channel leaf (distinct kind so the UI can route it to playback).
"ChannelFolderItem" => {
if is_folder {
MediaKind::Folder
MediaKind::ChannelFolder
} else {
MediaKind::ChannelItem
}
@@ -142,11 +142,18 @@ mod tests {
assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
}
/// A channel container is not an ordinary folder. Jellyfin gives both the
/// same item type, but only the channel one holds plugin content whose
/// natural order is by release date — a podcast, for instance. Collapsing
/// it into `Folder` left the repository with no way to tell the two apart,
/// so every podcast listed alphabetically.
///
/// TRACES: UR-007 | DR-257 | UT-230
#[test]
fn channel_folder_item_disambiguates_on_is_folder() {
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", true),
MediaKind::Folder
MediaKind::ChannelFolder
);
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", false),
+8
View File
@@ -48,6 +48,14 @@ pub enum MediaKind {
/// seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
/// and from `Other` so the UI can route it to playback.
ChannelItem,
/// A *container* inside a channel — a Jellyfin `ChannelFolderItem` that is
/// itself a folder, e.g. one podcast within a podcast channel. Distinct
/// from `Folder` because its children are plugin content with an order of
/// their own (newest episode first), which a folder's name order silently
/// overrode.
///
/// TRACES: UR-007 | DR-257
ChannelFolder,
/// A kind we do not model explicitly. Reached only for provider item types
/// that map to nothing meaningful; consumers treat it like an opaque
/// container. The mapping must be *total* — it never panics — so this is the
+5 -1
View File
@@ -53,7 +53,11 @@ fn kind_rank(kind: MediaKind) -> u8 {
// Top-level containers a user is most likely to be looking for.
MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
// Sub-containers and standalone collections.
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
MediaKind::Season
| MediaKind::Playlist
| MediaKind::Channel
| MediaKind::ChannelFolder
| MediaKind::Folder => 1,
// Leaves — an episode/track is a match *inside* something bigger.
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
2
+2
View File
@@ -23,6 +23,7 @@ pub mod session;
pub mod sleep_timer;
pub mod state;
pub mod stream_end;
pub mod track_switch;
#[cfg(test)]
mod mpv_backend_test;
@@ -71,6 +72,7 @@ pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
pub use session::{MediaSessionManager, MediaSessionType};
pub use sleep_timer::{SleepTimerMode, SleepTimerState};
pub use state::{EndReason, PlayerState};
pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchStrategy};
// Re-export platform-specific backends
#[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
);
}
}
+23 -4
View File
@@ -1239,10 +1239,29 @@ impl MediaRepository for OfflineRepository {
let start_index = opts.start_index.unwrap_or(0);
// SortBy=Random is the only sort the landing pages rely on offline (the
// hero "surprise" pool); everything else keeps the stable name order.
let order_by = match opts.sort_by.as_deref() {
Some("Random") => "RANDOM()",
_ => "i.sort_name ASC, i.name ASC",
// hero "surprise" pool); PremiereDate is what a channel folder's
// children are listed by (DR-257), so the cached leg of the race agrees
// with the server's order instead of flashing a name-sorted list first.
// Everything else keeps the stable name order.
//
// Rows with no premiere date sort last rather than leading the list.
let default_sort = default_listing_sort(opts.parent_kind);
let sort_field = opts
.sort_by
.as_deref()
.or(default_sort.map(|(field, _)| field));
let descending = opts
.sort_order
.as_deref()
.or(default_sort.map(|(_, order)| order))
== Some("Descending");
let order_by = match sort_field {
Some("Random") => "RANDOM()".to_string(),
Some("PremiereDate") => format!(
"i.premiere_date IS NULL, i.premiere_date {}, i.sort_name ASC",
if descending { "DESC" } else { "ASC" }
),
_ => "i.sort_name ASC, i.name ASC".to_string(),
};
// Bind the type filter rather than interpolating it: `include_item_types`
+119 -3
View File
@@ -1230,7 +1230,22 @@ fn build_get_items_endpoint(
.collect();
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
}
if let Some(sort_by) = &opts.sort_by {
// An explicit sort always wins; the container's default only fills the
// gap when the caller named none. A caller that names neither gets no
// SortBy at all, leaving the server's own order intact.
//
// TRACES: UR-007 | DR-257 | UT-229
let default_sort = default_listing_sort(opts.parent_kind);
let sort_by = opts
.sort_by
.as_deref()
.or(default_sort.map(|(field, _)| field));
let sort_order = opts
.sort_order
.as_deref()
.or(default_sort.map(|(_, order)| order));
if let Some(sort_by) = sort_by {
// SortBy is likewise a comma-delimited list (`hybrid.rs` sends
// "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
let encoded: Vec<String> = sort_by
@@ -1239,7 +1254,7 @@ fn build_get_items_endpoint(
.collect();
endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
}
if let Some(sort_order) = &opts.sort_order {
if let Some(sort_order) = sort_order {
endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
}
if let Some(recursive) = opts.recursive {
@@ -2454,8 +2469,15 @@ impl MediaRepository for OnlineRepository {
stream_index: i32,
format: &str,
) -> 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!(
"{}/Videos/{}/{}/Subtitles/{}/{}",
"{}/Videos/{}/{}/Subtitles/{}/Stream.{}",
self.server_url, item_id, media_source_id, stream_index, format
)
}
@@ -2988,6 +3010,7 @@ impl MediaRepository for OnlineRepository {
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::MediaKind;
use crate::utils::lock::MutexSafe;
use std::sync::Arc;
@@ -3003,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
/// how `report_outcome` classifies each `RepoError` into reachability.
/// (No app handle → event emission is a harmless no-op.)
@@ -3987,6 +4039,70 @@ mod tests {
assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
}
/// The reported bug: a Jellypod podcast listed its episodes alphabetically,
/// so "[Played] …" titles clumped at the top and a new episode landed
/// wherever its name happened to fall.
///
/// The cause was the frontend asking for `SortBy=SortName` on *every*
/// drill-down, which overrides the order the channel plugin itself would
/// have returned. Which order a container's children take is domain
/// knowledge, so the caller now names the container and the repository
/// answers with the sort: a channel folder is release-date-newest-first,
/// everything else keeps the name order it had.
///
/// TRACES: UR-007 | DR-257 | UT-229
#[test]
fn test_get_items_endpoint_orders_channel_folders_by_release_date() {
let podcast = build_get_items_endpoint(
"u1",
"podcast-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::ChannelFolder),
..Default::default()
}),
);
assert!(
podcast.contains("&SortBy=PremiereDate&SortOrder=Descending"),
"{podcast}"
);
// Every other container keeps the name order the app has always used.
let season = build_get_items_endpoint(
"u1",
"season-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::Season),
..Default::default()
}),
);
assert!(
season.contains("&SortBy=SortName&SortOrder=Ascending"),
"{season}"
);
// An explicit sort still wins — the default only fills a gap.
let explicit = build_get_items_endpoint(
"u1",
"podcast-1",
Some(&GetItemsOptions {
parent_kind: Some(MediaKind::ChannelFolder),
sort_by: Some("SortName".to_string()),
sort_order: Some("Ascending".to_string()),
..Default::default()
}),
);
assert!(
explicit.contains("&SortBy=SortName&SortOrder=Ascending"),
"{explicit}"
);
assert!(!explicit.contains("SortBy=PremiereDate"), "{explicit}");
// A caller that names no container is left alone, so the paths that
// rely on the server's own order (a playlist's stored order) keep it.
let unspecified = build_get_items_endpoint("u1", "lib-1", None);
assert!(!unspecified.contains("SortBy="), "{unspecified}");
}
/// A newly-added album must arrive as one entry, not one per track.
///
/// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
+30
View File
@@ -342,6 +342,36 @@ pub struct GetItemsOptions {
/// TRACES: UR-067 | DR-116 | UT-104
#[serde(skip_serializing_if = "Option::is_none")]
pub favorites_only: Option<bool>,
/// What the container being listed *is*, so the repository can pick the
/// order its children belong in when the caller names none. The frontend
/// sends the neutral kind it already holds; what that kind implies about
/// ordering is decided here, the same division as `SearchScope`.
///
/// TRACES: UR-007 | DR-257
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_kind: Option<crate::domain::MediaKind>,
}
/// The order a container's children take when the caller asked for none.
///
/// Ordering by *name* is right for a library, a series or an album, and wrong
/// for a channel folder: plugin channels — a podcast feed, say — carry a
/// release date and are read newest-first, and Jellypod additionally prefixes
/// played episodes with "[Played]", so a name sort clumped every heard episode
/// at the top of the list. Returns `None` when no container kind was given, so
/// callers that deliberately rely on the server's own order keep it.
///
/// This mapping is domain vocabulary and lives here rather than in the
/// frontend, for the reason in docs/specs/scoped-search-boundary.md.
///
/// TRACES: UR-007 | DR-257 | UT-229
pub fn default_listing_sort(
parent_kind: Option<crate::domain::MediaKind>,
) -> Option<(&'static str, &'static str)> {
match parent_kind? {
crate::domain::MediaKind::ChannelFolder => Some(("PremiereDate", "Descending")),
_ => Some(("SortName", "Ascending")),
}
}
/// An opaque search scope the frontend selects; Rust owns what it *means*.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.11.0",
"version": "0.11.1",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+48 -10
View File
@@ -145,18 +145,37 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
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
*
* 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
*/
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 });
@@ -2305,7 +2324,16 @@ export type GetItemsOptions = { startIndex?: number | null; limit?: number | nul
*
* TRACES: UR-067 | DR-116 | UT-104
*/
favoritesOnly?: boolean | null }
favoritesOnly?: boolean | null;
/**
* What the container being listed *is*, so the repository can pick the
* order its children belong in when the caller names none. The frontend
* sends the neutral kind it already holds; what that kind implies about
* ordering is decided here, the same division as `SearchScope`.
*
* TRACES: UR-007 | DR-257
*/
parentKind?: MediaKind | null }
/**
* Image options
*/
@@ -2463,6 +2491,16 @@ export type MediaKind = "track" | "album" | "artist" | "playlist" | "movie" | "s
* and from `Other` so the UI can route it to playback.
*/
"channelItem" |
/**
* A *container* inside a channel a Jellyfin `ChannelFolderItem` that is
* itself a folder, e.g. one podcast within a podcast channel. Distinct
* from `Folder` because its children are plugin content with an order of
* their own (newest episode first), which a folder's name order silently
* overrode.
*
* TRACES: UR-007 | DR-257
*/
"channelFolder" |
/**
* A kind we do not model explicitly. Reached only for provider item types
* that map to nothing meaningful; consumers treat it like an opaque
@@ -0,0 +1,239 @@
/**
* Regression tests for the video player's track / quality / subtitle menus,
* rendered against the REAL component.
*
* TRACES: UR-020, UR-021, UR-066, UR-074 | DR-256 | UT-227, UT-228
*
* Two defects shipped together, and neither is visible from a pure helper:
*
* 1. Each menu owned its own `show…` boolean and no toggle cleared the others,
* so opening the subtitle menu on top of the audio menu left two panels
* overlapping in the same corner the newer one covering rows of the
* older one, both still live.
*
* 2. Each panel was `absolute right-0` against *its own icon button*, which
* sits mid-row. A 200220 px panel hung off the left edge of a portrait
* phone, so half the tracks could not be read or tapped.
*
* Both are properties of the composition, so these tests drive the real
* markup: click the toggles, then assert what a viewer would see.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, fireEvent } from "@testing-library/svelte";
import { tick } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import VideoPlayer from "./VideoPlayer.svelte";
function testSelection() {
return {
url: "http://x/master.m3u8",
transport: { type: "hls" },
playbackKind: { type: "transcode" },
rendition: null,
available: [
{
quality: "original",
label: "Original",
detail: "Source",
exceedsSource: false,
sourceBitrate: 8_000_000,
},
{
quality: "high",
label: "8 Mbps",
detail: "1080p",
exceedsSource: false,
sourceBitrate: 8_000_000,
},
],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: true,
} as unknown as import("$lib/api/bindings").StreamSelection;
}
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/player", () => ({
playerController: {
toggle: vi.fn(() => Promise.resolve()),
seekVideo: vi.fn(() => Promise.resolve()),
seek: vi.fn(() => Promise.resolve()),
setActiveAdapter: vi.fn(),
clearActiveAdapter: vi.fn(),
getActiveAdapter: vi.fn(() => null),
switchAudioTrack: vi.fn(() => Promise.resolve()),
setStreamQuality: vi.fn(() => Promise.resolve(null)),
},
}));
vi.mock("$lib/player/adapters/rustReportHost", () => ({
createRustReportHost: () => ({
onState: vi.fn(),
onPosition: vi.fn(),
onMediaLoaded: vi.fn(),
onEnded: vi.fn(),
onError: vi.fn(),
onStreamUrlChanged: vi.fn(),
onBuffering: vi.fn(),
onReady: vi.fn(),
}),
}));
vi.mock("$lib/player/html5Adapter", () => ({
reportState: vi.fn(),
reportPosition: vi.fn(),
reportMediaLoaded: vi.fn(),
resetReporting: vi.fn(),
}));
vi.mock("$lib/utils/pictureInPicture", () => ({
isPipSupported: () => false,
enterPip: vi.fn(),
setAutoEnterEnabled: vi.fn(),
setHtml5VideoState: vi.fn(),
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getUserId: () => "user-1",
getRepository: () => ({ getHandle: () => "h", jrayActorsAt: async () => [] }),
subscribe: (fn: (v: unknown) => void) => {
fn({ isAuthenticated: true });
return () => {};
},
},
}));
/** Two audio tracks and one subtitle track — enough for all three menus. */
const MEDIA = {
id: "item-1",
name: "Test Episode",
type: "Episode",
runTimeTicks: 6_000_000_000,
durationMs: 600_000,
mediaStreams: [
{ index: 1, kind: "audio", displayTitle: "English AAC", language: "eng", isDefault: true },
{ index: 2, kind: "audio", displayTitle: "Commentary", language: "eng" },
{
index: 3,
kind: "subtitle",
displayTitle: "English SRT",
language: "eng",
codec: "srt",
deliverableAsSidecar: true,
},
],
} as any;
function renderPlayer() {
return render(VideoPlayer, {
props: { media: MEDIA, selection: testSelection(), onClose: vi.fn() },
});
}
/** The menu panels currently on screen, found by their headings. */
function openPanels(container: HTMLElement): string[] {
return ["Audio Track", "Quality", "Subtitles"].filter((heading) =>
[...container.querySelectorAll("div")].some(
(el) => el.children.length === 0 && el.textContent?.trim() === heading,
),
);
}
function clickToggle(container: HTMLElement, label: string) {
const button = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
expect(button, `expected a "${label}" button in the controls`).toBeTruthy();
return fireEvent.click(button!);
}
describe("VideoPlayer track menus", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
switch (cmd) {
case "player_get_streaming_qualities":
return [];
case "player_get_video_settings":
return { streamingQuality: "original" };
default:
return undefined;
}
});
});
// UT-227
it("opening one menu closes any other — never two panels stacked in the corner", async () => {
const { container } = renderPlayer();
await tick();
await clickToggle(container, "Select audio track");
await tick();
expect(openPanels(container)).toEqual(["Audio Track"]);
await clickToggle(container, "Select subtitles");
await tick();
expect(openPanels(container)).toEqual(["Subtitles"]);
await clickToggle(container, "Select streaming quality");
await tick();
expect(openPanels(container)).toEqual(["Quality"]);
// A second click on the open menu's own toggle closes it.
await clickToggle(container, "Select streaming quality");
await tick();
expect(openPanels(container)).toEqual([]);
});
// UT-227
it("the volume slider is part of the same group — it closes an open track menu", async () => {
const { container } = renderPlayer();
await tick();
await clickToggle(container, "Select subtitles");
await tick();
expect(openPanels(container)).toEqual(["Subtitles"]);
// The volume popup (desktop only) is a menu of this bar too.
const volume = container.querySelector<HTMLButtonElement>('button[title="Volume"]');
expect(volume).toBeTruthy();
await fireEvent.click(volume!);
await tick();
expect(container.querySelector("[aria-label='Volume controls']")).toBeTruthy();
expect(openPanels(container)).toEqual([]);
// …and a track menu closes the volume popup again.
await clickToggle(container, "Select subtitles");
await tick();
expect(container.querySelector("[aria-label='Volume controls']")).toBeNull();
expect(openPanels(container)).toEqual(["Subtitles"]);
});
// UT-228
it("the open panel is anchored to the control bar and clamped to the viewport", async () => {
const { container } = renderPlayer();
await tick();
for (const label of ["Select audio track", "Select subtitles", "Select streaming quality"]) {
await clickToggle(container, label);
await tick();
const panel = container.querySelector<HTMLElement>("[data-testid='player-menu']");
expect(panel, `${label} should open the shared menu panel`).toBeTruthy();
// Anchored to the control row, not to the icon button: a panel anchored
// to a mid-row button runs off the left edge in portrait.
const toggle = container.querySelector<HTMLElement>(`button[aria-label="${label}"]`);
expect(panel!.contains(toggle!)).toBe(false);
expect(toggle!.parentElement!.contains(panel!)).toBe(false);
// …and never wider than the screen it opens on.
expect(panel!.className).toMatch(/max-w-\[|w-\[min\(/);
await clickToggle(container, label);
await tick();
}
});
});
+141 -131
View File
@@ -305,18 +305,38 @@
getMediaSourceId: () => mediaSourceId ?? null,
};
/**
* Which of the control bar's menus is open, if any.
*
* ONE piece of state for all three, deliberately. They each used to own a
* `show…` boolean and no toggle cleared the others, so opening the subtitle
* menu while the audio menu was up left two panels overlapping in the same
* corner — the second covering rows of the first, both still live and both
* still taking clicks. A single value makes "at most one menu is open" a
* property of the type rather than something every handler has to remember.
*
* TRACES: UR-020, UR-021, UR-074 | DR-256 | UT-227
*/
type PlayerMenu = "audio" | "quality" | "subtitle" | "volume";
let openMenu = $state<PlayerMenu | null>(null);
function toggleMenu(menu: PlayerMenu) {
openMenu = openMenu === menu ? null : menu;
}
function closeMenu() {
openMenu = null;
}
// Audio track selection
let showAudioTrackMenu = $state(false);
let selectedAudioTrackIndex = $state<number | null>(null);
// Subtitle track selection
let showSubtitleMenu = $state(false);
let selectedSubtitleIndex = $state<number | null>(null);
// Streaming bandwidth ceiling. The ladder and the current value both come from
// Rust — the frontend never encodes what a step means.
// TRACES: UR-074 | DR-162
let showQualityMenu = $state(false);
let changingQuality = $state(false);
/**
* The device's durable default, shown when the stream is a direct play and so
@@ -580,7 +600,7 @@
!shouldHideControls({
isPlaying,
isSeeking,
menuOpen: showAudioTrackMenu || showSubtitleMenu || showQualityMenu,
menuOpen: openMenu !== null,
})
) {
return;
@@ -2353,15 +2373,11 @@
}, 800);
}
function toggleAudioTrackMenu() {
showAudioTrackMenu = !showAudioTrackMenu;
}
async function selectAudioTrack(streamIndex: number, arrayIndex: number) {
log.debug("Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
const previousTrackIndex = selectedAudioTrackIndex;
selectedAudioTrackIndex = streamIndex;
showAudioTrackMenu = false;
closeMenu();
try {
// The BACKEND decides whether the audio-track switch needs a transcode
@@ -2414,10 +2430,6 @@
}
}
function toggleQualityMenu() {
showQualityMenu = !showQualityMenu;
}
/**
* Re-open the current stream at a different bandwidth ceiling.
*
@@ -2434,7 +2446,7 @@
* TRACES: UR-074, UR-079 | DR-162, DR-226, DR-227
*/
async function selectQuality(quality: StreamingQuality) {
showQualityMenu = false;
closeMenu();
if (quality === selectedQuality || changingQuality) return;
changingQuality = true;
@@ -2470,10 +2482,6 @@
}
}
function toggleSubtitleMenu() {
showSubtitleMenu = !showSubtitleMenu;
}
/**
* Show exactly one (or no) text track on the HTML5 element. `null` disables
* every track, which is what the menu's "Off" entry means.
@@ -2516,7 +2524,7 @@
async function selectSubtitle(streamIndex: number | null) {
log.debug("Selecting subtitle - streamIndex:", streamIndex);
selectedSubtitleIndex = streamIndex;
showSubtitleMenu = false;
closeMenu();
// For HTML5 video element, update the text tracks
if (useHtml5Element) {
@@ -2841,58 +2849,31 @@
</div>
{/if}
<!-- Control buttons -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<!-- Play/Pause -->
<!-- Control buttons.
`relative` because the track / quality / subtitle panel below is
anchored to this ROW, not to the icon that opens it. Anchoring each
panel to its own button put a 220 px panel under a mid-row icon, which
hangs off the left edge of a portrait phone. TRACES: UR-066 | DR-256 -->
<div class="relative flex items-center justify-between">
<!-- One panel, one open menu. Each menu used to own a `show…` boolean
that no other toggle cleared, so a second menu opened stacked on top
of the first. TRACES: DR-256 -->
{#if openMenu && openMenu !== "volume"}
<!-- Tapping anywhere else dismisses the menu. Inside the controls
subtree, so a tap here never reaches the container tap gestures
(DR-098) and it disappears with the bar. -->
<button
onclick={togglePlayPause}
class="text-white hover:text-gray-300"
aria-label={isPlaying ? "Pause" : "Play"}
>
{#if isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
{:else}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Next Episode -->
{#if hasNext}
<button onclick={onNext} class="text-white hover:text-gray-300" aria-label="Next episode">
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
</button>
{/if}
</div>
<div class="flex items-center gap-4">
<!-- Audio Track Selection -->
{#if audioTracks().length > 1}
<div class="relative">
<button
onclick={toggleAudioTrackMenu}
class="text-white hover:text-gray-300"
aria-label="Select audio track"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
</button>
<!-- Audio Track Menu -->
{#if showAudioTrackMenu}
class="fixed inset-0 z-10 cursor-default"
onclick={closeMenu}
aria-label="Close menu"
tabindex="-1"
></button>
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
data-testid="player-menu"
class="absolute bottom-full right-0 mb-2 z-20 w-[min(20rem,calc(100vw-2rem))] max-h-[min(300px,45vh)] overflow-y-auto bg-black/90 backdrop-blur-sm rounded-lg shadow-xl"
>
<div class="p-2">
{#if openMenu === "audio"}
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Audio Track
</div>
@@ -2912,7 +2893,7 @@
</span>
{#if selectedAudioTrackIndex === track.index}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
@@ -2921,37 +2902,7 @@
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!--
Streaming quality (bandwidth ceiling), populated from what this media
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-227
-->
{#if qualityOptions.length > 1}
<div class="relative">
<button
onclick={toggleQualityMenu}
class="text-white hover:text-gray-300 disabled:opacity-50"
disabled={changingQuality}
aria-label="Select streaming quality"
>
<!-- Speedometer: bitrate ceiling -->
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
/>
</svg>
</button>
{#if showQualityMenu}
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
{:else if openMenu === "quality"}
<div class="px-3 py-2 border-b border-white/20">
<div class="text-white text-sm font-semibold">Quality</div>
<!--
@@ -2977,7 +2928,7 @@
</div>
{#if selectedQuality === option.quality}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
@@ -2986,33 +2937,7 @@
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Subtitle Selection -->
{#if subtitleTracks().length > 0}
<div class="relative">
<button
onclick={toggleSubtitleMenu}
class="text-white hover:text-gray-300"
aria-label="Select subtitles"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
/>
</svg>
</button>
<!-- Subtitle Menu -->
{#if showSubtitleMenu}
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
{:else if openMenu === "subtitle"}
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Subtitles
</div>
@@ -3027,7 +2952,7 @@
<span class="text-sm">Off</span>
{#if selectedSubtitleIndex === null}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
@@ -3060,7 +2985,7 @@
</div>
{#if selectedSubtitleIndex === track.index}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
@@ -3069,10 +2994,90 @@
{/if}
</button>
{/each}
{/if}
</div>
</div>
{/if}
<div class="flex items-center gap-4">
<!-- Play/Pause -->
<button
onclick={togglePlayPause}
class="text-white hover:text-gray-300"
aria-label={isPlaying ? "Pause" : "Play"}
>
{#if isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
{:else}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Next Episode -->
{#if hasNext}
<button onclick={onNext} class="text-white hover:text-gray-300" aria-label="Next episode">
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
</button>
{/if}
</div>
<!-- Wraps rather than overflowing: in portrait these icons plus the
transport controls are wider than the screen, and the last of them
(fullscreen, close) went off the edge. TRACES: UR-066 | DR-256 -->
<div class="flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<!-- Audio Track Selection -->
{#if audioTracks().length > 1}
<button
onclick={() => toggleMenu("audio")}
class="text-white hover:text-gray-300"
aria-label="Select audio track"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
</button>
{/if}
<!--
Streaming quality (bandwidth ceiling), populated from what this media
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-227
-->
{#if qualityOptions.length > 1}
<button
onclick={() => toggleMenu("quality")}
class="text-white hover:text-gray-300 disabled:opacity-50"
disabled={changingQuality}
aria-label="Select streaming quality"
>
<!-- Speedometer: bitrate ceiling -->
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
/>
</svg>
</button>
{/if}
<!-- Subtitle Selection -->
{#if subtitleTracks().length > 0}
<button
onclick={() => toggleMenu("subtitle")}
class="text-white hover:text-gray-300"
aria-label="Select subtitles"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
/>
</svg>
</button>
{/if}
<!-- Sleep Timer -->
@@ -3098,8 +3103,13 @@
</button>
{/if}
<!-- Volume Control -->
<VolumeControl size="md" />
<!-- Volume Control. Its popup is a menu of this bar like any other,
so the bar owns whether it is open. TRACES: DR-256 -->
<VolumeControl
size="md"
open={openMenu === "volume"}
onOpenChange={(next) => (openMenu = next ? "volume" : null)}
/>
<!-- Picture-in-picture (Android only) -->
{#if pipSupported}
+20 -8
View File
@@ -7,16 +7,31 @@
interface Props {
size?: "sm" | "md" | "lg";
/**
* Controlled open state. Omit it and the slider manages its own; pass it
* (with `onOpenChange`) when the host has other menus that must not be
* open at the same time — the video player's control bar does.
*
* TRACES: DR-256
*/
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { size = "md" }: Props = $props();
let { size = "md", open, onOpenChange }: Props = $props();
// On Android, volume is controlled by system volume buttons (not a slider)
const isAndroid = platform() === "android";
let showSlider = $state(false);
let selfOpen = $state(false);
const showSlider = $derived(open ?? selfOpen);
let sliderValue = $state($mergedVolume);
function setOpen(next: boolean) {
if (onOpenChange) onOpenChange(next);
else selfOpen = next;
}
// Sync slider with merged volume (handles both local and remote)
$effect(() => {
sliderValue = $mergedVolume;
@@ -44,7 +59,7 @@
}
function toggleSlider() {
showSlider = !showSlider;
setOpen(!showSlider);
}
// Icon sizes based on prop (use $derived for reactivity)
@@ -106,7 +121,7 @@
<!-- Volume Slider (toggle on click) -->
{#if showSlider}
<div
class="absolute left-full ml-2 bg-[var(--color-surface)] rounded-lg shadow-lg p-3 z-[70] flex items-center gap-2"
class="absolute bottom-full right-0 mb-2 max-w-[calc(100vw-2rem)] bg-[var(--color-surface)] rounded-lg shadow-lg p-3 z-[70] flex items-center gap-2"
role="group"
aria-label="Volume controls"
>
@@ -153,9 +168,6 @@
<!-- Click outside to close volume slider -->
{#if showSlider}
<button
class="fixed inset-0 z-[65]"
onclick={() => (showSlider = false)}
aria-label="Close volume"
<button class="fixed inset-0 z-[65]" onclick={() => setOpen(false)} aria-label="Close volume"
></button>
{/if}
+15 -4
View File
@@ -3,7 +3,7 @@
import { writable, derived } from "svelte/store";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
import type { Library, MediaItem, MediaKind, SearchResult, Genre } from "$lib/api/types";
import type { SearchOptions } from "$lib/api/bindings";
import type { SearchScope } from "$lib/utils/searchScope";
import { auth } from "./auth";
@@ -113,9 +113,21 @@ function createLibraryStore() {
}
}
// What a container's children are ordered by is domain knowledge, so the
// store names the *container* and Rust answers with the sort (see
// `default_listing_sort`). A channel folder — one podcast inside a plugin
// channel — is read newest-episode-first; naming `SortName` here, as this did
// for every drill-down, threw that order away.
//
// TRACES: UR-007 | DR-257 | UT-231
async function loadItems(
parentId: string,
options: { startIndex?: number; limit?: number; genres?: string[] } = {},
options: {
startIndex?: number;
limit?: number;
genres?: string[];
parentKind?: MediaKind;
} = {},
) {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
@@ -129,8 +141,7 @@ function createLibraryStore() {
startIndex: options.startIndex ?? 0,
limit: options.limit ?? 10000,
fields: ["PrimaryImageAspectRatio", "Overview", "MediaStreams"],
sortBy: "SortName",
sortOrder: "Ascending",
parentKind: options.parentKind ?? "folder",
genres: options.genres,
});
@@ -0,0 +1,53 @@
/**
* What order a container's children come back in.
*
* The store used to pin `sortBy: "SortName"` onto every drill-down, which is
* where the podcast bug came from: a Jellypod channel folder lists its episodes
* newest-first, and an alphabetical sort not only lost that order but clumped
* every "[Played] …" title at the top. The store now says *what the container
* is* and lets Rust say how it orders the same division as `SearchScope`.
*
* TRACES: UR-007 | DR-257 | UT-231
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
const getItemsMock = vi.fn();
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock("./auth", () => ({
auth: {
getRepository: () => ({ getItems: getItemsMock }),
},
}));
import { library } from "./library";
describe("library.loadItems ordering", () => {
beforeEach(() => {
getItemsMock.mockReset();
getItemsMock.mockResolvedValue({ items: [], totalRecordCount: 0 });
});
it("names the container rather than a sort field", async () => {
await library.loadItems("podcast-1", { parentKind: "channelFolder" });
const options = getItemsMock.mock.calls[0][1];
expect(options.parentKind).toBe("channelFolder");
// Naming a sort field here would put the ordering rule back in the
// presentation layer, which is the leak this fix removes.
expect(options.sortBy).toBeUndefined();
expect(options.sortOrder).toBeUndefined();
});
it("falls back to a plain folder when the caller names no container", async () => {
await library.loadItems("library-1");
const options = getItemsMock.mock.calls[0][1];
expect(options.parentKind).toBe("folder");
expect(options.sortBy).toBeUndefined();
});
});
+1
View File
@@ -20,6 +20,7 @@ const KIND_LABELS: Record<MediaKind, string> = {
channel: "Channel",
liveChannel: "Live TV",
channelItem: "Channel",
channelFolder: "Channel",
folder: "Folder",
other: "",
};
+1
View File
@@ -155,6 +155,7 @@
case "album":
case "artist":
case "folder":
case "channelFolder":
case "playlist":
case "channel":
// Navigate to detail view
+5 -1
View File
@@ -171,7 +171,10 @@
}
}
await library.loadItems(itemId, { limit: 100 });
// Name the container so Rust can order its children: a podcast (a channel
// folder) is listed newest episode first, everything else by name.
// TRACES: UR-007 | DR-257
await library.loadItems(itemId, { limit: 100, parentKind: item?.kind });
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
// Some APIs/caches may not include people data on first load
@@ -277,6 +280,7 @@
case "album":
case "artist":
case "folder":
case "channelFolder":
case "playlist":
case "channel":
case "movie":
+1
View File
@@ -174,6 +174,7 @@
"series",
"season",
"folder",
"channelFolder",
"playlist",
"channel",
];