Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66e7889030 | ||
|
|
d25f6be697 | ||
|
|
079153d9d5 | ||
|
|
1ba836928f | ||
|
|
bbccc8567c | ||
|
|
ad05dcd484 | ||
|
|
8a2b484e36 | ||
|
|
1d6487774c | ||
|
|
1aec38b760 | ||
|
|
0187ee179e | ||
|
|
a94632461b | ||
|
|
64de22bd51 | ||
|
|
231ffae626 | ||
|
|
2ff07bfa49 |
+130
@@ -9,6 +9,126 @@ 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.4
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **A newly-added album shows up as one album, not as fourteen songs.**
|
||||
Importing an album filled the whole Recently Added row with that one album,
|
||||
one card per track, burying everything else added that week. The app does ask
|
||||
the server to group new tracks under their album, but the server only manages
|
||||
it for a track whose folder structure actually resolves an album — and older
|
||||
servers ignore the request altogether. The row is now grouped here as well, so
|
||||
what it shows is a property of the app rather than of the server it is talking
|
||||
to: one card per album, opening the album, keeping its artwork and artists, and
|
||||
standing where the first of its tracks stood so the newest is still first.
|
||||
Standalone tracks and movies are unaffected. (UR-024 → JA-016)
|
||||
|
||||
- **A binge plays on past the end of a season.** Autoplay only ever looked
|
||||
inside the current season, so the last episode of one was the end of the line:
|
||||
with the screen locked and background audio playing, that is felt as playback
|
||||
simply stopping mid-binge, with no visible player to start it again. It now
|
||||
crosses into the first episode of the next season that has any, skipping empty
|
||||
seasons and never wandering into Specials. A sleep timer set to end-of-episode
|
||||
still stops at the boundary — crossing it is autoplay's decision, not the
|
||||
timer's. (UR-023, UR-040 → DR-263)
|
||||
|
||||
- **The episode you just finished is no longer offered as the one to play next.**
|
||||
Watch an episode to the end, press Back, and the season list still ringed that
|
||||
same episode as "Up next" and scrolled to it. Nothing recorded the completion
|
||||
on the device — progress writes saved a position but never marked an episode
|
||||
watched, and cached server data dropped the watched flag on the way in — while
|
||||
the server's own "next up" answer is briefly one report behind and named the
|
||||
episode that had just ended. An episode is now counted as finished either when
|
||||
the server says so or when its position is past the same 95% mark that already
|
||||
disqualifies it from "continue watching", and the server's answer is checked
|
||||
against that before it is accepted. The highlight, the badge, the auto-scroll
|
||||
and which season opens expanded all follow the episode that really is next —
|
||||
and the checkmarks in the season list stay put. (UR-025, UR-062 → DR-264)
|
||||
|
||||
### 🛠 Development
|
||||
|
||||
- **A debug build installs alongside a release build again.** `bun run
|
||||
android:dev` produced an APK carrying the release application id, so installing
|
||||
it over a real release build failed outright and the obvious way out was to
|
||||
uninstall the release app and lose its data. The Tauri CLI rewrites the debug
|
||||
section of the generated Gradle file to inject its own settings, and that
|
||||
rewrite dropped the suffix that keeps the two apps apart; the suffix has moved
|
||||
somewhere the rewrite does not reach, and the build now asserts the id the APK
|
||||
actually carries rather than assuming it.
|
||||
|
||||
## v0.11.3
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **The A-Z jump strip no longer runs under the mini player.** On a long
|
||||
alphabetical list — an album's tracks, the music library — the last few
|
||||
letters sat behind the mini player and bottom nav, where they could not be
|
||||
tapped. The strip was sizing itself against the window minus a hardcoded guess
|
||||
at how tall those bars are, which stopped being true when they became part of
|
||||
the normal layout instead of floating over it, and was always short by the
|
||||
height of your phone's navigation bar. It now measures the list it belongs to
|
||||
and stops exactly where that list stops, whatever is below. (UR-007 → DR-262)
|
||||
|
||||
- **Subtitles sit on the picture instead of on a black bar.** Every line arrived
|
||||
in an opaque box, which is what Android hands back when no captioning
|
||||
preferences have been set. The box is gone; the text keeps its own outline so
|
||||
it stays readable over a bright scene. If you *have* set up captions in
|
||||
Android's accessibility settings, your colours, typeface and edges are still
|
||||
used — only the background is cleared. (UR-020 → DR-261)
|
||||
|
||||
## v0.11.2
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Subtitles appear on screen on Android.** Turning one on did nothing, even
|
||||
once they were loading again: the player hands finished subtitles to a view
|
||||
that draws them, and on the native Android path there was no such view — so
|
||||
every cue was decoded, delivered and dropped. There is one now, sitting over
|
||||
the picture and under the controls, following the video's shape when the
|
||||
screen turns. This was hidden behind the loading failure fixed in v0.11.1;
|
||||
with nothing to select, there had never been a cue to lose. (UR-020, UR-003 →
|
||||
DR-260)
|
||||
|
||||
## 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 +213,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -82,6 +85,7 @@ silently correct an out-of-range index — which is exactly why it was reported
|
||||
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
|
||||
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
|
||||
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | **v0.8.2** | feature (the handoff's progressive-mp3 choice) |
|
||||
| Recently Added trusted the server to group new tracks — `GroupItems=true` only groups a track whose parent chain resolves a `MusicAlbum`, and older servers ignore it | v0.5.1 | **v0.11.4** | feature (the v0.5.1 fix for the same symptom) |
|
||||
|
||||
Three of these are worth separating out, because the defect is not a mistake in
|
||||
the code so much as **plumbing that was built and never connected**:
|
||||
|
||||
+30
-3
@@ -449,6 +449,15 @@ 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-260 | Subtitle cues are **drawn**. ExoPlayer decodes subtitles and delivers them to a listener; it draws none of them itself, and native video here is a bare `TextureView` the WebView composites over rather than a `PlayerView`, so nothing was holding the cues and a selected track rendered nowhere. The gap was invisible while every subtitle URL 404ed (DR-259) — with no text track to select there was never a cue to drop, so fixing the URL is what exposed it. `media3-ui`'s `SubtitleView` now takes each `CueGroup` from `onCues` and is attached at index 1 of the content view: above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is torn down with the surface it belongs to. Verified on a device | Player | UR-020, UR-003 | Done |
|
||||
| DR-261 | Subtitles are drawn **over the picture, not on a black bar across it**. `SubtitleView.setUserDefaultStyle()` reads Android's captioning preferences and falls back to media3's `DEFAULT` when the viewer has set none — and that default is white on opaque black, so every line arrived in a box as wide as the text. The viewer's own style is kept and only the two colours that paint a box, background and window, are cleared: someone who has configured captions in accessibility settings has said something specific about colour, typeface and edges, and replacing all of it to remove a background would answer a question they did not ask. A style specifying no edge gets a black outline, because without a box the text must supply its own contrast or it is unreadable over a bright scene; a style that already names an edge keeps it | Player | UR-020 | Done (pending device verification) |
|
||||
| DR-262 | The A-Z jump strip is bounded by the **scroller it lives in**, not by the viewport minus a guess at the bottom bars. `AlphabetScrollBar` sized itself as `window.innerHeight` minus a hardcoded `bottomGap` — 5rem, 7rem or 11rem, picked by platform and whether the mini player was showing — which dates from when the mini player and bottom nav were `position: fixed` overlays. They have been in-flow flex siblings below the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is* the top of the mini player and can simply be measured. The guess was short on every device with a navigation or gesture bar, because `--safe-bottom` is padded *inside* BottomUi (DR-112) and no guess knew about it: the strip overran the scrollport by ~45px with the nav alone, ~18px with the mini player and ~50px in remote mode, burying one to three letters where they could not be tapped. The ancestor is resolved by computed `overflow-y` rather than `closest("main")`, since the root shell scrolls in a plain `<div>` and a miss silently fell back to the viewport — reinstating the bug on any route outside `/library`. Observing the scroller for resize is also what makes the mini player appearing re-measure, so the component no longer subscribes to player or platform stores at all | UI | UR-007 | Done |
|
||||
| DR-263 | Autoplay crosses the **season boundary**. `fetch_next_episode_for_item` listed the episodes of the current season and stopped dead at the last one, so the end of a season produced `AutoplayDecision::Stop`. On the Android background-audio handoff (UR-040) that is felt as playback simply pausing mid-binge with the screen locked and no UI to un-pause it — the same end that mid-season advances through in the backend. The lookup now walks the series' seasons, sorted client-side by index number because the offline repository ignores `sort_by`, and takes the first episode of the next season that has any, skipping empty ones. Specials are never rolled *into*: Jellyfin numbers them 0 so they sort ahead of season 1, but a server that leaves the index unset sorts them last, exactly where the walk would land. The lookup sits below the sleep-timer gate in `on_playback_ended`, so a timer set to end-of-episode or a remaining-episode count still stops at the boundary rather than being carried past it | Player | UR-023, UR-040 | Done |
|
||||
| DR-264 | The episode a viewer *just finished* is no longer offered as the one they are up to. Nothing records completion locally: the stop report writes a position through `storage_update_playback_progress` (which never sets `is_played`), and the cache mirror carried the server's flag not at all — so on a cache hit every episode read back as unwatched. Leaving the player with Back reloads the series page within a second of the stop report, inside the window where Jellyfin's Next Up still names the episode that just ended, and `pick_current_episode` handed it straight back: the season view kept the yellow ring and the "Up next" badge on the episode the viewer had just watched, and scrolled to it. Two halves. (a) `is_finished` — the played flag **or** a position at or past `MAX_PROGRESS_FRACTION` of the runtime, the same 95% threshold that already disqualifies an episode from counting as in-progress — replaces the bare `is_played` in the furthest-watched scan and the first-unwatched fallback, and screens the Next Up candidate: the server is one stop-report behind for a moment, the local position is not. (b) `OfflineRepository::mirror_user_data` carries `is_played` alongside the favourite flag and the position, under the same `pending_sync = 0` conflict rule, so watched state survives a cache write instead of being dropped — that flag was previously written by nothing but an explicit local toggle | Repository | UR-062 | 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 +474,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, DR-262 |
|
||||
| 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 |
|
||||
@@ -481,7 +490,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
||||
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
||||
| UR-022 | IR-017 | DR-025 |
|
||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263 |
|
||||
| UR-024 | IR-010 | DR-027 |
|
||||
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||
@@ -498,7 +507,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| UR-039 | - | DR-045, DR-046 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263 |
|
||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
@@ -767,6 +776,24 @@ 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 |
|
||||
| UT-235 | The A-Z strip's last letter stays above the bottom nav, above the mini player while audio plays, and above the taller remote-mode mini player | DR-262 | Done |
|
||||
| UT-236 | The strip still fills the space it does have rather than stopping a letter short | DR-262 | Done |
|
||||
| UT-237 | The strip falls back to the viewport with no scroll container, and never computes a negative height when scrolled past its floor | DR-262 | Done |
|
||||
| UT-238 | The last episode of a season rolls over into the first of the next, skips an empty season on the way, and still stops there when the sleep timer says so | DR-263 | Done |
|
||||
| UT-239 | An episode watched to the end is not the current episode: not when the server's Next Up still names it (the stale answer is skipped in favour of the one after it), and not offline, where it counts as watched in the furthest-watched scan | DR-264 | Done |
|
||||
| UT-240 | Caching a server result mirrors its played flag locally — as synced, never invented for an item that carries no user data, and never over an unsynced local toggle | DR-264 | Done |
|
||||
| UT-241 | A newly-imported album reads as one album card, not one card per song: three tracks sharing an album id collapse into a single `MusicAlbum` entry that opens the album, keeps its artwork and album artist, drops track-only detail (track number, duration, album link), and takes the position of the first of its tracks so recency order and the neighbouring movie survive | JA-016 | Done |
|
||||
| UT-242 | When the server did group, its own album row wins — its overview and detail survive and the tracks it also returned add no second card for the same album | JA-016 | Done |
|
||||
| UT-243 | A track that names no album has no container to collapse into and stays a track, the same way a movie does | JA-016 | Done |
|
||||
| UT-244 | Recently Added over-fetches before collapsing, so folding one 14-track import together does not leave the row nearly empty | JA-016 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
+7769
-6916
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.4",
|
||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||
|
||||
pkgname=jellytau
|
||||
pkgver=0.10.1
|
||||
pkgver=0.11.4
|
||||
pkgrel=1
|
||||
pkgdesc="A cross-platform Jellyfin client"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -128,6 +128,44 @@ else
|
||||
bun run tauri android build --apk --debug "${TARGET_ARGS[@]}"
|
||||
fi
|
||||
|
||||
# The applicationId the APK actually carries — not the one build.gradle.kts asks
|
||||
# for. `tauri android build` rewrites the debug `buildTypes` block in the
|
||||
# generated gradle file to inject its keepDebugSymbols entries, and that rewrite
|
||||
# used to drop `applicationIdSuffix` with it, silently producing a debug APK
|
||||
# under the release applicationId. Installing that over a real release build
|
||||
# fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE, whose only obvious remedy is
|
||||
# uninstalling the release app and losing its data — so this fails the build
|
||||
# instead. The suffix now lives outside the rewritten block (see
|
||||
# src-tauri/android/app/build.gradle.kts); this checks that it survived.
|
||||
assert_application_id() {
|
||||
local variant="$1" expected="$2"
|
||||
local metadata="src-tauri/gen/android/app/build/outputs/apk/universal/$variant/output-metadata.json"
|
||||
|
||||
[ -f "$metadata" ] || return 0
|
||||
|
||||
local actual
|
||||
actual=$(sed -n 's/.*"applicationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$metadata" | head -1)
|
||||
|
||||
if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then
|
||||
echo ""
|
||||
echo "❌ APK applicationId is '$actual', expected '$expected'."
|
||||
echo " A build meant for the side-by-side slot came out under the"
|
||||
echo " release applicationId; installing it would collide with a real"
|
||||
echo " install. Check that the applicationIdSuffix at the bottom of"
|
||||
echo " src-tauri/android/app/build.gradle.kts survived into"
|
||||
echo " src-tauri/gen/android/app/build.gradle.kts."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
assert_application_id debug "com.dtourolle.jellytau.debug"
|
||||
elif [ "$SIDE_BY_SIDE" = "1" ]; then
|
||||
assert_application_id release "com.dtourolle.jellytau.debug"
|
||||
else
|
||||
assert_application_id release "com.dtourolle.jellytau"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ APK build complete!"
|
||||
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
||||
|
||||
Generated
+1
-1
@@ -2181,7 +2181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.11.0"
|
||||
version = "0.11.4"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -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.4"
|
||||
description = "A cross-platform Jellyfin client"
|
||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -73,7 +73,9 @@ android {
|
||||
// fully-qualified class names Rust looks up over JNI, the manifest
|
||||
// <service> entry and the R8 keep rules are all unaffected. The
|
||||
// FileProvider authority is already ${applicationId}-relative.
|
||||
applicationIdSuffix = ".debug"
|
||||
//
|
||||
// The suffix itself is applied AFTER this block -- see the bottom of
|
||||
// this file. It cannot live here.
|
||||
versionNameSuffix = "-debug"
|
||||
manifestPlaceholders["appLabel"] = "JellyTau Debug"
|
||||
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
|
||||
@@ -127,6 +129,30 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// The debug applicationId suffix, applied from OUTSIDE the `buildTypes` block.
|
||||
//
|
||||
// `tauri android build` rewrites the `getByName("debug")` block in the *generated*
|
||||
// copy of this file (src-tauri/gen/android/app/build.gradle.kts) to inject its
|
||||
// `jniLibs.keepDebugSymbols` entries -- you can see the damage in the generated
|
||||
// file, where `packaging {` ends up with the first injected line welded onto it.
|
||||
// That rewrite drops `applicationIdSuffix` and nothing else: `versionNameSuffix`
|
||||
// and the manifest placeholders beside it survive. It happens after
|
||||
// sync-android-sources.sh has copied this file into place and before Gradle
|
||||
// configures, so no amount of syncing can beat it.
|
||||
//
|
||||
// The result was a debug APK whose applicationId was plain
|
||||
// `com.dtourolle.jellytau`, colliding with a real release install:
|
||||
// INSTALL_FAILED_UPDATE_INCOMPATIBLE, with the only obvious way out being to
|
||||
// uninstall the release app and lose its data. The `sideBySideRelease` suffix in
|
||||
// the *release* build type is untouched by the same rewrite, which is why that
|
||||
// path kept working and this one did not.
|
||||
//
|
||||
// A top-level statement is not inside the block the rewriter looks for, so it
|
||||
// survives. scripts/build-android.sh asserts the built applicationId afterwards,
|
||||
// so a future CLI that reaches further fails the build instead of shipping a
|
||||
// colliding APK.
|
||||
android.buildTypes.getByName("debug").applicationIdSuffix = ".debug"
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
@@ -142,6 +168,10 @@ dependencies {
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
|
||||
implementation("androidx.media3:media3-session:1.5.0")
|
||||
implementation("androidx.media3:media3-common:1.5.0")
|
||||
// SubtitleView. ExoPlayer delivers cues to a listener and draws none of them
|
||||
// itself: without a view to hand them to, a selected subtitle track renders
|
||||
// nowhere. See JellyTauPlayer.onCues. (DR-260)
|
||||
implementation("androidx.media3:media3-ui:1.5.0")
|
||||
implementation("com.google.guava:guava:33.0.0-android")
|
||||
|
||||
// Media library for VolumeProviderCompat (remote volume control)
|
||||
|
||||
@@ -15,6 +15,9 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||
object VideoOverlayManager {
|
||||
|
||||
private var attachedSurfaceView: TextureView? = null
|
||||
|
||||
/** The cue view attached alongside it, removed by the same teardown. */
|
||||
private var attachedSubtitleView: androidx.media3.ui.SubtitleView? = null
|
||||
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
||||
private var listenerContentView: ViewGroup? = null
|
||||
|
||||
@@ -56,6 +59,25 @@ object VideoOverlayManager {
|
||||
contentView.addView(surfaceView, 0, layoutParams)
|
||||
attachedSurfaceView = surfaceView
|
||||
|
||||
// Subtitles go directly above the video and still below the WebView:
|
||||
// visible through the transparent page, and under the app's own
|
||||
// controls rather than over them. Index 1 is what makes that
|
||||
// sandwich — the same reason the video is pinned to index 0.
|
||||
// TRACES: UR-020, UR-003 | DR-260
|
||||
player.getSubtitleView()?.let { subtitles ->
|
||||
(subtitles.parent as? ViewGroup)?.removeView(subtitles)
|
||||
contentView.addView(
|
||||
subtitles,
|
||||
1,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
)
|
||||
attachedSubtitleView = subtitles
|
||||
android.util.Log.d("VideoOverlayManager", "Subtitle view attached above the video")
|
||||
}
|
||||
|
||||
// Re-fit the video whenever the content view's bounds change (e.g. on
|
||||
// device rotation) so the video is letterboxed to fit instead of being
|
||||
// stretched/cropped by the MATCH_PARENT surface.
|
||||
@@ -101,6 +123,10 @@ object VideoOverlayManager {
|
||||
fun detachVideoSurface() {
|
||||
try {
|
||||
removeLayoutListener()
|
||||
attachedSubtitleView?.let { subtitles ->
|
||||
(subtitles.parent as? ViewGroup)?.removeView(subtitles)
|
||||
attachedSubtitleView = null
|
||||
}
|
||||
attachedSurfaceView?.let { surfaceView ->
|
||||
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
|
||||
attachedSurfaceView = null
|
||||
|
||||
@@ -233,6 +233,23 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
|
||||
private var videoSurface: android.view.Surface? = null
|
||||
|
||||
/**
|
||||
* Draws subtitle cues over the picture.
|
||||
*
|
||||
* ExoPlayer decodes subtitles and *delivers* them to a listener; it draws
|
||||
* none of them itself. A `PlayerView` would supply this view, but native
|
||||
* video here is a bare TextureView the WebView composites over, so nothing
|
||||
* was holding the cues and a selected subtitle track rendered nowhere. That
|
||||
* gap was invisible for as long as every subtitle URL 404ed (DR-259) — with
|
||||
* no text track to select, there was never a cue to drop.
|
||||
*
|
||||
* Sized and positioned to the video rect rather than the screen, so cues sit
|
||||
* inside the picture rather than in a letterbox bar.
|
||||
*
|
||||
* TRACES: UR-020, UR-003 | DR-260
|
||||
*/
|
||||
private var subtitleView: androidx.media3.ui.SubtitleView? = null
|
||||
|
||||
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
@@ -539,6 +556,19 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand each cue group to the view that draws it.
|
||||
*
|
||||
* Fires with an empty list when subtitles are turned off or the
|
||||
* track has nothing to show at this moment, which is what clears the
|
||||
* previous cue — so this is the whole of both showing and hiding.
|
||||
*
|
||||
* TRACES: UR-020 | DR-260
|
||||
*/
|
||||
override fun onCues(cueGroup: androidx.media3.common.text.CueGroup) {
|
||||
subtitleView?.setCues(cueGroup.cues)
|
||||
}
|
||||
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
android.util.Log.d("JellyTauPlayer", "▶▶▶ AUDIO SESSION ID CHANGED: $audioSessionId")
|
||||
// ExoPlayer rebuilt its audio sink (e.g. on a format change), so
|
||||
@@ -1294,9 +1324,85 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
}
|
||||
android.util.Log.d("JellyTauPlayer", "Video TextureView created")
|
||||
}
|
||||
if (subtitleView == null) {
|
||||
subtitleView = androidx.media3.ui.SubtitleView(appContext).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
// Text over the picture, not a black bar across it. (DR-261)
|
||||
setStyle(captionStyle())
|
||||
setUserDefaultTextSize()
|
||||
}
|
||||
android.util.Log.d("JellyTauPlayer", "SubtitleView created")
|
||||
}
|
||||
return videoView!!.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* The caption style to draw cues in: the viewer's own, with the background
|
||||
* taken out.
|
||||
*
|
||||
* `setUserDefaultStyle()` reads Android's captioning preferences and falls
|
||||
* back to media3's `DEFAULT` when the viewer has set none — and that
|
||||
* default is white on **opaque black**, which is what put a black box under
|
||||
* every line, wide enough to sit across the picture.
|
||||
*
|
||||
* Dropping the box is not the same as replacing the style. A viewer who has
|
||||
* configured captions in accessibility settings has said something specific
|
||||
* about colour, typeface and edges, and overriding all of that to get a
|
||||
* transparent background would be answering a question they did not ask. So
|
||||
* their style is kept and only the two colours that paint a box —
|
||||
* background and window — are cleared.
|
||||
*
|
||||
* With no box the text supplies its own contrast or it is unreadable over a
|
||||
* bright scene, so a style that asked for no edge gets a black outline. One
|
||||
* that already specifies an edge keeps it: that viewer has already said how
|
||||
* they want their captions separated from the picture.
|
||||
*
|
||||
* TRACES: UR-020 | DR-261
|
||||
*/
|
||||
private fun captionStyle(): androidx.media3.ui.CaptionStyleCompat {
|
||||
val base = try {
|
||||
val captioning = appContext.getSystemService(Context.CAPTIONING_SERVICE)
|
||||
as? android.view.accessibility.CaptioningManager
|
||||
if (captioning != null && captioning.isEnabled) {
|
||||
androidx.media3.ui.CaptionStyleCompat.createFromCaptionStyle(captioning.userStyle)
|
||||
} else {
|
||||
androidx.media3.ui.CaptionStyleCompat.DEFAULT
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// A captioning service that refuses to answer is not a reason to
|
||||
// draw nothing; fall back to the same default media3 would use.
|
||||
android.util.Log.w("JellyTauPlayer", "Captioning preferences unavailable", e)
|
||||
androidx.media3.ui.CaptionStyleCompat.DEFAULT
|
||||
}
|
||||
|
||||
val needsOwnEdge = base.edgeType == androidx.media3.ui.CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
|
||||
return androidx.media3.ui.CaptionStyleCompat(
|
||||
base.foregroundColor,
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
if (needsOwnEdge) {
|
||||
androidx.media3.ui.CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
} else {
|
||||
base.edgeType
|
||||
},
|
||||
if (needsOwnEdge) android.graphics.Color.BLACK else base.edgeColor,
|
||||
base.typeface
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The view that draws subtitle cues, for VideoOverlayManager to attach
|
||||
* directly above the video and below the WebView. Null before the first
|
||||
* video load.
|
||||
*/
|
||||
fun getSubtitleView(): androidx.media3.ui.SubtitleView? {
|
||||
return subtitleView
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the video view instance (for VideoOverlayManager).
|
||||
* Returns null if none has been created yet.
|
||||
@@ -1413,6 +1519,20 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
lp.height = targetH
|
||||
view.layoutParams = lp
|
||||
view.requestLayout()
|
||||
|
||||
// Cues belong to the picture, not to the screen: matching the
|
||||
// letterboxed rect keeps them off the black bars and moves them
|
||||
// with the video on rotation. (DR-260)
|
||||
subtitleView?.let { subs ->
|
||||
val slp = subs.layoutParams
|
||||
if (slp is FrameLayout.LayoutParams) {
|
||||
slp.gravity = android.view.Gravity.CENTER
|
||||
}
|
||||
slp.width = targetW
|
||||
slp.height = targetH
|
||||
subs.layoutParams = slp
|
||||
subs.requestLayout()
|
||||
}
|
||||
android.util.Log.d(
|
||||
"JellyTauPlayer",
|
||||
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
|
||||
@@ -1442,6 +1562,10 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
exoPlayer.clearVideoSurface()
|
||||
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
|
||||
videoView = null
|
||||
// Released with the surface it belonged to; detachVideoSurface
|
||||
// removes it from the hierarchy, and keeping the reference would
|
||||
// leave the next video's cues going to an orphaned view.
|
||||
subtitleView = null
|
||||
android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ use super::DatabaseWrapper;
|
||||
use crate::download::cache::{CacheConfig, SmartCache};
|
||||
use crate::jellyfin::{JellyfinClient, JellyfinConfig};
|
||||
use crate::player::{
|
||||
determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
|
||||
PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
|
||||
determine_audio_track_switch_strategy, determine_video_seek_strategy, AudioTrackSwitchStrategy,
|
||||
MediaItem, MediaSessionManager, MediaSource, MediaType, PlayerController, PlayerState,
|
||||
PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
|
||||
};
|
||||
use crate::repository::{
|
||||
types::{GetItemsOptions, ImageOptions, ImageType},
|
||||
@@ -1595,18 +1596,37 @@ pub async fn player_seek_video(
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
||||
/// Switch audio track.
|
||||
/// Note: Frontend should handle saving series preferences after this command succeeds
|
||||
///
|
||||
/// The split is the requirement: an HTML5 `<video>` element cannot be told to
|
||||
/// change audio track, so the stream is re-opened at the chosen
|
||||
/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
|
||||
/// `position`; a native backend (ExoPlayer) switches in place by track-group
|
||||
/// index. libmpv implements neither — it is the audio-only backend here and
|
||||
/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
|
||||
/// which is why IR-019 is met by these two paths rather than by MPV.
|
||||
/// What decides the route is **whether the stream in front of the engine
|
||||
/// carries the requested track at all** — see
|
||||
/// [`determine_audio_track_switch_strategy`]:
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
/// - An HTML5 `<video>` element has no track-selection API, so the stream is
|
||||
/// always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
|
||||
/// the reloaded element back to `position`.
|
||||
/// - A native backend playing a **direct play** holds the source file with
|
||||
/// every track in it, so ExoPlayer selects in place by track-group index.
|
||||
/// - A native backend playing a **transcode** does not. Jellyfin builds a
|
||||
/// transcode around one `AudioStreamIndex`, so the alternate tracks are not
|
||||
/// in the stream; the switch has to re-open it, which this command does
|
||||
/// itself and resumes at `current_position`.
|
||||
///
|
||||
/// That last case is a bug fix, and it was the common case on Android: any
|
||||
/// source whose default audio codec the device cannot decode is transcoded, so
|
||||
/// ExoPlayer saw `Audio tracks: 1` while the menu listed every track in the
|
||||
/// file. The old code called `setAudioTrack(n)` regardless, which indexes
|
||||
/// ExoPlayer's audio track *groups*, found nothing at `n`, warned `Invalid
|
||||
/// audio track index` and dropped the request — the default track just kept
|
||||
/// playing, with nothing in the UI saying so.
|
||||
///
|
||||
/// libmpv implements neither selection nor reload here — it is the audio-only
|
||||
/// backend and leaves `PlayerBackend::set_audio_track` at its
|
||||
/// `not_implemented()` default, which is why IR-019 is met by these paths
|
||||
/// rather than by MPV.
|
||||
///
|
||||
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
|
||||
@@ -1626,56 +1646,128 @@ pub async fn player_switch_audio_track(
|
||||
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
|
||||
stream_index, array_index, use_html5);
|
||||
|
||||
if use_html5 {
|
||||
// HTML5 backend needs stream reload
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or("Repository not found - user may need to log in")?;
|
||||
// Read what the engine is playing before deciding anything — including
|
||||
// where it is, which has to be captured before the stop below wipes it.
|
||||
// Locks are dropped at the end of this block so none is held across an
|
||||
// await.
|
||||
let (jellyfin_item_id, needs_transcoding, engine_position) = {
|
||||
let controller = player.0.lock().await;
|
||||
let engine_position = controller.absolute_position();
|
||||
let queue_arc = controller.queue();
|
||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Get current item to find Jellyfin ID
|
||||
let jellyfin_item_id = {
|
||||
let controller = player.0.lock().await;
|
||||
let queue_arc = controller.queue();
|
||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let current_item = queue.current().ok_or("No item currently playing")?;
|
||||
let current_item = queue.current().ok_or("No item currently playing")?;
|
||||
|
||||
(
|
||||
current_item
|
||||
.jellyfin_id()
|
||||
.ok_or("Current item has no Jellyfin ID")?
|
||||
.to_string()
|
||||
};
|
||||
.to_string(),
|
||||
current_item.needs_transcoding,
|
||||
engine_position,
|
||||
)
|
||||
};
|
||||
|
||||
// Select a stream carrying the chosen audio track. It starts at zero —
|
||||
// an HLS playlist cannot carry a position (DR-181) — and `position`
|
||||
// below tells the frontend where to seek the reloaded element back to.
|
||||
//
|
||||
// Pinning a track is itself a reason the source cannot be direct-played:
|
||||
// the file has one default track and the viewer asked for another, so
|
||||
// the negotiation returns a transcode. That decision lives in
|
||||
// `decide_playback_kind`, not here.
|
||||
let selection = repository
|
||||
.get_stream_selection(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
Some(stream_index),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
||||
let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
|
||||
|
||||
Ok(AudioTrackSwitchResponse::ReloadStream {
|
||||
selection,
|
||||
position: current_position.unwrap_or(0.0),
|
||||
})
|
||||
} else {
|
||||
// Native backend (Android ExoPlayer) - use array index
|
||||
info!(
|
||||
"[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
|
||||
needs_transcoding, use_html5, strategy
|
||||
);
|
||||
|
||||
if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
|
||||
// A direct play: the engine holds the source file, every track included.
|
||||
let controller = player.0.lock().await;
|
||||
controller
|
||||
.set_audio_track(array_index)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(AudioTrackSwitchResponse::Native { success: true })
|
||||
return Ok(AudioTrackSwitchResponse::Native { success: true });
|
||||
}
|
||||
|
||||
// Both reload strategies need a stream built around the chosen track.
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or("Repository not found - user may need to log in")?;
|
||||
|
||||
// Select a stream carrying the chosen audio track. It starts at zero —
|
||||
// an HLS playlist cannot carry a position (DR-181) — so the position is
|
||||
// restored by seeking afterwards, here or in the frontend.
|
||||
//
|
||||
// Pinning a track is itself a reason the source cannot be direct-played:
|
||||
// the file has one default track and the viewer asked for another, so
|
||||
// the negotiation returns a transcode. That decision lives in
|
||||
// `decide_playback_kind`, not here.
|
||||
let selection = repository
|
||||
.get_stream_selection(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
Some(stream_index),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
||||
|
||||
// The caller's position if it has one, the engine's otherwise. The native
|
||||
// path has no `<video>` element to read, so it sends none — and defaulting
|
||||
// that to zero re-opened the stream at the start of the film.
|
||||
let position = crate::player::track_switch::resume_position(current_position, engine_position);
|
||||
|
||||
match strategy {
|
||||
AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
|
||||
selection,
|
||||
position,
|
||||
}),
|
||||
AudioTrackSwitchStrategy::BackendReloadStream => {
|
||||
// The native backend re-opens its own stream, the same sequence the
|
||||
// transcoded seek and quality change use: stop, repoint the queue
|
||||
// entry at the new URL, load, then seek back to where the viewer
|
||||
// was. Nothing is left for the frontend to do.
|
||||
let new_url = selection.url.clone();
|
||||
|
||||
{
|
||||
let controller = player.0.lock().await;
|
||||
controller.stop().map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
{
|
||||
let controller = player.0.lock().await;
|
||||
let queue_arc = controller.queue();
|
||||
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
if !queue.update_current_stream_url(new_url) {
|
||||
return Err("Failed to update stream URL in queue".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let controller = player.0.lock().await;
|
||||
let queue_arc = controller.queue();
|
||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let updated_item = queue
|
||||
.current()
|
||||
.ok_or("No current item after URL update")?
|
||||
.clone();
|
||||
drop(queue);
|
||||
|
||||
controller
|
||||
.load_and_play(&updated_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
controller.seek(position).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"[player_switch_audio_track] Re-opened the stream on audio stream {} and resumed at {}",
|
||||
stream_index, position
|
||||
);
|
||||
|
||||
Ok(AudioTrackSwitchResponse::Native { success: true })
|
||||
}
|
||||
// Handled above, before the stream was negotiated.
|
||||
AudioTrackSwitchStrategy::BackendSelectInPlace => {
|
||||
Ok(AudioTrackSwitchResponse::Native { success: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+255
-16
@@ -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")]
|
||||
@@ -2249,6 +2251,12 @@ impl PlayerController {
|
||||
return Ok(Some((current_repo_item, next.clone())));
|
||||
} else {
|
||||
log::info!("[PlayerController] Current episode is the last in the season");
|
||||
if let Some(next) = self
|
||||
.first_episode_of_next_season(¤t_repo_item, repo)
|
||||
.await
|
||||
{
|
||||
return Ok(Some((current_repo_item, next)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::info!(
|
||||
@@ -2264,6 +2272,97 @@ impl PlayerController {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The first episode of the season after this one, if the series has one.
|
||||
///
|
||||
/// A season boundary is not the end of a series, and stopping there is felt
|
||||
/// most sharply on the background-audio path (UR-040): the screen is locked,
|
||||
/// nothing shows a "next" button, and playback simply stops mid-binge. Every
|
||||
/// other autoplay entry point shares this lookup, so foreground video and
|
||||
/// the Android native path cross the boundary too.
|
||||
///
|
||||
/// Lookup failures degrade to `None` rather than an error: the episode has
|
||||
/// already finished, and the caller's only alternative is to stop anyway.
|
||||
///
|
||||
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
|
||||
async fn first_episode_of_next_season(
|
||||
&self,
|
||||
current: &crate::repository::types::MediaItem,
|
||||
repo: &Arc<dyn crate::repository::MediaRepository>,
|
||||
) -> Option<crate::repository::types::MediaItem> {
|
||||
use crate::repository::types::GetItemsOptions;
|
||||
|
||||
let series_id = current.series_id.as_deref()?;
|
||||
let season_id = current.season_id.as_deref()?;
|
||||
|
||||
let season_options = GetItemsOptions {
|
||||
sort_by: Some("IndexNumber".to_string()),
|
||||
sort_order: Some("Ascending".to_string()),
|
||||
limit: Some(500),
|
||||
include_item_types: Some(vec!["Season".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
|
||||
Ok(result) => result.items,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[PlayerController] Season lookup failed for series {}: {}",
|
||||
series_id,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// Same client-side sort as the episode list: the offline repository
|
||||
// ignores sort_by and orders by sort_name instead.
|
||||
seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
|
||||
|
||||
let current_idx = seasons.iter().position(|s| s.id == season_id)?;
|
||||
|
||||
for season in &seasons[current_idx + 1..] {
|
||||
// Never roll into Specials. Jellyfin numbers them 0, so they sort
|
||||
// ahead of season 1 and are normally unreachable from here -- but a
|
||||
// server that leaves the index unset sorts them last, right where
|
||||
// this walk would otherwise land.
|
||||
if season.index_number == Some(0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let episode_options = GetItemsOptions {
|
||||
sort_by: Some("IndexNumber".to_string()),
|
||||
sort_order: Some("Ascending".to_string()),
|
||||
limit: Some(500),
|
||||
include_item_types: Some(vec!["Episode".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
|
||||
Ok(result) => result.items,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[PlayerController] Episode lookup failed for season {}: {}",
|
||||
season.id,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
|
||||
|
||||
// An empty season is a gap in the series, not the end of it.
|
||||
if let Some(first) = episodes.into_iter().next() {
|
||||
log::info!(
|
||||
"[PlayerController] Rolling over to {} of {}: {}",
|
||||
first.name,
|
||||
season.name,
|
||||
first.id
|
||||
);
|
||||
return Some(first);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("[PlayerController] No further season to roll over into");
|
||||
None
|
||||
}
|
||||
|
||||
/// Start autoplay countdown thread
|
||||
pub fn start_autoplay_countdown(
|
||||
&self,
|
||||
@@ -3683,23 +3782,67 @@ mod tests {
|
||||
/// lookup tests. Only `get_item` and `get_items` are used by
|
||||
/// `fetch_next_episode_for_item`; everything else is unreachable.
|
||||
struct MockEpisodeRepo {
|
||||
episodes: Vec<repo_types::MediaItem>,
|
||||
/// Seasons in the order the series lists them, each with its episodes.
|
||||
seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
|
||||
}
|
||||
|
||||
impl MockEpisodeRepo {
|
||||
/// A one-season series, whose episodes keep the historical `ep{n}` ids.
|
||||
fn season(count: usize) -> Self {
|
||||
let episodes = (1..=count)
|
||||
.map(|i| {
|
||||
let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
|
||||
item.name = format!("Episode {}", i);
|
||||
item
|
||||
Self::series(&[count])
|
||||
}
|
||||
|
||||
/// A series whose seasons hold the given episode counts. Season 1 keeps
|
||||
/// the `ep{n}` ids the single-season tests use; later seasons get
|
||||
/// `s{season}e{n}` so a rollover assertion names the season it landed in.
|
||||
fn series(counts: &[usize]) -> Self {
|
||||
let seasons = counts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(s, count)| {
|
||||
let season_number = s as i32 + 1;
|
||||
let episodes = (1..=*count)
|
||||
.map(|i| {
|
||||
let id = if season_number == 1 {
|
||||
format!("ep{}", i)
|
||||
} else {
|
||||
format!("s{}e{}", season_number, i)
|
||||
};
|
||||
make_repo_episode_in(season_number, &id, i as i32)
|
||||
})
|
||||
.collect();
|
||||
(make_repo_season(season_number), episodes)
|
||||
})
|
||||
.collect();
|
||||
Self { episodes }
|
||||
Self { seasons }
|
||||
}
|
||||
|
||||
fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
|
||||
self.seasons.iter().flat_map(|(_, eps)| eps.iter())
|
||||
}
|
||||
}
|
||||
|
||||
fn make_repo_season(index: i32) -> repo_types::MediaItem {
|
||||
repo_types::MediaItem {
|
||||
id: format!("season{}", index),
|
||||
name: format!("Season {}", index),
|
||||
item_type: "Season".to_string(),
|
||||
kind: crate::domain::MediaKind::Season,
|
||||
is_folder: true,
|
||||
parent_id: Some("series1".to_string()),
|
||||
index_number: Some(index),
|
||||
season_id: None,
|
||||
season_name: None,
|
||||
parent_index_number: None,
|
||||
..make_repo_episode(&format!("season{}", index), index)
|
||||
}
|
||||
}
|
||||
|
||||
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
|
||||
make_repo_episode_in(1, id, index)
|
||||
}
|
||||
|
||||
fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
|
||||
repo_types::MediaItem {
|
||||
id: id.to_string(),
|
||||
name: format!("Episode {}", index),
|
||||
@@ -3707,7 +3850,7 @@ mod tests {
|
||||
kind: crate::domain::MediaKind::Episode,
|
||||
is_folder: false,
|
||||
server_id: "server".to_string(),
|
||||
parent_id: Some("season1".to_string()),
|
||||
parent_id: Some(format!("season{}", season_number)),
|
||||
library_id: None,
|
||||
overview: None,
|
||||
genres: None,
|
||||
@@ -3729,9 +3872,9 @@ mod tests {
|
||||
index_number: Some(index),
|
||||
series_id: Some("series1".to_string()),
|
||||
series_name: Some("Test Series".to_string()),
|
||||
season_id: Some("season1".to_string()),
|
||||
season_name: Some("Season 1".to_string()),
|
||||
parent_index_number: Some(1),
|
||||
season_id: Some(format!("season{}", season_number)),
|
||||
season_name: Some(format!("Season {}", season_number)),
|
||||
parent_index_number: Some(season_number),
|
||||
user_data: None,
|
||||
media_streams: None,
|
||||
media_sources: None,
|
||||
@@ -3749,18 +3892,29 @@ mod tests {
|
||||
parent_id: &str,
|
||||
_options: Option<repo_types::GetItemsOptions>,
|
||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||
assert_eq!(parent_id, "season1", "episode lookup must query the season");
|
||||
// The series lists its seasons; a season lists its episodes. Both
|
||||
// are real lookups the autoplay path makes -- the second only once
|
||||
// the first has told it which season comes next.
|
||||
let items = if parent_id == "series1" {
|
||||
self.seasons.iter().map(|(s, _)| s.clone()).collect()
|
||||
} else {
|
||||
self.seasons
|
||||
.iter()
|
||||
.find(|(season, _)| season.id == parent_id)
|
||||
.map(|(_, eps)| eps.clone())
|
||||
.unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
|
||||
};
|
||||
let total_record_count = items.len();
|
||||
Ok(repo_types::SearchResult {
|
||||
items: self.episodes.clone(),
|
||||
total_record_count: self.episodes.len(),
|
||||
items,
|
||||
total_record_count,
|
||||
})
|
||||
}
|
||||
async fn get_item(
|
||||
&self,
|
||||
item_id: &str,
|
||||
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||
self.episodes
|
||||
.iter()
|
||||
self.all_episodes()
|
||||
.find(|e| e.id == item_id)
|
||||
.cloned()
|
||||
.ok_or(repo_types::RepoError::NotFound {
|
||||
@@ -4002,6 +4156,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A season boundary is not the end of the series. The lookup used to stop
|
||||
/// dead at the last episode of a season, which on Android's background-audio
|
||||
/// path is felt as playback simply pausing at the end of an episode with the
|
||||
/// screen locked and nothing to un-pause it.
|
||||
///
|
||||
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
|
||||
#[tokio::test]
|
||||
async fn test_next_episode_rolls_over_to_the_next_season() {
|
||||
let controller = PlayerController::default();
|
||||
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
|
||||
|
||||
let decision = controller
|
||||
.on_video_playback_ended("ep2", repo)
|
||||
.await
|
||||
.expect("decision should succeed");
|
||||
|
||||
match decision {
|
||||
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
|
||||
assert_eq!(
|
||||
next_episode.id, "s2e1",
|
||||
"the first episode of the next season follows the last of this one"
|
||||
);
|
||||
}
|
||||
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// A season with nothing in it is not the end of the series either.
|
||||
///
|
||||
/// TRACES: UR-023 | DR-263 | UT-238
|
||||
#[tokio::test]
|
||||
async fn test_next_episode_skips_an_empty_season() {
|
||||
let controller = PlayerController::default();
|
||||
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
|
||||
|
||||
let decision = controller
|
||||
.on_video_playback_ended("ep1", repo)
|
||||
.await
|
||||
.expect("decision should succeed");
|
||||
|
||||
match decision {
|
||||
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
|
||||
assert_eq!(next_episode.id, "s3e1");
|
||||
}
|
||||
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rollover must not out-rank the sleep timer: crossing a season
|
||||
/// boundary is still a track boundary, and that is exactly where a timer set
|
||||
/// to "end of episode" is supposed to stop.
|
||||
///
|
||||
/// TRACES: UR-023, UR-026 | DR-263 | UT-238
|
||||
#[tokio::test]
|
||||
async fn test_sleep_timer_still_stops_at_a_season_boundary() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
|
||||
controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
|
||||
|
||||
let episode = MediaItem {
|
||||
transport: None,
|
||||
media_type: MediaType::Audio,
|
||||
item_type: Some("Episode".to_string()),
|
||||
series_id: Some("series1".to_string()),
|
||||
duration: Some(180.0),
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep1.mp3".to_string(),
|
||||
jellyfin_item_id: "ep1".to_string(),
|
||||
},
|
||||
..create_test_items(1).remove(0)
|
||||
};
|
||||
controller.play_queue(vec![episode], 0).unwrap();
|
||||
// Played through to the end -- a natural finish, not a stream cut short.
|
||||
controller.seek(180.0).unwrap();
|
||||
controller.take_end_reason();
|
||||
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
|
||||
assert!(
|
||||
matches!(decision, AutoplayDecision::Stop),
|
||||
"the last episode the timer allows must stop, not roll into season 2 (got {:?})",
|
||||
decision
|
||||
);
|
||||
}
|
||||
|
||||
/// Last episode of the season: no popup, stop.
|
||||
#[tokio::test]
|
||||
async fn test_video_playback_ended_last_episode_stops() {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -684,26 +684,33 @@ impl OfflineRepository {
|
||||
/// The position half is what makes cross-device resume work: the resume
|
||||
/// check reads this table alone, so before it was mirrored an item watched
|
||||
/// elsewhere resumed from whatever *this* device last saw, or not at all.
|
||||
/// The played flag rides along for the same reason: nothing else writes it
|
||||
/// but an explicit local toggle, so a cached episode list read every
|
||||
/// episode back as unwatched — the list the season view ticks and the one
|
||||
/// `pick_current_episode` reads to decide what is up next (DR-264).
|
||||
///
|
||||
/// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
|
||||
/// TRACES: UR-025, UR-062, UR-069 | DR-114, DR-155, DR-264 | UT-102, UT-152, UT-240
|
||||
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
|
||||
let user_data = item.user_data.as_ref();
|
||||
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
|
||||
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
|
||||
let is_played = user_data.and_then(|ud| ud.is_played);
|
||||
|
||||
// Nothing the server actually told us about — do not invent a row.
|
||||
if is_favorite.is_none() && position_ticks.is_none() {
|
||||
if is_favorite.is_none() && position_ticks.is_none() && is_played.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO user_data
|
||||
(user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 0)
|
||||
(user_id, item_id, is_favorite, playback_position_ticks, is_played,
|
||||
synced_at, pending_sync)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
|
||||
playback_position_ticks = COALESCE(
|
||||
excluded.playback_position_ticks, user_data.playback_position_ticks),
|
||||
is_played = COALESCE(excluded.is_played, user_data.is_played),
|
||||
synced_at = excluded.synced_at
|
||||
WHERE user_data.pending_sync = 0",
|
||||
vec![
|
||||
@@ -715,6 +722,9 @@ impl OfflineRepository {
|
||||
position_ticks
|
||||
.map(QueryParam::Int64)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
is_played
|
||||
.map(|p| QueryParam::Int(if p { 1 } else { 0 }))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
QueryParam::String(now.to_string()),
|
||||
],
|
||||
);
|
||||
@@ -1239,10 +1249,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`
|
||||
@@ -4767,6 +4796,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-240 — the server's *played* flag is mirrored locally, so an episode
|
||||
/// watched anywhere is watched here.
|
||||
///
|
||||
/// The mirror carried only the favourite flag and the position, so
|
||||
/// `user_data.is_played` was written by nothing but an explicit local
|
||||
/// toggle: a cached episode list reported every episode as unwatched, which
|
||||
/// is the list `pick_current_episode` reads to decide what is up next
|
||||
/// (DR-264), and the list the season view ticks.
|
||||
///
|
||||
/// TRACES: UR-025, UR-062 | DR-264 | UT-240
|
||||
#[tokio::test]
|
||||
async fn test_save_to_cache_mirrors_played_flag_without_clobbering_pending() {
|
||||
use crate::storage::db_service::DatabaseService;
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let played_flag = |id: &'static str| {
|
||||
let db = db_service.clone();
|
||||
async move {
|
||||
db.query_optional(
|
||||
Query::with_params(
|
||||
"SELECT is_played, pending_sync FROM user_data \
|
||||
WHERE user_id = ? AND item_id = ?",
|
||||
vec![
|
||||
QueryParam::String("test-user".to_string()),
|
||||
QueryParam::String(id.to_string()),
|
||||
],
|
||||
),
|
||||
|row| Ok((row.get::<_, Option<i32>>(0)?, row.get::<_, Option<i32>>(1)?)),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
// Watched to the end on another client.
|
||||
let mut watched = create_test_item("ep-4", "Watched Elsewhere", None);
|
||||
watched.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
// No user data at all — must not fabricate an "unwatched" record.
|
||||
let untouched = create_test_item("ep-5", "No User Data", None);
|
||||
|
||||
repo.save_to_cache("parent-1", &[watched, untouched])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
played_flag("ep-4").await,
|
||||
Some((Some(1), Some(0))),
|
||||
"the server's played flag should be mirrored as synced"
|
||||
);
|
||||
assert_eq!(
|
||||
played_flag("ep-5").await,
|
||||
None,
|
||||
"an item without UserData should not get an invented played flag"
|
||||
);
|
||||
|
||||
// Marked unwatched here while the server was unreachable.
|
||||
db_service
|
||||
.execute(Query::with_params(
|
||||
"UPDATE user_data SET is_played = 0, pending_sync = 1 \
|
||||
WHERE user_id = ? AND item_id = ?",
|
||||
vec![
|
||||
QueryParam::String("test-user".to_string()),
|
||||
QueryParam::String("ep-4".to_string()),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut still_played = create_test_item("ep-4", "Watched Elsewhere", None);
|
||||
still_played.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
repo.save_to_cache("parent-1", &[still_played])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
played_flag("ep-4").await,
|
||||
Some((Some(0), Some(1))),
|
||||
"an unsynced local toggle must survive a cache write"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-152 — a server item carrying *only* a position (no favourite flag)
|
||||
/// still gets mirrored.
|
||||
///
|
||||
|
||||
@@ -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 {
|
||||
@@ -1291,6 +1306,117 @@ fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usi
|
||||
)
|
||||
}
|
||||
|
||||
/// How many rows to ask the server for, given how many the row will show.
|
||||
///
|
||||
/// Collapsing only ever shrinks a listing, so a request for exactly the number
|
||||
/// of cards the row shows can come back as a handful after one freshly-ripped
|
||||
/// album folds its tracks together. Over-fetch and truncate after collapsing.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
|
||||
fn latest_items_fetch_limit(limit: usize) -> usize {
|
||||
limit.saturating_mul(3)
|
||||
}
|
||||
|
||||
/// Collapse newly-added *tracks* into the album they belong to.
|
||||
///
|
||||
/// `GroupItems=true` asks Jellyfin to do this server-side, but it only groups a
|
||||
/// track whose parent chain actually resolves a `MusicAlbum`, and older servers
|
||||
/// ignore the parameter outright — so "Recently Added" still filled up with one
|
||||
/// card per song of a single import. Grouping again here makes the row's shape
|
||||
/// a property of this app rather than of the server it is talking to.
|
||||
///
|
||||
/// Rules: a track collapses only when it names an `album_id` (without one there
|
||||
/// is no album to open, so a standalone track stays a track); if the server did
|
||||
/// return the album row itself, that row wins and its tracks are dropped; the
|
||||
/// album takes the position of the first of its tracks, so recency order
|
||||
/// survives. Everything else — movies, episodes, folders — passes through
|
||||
/// untouched.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-242, UT-243
|
||||
fn collapse_tracks_into_albums(items: Vec<MediaItem>) -> Vec<MediaItem> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Albums the server already returned in their own right: their tracks are
|
||||
// redundant, and the real row carries detail a stand-in cannot.
|
||||
let server_albums: HashSet<String> = items
|
||||
.iter()
|
||||
.filter(|i| i.kind == crate::domain::MediaKind::Album)
|
||||
.map(|i| i.id.clone())
|
||||
.collect();
|
||||
|
||||
let mut seen_albums: HashSet<String> = HashSet::new();
|
||||
let mut collapsed = Vec::with_capacity(items.len());
|
||||
|
||||
for item in items {
|
||||
let album_id = match (&item.kind, &item.album_id) {
|
||||
(crate::domain::MediaKind::Track, Some(id)) => id.clone(),
|
||||
_ => {
|
||||
collapsed.push(item);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if server_albums.contains(&album_id) || !seen_albums.insert(album_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
collapsed.push(album_from_track(&item, album_id));
|
||||
}
|
||||
|
||||
collapsed
|
||||
}
|
||||
|
||||
/// Build the album card a collapsed group of tracks stands for.
|
||||
///
|
||||
/// The track's own artwork tag is reused: Jellyfin serves an item's primary
|
||||
/// image by id and treats the tag as a cache key, and an embedded-art track
|
||||
/// carries the album cover anyway.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
|
||||
fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
|
||||
MediaItem {
|
||||
id: album_id,
|
||||
name: track
|
||||
.album_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown Album".to_string()),
|
||||
item_type: "MusicAlbum".to_string(),
|
||||
kind: crate::domain::MediaKind::Album,
|
||||
is_folder: true,
|
||||
server_id: track.server_id.clone(),
|
||||
parent_id: None,
|
||||
library_id: track.library_id.clone(),
|
||||
overview: None,
|
||||
genres: track.genres.clone(),
|
||||
production_year: track.production_year,
|
||||
premiere_date: track.premiere_date.clone(),
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
// A track's duration says nothing about the album's, and its track
|
||||
// number, album link and streams belong to the leaf alone.
|
||||
runtime_ticks: None,
|
||||
duration_ms: None,
|
||||
primary_image_tag: track.primary_image_tag.clone(),
|
||||
image_id: track.image_id.clone(),
|
||||
backdrop_image_tags: track.backdrop_image_tags.clone(),
|
||||
parent_backdrop_image_tags: track.parent_backdrop_image_tags.clone(),
|
||||
album_id: None,
|
||||
album_name: None,
|
||||
album_artist: track.album_artist.clone(),
|
||||
artists: track.artists.clone(),
|
||||
artist_items: track.artist_items.clone(),
|
||||
index_number: None,
|
||||
parent_index_number: None,
|
||||
series_id: None,
|
||||
series_name: None,
|
||||
season_id: None,
|
||||
season_name: None,
|
||||
user_data: None,
|
||||
media_streams: None,
|
||||
media_sources: None,
|
||||
people: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a Next Up listing.
|
||||
///
|
||||
/// `EnableResumable=false` is the point of this query: the server default is
|
||||
@@ -1740,18 +1866,34 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(media_item)
|
||||
}
|
||||
|
||||
/// Recently Added, one card per thing that was added.
|
||||
///
|
||||
/// The server is asked to group (`GroupItems=true`) *and* the answer is
|
||||
/// grouped again here — see `collapse_tracks_into_albums` for why trusting
|
||||
/// the server alone left the row full of one album's songs.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241, UT-244
|
||||
async fn get_latest_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
|
||||
let limit_val = limit.unwrap_or(16);
|
||||
let endpoint = build_latest_items_endpoint(
|
||||
&self.user_id,
|
||||
parent_id,
|
||||
Some(latest_items_fetch_limit(limit_val)),
|
||||
);
|
||||
|
||||
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
||||
Ok(items
|
||||
let items = items
|
||||
.into_iter()
|
||||
.map(|item| item.into_media_item(self.user_id.clone()))
|
||||
.collect())
|
||||
.collect();
|
||||
|
||||
let mut collapsed = collapse_tracks_into_albums(items);
|
||||
collapsed.truncate(limit_val);
|
||||
Ok(collapsed)
|
||||
}
|
||||
|
||||
/// Continue Watching: the items this user has started and not finished.
|
||||
@@ -2454,8 +2596,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 +3137,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 +3153,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 +4166,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
|
||||
@@ -4006,6 +4249,135 @@ mod tests {
|
||||
assert!(endpoint.contains("Limit=16"));
|
||||
}
|
||||
|
||||
/// Build a `MediaItem` the way a real listing does — through the Jellyfin
|
||||
/// payload — so the fixtures cannot drift from the parsed shape.
|
||||
fn item_from_json(json: &str) -> MediaItem {
|
||||
let parsed: JellyfinItem = serde_json::from_str(json).expect("fixture must parse");
|
||||
parsed.into_media_item("srv".to_string())
|
||||
}
|
||||
|
||||
fn track(id: &str, name: &str, album_id: Option<&str>) -> MediaItem {
|
||||
let album = match album_id {
|
||||
Some(a) => format!(r#""AlbumId": "{a}", "Album": "Kind of Blue","#),
|
||||
None => String::new(),
|
||||
};
|
||||
item_from_json(&format!(
|
||||
r#"{{
|
||||
"Id": "{id}",
|
||||
"Name": "{name}",
|
||||
"Type": "Audio",
|
||||
{album}
|
||||
"ImageTags": {{"Primary": "art-{id}"}},
|
||||
"AlbumArtist": "Miles Davis",
|
||||
"Artists": ["Miles Davis"],
|
||||
"IndexNumber": 1,
|
||||
"RunTimeTicks": 1000
|
||||
}}"#
|
||||
))
|
||||
}
|
||||
|
||||
/// A newly-imported album must read as *one* new album, not one new song
|
||||
/// per track — even when the server hands back the raw leaves despite
|
||||
/// `GroupItems=true` (older servers, and libraries whose tracks resolve no
|
||||
/// album parent, ignore it).
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-241
|
||||
#[test]
|
||||
fn test_collapse_tracks_into_albums_shows_one_card_per_album() {
|
||||
let movie = item_from_json(
|
||||
r#"{"Id": "mov-1", "Name": "Heat", "Type": "Movie", "ImageTags": {"Primary": "art-mov"}}"#,
|
||||
);
|
||||
let items = vec![
|
||||
track("trk-1", "So What", Some("alb-1")),
|
||||
track("trk-2", "Blue in Green", Some("alb-1")),
|
||||
movie,
|
||||
track("trk-3", "Flamenco Sketches", Some("alb-1")),
|
||||
];
|
||||
|
||||
let collapsed = collapse_tracks_into_albums(items);
|
||||
|
||||
assert_eq!(
|
||||
collapsed.len(),
|
||||
2,
|
||||
"three tracks of one album plus a movie must read as two cards, got: {:?}",
|
||||
collapsed.iter().map(|i| &i.name).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let album = &collapsed[0];
|
||||
assert_eq!(album.id, "alb-1", "the card must open the album");
|
||||
assert_eq!(album.name, "Kind of Blue");
|
||||
assert_eq!(album.item_type, "MusicAlbum");
|
||||
assert_eq!(album.kind, crate::domain::MediaKind::Album);
|
||||
assert!(album.is_folder);
|
||||
assert_eq!(album.album_artist.as_deref(), Some("Miles Davis"));
|
||||
assert!(album.image_id.is_some(), "album card needs artwork");
|
||||
// Track-only detail must not ride along on a container.
|
||||
assert!(album.index_number.is_none());
|
||||
assert!(album.album_id.is_none());
|
||||
assert!(album.runtime_ticks.is_none());
|
||||
|
||||
// The movie keeps its place after the album its tracks stood in front of.
|
||||
assert_eq!(collapsed[1].id, "mov-1");
|
||||
}
|
||||
|
||||
/// When the server *did* group, its own album row wins — the tracks it also
|
||||
/// returned must not add a second card for the same album.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-242
|
||||
#[test]
|
||||
fn test_collapse_prefers_the_album_row_the_server_returned() {
|
||||
let album = item_from_json(
|
||||
r#"{"Id": "alb-1", "Name": "Kind of Blue", "Type": "MusicAlbum", "IsFolder": true,
|
||||
"Overview": "1959", "ImageTags": {"Primary": "art-alb"}}"#,
|
||||
);
|
||||
let items = vec![
|
||||
album,
|
||||
track("trk-1", "So What", Some("alb-1")),
|
||||
track("trk-2", "Blue in Green", Some("alb-1")),
|
||||
];
|
||||
|
||||
let collapsed = collapse_tracks_into_albums(items);
|
||||
|
||||
assert_eq!(collapsed.len(), 1, "one album, one card");
|
||||
assert_eq!(collapsed[0].id, "alb-1");
|
||||
assert_eq!(
|
||||
collapsed[0].overview.as_deref(),
|
||||
Some("1959"),
|
||||
"the server's own album row must survive, not a track-built stand-in"
|
||||
);
|
||||
}
|
||||
|
||||
/// A track with no album has no container to collapse into, so it stays —
|
||||
/// same reasoning that leaves movies alone.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-243
|
||||
#[test]
|
||||
fn test_collapse_leaves_a_standalone_track_alone() {
|
||||
let items = vec![track("trk-1", "Field Recording", None)];
|
||||
|
||||
let collapsed = collapse_tracks_into_albums(items);
|
||||
|
||||
assert_eq!(collapsed.len(), 1);
|
||||
assert_eq!(collapsed[0].id, "trk-1");
|
||||
assert_eq!(collapsed[0].item_type, "Audio");
|
||||
}
|
||||
|
||||
/// Collapsing shrinks the listing, so the request has to over-fetch: asking
|
||||
/// for exactly 16 rows and then folding one 14-track album into them leaves
|
||||
/// an almost empty "Recently Added".
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016 | UT-244
|
||||
#[test]
|
||||
fn test_latest_items_over_fetches_before_collapsing() {
|
||||
assert!(
|
||||
latest_items_fetch_limit(16) > 16,
|
||||
"must ask for more rows than the row shows"
|
||||
);
|
||||
let endpoint =
|
||||
build_latest_items_endpoint("u1", "lib-1", Some(latest_items_fetch_limit(16)));
|
||||
assert!(endpoint.contains(&format!("Limit={}", latest_items_fetch_limit(16))));
|
||||
}
|
||||
|
||||
/// UT-190 — Next Up asks the server to leave resumable episodes out.
|
||||
///
|
||||
/// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! I/O half (`fetch_series_episodes`, `resolve_current_episode`) so the policy
|
||||
//! can be unit-tested without standing up a repository.
|
||||
//!
|
||||
//! TRACES: UR-062 | DR-101
|
||||
//! TRACES: UR-062 | DR-101, DR-264 | UT-239
|
||||
|
||||
use super::{GetItemsOptions, MediaItem, MediaRepository, RepoError};
|
||||
|
||||
@@ -83,6 +83,34 @@ fn is_played(item: &MediaItem) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Has the viewer reached the end of this episode?
|
||||
///
|
||||
/// The played *flag* is not enough. Nothing records completion locally — the
|
||||
/// stop report writes a position, and the cache mirror carries the server's
|
||||
/// flag only on the next refresh — so within seconds of an episode ending the
|
||||
/// only local evidence that it is over is its position, parked at the very end
|
||||
/// of its runtime. Leaving the player with Back reloads the series page inside
|
||||
/// that window (DR-264).
|
||||
fn is_finished(item: &MediaItem) -> bool {
|
||||
if is_played(item) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(user_data) = item.user_data.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let position_ms = user_data
|
||||
.playback_position_ms
|
||||
.or_else(|| user_data.playback_position_ticks.map(|t| t / 10_000))
|
||||
.unwrap_or(0);
|
||||
// Without a duration a position says nothing about how much is left.
|
||||
let Some(duration_ms) = item.duration_ms.filter(|d| *d > 0) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
position_ms as f64 / duration_ms as f64 >= MAX_PROGRESS_FRACTION
|
||||
}
|
||||
|
||||
fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
||||
item.series_id.as_deref() == Some(series_id)
|
||||
}
|
||||
@@ -130,11 +158,20 @@ pub fn pick_current_episode(
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
// 2. Next Up for this series.
|
||||
if let Some(found) = next_up
|
||||
.iter()
|
||||
.find(|e| e.series_id.is_none() || belongs_to_series(e, series_id))
|
||||
{
|
||||
// 2. Next Up for this series — unless it names an episode we can already
|
||||
// see is over. Next Up is the server's answer, and the server is one
|
||||
// stop-report behind for a moment after an episode ends; the local
|
||||
// position is not, so a finished candidate is dropped rather than handed
|
||||
// back as "up next" (DR-264).
|
||||
if let Some(found) = next_up.iter().find(|e| {
|
||||
if !(e.series_id.is_none() || belongs_to_series(e, series_id)) {
|
||||
return false;
|
||||
}
|
||||
// Judge it by the copy from `episodes` when there is one: that is the
|
||||
// copy carrying the local user-data.
|
||||
let local = episodes.iter().find(|listed| listed.id == e.id);
|
||||
!is_finished(local.unwrap_or(e))
|
||||
}) {
|
||||
// Prefer the copy from `episodes` when we have one: it carries the
|
||||
// user-data and images the list already fetched.
|
||||
let matched = episodes.iter().find(|e| e.id == found.id);
|
||||
@@ -145,7 +182,7 @@ pub fn pick_current_episode(
|
||||
// unwatched: a viewer who skipped the pilot but is deep into season 3
|
||||
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
|
||||
// where they stopped is the *last* thing they watched.
|
||||
if let Some(furthest) = episodes.iter().rposition(is_played) {
|
||||
if let Some(furthest) = episodes.iter().rposition(is_finished) {
|
||||
if let Some(found) = episodes.get(furthest + 1) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
@@ -153,7 +190,7 @@ pub fn pick_current_episode(
|
||||
|
||||
// Nothing watched yet (or the furthest-watched episode is the finale):
|
||||
// the first unwatched episode in series order.
|
||||
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
||||
if let Some(found) = episodes.iter().find(|e| !is_finished(e)) {
|
||||
return Some(found.clone());
|
||||
}
|
||||
|
||||
@@ -427,6 +464,41 @@ mod tests {
|
||||
assert_eq!(current.id, "s2e1");
|
||||
}
|
||||
|
||||
/// The episode the viewer just finished must not still be "up next".
|
||||
///
|
||||
/// Leaving the player with Back reloads the series page within a second of
|
||||
/// the stop report, and Jellyfin's Next Up can still name the episode that
|
||||
/// just ended. Locally we know better: the position sits at the very end of
|
||||
/// its runtime.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-264 | UT-239
|
||||
#[test]
|
||||
fn a_just_finished_episode_is_not_current_even_when_next_up_still_names_it() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
// Just finished: the position is at the end, the flag has not landed.
|
||||
eps[1] = in_progress(eps[1].clone(), 0.99);
|
||||
// The server has not caught up with the stop report.
|
||||
let next_up = vec![episode("s1e2", 1, 2)];
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &next_up, &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e3");
|
||||
}
|
||||
|
||||
/// The same, offline: with no Next Up to lean on, an episode watched to the
|
||||
/// end counts as watched when scanning for the furthest-watched one.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-264 | UT-239
|
||||
#[test]
|
||||
fn an_episode_watched_to_the_end_counts_as_watched_offline() {
|
||||
let mut eps = season(1, 5);
|
||||
eps[0] = watched(eps[0].clone());
|
||||
eps[1] = in_progress(eps[1].clone(), 0.99);
|
||||
|
||||
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||
assert_eq!(current.id, "s1e3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_never_watched_series_opens_on_its_premiere() {
|
||||
let eps = [season(2, 3), season(1, 3)].concat();
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "JellyTau",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.4",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+48
-10
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-007 | DR-007 -->
|
||||
<!-- TRACES: UR-007 | DR-007, DR-262 -->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A vertical A-Z index strip for long, alphabetically-sorted lists.
|
||||
@@ -6,8 +6,14 @@
|
||||
*
|
||||
* The parent owns the actual scrolling: it passes `availableLetters`
|
||||
* (which letters have items) and an `onJump(letter)` callback.
|
||||
*
|
||||
* The strip's floor is the scroll container's own bottom edge, measured — not
|
||||
* the viewport minus a guess at the bottom bars. See alphabetStrip.ts for why
|
||||
* that distinction is the whole bug (DR-262).
|
||||
*/
|
||||
|
||||
import { stripHeightFor } from "./alphabetStrip";
|
||||
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
||||
const HASH = "#"; // bucket for names starting with a digit/symbol
|
||||
|
||||
@@ -16,43 +22,45 @@
|
||||
availableLetters: Set<string>;
|
||||
/** Called with the chosen letter when the user picks one. */
|
||||
onJump: (letter: string) => void;
|
||||
/**
|
||||
* CSS length reserved at the bottom of the viewport for the bottom nav /
|
||||
* mini-player bars. The strip stretches to fill the space between the top
|
||||
* sticky offset and this gap, so it ends just above those bars.
|
||||
*/
|
||||
bottomGap?: string;
|
||||
}
|
||||
|
||||
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
|
||||
let { availableLetters, onJump }: Props = $props();
|
||||
|
||||
// The strip stretches from where it sits down to just above the bottom nav /
|
||||
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
|
||||
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
|
||||
// top against that floor (clamped to non-negative) and update on scroll/resize
|
||||
// so it never slides under the bars regardless of header or platform.
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
let stripHeight = $state(0);
|
||||
|
||||
function measure() {
|
||||
if (!container) return;
|
||||
const top = container.getBoundingClientRect().top;
|
||||
const floor = window.innerHeight - remToPx(bottomGap);
|
||||
stripHeight = Math.max(0, floor - top);
|
||||
/**
|
||||
* Nearest scrollable ancestor. Resolved by computed `overflow-y` rather than
|
||||
* by tag name: the library routes scroll in a `<main>`, but the root shell
|
||||
* scrolls in a plain `<div>`, and a `closest("main")` that misses falls back
|
||||
* to the viewport — which is exactly the too-tall strip this replaced.
|
||||
*/
|
||||
function nearestScroller(el: HTMLElement | null): HTMLElement | null {
|
||||
let node = el?.parentElement ?? null;
|
||||
while (node) {
|
||||
const overflowY = getComputedStyle(node).overflowY;
|
||||
if (overflowY === "auto" || overflowY === "scroll") return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function remToPx(len: string): number {
|
||||
const n = parseFloat(len);
|
||||
if (len.trim().endsWith("rem")) {
|
||||
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
return n * root;
|
||||
}
|
||||
return n; // assume px otherwise
|
||||
function measure() {
|
||||
if (!container) return;
|
||||
const scroller = nearestScroller(container);
|
||||
stripHeight = stripHeightFor({
|
||||
stripTop: container.getBoundingClientRect().top,
|
||||
scrollerBottom: scroller?.getBoundingClientRect().bottom ?? null,
|
||||
viewportHeight: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
measure();
|
||||
const scroller = container?.closest("main");
|
||||
const scroller = nearestScroller(container);
|
||||
// Observing the scroller is what makes the mini player showing or hiding
|
||||
// re-measure: it is an in-flow sibling, so the scroller resizes when it
|
||||
// appears. No store subscription and no platform guess needed.
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (scroller) ro.observe(scroller);
|
||||
scroller?.addEventListener("scroll", measure, { passive: true });
|
||||
@@ -64,15 +72,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
|
||||
$effect(() => {
|
||||
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
|
||||
// idiom, not a stray expression.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
bottomGap;
|
||||
measure();
|
||||
});
|
||||
|
||||
const letters = $derived([HASH, ...ALPHABET]);
|
||||
let activeLetter = $state<string | null>(null);
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
import { navigateUp } from "$lib/utils/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
|
||||
import { isAndroid } from "$lib/stores/appState";
|
||||
import SearchBar from "$lib/components/common/SearchBar.svelte";
|
||||
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
|
||||
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
|
||||
@@ -259,11 +257,6 @@
|
||||
const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
|
||||
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
|
||||
// whichever bars are visible.
|
||||
const bottomGap = $derived($shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem");
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -363,7 +356,7 @@
|
||||
</div>
|
||||
{#if showAlphaBar}
|
||||
<div class="sticky top-2 self-start flex-shrink-0 h-fit">
|
||||
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} {bottomGap} />
|
||||
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { stripHeightFor, type StripHeightInput } from "./alphabetStrip";
|
||||
|
||||
/**
|
||||
* Regression: the A-Z jump strip ran under the mini player, so the tail of the
|
||||
* alphabet could not be tapped.
|
||||
*
|
||||
* The strip used to size itself against `window.innerHeight` minus a hardcoded
|
||||
* guess at the bottom bars' height (5rem / 7rem / 11rem, chosen by platform and
|
||||
* whether the mini player was showing). Those bars stopped being fixed overlays
|
||||
* when BottomUi became an in-flow flex sibling below the scroller, so the guess
|
||||
* has no relationship to the real stack — and it is short on any device with a
|
||||
* navigation/gesture bar, because `--safe-bottom` is padded *inside* BottomUi.
|
||||
*
|
||||
* The invariant every case below asserts: the strip must end at or above the
|
||||
* scroller's own bottom edge, which is exactly the top of the mini player.
|
||||
*
|
||||
* TRACES: UR-007 | DR-007 | UT-235, UT-236, UT-237
|
||||
*/
|
||||
|
||||
/** 800px-tall phone viewport; the library scroller starts 120px down. */
|
||||
const VIEWPORT = 800;
|
||||
const STRIP_TOP = 120;
|
||||
|
||||
/** Measured heights of the real bottom UI, in CSS px. */
|
||||
const NAV = 77; // BottomNav: py-2 + icon 24 + gap 4 + label 16 + py-2, + 1px border
|
||||
const MINI = 69; // MiniPlayer: 4px progress bar + 48px artwork row + py-2, + 1px border
|
||||
const REMOTE_ROW = 32; // "Playing on <device>" banner, remote mode only
|
||||
const GESTURE_BAR = 48; // --safe-bottom on a 3-button nav device
|
||||
|
||||
function bounds(bottomUiHeight: number): StripHeightInput {
|
||||
return {
|
||||
stripTop: STRIP_TOP,
|
||||
scrollerBottom: VIEWPORT - bottomUiHeight,
|
||||
viewportHeight: VIEWPORT,
|
||||
};
|
||||
}
|
||||
|
||||
describe("stripHeightFor", () => {
|
||||
it("keeps the last letter above the bottom nav when nothing is playing", () => {
|
||||
const input = bounds(NAV + GESTURE_BAR);
|
||||
const bottom = STRIP_TOP + stripHeightFor(input);
|
||||
|
||||
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
|
||||
});
|
||||
|
||||
it("keeps the last letter above the mini player while audio plays", () => {
|
||||
const input = bounds(MINI + NAV + GESTURE_BAR);
|
||||
const bottom = STRIP_TOP + stripHeightFor(input);
|
||||
|
||||
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
|
||||
});
|
||||
|
||||
it("survives the taller mini player of remote mode", () => {
|
||||
const input = bounds(REMOTE_ROW + MINI + NAV + GESTURE_BAR);
|
||||
const bottom = STRIP_TOP + stripHeightFor(input);
|
||||
|
||||
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
|
||||
});
|
||||
|
||||
it("still fills the space it does have, rather than stopping short", () => {
|
||||
const input = bounds(MINI + NAV + GESTURE_BAR);
|
||||
const available = input.scrollerBottom! - STRIP_TOP;
|
||||
|
||||
// Within one letter's worth of the space available (letters are ~16px).
|
||||
expect(stripHeightFor(input)).toBeGreaterThan(available - 16);
|
||||
});
|
||||
|
||||
it("falls back to the viewport when the strip has no scroll container", () => {
|
||||
const height = stripHeightFor({
|
||||
stripTop: STRIP_TOP,
|
||||
scrollerBottom: null,
|
||||
viewportHeight: VIEWPORT,
|
||||
});
|
||||
|
||||
expect(STRIP_TOP + height).toBeLessThanOrEqual(VIEWPORT);
|
||||
});
|
||||
|
||||
it("never returns a negative height when the strip is scrolled past the floor", () => {
|
||||
const height = stripHeightFor({
|
||||
stripTop: 900,
|
||||
scrollerBottom: 600,
|
||||
viewportHeight: VIEWPORT,
|
||||
});
|
||||
|
||||
expect(height).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Pure geometry for the A-Z jump strip (see AlphabetScrollBar.svelte).
|
||||
*
|
||||
* The strip stretches from wherever it sits down to a floor, and the floor is
|
||||
* the whole question: get it wrong and the tail of the alphabet renders past
|
||||
* the bottom of the scroller, under the mini player / bottom nav, where it
|
||||
* cannot be tapped.
|
||||
*
|
||||
* The floor is the scroller's own bottom edge — never the viewport's. The strip
|
||||
* used to size itself as `window.innerHeight` minus a hardcoded guess at the
|
||||
* bars' height (5rem/7rem/11rem by platform and mini-player visibility), which
|
||||
* dates from when those bars were `position: fixed` overlays. They are in-flow
|
||||
* flex siblings below the scroller now (see BottomUi.svelte), so the scroller's
|
||||
* bottom edge *is* the top of the mini player, measured exactly, every frame —
|
||||
* and the guess was short on every device with a navigation/gesture bar,
|
||||
* because `--safe-bottom` is padded inside BottomUi and the guess never knew
|
||||
* about it.
|
||||
*
|
||||
* Extracted from the component so the floor rule is unit-testable — the
|
||||
* component only supplies measurements.
|
||||
*
|
||||
* TRACES: UR-007 | DR-007, DR-262
|
||||
*/
|
||||
|
||||
/**
|
||||
* Breathing room left under the last letter, in px. Mirrors the `top-2` sticky
|
||||
* offset at the other end so the strip sits symmetrically in the scrollport.
|
||||
*/
|
||||
export const STRIP_BOTTOM_GAP = 8;
|
||||
|
||||
export interface StripHeightInput {
|
||||
/** Viewport-relative top of the strip container (`getBoundingClientRect().top`). */
|
||||
stripTop: number;
|
||||
/**
|
||||
* Viewport-relative bottom edge of the scroll container the strip lives in,
|
||||
* or `null` when the strip has no scrollable ancestor to measure.
|
||||
*/
|
||||
scrollerBottom: number | null;
|
||||
/** Viewport height — the fallback floor when there is no scroll container. */
|
||||
viewportHeight: number;
|
||||
/** Override for {@link STRIP_BOTTOM_GAP}, in px. */
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
/** How tall the A-Z strip may be without running under the bottom bars. */
|
||||
export function stripHeightFor({
|
||||
stripTop,
|
||||
scrollerBottom,
|
||||
viewportHeight,
|
||||
gap = STRIP_BOTTOM_GAP,
|
||||
}: StripHeightInput): number {
|
||||
const floor = scrollerBottom ?? viewportHeight;
|
||||
return Math.max(0, floor - gap - stripTop);
|
||||
}
|
||||
@@ -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 200–220 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,8 +2849,155 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Control buttons -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 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
|
||||
class="fixed inset-0 z-10 cursor-default"
|
||||
onclick={closeMenu}
|
||||
aria-label="Close menu"
|
||||
tabindex="-1"
|
||||
></button>
|
||||
<div
|
||||
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>
|
||||
{#each audioTracks() as track, i}
|
||||
<button
|
||||
onclick={() => selectAudioTrack(track.index, i)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">
|
||||
{track.displayTitle || track.language || `Track ${i + 1}`}
|
||||
{#if track.isDefault}
|
||||
<span class="text-xs text-gray-400 ml-1">(Default)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if selectedAudioTrackIndex === track.index}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{: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>
|
||||
<!--
|
||||
What the server is actually doing. Only knowable now that
|
||||
the backend reports it. TRACES: UR-079 | DR-228
|
||||
-->
|
||||
<div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
|
||||
</div>
|
||||
{#each qualityOptions as option (option.quality)}
|
||||
<button
|
||||
onclick={() => selectQuality(option.quality)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
|
||||
option.quality
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{option.label}</span>
|
||||
<span class="text-xs text-gray-400">
|
||||
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
|
||||
· {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if selectedQuality === option.quality}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if openMenu === "subtitle"}
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Subtitles
|
||||
</div>
|
||||
<!-- Off option -->
|
||||
<button
|
||||
onclick={() => selectSubtitle(null)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
null
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">Off</span>
|
||||
{#if selectedSubtitleIndex === null}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Subtitle tracks -->
|
||||
{#each subtitleTracks() as track}
|
||||
<button
|
||||
onclick={() => selectSubtitle(track.index)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">
|
||||
{track.displayTitle || track.language || `Track ${track.index}`}
|
||||
{#if track.isDefault}
|
||||
<span class="text-xs text-gray-400 ml-1">(Default)</span>
|
||||
{/if}
|
||||
{#if track.isForced}
|
||||
<span class="text-xs text-gray-400 ml-1">(Forced)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if track.codec}
|
||||
<span class="text-xs text-gray-500">{track.codec.toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedSubtitleIndex === track.index}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Play/Pause -->
|
||||
<button
|
||||
@@ -2871,60 +3026,23 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 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}
|
||||
<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}
|
||||
<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">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Audio Track
|
||||
</div>
|
||||
{#each audioTracks() as track, i}
|
||||
<button
|
||||
onclick={() => selectAudioTrack(track.index, i)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">
|
||||
{track.displayTitle || track.language || `Track ${i + 1}`}
|
||||
{#if track.isDefault}
|
||||
<span class="text-xs text-gray-400 ml-1">(Default)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if selectedAudioTrackIndex === track.index}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<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}
|
||||
|
||||
<!--
|
||||
@@ -2932,147 +3050,34 @@
|
||||
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">
|
||||
<div class="px-3 py-2 border-b border-white/20">
|
||||
<div class="text-white text-sm font-semibold">Quality</div>
|
||||
<!--
|
||||
What the server is actually doing. Only knowable now that
|
||||
the backend reports it. TRACES: UR-079 | DR-228
|
||||
-->
|
||||
<div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
|
||||
</div>
|
||||
{#each qualityOptions as option (option.quality)}
|
||||
<button
|
||||
onclick={() => selectQuality(option.quality)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
|
||||
option.quality
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{option.label}</span>
|
||||
<span class="text-xs text-gray-400">
|
||||
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
|
||||
· {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if selectedQuality === option.quality}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<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}
|
||||
<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">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Subtitles
|
||||
</div>
|
||||
<!-- Off option -->
|
||||
<button
|
||||
onclick={() => selectSubtitle(null)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
null
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">Off</span>
|
||||
{#if selectedSubtitleIndex === null}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Subtitle tracks -->
|
||||
{#each subtitleTracks() as track}
|
||||
<button
|
||||
onclick={() => selectSubtitle(track.index)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">
|
||||
{track.displayTitle || track.language || `Track ${track.index}`}
|
||||
{#if track.isDefault}
|
||||
<span class="text-xs text-gray-400 ml-1">(Default)</span>
|
||||
{/if}
|
||||
{#if track.isForced}
|
||||
<span class="text-xs text-gray-400 ml-1">(Forced)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if track.codec}
|
||||
<span class="text-xs text-gray-500">{track.codec.toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedSubtitleIndex === track.index}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const KIND_LABELS: Record<MediaKind, string> = {
|
||||
channel: "Channel",
|
||||
liveChannel: "Live TV",
|
||||
channelItem: "Channel",
|
||||
channelFolder: "Channel",
|
||||
folder: "Folder",
|
||||
other: "",
|
||||
};
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
case "album":
|
||||
case "artist":
|
||||
case "folder":
|
||||
case "channelFolder":
|
||||
case "playlist":
|
||||
case "channel":
|
||||
// Navigate to detail view
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
"series",
|
||||
"season",
|
||||
"folder",
|
||||
"channelFolder",
|
||||
"playlist",
|
||||
"channel",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user