Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20e683d705 | ||
|
|
8904acb5f7 | ||
|
|
a3190cd52b | ||
|
|
f4892f4cb2 | ||
|
|
3b91922cca | ||
|
|
f388777185 | ||
|
|
14b6a8609d | ||
|
|
d3ecd8ee91 | ||
|
|
2f637d4775 | ||
|
|
45144cb6b0 | ||
|
|
7545de6cc7 | ||
|
|
0445a6d0aa | ||
|
|
a8c44145ff | ||
|
|
fecd6022fe | ||
|
|
156b9e3684 | ||
|
|
4f6cf22419 | ||
|
|
84cf31b929 | ||
|
|
7cc392d78f | ||
|
|
109700b949 |
-164
@@ -9,170 +9,6 @@ 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.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
|
||||
platform's playback goes through was rebuilt around one contract. Nine defects
|
||||
fell out of doing it — each one a capability the code had written down as a
|
||||
fact about the platform rather than asking the thing that would know.
|
||||
|
||||
### ✨ Changes
|
||||
|
||||
- **Video can decode natively on Linux, without the server re-encoding it.**
|
||||
Until now every video played on the desktop was transcoded by Jellyfin to
|
||||
h264 and handed to the browser engine, whatever the file actually was — so the
|
||||
server burned CPU on every play, and quality was capped by that conversion.
|
||||
mpv can now draw the picture directly, composited beneath the interface so the
|
||||
controls, subtitles and overlays still sit on top of it. Direct play means the
|
||||
original file, hardware decoding, and no server work at all. This is off by
|
||||
default while it settles: set `JELLYTAU_NATIVE_VIDEO=1` to try it. The browser
|
||||
path is untouched and remains what you get otherwise. (UR-080 → DR-231 …
|
||||
DR-237)
|
||||
|
||||
- **Playback speaks one language across every player.** Linux, Android and
|
||||
Windows each drove their engine through a different set of calls, and a rule
|
||||
learned on one did not reach the others — which is why several of the fixes
|
||||
below existed on one platform and not another. All three now go through a
|
||||
single contract, and one suite of behaviours runs against every engine,
|
||||
including ExoPlayer on a real device. An engine is either correct or visibly
|
||||
failing. Nothing about this is visible while it works, which is the point.
|
||||
(UR-081 → DR-242 … DR-247)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Resuming a film starts where you left it, instead of at the beginning.**
|
||||
Asking a player to open a file and asking it to start at a position were two
|
||||
separate steps, and the second was issued before the first had finished — so
|
||||
it failed, was discarded, and playback began at zero. It affected resume and
|
||||
any skip on a stream the server was converting. The position is now part of
|
||||
opening the file, so there is no gap for it to fall into. (DR-241)
|
||||
|
||||
- **Skipping works on films the server is converting.** A skip was routed by the
|
||||
*shape* of the stream rather than by what the player could do with it. That
|
||||
happened to be right while one particular player handled those streams and
|
||||
became wrong the moment another did — after which skipping simply did nothing,
|
||||
silently. Players now say what they can do and are asked. (DR-238, DR-246)
|
||||
|
||||
- **The play and pause button follows the player again.** The code that reacted
|
||||
to pausing was never subscribed to the event it was waiting for, so the button
|
||||
stayed where it was while playback did something else. (DR-239)
|
||||
|
||||
- **Fullscreen fills the screen.** It expanded the page rather than the window,
|
||||
which was invisible while the picture was drawn inside the page and obvious as
|
||||
soon as it was not. (DR-240)
|
||||
|
||||
- **The seek bar knows how long the film is.** A player that had not yet worked
|
||||
out the duration reported zero, and zero was believed — leaving the bar with
|
||||
no scale and nothing to drag against, even though the length had been known
|
||||
since the library listed it. (DR-251)
|
||||
|
||||
- **Leaving the player stops the sound.** The stop was aimed at whichever
|
||||
renderer the app believed was in charge. Enabling background audio hands over
|
||||
to a different one, so afterwards the app stopped something that was no longer
|
||||
playing and the film carried on as an audio track in the mini player. Closing
|
||||
now stops everything, regardless of who was in charge. (DR-250)
|
||||
|
||||
- **Coming back from background audio no longer leaves a black screen.** The
|
||||
stream that plays while the app is hidden has no fixed length, and the value a
|
||||
player uses to say so is a very large negative number. Converting it crashed
|
||||
the playback engine outright, which looked like a dead player with no
|
||||
controls. (DR-252)
|
||||
|
||||
- **Android builds again.** A rule that only applied to Linux stayed attached to
|
||||
code that had stopped being Linux-only, and the Android build had not compiled
|
||||
since. (DR-247)
|
||||
|
||||
- **A quality you chose for one episode no longer caps every episode after it.**
|
||||
Dropping the quality mid-episode is meant to describe that episode. When the
|
||||
next one started in the background, nothing reset it — so the ceiling stayed
|
||||
in force indefinitely, with nothing in the interface saying why later episodes
|
||||
looked worse. (DR-254)
|
||||
|
||||
- **Skipping to the next item no longer starts it part-way through.** Scrubbing
|
||||
near the end of a converted stream re-opens it, and the position being waited
|
||||
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
|
||||
desktop engines, `bun run test:player:android` for ExoPlayer on a connected
|
||||
device. Both build a test fixture rather than carrying media in the
|
||||
repository.
|
||||
|
||||
- [docs/native-player-verification.md](docs/native-player-verification.md)
|
||||
records what to check before a release, including the exact sequences that
|
||||
found two of the defects above — both of which passed every automated test.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Resume reads progress saved on the device, not from the server, so a fresh
|
||||
install or a second device will not offer to resume something watched
|
||||
elsewhere.
|
||||
- Native video on Linux is opt-in and is not yet the default.
|
||||
|
||||
## v0.10.1
|
||||
|
||||
A single fix, for something that had been quietly overriding a choice you made.
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
|
||||
- [Playback Backend Unification](specs/playback-backend-unification.md)
|
||||
- [Linux Native Video Spike](specs/linux-native-video-spike.md)
|
||||
- [Backend-Owned Stream Selection](specs/backend-owned-stream-selection.md)
|
||||
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
|
||||
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
|
||||
- [libmpv2 Migration](specs/libmpv2-migration.md)
|
||||
@@ -49,7 +48,6 @@
|
||||
|
||||
- [Build & Release](build/build-release.md)
|
||||
- [Release Checklist](release-checklist.md)
|
||||
- [Native Player Verification](native-player-verification.md)
|
||||
- [Desktop Packaging](build/build-desktop-packages.md)
|
||||
- [Windows Build](build/build-windows.md)
|
||||
- [Defect Windows](defect-windows.md)
|
||||
|
||||
@@ -49,33 +49,6 @@ 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
|
||||
|
||||
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.
|
||||
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.
|
||||
That is the dominant pattern here: not regressions, but original assumptions that
|
||||
went unexercised until a later feature leaned on them.
|
||||
|
||||
@@ -34,12 +34,9 @@ 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
|
||||
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
# Native player — verification plan
|
||||
|
||||
What to check before the `MediaPlayer` contract and Linux native video reach
|
||||
`master`.
|
||||
|
||||
This is not a generic smoke test. Every case below exists because something
|
||||
specific went wrong, and most of them were found on hardware **after** the
|
||||
automated suites were green. Treat the sequences as load-bearing: several
|
||||
defects only appeared in a particular order of actions, and testing the same
|
||||
features in a different order missed them entirely.
|
||||
|
||||
Companion to [release-checklist.md](release-checklist.md), which covers the
|
||||
release mechanics. This covers whether the player is fit to release at all.
|
||||
|
||||
## What is risky about this change
|
||||
|
||||
- `PlayerController` now talks to a `MediaPlayer` contract instead of
|
||||
`PlayerBackend`. Every engine reaches it through an adapter that did not exist
|
||||
before (DR-245).
|
||||
- mpv decodes video on Linux for the first time, composited under the webview
|
||||
(DR-231).
|
||||
- Seek strategy is driven by an ability each engine declares rather than by a
|
||||
truth table (DR-246).
|
||||
- Two regressions were introduced during this work and caught only on a device:
|
||||
a wrong capability for ExoPlayer (DR-246 follow-up) and a `Duration` panic
|
||||
(DR-252). Both were invisible to the test suites.
|
||||
|
||||
The suites originally verified only engines that *behave*, which is why both
|
||||
regressions passed them. That gap is now partly closed in code rather than in
|
||||
this document: `UT-223` drives a deliberately hostile engine — `C.TIME_UNSET`,
|
||||
NaN, infinities, negatives — through the adapter, and fails with the exact
|
||||
panic that produced a black screen on a tablet. `UT-224` pins the handoff
|
||||
clearing that was previously verified by listening to a device.
|
||||
|
||||
**Prefer moving cases out of this file and into tests.** Anything here that
|
||||
could fail automatically should; a checklist depends on someone remembering to
|
||||
follow it, and the two defects it was written for cost hardware time that would
|
||||
have been better spent making the suites realistic. What is left below is what
|
||||
genuinely needs eyes, ears, or a display — not what merely has not been
|
||||
automated yet.
|
||||
|
||||
## 1. Automated gates
|
||||
|
||||
Cheap, fast, and non-negotiable. Run from the worktree.
|
||||
|
||||
```bash
|
||||
bun run check # 0 errors, 0 warnings
|
||||
bun run test # frontend
|
||||
bun run test:rust # Rust
|
||||
bun run format:check
|
||||
bun run lint # 0 errors; warnings at or below the CI ratchet
|
||||
bun run check:boundary
|
||||
bun run traces:validate
|
||||
bun run traces:coverage # at or above MIN_THRESHOLD
|
||||
cd src-tauri && cargo fmt --check && cargo clippy --all-targets -- -D warnings
|
||||
cargo clippy --all-targets --features conformance -- -D warnings
|
||||
```
|
||||
|
||||
The eslint warning count is a **ratchet**: equal to the CI limit is a pass, one
|
||||
over fails the build. Going one over is how a piece of dead state was found
|
||||
during this work — do not raise the limit to get past it.
|
||||
|
||||
## 2. Engine conformance
|
||||
|
||||
```bash
|
||||
bun run test:player # mpv + legacy, desktop
|
||||
bun run test:player:android # ExoPlayer, on a connected device
|
||||
```
|
||||
|
||||
Expected, and each deviation is meaningful rather than noise:
|
||||
|
||||
| Engine | Result | If it differs |
|
||||
|---|---|---|
|
||||
| `MpvPlayer` | 9/9 | A real regression. Stop. |
|
||||
| `LegacyPlayer` | 8/9 | The one failure is `transport_settings_round_trip`: the old trait has no mute or rate. Any *other* failure is a regression. |
|
||||
| ExoPlayer (device) | 7/7 | Two cases are absent because the Kotlin player exposes no mute or rate. |
|
||||
|
||||
A green conformance run is **not** sufficient evidence to ship. Both regressions
|
||||
introduced during this work passed conformance.
|
||||
|
||||
## 3. Desktop (Linux)
|
||||
|
||||
Run with native video on, since that is what is new:
|
||||
|
||||
```bash
|
||||
JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
|
||||
```
|
||||
|
||||
- [ ] **Direct play** — a file the server does not transcode. Picture and sound.
|
||||
- [ ] **Transcoded play** — something the server must re-encode (4K, HEVC, or an
|
||||
audio codec the renderer cannot take).
|
||||
- [ ] **Resume** — an item watched previously *on this install*. The prompt
|
||||
appears and playback starts at the offered position, not at zero.
|
||||
*(Resume is device-local — see "Known open".)*
|
||||
- [ ] **Scrub** on a direct-play item; position lands and playback continues.
|
||||
- [ ] **Scrub on a transcoded item.** Separate case on purpose: it takes a
|
||||
different path, and it silently did nothing for months (DR-238).
|
||||
- [ ] **Pause and resume** — the button follows the player. It stopped doing so
|
||||
when a property was handled but never observed (DR-239).
|
||||
- [ ] **Fullscreen** — the window really fills the display. Measure it if
|
||||
unsure: the log prints `rendering WxH`, and a height short of the panel
|
||||
means the document went fullscreen and the window did not (DR-240).
|
||||
- [ ] **Exit the player** — audio stops. Listen; do not assume.
|
||||
- [ ] **Audio-only playback** still works: mini player, queue, next/previous.
|
||||
- [ ] Nothing in the log matches `PANIC` or `ERROR`.
|
||||
|
||||
## 4. Android
|
||||
|
||||
The tablet needs the *side-by-side* build. **Do not uninstall the release app**
|
||||
to make an install succeed — see "Known open" for why the normal command is
|
||||
currently wrong.
|
||||
|
||||
```bash
|
||||
bun run android:build --device
|
||||
./scripts/sync-android-sources.sh
|
||||
cd src-tauri/gen/android && ANDROID_HOME="$HOME/Android/Sdk" ./gradlew \
|
||||
:app:assembleUniversalDebug -x :app:rustBuildUniversalDebug \
|
||||
-x :app:rustBuildArm64Debug -x :app:rustBuildArmDebug \
|
||||
-x :app:rustBuildX86Debug -x :app:rustBuildX86_64Debug
|
||||
adb install -r app/build/outputs/apk/universal/debug/app-universal-debug.apk
|
||||
```
|
||||
|
||||
Confirm the package is `com.dtourolle.jellytau.debug` before installing:
|
||||
|
||||
```bash
|
||||
aapt2 dump packagename <apk>
|
||||
```
|
||||
|
||||
If it says `com.dtourolle.jellytau`, the suffix was lost — **stop**, re-sync and
|
||||
re-assemble. Installing it would try to replace the real app.
|
||||
|
||||
Then, with `adb logcat` capturing:
|
||||
|
||||
- [ ] Play a video. Picture, sound, and controls.
|
||||
- [ ] **Scrub.** The bar has a scale — a duration of `0.0` means the seek bar has
|
||||
nothing to scrub against (DR-251).
|
||||
- [ ] Transcoded seek lands rather than restarting the stream. ExoPlayer seeks a
|
||||
transcode in place; declaring otherwise re-opened it (DR-246).
|
||||
- [ ] PiP.
|
||||
- [ ] Lockscreen: controls respond and position tracks.
|
||||
- [ ] **The handoff sequence, in this exact order:**
|
||||
1. play a video
|
||||
2. enable background audio
|
||||
3. background the app — audio continues
|
||||
4. foreground the app — **video returns**
|
||||
5. exit the player — **everything stops**
|
||||
|
||||
Steps 4 and 5 are where two separate defects lived (DR-250, DR-252). Doing
|
||||
the same actions in another order finds neither.
|
||||
- [ ] `grep -c 'PANIC at' <logcat>` returns 0.
|
||||
|
||||
## 5. Regression checks with a named cause
|
||||
|
||||
Each of these presented as something other than its cause, which is why they are
|
||||
listed separately from the feature passes above.
|
||||
|
||||
| Symptom to look for | Was actually | Ref |
|
||||
|---|---|---|
|
||||
| Skip on a transcoded item does nothing, or jumps to zero | Seek strategy keyed on the container, not the engine | DR-238, DR-246 |
|
||||
| Play/pause button does not follow the player | A property handled but never observed, so the event never arrived | DR-239 |
|
||||
| Fullscreen leaves a strip of desktop | The document went fullscreen, the window did not | DR-240 |
|
||||
| Resume plays from the beginning | A seek issued before the engine had a file was discarded | DR-241 |
|
||||
| Scrub bar has no scale | Duration reported as `0.0` and believed | DR-251 |
|
||||
| Black screen, no controls, after a background-audio round trip | A junk duration converted to a `Duration` panicked the backend | DR-252 |
|
||||
| Audio still playing after leaving the player | The stop was aimed at whichever renderer bookkeeping believed was active | DR-250 |
|
||||
|
||||
## Known open — decide, do not discover
|
||||
|
||||
None of these are fixed. Each needs an explicit ship / do-not-ship call rather
|
||||
than being met with surprise during testing.
|
||||
|
||||
- **Resume is device-local.** Progress is read from the local database and
|
||||
nothing consults the server's `UserData`. A fresh install, a second device or
|
||||
a reinstall offers no resume even though the server knows the position. Not a
|
||||
regression — it has always been so.
|
||||
- **The background-audio handoff is an unconfirmed state swap.**
|
||||
`exit_background_audio` marks the video element the player again the moment it
|
||||
is called, while the element has not reloaded. DR-250 makes the visible
|
||||
symptom impossible; the race is intact and can still misdirect a lockscreen
|
||||
command or a position read. See
|
||||
[media-player-controller.md](specs/media-player-controller.md).
|
||||
- 🔴 **The side-by-side debug install is broken.** `bun run android:dev`
|
||||
produces an APK with the *release* application id, because the Tauri build
|
||||
regenerates `gen/build.gradle.kts` after the sync drops the `.debug` suffix in.
|
||||
It then fails on signatures, and its own error message advises uninstalling —
|
||||
which would destroy the real app's data. **Fix this before anyone else builds
|
||||
for Android.**
|
||||
- **`PlayerBackend` still exists** behind `LegacyPlayer`, and the frontend still
|
||||
carries some playback state. DR-248 and DR-249 are not started.
|
||||
|
||||
## Ship criteria
|
||||
|
||||
Ship when:
|
||||
|
||||
1. Every automated gate in §1 passes.
|
||||
2. Conformance matches §2 exactly, deviations included.
|
||||
3. §3 and §4 are complete, on real hardware, by a person.
|
||||
4. §5 shows no symptom returning.
|
||||
5. Every item in "Known open" has a recorded decision.
|
||||
|
||||
Do not ship on green suites alone. Both regressions introduced during this work
|
||||
passed every suite and were caught by a person using the app.
|
||||
+2
-28
@@ -440,20 +440,8 @@ Internal architecture, components, and application logic.
|
||||
| DR-242 | The player contract expresses intent, not device operations. `MediaPlayer::open` carries the start position, so no caller sequences load-then-seek and none can race an engine's asynchronous load; `seek` states a destination and leaves in-place-vs-re-open to the engine, which is the only layer that knows its own transport; `snapshot` is one coherent read; and `Phase::Opening` names the window a seek used to be lost in. Replaces `PlayerBackend`, which abstracted a device and required each of the three engines to re-derive the same rules | Player | UR-081 | In Progress |
|
||||
| DR-243 | Every engine passes one conformance suite, and a `FakePlayer` implements the contract deterministically. The suite is written before the second engine so it cannot encode whatever the first happened to do, and it drives readiness through a harness rather than sleeping. `FakePlayer` models the one behaviour that matters — opening is not instantaneous — so the load/seek race can be expressed on purpose, and lets the controller, queue, autoplay and session logic be tested with no engine at all | Player | UR-081 | In Progress |
|
||||
| DR-244 | `MpvPlayer` implements `MediaPlayer` over libmpv, applying the start position at load time via mpv's own `start` option rather than seeking after an asynchronous `loadfile`, and holding a seek that arrives during `Opening` until the file loads. A standalone `player-conformance` binary runs the suite against it with audio and video routed to null, so a wrapper is verifiable without building or launching the app | Player | UR-081, UR-040 | Done |
|
||||
| DR-245 | `PlayerController` holds a `MediaPlayer` rather than a `PlayerBackend`, and every engine reaches it through that one contract — `LegacyPlayer` carries the not-yet-ported ones across unchanged, so the port swaps a seam rather than four implementations. Loading an item is now a single `open` carrying its start position, and the controller maps the engine's `Phase` back onto `PlayerState` using the queue, so nothing outside changes. `LegacyPlayer` drives the old `PlayerBackend` through the `MediaPlayer` contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purpose | Player | UR-081 | Done |
|
||||
| DR-246 | The seek strategy turns on an ability the engine declares, not on the container the stream arrives in. `Capabilities::seeks_transcoded_in_place` is stated by each engine — true for hls.js, which seeks within the VOD playlist it was handed; false for mpv, which cannot make the server transcode from a new offset — and the command asks the engine currently rendering instead of inferring from `is_hls` and `use_html5`. The item's transport is no longer read at the seek site at all. Re-negotiating a stream needs the repository, which sits above the engine, so the engine states the capability and the caller acts on it rather than the engine owning the whole decision | Player | UR-040, UR-081 | Done |
|
||||
| DR-245 | `LegacyPlayer` drives the old `PlayerBackend` through the `MediaPlayer` contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purpose | Player | UR-081 | In Progress |
|
||||
| DR-247 | ExoPlayer can be told where to start. `JellyTauPlayer.load(url, mediaId)` had no way to express a start position, so every caller loaded and then seeked; the position is now handed to ExoPlayer with the media item via `setMediaItem(item, startPositionMs)`, and the two-argument form delegates to it. Running the conformance cases on a device also settled which half of DR-241 was engine-specific: ExoPlayer already queues a seek issued before `prepare()` completes, so it never had the lost-seek defect mpv did — only the missing vocabulary for a start position | Player | UR-081, UR-005 | Done |
|
||||
| DR-250 | Stopping means nothing is playing, from any renderer — not "whatever we believe owns playback has been asked to stop". A background-audio handoff swaps which renderer that is, and the swap is bookkeeping that can be mid-flight: `exit_background_audio` marks the webview element the player again the moment it is called, while the element has not reloaded. The teardown's stop was gated on flags describing what the component started, so after a handoff it described a player that was no longer making sound and the stop was skipped — the audio stream kept running and the mini player adopted it, which is why a movie reappeared as an audio track. The stop is now unconditional (it is idempotent) and clears the handoff base and flag, so a later position read cannot be interpreted against a handoff that no longer exists | Player | UR-040, UR-005 | Done |
|
||||
| DR-251 | A duration of zero is treated as "the engine does not know yet", and falls back to the runtime the item already carries. ExoPlayer reports `C.TIME_UNSET` until it resolves one and `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answered `Some(0.0)` rather than `None` — which satisfied every "unknown duration" fallback and left the seek bar with no scale. It presented as scrubbing being broken rather than as a duration that never arrived, and the catalog had the runtime the whole time | Player | UR-005, UR-040 | Done |
|
||||
| DR-252 | Seconds reported by an engine are converted to a `Duration` only when finite and positive. `Duration::from_secs_f64` panics on a negative or non-finite value and no engine promises otherwise: ExoPlayer reports `C.TIME_UNSET` (`Long::MIN_VALUE`, about -9.2e15) for a stream whose length it does not know, which is every background-audio handoff — `/Audio/{id}/universal` is a chunked, length-less transcode. Held as a float that junk was harmless; converted to a `Duration` by the `MediaPlayer` adapter it became a panic that killed the backend mid-handoff and left a black screen with no controls. One guard on the contract, used by every engine crossing into it | Player | UR-005 | Done |
|
||||
| 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-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 |
|
||||
|
||||
---
|
||||
@@ -470,7 +458,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, DR-257 |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||
| 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 |
|
||||
@@ -766,20 +754,6 @@ Internal architecture, components, and application logic.
|
||||
| UT-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done |
|
||||
| UT-219 | A fullscreen toggle moves the document only when an in-document `<video>` renders, and moves the OS window as well when a native surface does | DR-240 | Done |
|
||||
| UT-220 | The conformance suite: opening at a position starts there and never at zero, a seek issued while opening is honoured and overrides the start it overtook, pause and play are observable, close is silent and idempotent, and an open cancelled by close never begins playing | DR-242, DR-243 | In Progress |
|
||||
| UT-221 | An engine that cannot report a duration does not erase the one the item carries: with the queue holding a 1800s item and the engine answering nothing usable, the controller still reports 1800s | DR-251 | Done |
|
||||
| UT-222 | The values that killed the backend are rejected rather than converted: `C.TIME_UNSET` as seconds, negatives, zero, NaN and both infinities all yield no duration, while a real runtime survives | DR-252 | Done |
|
||||
| UT-223 | The adapter survives an engine that answers badly. A `HostileBackend` reports `C.TIME_UNSET` as seconds, NaN, both infinities, a negative and a zero; reading a snapshot yields no duration and a zero position rather than panicking, and a well-behaved engine still round-trips. The conformance suite could not have caught this — it only ever drives engines that report sane numbers, which is why it stayed green while a real one took the backend down | DR-252 | Done |
|
||||
| UT-224 | Stopping clears an active background-audio handoff, both the flag and the base offset, so a later position read cannot be interpreted against a handoff that no longer exists. Previously verified only by listening to a device | DR-250 | Done |
|
||||
| UT-225 | Both `load` and `stop` discard a deferred seek, so a position held for a file that is no longer loading cannot be applied to whatever loads next | DR-253 | Done |
|
||||
| UT-226 | The background episode advance clears the per-playback quality override, so a ceiling chosen for one episode does not cap every episode after it | DR-254 | Done |
|
||||
| UT-227 | Opening any one of the control bar's menus closes whichever was open — track, quality, subtitle and the desktop volume popup are one group, never two panels at once — and a second click on the open menu's own toggle closes it | DR-256 | Done |
|
||||
| UT-228 | The open menu panel is anchored to the control row rather than to the icon that opened it, and carries a viewport-clamped width, so it cannot hang off the edge of a portrait screen | DR-256 | Done |
|
||||
| UT-229 | A channel folder's children are requested by release date, newest first, while every other container keeps name order; an explicit sort still wins, and a caller naming no container gets no `SortBy` at all | DR-257 | Done |
|
||||
| UT-230 | A `ChannelFolderItem` that is a folder maps to `ChannelFolder`, not to the generic `Folder` it was indistinguishable from | DR-257 | Done |
|
||||
| UT-231 | The library store sends the container's kind and no sort field, defaulting to a plain folder when the caller names none | DR-257 | Done |
|
||||
| UT-232 | A transcode's audio-track change re-opens the stream, a direct play selects in place, and an HTML5 element reloads either way — the engine is only asked to select a track the stream actually carries | DR-258 | Done |
|
||||
| UT-233 | The position a re-opened stream resumes at comes from the engine when the caller has none, and a non-finite or negative position is treated as absent rather than passed to a backend that rejects it | DR-258 | Done |
|
||||
| UT-234 | A subtitle URL targets Jellyfin's `Stream.{format}` route, asserted against the repository that builds it rather than a mock that restates it | DR-259 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ taken by other work; each carries a ⚠️ note at the top.
|
||||
| Spec | Blocked on / note |
|
||||
|---|---|
|
||||
| [desktop-native-video.md](desktop-native-video.md) | mpv draws video on every desktop platform, then the webview `<video>` path and hls.js are deleted. Converts a measured 7% direct-play rate toward Android's 85%. Stacked on backend-owned stream selection. |
|
||||
| [backend-owned-stream-selection.md](backend-owned-stream-selection.md) | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Partly built — `StreamSelection`, `Transport` and the `.m3u8` sniff removal have landed. |
|
||||
| [build-provenance.md](build-provenance.md) | `build.rs` is still bare. ⚠️ suggested id DR-093 is taken. |
|
||||
| [player-facade-enforcement.md](player-facade-enforcement.md) | ~60 `commands.player*` sites still outside the facade; no lint rule. ⚠️ suggested id DR-095 is taken. |
|
||||
| [windows-native-audio-backend.md](windows-native-audio-backend.md) | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken. |
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
# Spec: Backend-owned stream selection
|
||||
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-079 (new) → DR-219 … DR-224 (new); **implements and extends
|
||||
DR-121**, currently allocated to
|
||||
[read-through-media-cache.md](read-through-media-cache.md) and not started.
|
||||
Re-check `requirements.md` before allocating — the ids moved twice while this was
|
||||
being written (`DR` max was 215, then 218).
|
||||
**UX spec:** the quality selector in `VideoPlayer.svelte` already exists; this
|
||||
changes what fills it, not how it looks.
|
||||
**Supersedes / revises:** takes DR-121 out of
|
||||
[read-through-media-cache.md](read-through-media-cache.md), which should keep
|
||||
only its capture/eviction half. Unblocks
|
||||
[linux-native-video-spike.md](linux-native-video-spike.md).
|
||||
|
||||
**Destination on completion:**
|
||||
[01-rust-backend.md](../architecture/01-rust-backend.md) — extends the
|
||||
"Streaming quality ladder" section; and
|
||||
[03-data-flow.md](../architecture/03-data-flow.md) — playback initiation. The
|
||||
durable half is the layer line and the `StreamSelection` contract; phases and
|
||||
acceptance criteria are disposable.
|
||||
|
||||
## Summary
|
||||
|
||||
Make Rust the single owner of *which stream to play* — direct play or transcode,
|
||||
at what ceiling, over what transport — and hand every player backend a
|
||||
self-describing selection instead of a bare URL. mpv, ExoPlayer and the HTML5
|
||||
`<video>`/hls.js path all become consumers of the same decision rather than three
|
||||
places that re-derive it.
|
||||
|
||||
Nothing about how playback *looks* changes. What changes is that the frontend
|
||||
stops inferring transport from a URL string, and that direct play becomes
|
||||
possible at all.
|
||||
|
||||
## Motivation
|
||||
|
||||
Four concrete problems, all the same shape.
|
||||
|
||||
**1. The frontend sniffs transport out of the URL.**
|
||||
[VideoPlayer.svelte:569](../../src/lib/components/player/VideoPlayer.svelte#L569):
|
||||
|
||||
```ts
|
||||
const isHlsStream = currentStreamUrl.includes(".m3u8");
|
||||
```
|
||||
|
||||
and again inline at line 2364. Rust *built* that URL and knows exactly what it
|
||||
is; the frontend re-derives it by substring match. Change the endpoint, add a DASH
|
||||
path, serve a progressive file, and this silently picks wrong. This is the
|
||||
boundary rule in miniature — not item-type taxonomy, but the same error: a
|
||||
domain fact reconstructed in the presentation layer because the wire shape did
|
||||
not carry it.
|
||||
|
||||
**2. There is no direct-play path.** `get_video_stream_url` always builds an HLS
|
||||
transcode URL (`TranscodingProtocol=hls`, `VideoCodec=h264` first). Every video
|
||||
play burns server CPU, even when the file would play untouched. This is the cost
|
||||
the Linux native-video work exists to remove, and it cannot be removed without a
|
||||
decision that does not currently exist anywhere in the codebase.
|
||||
|
||||
**3. Quality is a process-wide global.** `streaming_quality()` /
|
||||
`set_streaming_quality()` in `repository/online.rs` read and write a static.
|
||||
It is not per-session or per-item, so it cannot express "this 4K remux needs a
|
||||
ceiling, that podcast does not", and two concurrent playbacks would share one
|
||||
setting.
|
||||
|
||||
**4. Rust cannot say what qualities *this* media source supports.** The selector
|
||||
is populated from a fixed enum rather than from what the source actually offers.
|
||||
DR-121 already names this; it has not been built.
|
||||
|
||||
### The prior question
|
||||
|
||||
Finding 3 of [playback-backend-unification.md](playback-backend-unification.md)
|
||||
holds that hls.js gives us real adaptive bitrate and mpv would lose it. Evidence
|
||||
in this repo suggests **there is no ABR today**: a single rendition is requested,
|
||||
no level-handling code exists anywhere in the frontend, and a quality switch is
|
||||
implemented by re-opening the stream.
|
||||
|
||||
**Run this before sizing the adaptation work.** It needs a live server:
|
||||
|
||||
```
|
||||
curl -s "https://<server>/Videos/<itemId>/master.m3u8?api_key=<key>&…" \
|
||||
| grep -c EXT-X-STREAM-INF
|
||||
```
|
||||
|
||||
`1` → there is no adaptation to preserve, and the adaptation half of this spec
|
||||
collapses to "pick well at open". `>1` → finding 3 stands and DR-223 applies.
|
||||
**Everything else in this spec is worth doing either way** — the ownership
|
||||
problems above are independent of the answer.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|---|---|---|
|
||||
| Direct play vs direct stream vs transcode | Rust | Depends on Jellyfin's `PlaybackInfo`, container/codec support and the device profile. Changes when Jellyfin's API or our profile changes → domain, by the litmus test. |
|
||||
| Transport of the chosen stream (HLS / progressive / local file) | Rust | Rust constructs the URL; it is the only place that *knows* rather than infers. Today the frontend guesses from `.m3u8`. |
|
||||
| Which qualities this media source can offer | Rust | Derived from the source's own streams and the quality→transcode-parameter mapping that `get_video_download_url` already holds. DR-121. |
|
||||
| The quality ceiling in force, per playback session | Rust | Domain state that outlives any one view and must survive a backend swap or a mode transfer. Currently a process-wide static. |
|
||||
| Deciding to re-negotiate mid-playback (if adaptation is needed) | Rust | It performs the HTTP and already derives reachability from real traffic via `ConnectivityMonitor`. Throughput estimation is the same pattern on the same data — a side-channel probe would repeat the mistake that principle exists to prevent. |
|
||||
| Frame-level delivery *within* the selected stream, including a player's own ABR | **Player** | ExoPlayer has genuine adaptive selection; if Rust hands it a multi-variant playlist it should use it. Rust chooses *what to request*, never how a player paces bytes. See "The line". |
|
||||
| Rendering the selector, showing the current quality, ordering the list | Frontend | Pure presentation over a backend-supplied list. |
|
||||
| Poster, letterbox, controls, overlay z-order | Frontend | Unchanged. |
|
||||
|
||||
### The line
|
||||
|
||||
**Rust decides *what stream*. The player decides *how to deliver it*.**
|
||||
|
||||
This matters most for ExoPlayer, which already does real adaptive track selection
|
||||
over HLS. This spec must not reimplement that or fight it — if a multi-variant
|
||||
playlist reaches ExoPlayer, ExoPlayer adapts and Rust stays out of the way. The
|
||||
same restraint applies to any future backend that gains the capability. Rust only
|
||||
steps in where the player has no such ability (mpv) *and* the server actually
|
||||
offers a ladder.
|
||||
|
||||
Borderline row, with its tie-breaker: "which media source of a multi-source item"
|
||||
looks like a user choice, and its *presentation* is. The default and the
|
||||
constraint set are domain → **Rust**, per the borderline-defaults-to-Rust rule.
|
||||
|
||||
## Design
|
||||
|
||||
### The contract
|
||||
|
||||
One self-describing selection replaces the bare URL. Nested fields are
|
||||
camelCase over the wire (`#[serde(rename_all = "camelCase")]`); the enums are
|
||||
tagged so the frontend matches a tag instead of parsing a string.
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamSelection {
|
||||
pub url: String,
|
||||
pub transport: Transport,
|
||||
pub playback_kind: PlaybackKind,
|
||||
/// The negotiated rendition; None when direct-playing the source as-is.
|
||||
pub rendition: Option<Rendition>,
|
||||
/// What this media source can offer — fills the selector (DR-121).
|
||||
pub available: Vec<QualityOption>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Transport { Hls, Progressive, LocalFile }
|
||||
|
||||
#[derive(Serialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PlaybackKind { DirectPlay, DirectStream, Transcode }
|
||||
```
|
||||
|
||||
`Transport` is the field that deletes the `.m3u8` sniff. The frontend picks
|
||||
hls.js on `Hls` and the element's own loader otherwise — a tag match, not a
|
||||
substring search.
|
||||
|
||||
### Re-negotiation
|
||||
|
||||
Rust emits `stream-selection-changed` (kebab-case, per convention) carrying a new
|
||||
`StreamSelection` plus the position to resume at. The existing
|
||||
`playerSetStreamQuality` response already has exactly the right shape — a tagged
|
||||
`strategy` that tells the caller who reloads, with the backend handling native
|
||||
itself and handing HTML5 a URL for `reloadSource`
|
||||
([index.ts:198](../../src/lib/player/index.ts#L198)). **Extend that; do not
|
||||
invent a second mechanism.** It is the one piece of this that is already right.
|
||||
|
||||
Note the existing wart to preserve or fix deliberately, not accidentally:
|
||||
tauri-specta keeps those response fields snake_case (`new_url`), and the facade
|
||||
comments say so.
|
||||
|
||||
### Phases
|
||||
|
||||
1. **DR-219** `StreamSelection` + `Transport`; delete the `.m3u8` sniff. No
|
||||
behaviour change — pure ownership move, and independently shippable.
|
||||
2. **DR-220** Per-session quality ceiling replacing the `online.rs` static.
|
||||
3. **DR-221** `available` populated from the media source (DR-121's substance).
|
||||
4. **DR-222** Direct-play/direct-stream negotiation via `PlaybackInfo`. This is
|
||||
the phase that unlocks native video and removes the transcode.
|
||||
5. **DR-223** Adaptation, **only if the playlist check says a ladder exists**.
|
||||
Cheapest sufficient design: re-negotiate on sustained throughput drop, reusing
|
||||
the phase-1 re-negotiation path. A local proxy synthesizing a single-variant
|
||||
playlist is a last resort, not a starting point.
|
||||
6. **DR-224** ExoPlayer and mpv consume `StreamSelection` unchanged, proving the
|
||||
contract is player-agnostic rather than HTML5-shaped.
|
||||
|
||||
Phases 1–4 stand on their own merits with no dependency on the ladder question.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Rendering, compositing, and the Linux native-video work itself. This spec
|
||||
unblocks [linux-native-video-spike.md](linux-native-video-spike.md); it does
|
||||
not contain it.
|
||||
- Replacing hls.js. It stays as the HLS loader for the webview path.
|
||||
- Reimplementing or overriding ExoPlayer's own adaptive selection. See "The line".
|
||||
- The download/capture half of [read-through-media-cache.md](read-through-media-cache.md)
|
||||
(DR-122, DR-124, DR-125), which keeps its own spec.
|
||||
- Audio. The same argument applies, but video is where the transcode cost is.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The `.m3u8` substring check is gone from `VideoPlayer.svelte` (both sites)
|
||||
and transport comes from the tagged enum.
|
||||
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes — and the reviewer confirms by reading that
|
||||
no transport/kind decision was reconstructed in `src/`, since the tripwire
|
||||
only catches item-type array literals.
|
||||
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
|
||||
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes and
|
||||
coverage stays ≥ the CI ratchet.
|
||||
- [ ] The `EXT-X-STREAM-INF` count is recorded in this spec before DR-223 is
|
||||
started or dropped.
|
||||
- [ ] DR-121 is removed from `read-through-media-cache.md` with a pointer here.
|
||||
|
||||
## Testing
|
||||
|
||||
- Rust: `PlaybackInfo` fixtures → expected `PlaybackKind`, one per branch
|
||||
(supported container direct-plays; unsupported codec transcodes; a ceiling
|
||||
below the source bitrate transcodes even when the codec is fine).
|
||||
- Rust: `Transport` round-trips through serde with the tag the frontend matches.
|
||||
- Frontend: adapter selection driven by `transport`, including the case a URL
|
||||
ending `.m3u8` is served as `Progressive` — that test fails on today's code,
|
||||
which is the point.
|
||||
- Extend `tauriIntegration.test.ts` for the new command params (camelCase rule).
|
||||
- No test asserts a URL substring.
|
||||
|
||||
## TRACES
|
||||
|
||||
| Piece | Tag |
|
||||
|---|---|
|
||||
| `StreamSelection` / `Transport` | `UR-079 \| DR-219` |
|
||||
| Per-session ceiling | `UR-074 \| DR-220` |
|
||||
| `available` from media source | `UR-079 \| DR-221, DR-121` |
|
||||
| Direct-play negotiation | `UR-079 \| DR-222` |
|
||||
| Adaptation, if built | `UR-079 \| DR-223` |
|
||||
| ExoPlayer/mpv consumers | `UR-003, UR-004 \| DR-224` |
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **Phase 1 is worth doing on its own**, even if everything after it is dropped.
|
||||
It removes a real leak and costs almost nothing.
|
||||
- Do not frame any phase as "no Rust changes required" — that framing is what
|
||||
produced the leak `scoped-search-boundary.md` records.
|
||||
- `ConnectivityMonitor` is the precedent for DR-223: derive network facts from
|
||||
real traffic, never from a side-channel poller.
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes. Requirement ids in particular moved twice
|
||||
during the writing of this spec.
|
||||
@@ -1,11 +1,6 @@
|
||||
# Spec: MediaPlayer — one controller API, three interchangeable engines
|
||||
|
||||
**Status:** **Partially implemented.** DR-242 … DR-247 have shipped: the
|
||||
contract, `FakePlayer` and the conformance suite, `MpvPlayer`, the standalone
|
||||
runner, `LegacyPlayer`, the controller port, the capability-driven seek
|
||||
strategy, and ExoPlayer conformance on a device. What is left is DR-248 (the
|
||||
webview as an engine) and DR-249 (deleting `PlayerBackend` and the frontend
|
||||
playback-state flags).
|
||||
**Status:** Proposed
|
||||
**Requirements:** UR-081 (new) → DR-242 … DR-249 (new); IR-034. Re-check
|
||||
`requirements.md` before allocating — ids moved several times while this was
|
||||
written.
|
||||
@@ -66,47 +61,6 @@ produced a regression: routing transcoded seeks to a reload path turned "seek
|
||||
does nothing" into "seek jumps to zero", because the reload path's own seek was
|
||||
broken in the same way. **Symptom fixes in this area compound.**
|
||||
|
||||
## The background-audio handoff is an unconfirmed state swap
|
||||
|
||||
Diagnosed on a device, 2026-08-23, and the likeliest explanation for "audio
|
||||
keeps playing after I leave the player" — the report this whole line of work
|
||||
started from.
|
||||
|
||||
`enter_background_audio` and `exit_background_audio` in `PlayerController` are
|
||||
pure bookkeeping: they flip a boolean and set or clear a base offset. Neither
|
||||
confirms that the audio stream actually opened, nor that the webview `<video>`
|
||||
actually came back. `exit_background_audio`'s own doc comment says the element
|
||||
"becomes the player again once it reloads" — a future event nothing waits for,
|
||||
while the flag declares the swap complete the moment it is called.
|
||||
|
||||
The sequence that exposes it:
|
||||
|
||||
1. Background audio is enabled.
|
||||
2. The app is backgrounded — `enter_background_audio(pos)`, audio stream opens.
|
||||
3. The app is foregrounded — `exit_background_audio()` sets the flag back, so
|
||||
the controller believes the video element owns playback again.
|
||||
4. The player is exited *before the element has reloaded*. The stop is aimed at
|
||||
an element that does not exist yet; the audio stream is still running.
|
||||
5. The mini player sees a live audio session and adopts it — which is why the
|
||||
symptom is a **movie appearing as an audio track**, and why it is
|
||||
intermittent rather than reliable.
|
||||
|
||||
Duration reporting `0.0` on Android widens the window: the reload is slower and
|
||||
less certain to land at the right position.
|
||||
|
||||
**This is the same defect class as DR-238 … DR-241: state asserted rather than
|
||||
confirmed.** It is what `Phase::Opening` and `MpvPlayer`'s open generation
|
||||
exist for — a handoff *is* an open in flight, and a `close` during one has to
|
||||
cancel it rather than race it. The handoff is not modelled as an open at all
|
||||
today; it is two booleans and an offset.
|
||||
|
||||
The fix therefore belongs with this contract rather than beside it: route the
|
||||
handoff through `open`/`close` so the swap has a phase, and so leaving the
|
||||
player during one cancels the thing that is actually playing instead of the
|
||||
thing the controller believes is playing. `close_during_open_never_plays`
|
||||
already states the required behaviour and passes on all four engines — the gap
|
||||
is that the handoff never reaches an engine as an open.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
@@ -272,15 +226,6 @@ Strangler, not a rewrite. Each step ships independently and leaves the app worki
|
||||
behind an adapter so the other engines keep working.
|
||||
5. **DR-246** Move seek strategy and reload orchestration out of
|
||||
`commands/player/mod.rs` into the engines; delete `seek.rs`'s truth table.
|
||||
|
||||
**Shipped with a deviation.** The engine cannot own this outright:
|
||||
re-negotiating a stream needs the repository, which sits *above* the engine.
|
||||
So the engine *declares* `seeks_transcoded_in_place` and the caller acts on
|
||||
it. That removes the defect — nobody guesses on another component's behalf,
|
||||
and adding an engine no longer means editing a shared table — without
|
||||
pretending an engine can reach upward. `determine_video_seek_strategy`
|
||||
survives as a correctly-typed decision over declared abilities rather than
|
||||
being deleted; the defect was its *input*, not its existence.
|
||||
6. **DR-247** `ExoPlayerPlayer`; conformance on device.
|
||||
7. **DR-248** `WebviewPlayer`; retire the adapter shim.
|
||||
8. **DR-249** Delete `PlayerBackend` and the frontend playback-state flags.
|
||||
|
||||
+2
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.11.2",
|
||||
"version": "0.10.1",
|
||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||
"license": "MIT",
|
||||
@@ -53,9 +53,7 @@
|
||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
||||
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
|
||||
"traces:validate": "bun run scripts/extract-traces.ts --format validate",
|
||||
"release:notes": "bun run scripts/release-notes.ts",
|
||||
"test:player": "./scripts/test-player-conformance.sh",
|
||||
"test:player:android": "./scripts/test-player-conformance.sh android"
|
||||
"release:notes": "bun run scripts/release-notes.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the MediaPlayer conformance suite.
|
||||
#
|
||||
# See docs/specs/media-player-controller.md. One set of behaviours, run against
|
||||
# every engine — so a wrapper is verified without building or launching the app.
|
||||
#
|
||||
# ./scripts/test-player-conformance.sh desktop engines (mpv, legacy)
|
||||
# ./scripts/test-player-conformance.sh android ExoPlayer, on a connected device
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TARGET="${1:-desktop}"
|
||||
|
||||
run_desktop() {
|
||||
local fixture="${TMPDIR:-/tmp}/jellytau-conformance-1200s.mp4"
|
||||
if [ ! -f "$fixture" ]; then
|
||||
# Generated, not committed: the repo carries no media, and the duration
|
||||
# is exact — the seek assertions depend on it.
|
||||
echo "Generating a 20-minute fixture at $fixture"
|
||||
ffmpeg -y -loglevel error \
|
||||
-f lavfi -i "testsrc2=size=640x360:rate=25" \
|
||||
-f lavfi -i "sine=frequency=440" \
|
||||
-t 1200 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -g 50 \
|
||||
-c:a aac -shortest "$fixture"
|
||||
fi
|
||||
|
||||
cd "$PROJECT_ROOT/src-tauri"
|
||||
local status=0
|
||||
for engine in mpv legacy; do
|
||||
echo
|
||||
cargo run --quiet --features conformance --bin player-conformance -- \
|
||||
"$fixture" "$engine" || status=1
|
||||
done
|
||||
return $status
|
||||
}
|
||||
|
||||
run_android() {
|
||||
if ! adb get-state >/dev/null 2>&1; then
|
||||
echo "No device. Connect one and enable USB debugging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$PROJECT_ROOT/scripts/sync-android-sources.sh" >/dev/null
|
||||
cd "$PROJECT_ROOT/src-tauri/gen/android"
|
||||
|
||||
# `-x rustBuild...` because raw gradle drives the Rust build through Tauri's
|
||||
# android-studio-script, which expects a dev-server address file that only
|
||||
# exists under `tauri android dev`. The native library already in
|
||||
# app/src/main/jniLibs is what the test process loads.
|
||||
ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" \
|
||||
./gradlew :app:connectedUniversalDebugAndroidTest \
|
||||
-x :app:rustBuildUniversalDebug --console=plain
|
||||
}
|
||||
|
||||
case "$TARGET" in
|
||||
desktop) run_desktop ;;
|
||||
android) run_android ;;
|
||||
*) echo "usage: $0 [desktop|android]" >&2; exit 2 ;;
|
||||
esac
|
||||
Generated
+1
-1
@@ -2181,7 +2181,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.11.2"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
# The app. Named explicitly because the crate also builds
|
||||
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
||||
# which `tauri dev` issues — ambiguous.
|
||||
default-run = "jellytau"
|
||||
version = "0.11.2"
|
||||
version = "0.10.1"
|
||||
description = "A cross-platform Jellyfin client"
|
||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -142,10 +142,6 @@ 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,9 +15,6 @@ 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
|
||||
|
||||
@@ -59,25 +56,6 @@ 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.
|
||||
@@ -123,10 +101,6 @@ 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,23 +233,6 @@ 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
|
||||
@@ -556,19 +539,6 @@ 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
|
||||
@@ -1324,32 +1294,9 @@ 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
|
||||
)
|
||||
// Honour the viewer's system captioning preferences (size,
|
||||
// colour, edge style) and whatever styling the track carries —
|
||||
// the same defaults a PlayerView would have applied.
|
||||
setUserDefaultStyle()
|
||||
setUserDefaultTextSize()
|
||||
}
|
||||
android.util.Log.d("JellyTauPlayer", "SubtitleView created")
|
||||
}
|
||||
return videoView!!.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -1466,20 +1413,6 @@ 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})"
|
||||
@@ -1509,10 +1442,6 @@ 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,9 +25,8 @@ use super::DatabaseWrapper;
|
||||
use crate::download::cache::{CacheConfig, SmartCache};
|
||||
use crate::jellyfin::{JellyfinClient, JellyfinConfig};
|
||||
use crate::player::{
|
||||
determine_audio_track_switch_strategy, determine_video_seek_strategy, AudioTrackSwitchStrategy,
|
||||
MediaItem, MediaSessionManager, MediaSource, MediaType, PlayerController, PlayerState,
|
||||
PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
|
||||
determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
|
||||
PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
|
||||
};
|
||||
use crate::repository::{
|
||||
types::{GetItemsOptions, ImageOptions, ImageType},
|
||||
@@ -1451,7 +1450,7 @@ pub async fn player_seek_video(
|
||||
|
||||
// Get current playing item to analyze stream characteristics
|
||||
// Clone what we need to avoid holding locks across await points
|
||||
let (needs_transcoding, jellyfin_item_id, is_local) = {
|
||||
let (needs_transcoding, jellyfin_item_id, is_local, transport) = {
|
||||
let controller = player.0.lock().await;
|
||||
let queue_arc = controller.queue();
|
||||
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
|
||||
@@ -1467,34 +1466,31 @@ pub async fn player_seek_video(
|
||||
.ok_or("Current video has no Jellyfin ID")?
|
||||
.to_string();
|
||||
|
||||
// Neither the URL nor the item's transport is read here any more. The
|
||||
// strategy turns on whether the *engine* can seek a transcode in place,
|
||||
// which it declares for itself — so the container the stream happens to
|
||||
// arrive in stopped being a proxy for anything (DR-246).
|
||||
// The URL itself is no longer read here: the seek strategy now comes
|
||||
// from the item's own `transport`, not from inspecting the string.
|
||||
let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
|
||||
|
||||
(current_item.needs_transcoding, jellyfin_id, is_local_file)
|
||||
let needs_trans = current_item.needs_transcoding;
|
||||
let transport = current_item.transport;
|
||||
(needs_trans, jellyfin_id, is_local_file, transport)
|
||||
}; // Locks are dropped here
|
||||
|
||||
// Whether a transcode can be seeked in place is asked of the engine that is
|
||||
// rendering, not guessed from the URL's shape or from who is rendering.
|
||||
// TRACES: UR-040, UR-079 | DR-238, DR-246
|
||||
let seeks_transcoded_in_place = {
|
||||
let controller = player.0.lock().await;
|
||||
controller.capabilities().seeks_transcoded_in_place
|
||||
// The transport comes from the backend's own decision, not from searching
|
||||
// the URL for `.m3u8` — Rust built that URL and knows what it is. Items
|
||||
// queued without one fall back to `needs_transcoding`, which is exact:
|
||||
// every transcode this app requests is HLS (DR-140).
|
||||
//
|
||||
// TRACES: UR-004, UR-079 | DR-225, DR-230
|
||||
let is_hls = match transport {
|
||||
Some(crate::repository::Transport::Hls) => true,
|
||||
Some(crate::repository::Transport::Progressive)
|
||||
| Some(crate::repository::Transport::LocalFile) => false,
|
||||
None => needs_transcoding,
|
||||
};
|
||||
let strategy = determine_video_seek_strategy(
|
||||
is_local,
|
||||
seeks_transcoded_in_place,
|
||||
needs_transcoding,
|
||||
use_html5,
|
||||
);
|
||||
let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);
|
||||
|
||||
info!(
|
||||
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
|
||||
needs_transcoding={}, use_html5={}, strategy={:?}",
|
||||
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
|
||||
);
|
||||
info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
|
||||
is_local, is_hls, needs_transcoding, use_html5, strategy);
|
||||
|
||||
match strategy {
|
||||
VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
|
||||
@@ -1596,37 +1592,18 @@ pub async fn player_seek_video(
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch audio track.
|
||||
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
||||
/// Note: Frontend should handle saving series preferences after this command succeeds
|
||||
///
|
||||
/// 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`]:
|
||||
/// 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.
|
||||
///
|
||||
/// - 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
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
|
||||
@@ -1646,128 +1623,56 @@ 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);
|
||||
|
||||
// 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())?;
|
||||
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")?;
|
||||
|
||||
let current_item = queue.current().ok_or("No item currently playing")?;
|
||||
// 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")?;
|
||||
|
||||
(
|
||||
current_item
|
||||
.jellyfin_id()
|
||||
.ok_or("Current item has no Jellyfin ID")?
|
||||
.to_string(),
|
||||
current_item.needs_transcoding,
|
||||
engine_position,
|
||||
)
|
||||
};
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
|
||||
// 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))?;
|
||||
|
||||
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.
|
||||
Ok(AudioTrackSwitchResponse::ReloadStream {
|
||||
selection,
|
||||
position: current_position.unwrap_or(0.0),
|
||||
})
|
||||
} else {
|
||||
// Native backend (Android ExoPlayer) - use array index
|
||||
let controller = player.0.lock().await;
|
||||
controller
|
||||
.set_audio_track(array_index)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
return Ok(AudioTrackSwitchResponse::Native { success: true });
|
||||
}
|
||||
|
||||
// Both reload strategies need a stream built around the chosen track.
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or("Repository not found - user may need to log in")?;
|
||||
|
||||
// Select a stream carrying the chosen audio track. It starts at zero —
|
||||
// an HLS playlist cannot carry a position (DR-181) — 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 })
|
||||
}
|
||||
Ok(AudioTrackSwitchResponse::Native { success: true })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,19 +68,6 @@ impl<P: MediaPlayer> Harness for EngineHarness<P> {
|
||||
fn seek_tolerance(&self) -> Duration {
|
||||
Duration::from_secs(10)
|
||||
}
|
||||
|
||||
/// Poll until the decoder reports the new position, rather than assuming a
|
||||
/// seek is visible the instant it is accepted.
|
||||
fn await_seek(&mut self, target: Duration) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
let pos = self.player.snapshot().position;
|
||||
if pos.abs_diff(target) <= self.seek_tolerance() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! run {
|
||||
@@ -151,8 +138,7 @@ pub fn run_engine(url: &str, engine: Engine) -> u32 {
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
|
||||
std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()),
|
||||
)
|
||||
.expect("could not create the legacy backend"),
|
||||
crate::player::media_player::Capabilities::mpv(),
|
||||
.expect("could not create the legacy backend")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::ChannelFolder
|
||||
MediaKind::Folder
|
||||
} else {
|
||||
MediaKind::ChannelItem
|
||||
}
|
||||
@@ -142,18 +142,11 @@ 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::ChannelFolder
|
||||
MediaKind::Folder
|
||||
);
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("ChannelFolderItem", false),
|
||||
|
||||
@@ -48,14 +48,6 @@ 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,11 +53,7 @@ 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::ChannelFolder
|
||||
| MediaKind::Folder => 1,
|
||||
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
|
||||
// Leaves — an episode/track is a match *inside* something bigger.
|
||||
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
|
||||
2
|
||||
|
||||
+1
-29
@@ -738,27 +738,6 @@ fn create_player_backend(
|
||||
|
||||
/// Construct the tauri-specta command builder. Shared by `run()` and the
|
||||
/// bindings-export test so the TypeScript bindings always match the handler.
|
||||
/// What the engine built for this platform can do.
|
||||
///
|
||||
/// Declared per engine, not per category. ExoPlayer speaks HLS and can seek a
|
||||
/// server-side transcode in place; mpv cannot, because its HLS demuxer will not
|
||||
/// make the server produce segments from a new offset. Grouping them as "native
|
||||
/// engines" gets that backwards — being native is not the property that
|
||||
/// matters, speaking HLS is — and treating a category as a proxy for an ability
|
||||
/// is exactly the inference DR-246 removed.
|
||||
///
|
||||
/// TRACES: UR-081 | DR-246
|
||||
fn engine_capabilities() -> crate::player::media_player::Capabilities {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
crate::player::media_player::Capabilities::exoplayer()
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
crate::player::media_player::Capabilities::mpv()
|
||||
}
|
||||
}
|
||||
|
||||
fn specta_builder() -> Builder<tauri::Wry> {
|
||||
Builder::<tauri::Wry>::new()
|
||||
// Throw on error so generated `commands.*` return Promise<T> and throw,
|
||||
@@ -1430,15 +1409,8 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Every engine reaches the controller through the one contract.
|
||||
// `LegacyPlayer` carries the not-yet-ported ones across unchanged,
|
||||
// so this port swaps a seam rather than four implementations.
|
||||
// TRACES: UR-081 | DR-245
|
||||
let player_controller = PlayerController::new(
|
||||
Box::new(crate::player::LegacyPlayer::new(
|
||||
backend,
|
||||
engine_capabilities(),
|
||||
)),
|
||||
backend,
|
||||
playback_reporter.clone(),
|
||||
position_throttler.clone(),
|
||||
);
|
||||
|
||||
@@ -249,56 +249,6 @@ impl PlayerBackend for NullBackend {
|
||||
}
|
||||
|
||||
// TRACES: UR-003, UR-004 | DR-004 | UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033
|
||||
/// Forward the trait through a box.
|
||||
///
|
||||
/// `Box<dyn PlayerBackend>` does not implement `PlayerBackend` on its own, so
|
||||
/// without this the boxed engine built at the composition root cannot be handed
|
||||
/// to anything generic over the trait — `LegacyPlayer` in particular.
|
||||
impl PlayerBackend for Box<dyn PlayerBackend> {
|
||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
|
||||
(**self).load(media)
|
||||
}
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
(**self).play()
|
||||
}
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
(**self).pause()
|
||||
}
|
||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||
(**self).stop()
|
||||
}
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
(**self).seek(position)
|
||||
}
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
(**self).set_volume(volume)
|
||||
}
|
||||
fn position(&self) -> f64 {
|
||||
(**self).position()
|
||||
}
|
||||
fn duration(&self) -> Option<f64> {
|
||||
(**self).duration()
|
||||
}
|
||||
fn state(&self) -> PlayerState {
|
||||
(**self).state()
|
||||
}
|
||||
fn volume(&self) -> f32 {
|
||||
(**self).volume()
|
||||
}
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
(**self).set_audio_settings(settings)
|
||||
}
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
(**self).audio_settings()
|
||||
}
|
||||
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||
(**self).set_audio_track(stream_index)
|
||||
}
|
||||
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
(**self).set_subtitle_track(stream_index)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -53,17 +53,6 @@ pub trait Harness {
|
||||
fn seek_tolerance(&self) -> Duration {
|
||||
Duration::from_secs(5)
|
||||
}
|
||||
|
||||
/// Wait for a completed seek to be visible in `snapshot()`.
|
||||
///
|
||||
/// Engines differ in when that happens: one may record the target the
|
||||
/// moment it accepts the seek, another may not report it until the decoder
|
||||
/// has actually moved. Asserting immediately therefore passes on the first
|
||||
/// and races on the second — which is precisely how this suite produced a
|
||||
/// failure that came and went with machine load rather than with the code.
|
||||
///
|
||||
/// Default is a no-op, for engines whose snapshot is synchronous.
|
||||
fn await_seek(&mut self, _target: Duration) {}
|
||||
}
|
||||
|
||||
fn assert_near(actual: Duration, expected: Duration, tolerance: Duration, what: &str) {
|
||||
@@ -162,7 +151,6 @@ pub fn seeks_after_open<H: Harness>(h: &mut H) {
|
||||
|
||||
let target = Duration::from_secs(420);
|
||||
h.player().seek(target).expect("seek failed");
|
||||
h.await_seek(target);
|
||||
|
||||
assert_near(
|
||||
h.player().snapshot().position,
|
||||
|
||||
@@ -72,8 +72,6 @@ impl FakePlayer {
|
||||
audio_settings: true,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: true,
|
||||
// The fake honours a seek in any phase, so it can claim this.
|
||||
seeks_transcoded_in_place: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,21 +18,16 @@
|
||||
//!
|
||||
//! TRACES: UR-081 | DR-245
|
||||
|
||||
#![allow(dead_code)] // Consumed when PlayerController is ported (DR-245).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::media_player::{
|
||||
duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
|
||||
};
|
||||
use super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot};
|
||||
use super::state::PlayerState;
|
||||
|
||||
pub struct LegacyPlayer<B: PlayerBackend> {
|
||||
inner: B,
|
||||
/// Declared at construction: this wrapper is generic over engines with very
|
||||
/// different abilities, and only the composition root knows which one it
|
||||
/// just built. Guessing here would reintroduce exactly the inference DR-238
|
||||
/// removed.
|
||||
capabilities: Capabilities,
|
||||
/// The old trait has no notion of "opening", so this is the best the wrapper
|
||||
/// can do: it knows an item was handed over, not whether the engine is ready
|
||||
/// for one. That gap is the whole problem.
|
||||
@@ -40,13 +35,16 @@ pub struct LegacyPlayer<B: PlayerBackend> {
|
||||
}
|
||||
|
||||
impl<B: PlayerBackend> LegacyPlayer<B> {
|
||||
pub fn new(inner: B, capabilities: Capabilities) -> Self {
|
||||
pub fn new(inner: B) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
capabilities,
|
||||
has_item: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inner_mut(&mut self) -> &mut B {
|
||||
&mut self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
|
||||
@@ -126,8 +124,8 @@ impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
|
||||
};
|
||||
PlaybackSnapshot {
|
||||
phase,
|
||||
position: duration_from_secs(self.inner.position()).unwrap_or(Duration::ZERO),
|
||||
duration: self.inner.duration().and_then(duration_from_secs),
|
||||
position: Duration::from_secs_f64(self.inner.position().max(0.0)),
|
||||
duration: self.inner.duration().map(Duration::from_secs_f64),
|
||||
seekable: true,
|
||||
volume: self.inner.volume(),
|
||||
muted: false,
|
||||
@@ -137,122 +135,12 @@ impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_audio_settings(
|
||||
&mut self,
|
||||
settings: &crate::settings::AudioSettings,
|
||||
) -> Result<(), PlayerError> {
|
||||
self.inner.set_audio_settings(settings)
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> crate::settings::AudioSettings {
|
||||
self.inner.audio_settings()
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
self.capabilities
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::player::media::MediaItem;
|
||||
use crate::settings::AudioSettings;
|
||||
|
||||
/// A backend that answers badly, on purpose.
|
||||
///
|
||||
/// Every engine the conformance suite drives reports sane numbers, which is
|
||||
/// why it passed while a real one did not: ExoPlayer returns
|
||||
/// `C.TIME_UNSET` — `Long::MIN_VALUE`, about -9.2e15 seconds — for any
|
||||
/// stream whose length it does not know, and the adapter converted that
|
||||
/// straight into a `Duration` and panicked the whole backend.
|
||||
///
|
||||
/// The old `PlayerBackend` contract is a plain `f64`. It never promised
|
||||
/// finite, never promised positive, and nothing enforced it. So this is the
|
||||
/// engine the suites were missing.
|
||||
struct HostileBackend {
|
||||
duration: f64,
|
||||
position: f64,
|
||||
}
|
||||
|
||||
impl PlayerBackend for HostileBackend {
|
||||
fn load(&mut self, _media: &MediaItem) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
fn seek(&mut self, _position: f64) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
fn set_volume(&mut self, _volume: f32) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
fn position(&self) -> f64 {
|
||||
self.position
|
||||
}
|
||||
fn duration(&self) -> Option<f64> {
|
||||
Some(self.duration)
|
||||
}
|
||||
fn state(&self) -> PlayerState {
|
||||
PlayerState::Idle
|
||||
}
|
||||
fn volume(&self) -> f32 {
|
||||
1.0
|
||||
}
|
||||
fn set_audio_settings(&mut self, _s: &AudioSettings) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
AudioSettings::default()
|
||||
}
|
||||
fn set_audio_track(&mut self, _i: i32) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
fn set_subtitle_track(&mut self, _i: Option<i32>) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn hostile(duration: f64, position: f64) -> LegacyPlayer<HostileBackend> {
|
||||
LegacyPlayer::new(
|
||||
HostileBackend { duration, position },
|
||||
crate::player::media_player::Capabilities::mpv(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reading an engine that answers badly must not take the process down.
|
||||
///
|
||||
/// This is DR-252 as a test. It fails — by panicking — against the adapter
|
||||
/// as originally written, which is the property the conformance suite could
|
||||
/// not have: it only ever drove engines that behave.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-252 | UT-223
|
||||
#[test]
|
||||
fn test_snapshot_survives_an_engine_that_answers_badly() {
|
||||
// The exact value ExoPlayer reports for an unknown length.
|
||||
let s = hostile(-9_223_372_036_854_776.0, 0.0).snapshot();
|
||||
assert_eq!(s.duration, None, "a negative duration is not a duration");
|
||||
|
||||
for bad in [f64::NAN, f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0] {
|
||||
let s = hostile(bad, bad).snapshot();
|
||||
assert_eq!(s.duration, None, "{bad} should not become a duration");
|
||||
assert_eq!(
|
||||
s.position,
|
||||
Duration::ZERO,
|
||||
"{bad} should not become a position"
|
||||
);
|
||||
}
|
||||
|
||||
// And a well-behaved engine still works.
|
||||
let s = hostile(6997.024, 540.0).snapshot();
|
||||
assert_eq!(s.duration, Some(Duration::from_secs_f64(6997.024)));
|
||||
assert_eq!(s.position, Duration::from_secs_f64(540.0));
|
||||
Capabilities {
|
||||
video: false,
|
||||
audio_settings: true,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,16 +185,10 @@ impl MediaItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL or path an engine should open.
|
||||
/// Get the playback URL or file path
|
||||
///
|
||||
/// Not gated to Android any more. It was, back when only ExoPlayer needed
|
||||
/// direct URL access — and that gate is why a byte-identical copy was later
|
||||
/// added for the cross-platform `MediaPlayer::open` path without anyone
|
||||
/// noticing this existed: it 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, silently.
|
||||
///
|
||||
/// TRACES: UR-081 | DR-245, DR-255
|
||||
/// Only available on Android where ExoPlayer needs direct URL access
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn playback_url(&self) -> String {
|
||||
match &self.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
|
||||
@@ -32,24 +32,6 @@ use std::time::Duration;
|
||||
use super::backend::PlayerError;
|
||||
use super::media::MediaItem;
|
||||
use crate::repository::stream_selection::StreamSelection;
|
||||
use crate::settings::AudioSettings;
|
||||
|
||||
/// Seconds reported by an engine, as a `Duration`, without trusting the number.
|
||||
///
|
||||
/// `Duration::from_secs_f64` **panics** on a negative or non-finite value, and
|
||||
/// no engine promises otherwise. ExoPlayer reports `C.TIME_UNSET` —
|
||||
/// `Long::MIN_VALUE`, about -9.2e15 — for a stream whose length it does not
|
||||
/// know, which is every background-audio handoff: `/Audio/{id}/universal` is a
|
||||
/// chunked, length-less transcode.
|
||||
///
|
||||
/// Held as a float that junk was harmless. Converted to a `Duration` it became
|
||||
/// a panic that killed the backend mid-handoff and left a black screen with no
|
||||
/// controls. Every engine crossing into this contract goes through here.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-252
|
||||
pub fn duration_from_secs(seconds: f64) -> Option<Duration> {
|
||||
(seconds.is_finite() && seconds > 0.0).then(|| Duration::from_secs_f64(seconds))
|
||||
}
|
||||
|
||||
/// What an engine is doing right now.
|
||||
///
|
||||
@@ -138,68 +120,6 @@ pub struct Capabilities {
|
||||
pub subtitle_switching: bool,
|
||||
/// Audio tracks can be selected without re-opening.
|
||||
pub audio_track_switching: bool,
|
||||
/// A *server-side transcode* can be seeked without re-opening the stream.
|
||||
///
|
||||
/// True for hls.js, which seeks within the VOD playlist it is handed and
|
||||
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make
|
||||
/// the server transcode from a new offset.
|
||||
///
|
||||
/// Declared by the engine rather than inferred by the caller. The previous
|
||||
/// design decided this from `is_hls` and `use_html5` in a command handler —
|
||||
/// on behalf of engines it did not own — which is how "who renders" came to
|
||||
/// mean "how do I seek" and why a transcoded seek silently did nothing the
|
||||
/// moment native video changed the renderer (DR-238).
|
||||
///
|
||||
/// Re-negotiating a stream needs the repository, which sits above the
|
||||
/// engine, so the engine states the capability and the caller acts on it.
|
||||
pub seeks_transcoded_in_place: bool,
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
/// mpv.
|
||||
///
|
||||
/// Cannot seek a server-side transcode in place: its HLS demuxer will not
|
||||
/// make the server produce segments from a new offset, so the stream has to
|
||||
/// be re-opened.
|
||||
pub fn mpv() -> Self {
|
||||
Self {
|
||||
video: true,
|
||||
audio_settings: true,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: true,
|
||||
seeks_transcoded_in_place: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// ExoPlayer.
|
||||
///
|
||||
/// **Can** seek a transcode in place. It is a full HLS client, so like
|
||||
/// hls.js it seeks within the VOD playlist it was handed and lets the
|
||||
/// server catch up. Grouping it with mpv as "a native engine" gets this
|
||||
/// exactly backwards — being native is not the property that matters here,
|
||||
/// speaking HLS is, and that is the whole reason this is declared per
|
||||
/// engine rather than inferred from a category.
|
||||
pub fn exoplayer() -> Self {
|
||||
Self {
|
||||
video: true,
|
||||
audio_settings: true,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: true,
|
||||
seeks_transcoded_in_place: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// An engine that renders through the webview element, where hls.js seeks
|
||||
/// within the playlist it was handed.
|
||||
pub fn webview() -> Self {
|
||||
Self {
|
||||
video: true,
|
||||
audio_settings: false,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: false,
|
||||
seeks_transcoded_in_place: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request to present an item.
|
||||
@@ -283,46 +203,4 @@ pub trait MediaPlayer: Send {
|
||||
fn snapshot(&self) -> PlaybackSnapshot;
|
||||
|
||||
fn capabilities(&self) -> Capabilities;
|
||||
|
||||
/// Apply EQ, normalisation and gapless settings.
|
||||
///
|
||||
/// Provided rather than required: engines that cannot honour them say so
|
||||
/// through [`Capabilities::audio_settings`] and inherit this no-op, instead
|
||||
/// of every implementation carrying an `Ok(())` it does not mean.
|
||||
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
AudioSettings::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The value that killed the backend: `C.TIME_UNSET` as seconds.
|
||||
///
|
||||
/// ExoPlayer reports it for any stream whose length it does not know, and
|
||||
/// `Duration::from_secs_f64` panics on it. A player must not be the place
|
||||
/// anyone discovers a float was strange.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-252 | UT-222
|
||||
#[test]
|
||||
fn test_junk_durations_do_not_panic() {
|
||||
// Long::MIN_VALUE milliseconds, as ExoPlayer hands it over.
|
||||
assert_eq!(duration_from_secs(-9_223_372_036_854_776.0), None);
|
||||
assert_eq!(duration_from_secs(-1.0), None);
|
||||
assert_eq!(duration_from_secs(0.0), None, "zero is not a duration");
|
||||
assert_eq!(duration_from_secs(f64::NAN), None);
|
||||
assert_eq!(duration_from_secs(f64::INFINITY), None);
|
||||
assert_eq!(duration_from_secs(f64::NEG_INFINITY), None);
|
||||
|
||||
// A real one still survives.
|
||||
assert_eq!(
|
||||
duration_from_secs(6997.024),
|
||||
Some(Duration::from_secs_f64(6997.024))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-223
@@ -12,6 +12,7 @@ pub mod events;
|
||||
pub mod fake_player;
|
||||
#[cfg(test)]
|
||||
mod fake_player_conformance;
|
||||
#[cfg(any(test, feature = "conformance"))]
|
||||
pub mod legacy_player;
|
||||
pub mod media;
|
||||
pub mod media_player;
|
||||
@@ -23,7 +24,6 @@ pub mod session;
|
||||
pub mod sleep_timer;
|
||||
pub mod state;
|
||||
pub mod stream_end;
|
||||
pub mod track_switch;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mpv_backend_test;
|
||||
@@ -60,19 +60,15 @@ pub mod video_surface;
|
||||
pub mod webview_audio_backend;
|
||||
|
||||
// Re-export commonly used types
|
||||
use crate::repository::stream_selection::StreamSelection;
|
||||
pub use autoplay::{AutoplayDecision, AutoplaySettings};
|
||||
pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
||||
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
||||
pub use legacy_player::LegacyPlayer;
|
||||
pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
|
||||
pub use media_player::{MediaPlayer, OpenRequest, Phase};
|
||||
pub use queue::{QueueManager, RepeatMode};
|
||||
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")]
|
||||
@@ -242,9 +238,7 @@ use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Central player controller that coordinates playback
|
||||
pub struct PlayerController {
|
||||
/// The engine. One contract, so the controller stops branching on which
|
||||
/// platform it is running on — see docs/specs/media-player-controller.md.
|
||||
backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
|
||||
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
||||
queue: Arc<Mutex<QueueManager>>,
|
||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||
muted: bool,
|
||||
@@ -343,7 +337,7 @@ pub struct PlayerController {
|
||||
|
||||
impl PlayerController {
|
||||
pub fn new(
|
||||
backend: Box<dyn MediaPlayer>,
|
||||
backend: Box<dyn PlayerBackend>,
|
||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||
position_throttler: Arc<EventThrottler>,
|
||||
) -> Self {
|
||||
@@ -521,12 +515,7 @@ impl PlayerController {
|
||||
/// Used on platforms where video is rendered outside the native backend
|
||||
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
|
||||
/// item, but MPV must not start a redundant decode for it.
|
||||
///
|
||||
/// Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became
|
||||
/// a runtime question — "does this renderer draw the picture?" — so the
|
||||
/// `else` arm is compiled on every platform even where it never runs. The
|
||||
/// gate outliving its caller broke the Android build outright, which went
|
||||
/// unnoticed because nothing built for Android afterwards.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
||||
debug!(
|
||||
"[PlayerController] set_current_item (no backend load): {}",
|
||||
@@ -579,16 +568,8 @@ impl PlayerController {
|
||||
*self.html5_playing.lock_safe() = None;
|
||||
|
||||
let mut backend = self.backend.lock_safe();
|
||||
// One operation: the engine is handed the item and where to begin, so
|
||||
// there is no window between them for a position to be lost in.
|
||||
backend.open(OpenRequest::new(
|
||||
item.clone(),
|
||||
StreamSelection::for_queued_item(
|
||||
item.playback_url(),
|
||||
item.transport,
|
||||
item.needs_transcoding,
|
||||
),
|
||||
))?;
|
||||
backend.load(item)?;
|
||||
backend.play()?;
|
||||
drop(backend);
|
||||
|
||||
// A different item is loading; the last one's reported position must not
|
||||
@@ -762,7 +743,7 @@ impl PlayerController {
|
||||
return Ok(());
|
||||
}
|
||||
let mut backend = self.backend.lock_safe();
|
||||
if backend.snapshot().phase.is_active() {
|
||||
if backend.state().is_playing() {
|
||||
backend.pause()
|
||||
} else {
|
||||
backend.play()
|
||||
@@ -787,32 +768,10 @@ impl PlayerController {
|
||||
let position = self.absolute_position();
|
||||
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.close()?;
|
||||
backend.stop()?;
|
||||
drop(backend);
|
||||
self.clear_reported_time();
|
||||
|
||||
// Stopping means *nothing is playing*, from any renderer — not "the
|
||||
// thing we currently believe owns playback has been asked to stop".
|
||||
//
|
||||
// A background-audio handoff swaps which renderer that is, and the swap
|
||||
// is bookkeeping that can be mid-flight: `exit_background_audio` marks
|
||||
// the webview element the player again the moment it is called, while
|
||||
// the element has not reloaded yet. A stop aimed at what the flags say
|
||||
// is playing therefore misses the audio stream that actually is, and it
|
||||
// resurfaces in the mini player as an audio track.
|
||||
//
|
||||
// Clearing the handoff here is the other half of that: a stop that
|
||||
// leaves the base offset and the active flag behind lets the next
|
||||
// position read be interpreted against a handoff that no longer exists.
|
||||
//
|
||||
// TRACES: UR-040, UR-005 | DR-250
|
||||
if self.is_background_audio_active() {
|
||||
debug!("[PlayerController] stop: clearing an active background-audio handoff");
|
||||
}
|
||||
*self.background_audio_active.lock_safe() = false;
|
||||
self.set_background_audio_base(0.0);
|
||||
*self.html5_playing.lock_safe() = None;
|
||||
|
||||
if let Some(jellyfin_id) = jellyfin_id {
|
||||
self.report_stopped_at(jellyfin_id, position);
|
||||
}
|
||||
@@ -890,7 +849,7 @@ impl PlayerController {
|
||||
// If we're more than 3 seconds in, restart current track
|
||||
{
|
||||
let backend = self.backend.lock_safe();
|
||||
if backend.snapshot().position.as_secs_f64() > 3.0 {
|
||||
if backend.position() > 3.0 {
|
||||
debug!("[PlayerController] previous: restarting current track (position > 3s)");
|
||||
drop(backend);
|
||||
return self.seek(0.0);
|
||||
@@ -922,7 +881,7 @@ impl PlayerController {
|
||||
/// timeline and is what every caller outside the player itself means.
|
||||
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.seek(Duration::from_secs_f64(position.max(0.0)))
|
||||
backend.seek(position)
|
||||
}
|
||||
|
||||
/// Seek to an **absolute** position on the item's own timeline.
|
||||
@@ -973,48 +932,23 @@ impl PlayerController {
|
||||
/// Set the active audio track by stream index
|
||||
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.select_audio_track(Some(stream_index))
|
||||
backend.set_audio_track(stream_index)
|
||||
}
|
||||
|
||||
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
let mut backend = self.backend.lock_safe();
|
||||
backend.select_subtitle_track(stream_index)
|
||||
backend.set_subtitle_track(stream_index)
|
||||
}
|
||||
|
||||
/// Get current state
|
||||
pub fn state(&self) -> PlayerState {
|
||||
let phase = self.backend.lock_safe().snapshot().phase;
|
||||
let media = self.queue.lock_safe().current().cloned();
|
||||
match (phase, media) {
|
||||
(Phase::Playing, Some(media)) => PlayerState::Playing {
|
||||
media,
|
||||
position: self.position(),
|
||||
duration: self.duration().unwrap_or(0.0),
|
||||
},
|
||||
(Phase::Paused, Some(media)) => PlayerState::Paused {
|
||||
media,
|
||||
position: self.position(),
|
||||
duration: self.duration().unwrap_or(0.0),
|
||||
},
|
||||
(Phase::Opening, Some(media)) => PlayerState::Loading { media },
|
||||
(Phase::Failed(error), media) => PlayerState::Error { media, error },
|
||||
// Ready without an item, or anything terminal, reads as idle: the
|
||||
// queue is what says whether there is something to resume.
|
||||
_ => PlayerState::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the engine currently rendering can do.
|
||||
///
|
||||
/// TRACES: UR-081 | DR-246
|
||||
pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
|
||||
self.backend.lock_safe().capabilities()
|
||||
self.backend.lock_safe().state()
|
||||
}
|
||||
|
||||
/// Get current position
|
||||
pub fn position(&self) -> f64 {
|
||||
self.backend.lock_safe().snapshot().position.as_secs_f64()
|
||||
self.backend.lock_safe().position()
|
||||
}
|
||||
|
||||
/// The position on the **item's own timeline**, whatever is rendering it.
|
||||
@@ -1040,7 +974,7 @@ impl PlayerController {
|
||||
///
|
||||
/// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
|
||||
pub fn absolute_position(&self) -> f64 {
|
||||
let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
|
||||
let native = self.backend.lock_safe().position().max(0.0);
|
||||
let reported = self.reported_time.lock_safe().last_position();
|
||||
let base = if self.is_background_audio_active() {
|
||||
*self.background_audio_base.lock_safe()
|
||||
@@ -1104,34 +1038,10 @@ impl PlayerController {
|
||||
///
|
||||
/// TRACES: UR-005 | DR-178
|
||||
pub fn duration(&self) -> Option<f64> {
|
||||
// Zero is not a duration, it is an engine saying it does not know yet.
|
||||
//
|
||||
// ExoPlayer reports `C.TIME_UNSET` until it has resolved one, and
|
||||
// `JellyTauPlayer.getDuration()` maps that to `0.0` — so the engine
|
||||
// answers `Some(0.0)`, every "unknown duration" fallback below is
|
||||
// skipped, and the seek bar is left with no scale. That presents as
|
||||
// scrubbing being broken rather than as a duration that never arrived.
|
||||
//
|
||||
// The item usually knows: the catalog carried a runtime long before
|
||||
// anything started decoding.
|
||||
//
|
||||
// TRACES: UR-005, UR-040 | DR-251
|
||||
let usable = |d: f64| (d > 0.0).then_some(d);
|
||||
|
||||
self.backend
|
||||
.lock_safe()
|
||||
.snapshot()
|
||||
.duration
|
||||
.map(|d| d.as_secs_f64())
|
||||
.and_then(usable)
|
||||
.or_else(|| self.observed_duration().and_then(usable))
|
||||
.or_else(|| {
|
||||
self.queue
|
||||
.lock_safe()
|
||||
.current()
|
||||
.and_then(|item| item.duration)
|
||||
.and_then(usable)
|
||||
})
|
||||
.duration()
|
||||
.or_else(|| self.observed_duration())
|
||||
}
|
||||
|
||||
/// Get queue reference
|
||||
@@ -1186,7 +1096,7 @@ impl PlayerController {
|
||||
|
||||
/// Get current volume (0.0 - 1.0)
|
||||
pub fn volume(&self) -> f32 {
|
||||
self.backend.lock_safe().snapshot().volume
|
||||
self.backend.lock_safe().volume()
|
||||
}
|
||||
|
||||
/// Check if muted
|
||||
@@ -1287,7 +1197,7 @@ impl PlayerController {
|
||||
drop(timer);
|
||||
|
||||
// Stop the backend
|
||||
if let Err(e) = backend.lock_safe().close() {
|
||||
if let Err(e) = backend.lock_safe().stop() {
|
||||
error!("[SleepTimer] Failed to stop playback: {}", e);
|
||||
}
|
||||
continue;
|
||||
@@ -1997,15 +1907,6 @@ impl PlayerController {
|
||||
&self,
|
||||
next_episode_id: &str,
|
||||
) -> Result<(), String> {
|
||||
// A new episode is a new playback, so a ceiling chosen for the previous
|
||||
// one does not carry into it. Every advance the frontend drives goes
|
||||
// through `player_play_item` and is cleared there; this one loads the
|
||||
// next episode in Rust and would otherwise keep the old cap forever,
|
||||
// with nothing in the UI saying why. Cleared before the URL is built,
|
||||
// since that is what reads it.
|
||||
// TRACES: UR-074 | DR-254
|
||||
crate::repository::online::clear_playback_quality_override();
|
||||
|
||||
let repo = self
|
||||
.repository
|
||||
.lock_safe()
|
||||
@@ -2311,10 +2212,7 @@ impl Default for PlayerController {
|
||||
let playback_reporter = Arc::new(TokioMutex::new(None));
|
||||
let position_throttler = Arc::new(EventThrottler::new());
|
||||
Self::new(
|
||||
Box::new(LegacyPlayer::new(
|
||||
NullBackend::new(),
|
||||
crate::player::media_player::Capabilities::mpv(),
|
||||
)),
|
||||
Box::new(NullBackend::new()),
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
)
|
||||
@@ -2323,107 +2221,6 @@ impl Default for PlayerController {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// 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
|
||||
/// one. `player_play_item`, `player_play_queue` and `player_play_tracks`
|
||||
/// all clear it, so every advance the frontend drives is covered — but the
|
||||
/// background audio-only advance loads the next episode in Rust and skips
|
||||
/// all three, so every later episode stayed capped at the old quality with
|
||||
/// nothing in the UI saying so.
|
||||
///
|
||||
/// A wiring assertion, like UT-218 and UT-225: the call site is what
|
||||
/// matters, and reaching it at runtime needs a repository, a server and a
|
||||
/// live player.
|
||||
///
|
||||
/// TRACES: UR-074 | DR-254 | UT-226
|
||||
#[test]
|
||||
fn test_background_episode_advance_clears_the_quality_override() {
|
||||
let src = include_str!("mod.rs");
|
||||
let start = src
|
||||
.find("fn advance_to_next_episode_audio_only")
|
||||
.expect("advance_to_next_episode_audio_only not found");
|
||||
let rest = &src[start..];
|
||||
let end = rest.find("\n pub ").unwrap_or(rest.len());
|
||||
let body = &rest[..end];
|
||||
|
||||
assert!(
|
||||
body.contains("clear_playback_quality_override"),
|
||||
"the background episode advance does not clear the per-playback \
|
||||
quality override, so a ceiling chosen for one episode silently \
|
||||
caps every episode after it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Stopping clears a background-audio handoff.
|
||||
///
|
||||
/// This was verified by listening to a tablet, which is not a test. The
|
||||
/// handoff swaps which renderer owns playback, and the swap is bookkeeping:
|
||||
/// leaving the base offset and the active flag behind after a stop lets a
|
||||
/// later position read be interpreted against a handoff that no longer
|
||||
/// exists, and left the film playing on as an audio track in the mini
|
||||
/// player.
|
||||
///
|
||||
/// TRACES: UR-040, UR-005 | DR-250 | UT-224
|
||||
#[test]
|
||||
fn test_stop_clears_an_active_background_audio_handoff() {
|
||||
let controller = PlayerController::default();
|
||||
let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
|
||||
{
|
||||
let queue_arc = controller.queue();
|
||||
let mut queue = queue_arc.lock_safe();
|
||||
queue.set_queue(vec![item], 0);
|
||||
}
|
||||
|
||||
controller.enter_background_audio(557.5);
|
||||
assert!(
|
||||
controller.is_background_audio_active(),
|
||||
"precondition: the handoff is active"
|
||||
);
|
||||
|
||||
controller.stop().expect("stop failed");
|
||||
|
||||
assert!(
|
||||
!controller.is_background_audio_active(),
|
||||
"a stop must not leave a handoff behind for the next position read"
|
||||
);
|
||||
assert_eq!(
|
||||
*controller.background_audio_base.lock_safe(),
|
||||
0.0,
|
||||
"the handoff base must be cleared with it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A duration the engine does not know must fall back to the one the item
|
||||
/// carries, and zero must count as "does not know".
|
||||
///
|
||||
/// ExoPlayer reports `C.TIME_UNSET` for a duration it has not resolved;
|
||||
/// `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answers
|
||||
/// `Some(0.0)` rather than `None` and every "unknown duration" fallback is
|
||||
/// skipped. The seek bar then has no scale, which presents as scrubbing
|
||||
/// being dead rather than as a missing duration.
|
||||
///
|
||||
/// TRACES: UR-005, UR-040 | DR-251 | UT-221
|
||||
#[test]
|
||||
fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
|
||||
let controller = PlayerController::default();
|
||||
let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
|
||||
item.duration = Some(1800.0);
|
||||
|
||||
{
|
||||
let queue_arc = controller.queue();
|
||||
let mut queue = queue_arc.lock_safe();
|
||||
queue.set_queue(vec![item], 0);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
controller.duration(),
|
||||
Some(1800.0),
|
||||
"an engine that cannot report a duration should not erase the one the item carries"
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
|
||||
/// Test emitter that captures events for asserting the HTML5 report methods
|
||||
|
||||
@@ -592,14 +592,6 @@ impl PlayerBackend for MpvBackend {
|
||||
// one's "last observed" position.
|
||||
self.observed.lock_safe().reset();
|
||||
|
||||
// Nor its deferred seek. A seek held for a file that is no longer the
|
||||
// one loading would be applied to this one by the `FileLoaded` handler
|
||||
// — so scrubbing near the end of a transcoded item, which re-opens the
|
||||
// stream, and then skipping to the next item before the reload finished
|
||||
// started the new item wherever the old one had been scrubbed to.
|
||||
// TRACES: UR-040, UR-005 | DR-253
|
||||
*self.pending_seek.lock_safe() = None;
|
||||
|
||||
// Load the media file
|
||||
self.mpv
|
||||
.command("loadfile", &[&stream_url])
|
||||
@@ -642,10 +634,6 @@ impl PlayerBackend for MpvBackend {
|
||||
message: format!("Failed to stop: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Stopping ends the seek's subject along with the playback.
|
||||
// TRACES: UR-040, UR-005 | DR-253
|
||||
*self.pending_seek.lock_safe() = None;
|
||||
|
||||
let mut state = self.state.lock_safe();
|
||||
state.current_media = None;
|
||||
|
||||
|
||||
@@ -59,43 +59,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A deferred seek belongs to the file it was issued against.
|
||||
///
|
||||
/// `seek` holds a position when MPV has nothing loaded yet, and the
|
||||
/// `FileLoaded` handler applies it (DR-241). Nothing discarded it when a
|
||||
/// *different* file was loaded or playback stopped — 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 silently started wherever you had scrubbed to in the
|
||||
/// previous one.
|
||||
///
|
||||
/// Asserted against the source: the state lives behind a live MPV handle,
|
||||
/// and constructing one needs libmpv and an audio device that CI cannot be
|
||||
/// assumed to have. Crude, but it pins the one thing that matters — that
|
||||
/// both lifecycle points discard it.
|
||||
///
|
||||
/// TRACES: UR-040, UR-005 | DR-253 | UT-225
|
||||
#[test]
|
||||
fn test_load_and_stop_discard_a_deferred_seek() {
|
||||
let src = include_str!("mpv_backend.rs");
|
||||
|
||||
for func in ["fn load(", "fn stop("] {
|
||||
let start = src
|
||||
.find(func)
|
||||
.unwrap_or_else(|| panic!("{func} not found - has the backend been restructured?"));
|
||||
// The body runs to the next top-level ` fn ` at the same depth.
|
||||
let rest = &src[start + func.len()..];
|
||||
let end = rest.find("\n fn ").unwrap_or(rest.len());
|
||||
let body = &rest[..end];
|
||||
|
||||
assert!(
|
||||
body.contains("pending_seek"),
|
||||
"{func} does not discard `pending_seek`. A seek held for a file \
|
||||
that is no longer loading will be applied to whatever loads next."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that simulates the position update thread spawning async tasks
|
||||
/// without a Tokio runtime (the bug we just fixed)
|
||||
#[test]
|
||||
|
||||
@@ -21,9 +21,7 @@ use libmpv::Mpv;
|
||||
use log::{debug, info, warn};
|
||||
|
||||
use super::backend::PlayerError;
|
||||
use super::media_player::{
|
||||
duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
|
||||
};
|
||||
use super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// State the event thread writes and the caller reads.
|
||||
@@ -147,7 +145,7 @@ impl MpvPlayer {
|
||||
s.duration = mpv
|
||||
.get_property::<f64>("duration")
|
||||
.ok()
|
||||
.and_then(duration_from_secs);
|
||||
.map(Duration::from_secs_f64);
|
||||
s.seekable = mpv.get_property::<bool>("seekable").unwrap_or(true);
|
||||
s.phase = Phase::Playing;
|
||||
s.deferred_seek.take()
|
||||
@@ -375,9 +373,6 @@ impl MediaPlayer for MpvPlayer {
|
||||
audio_settings: true,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: true,
|
||||
// mpv's HLS demuxer cannot make the server transcode from a new
|
||||
// offset, so a transcoded seek must re-open the stream.
|
||||
seeks_transcoded_in_place: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,12 @@ pub enum VideoSeekStrategy {
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `is_local` - Whether the file is a local download
|
||||
/// * `seeks_transcoded_in_place` - Whether the engine rendering this stream
|
||||
/// can seek a server-side transcode without re-opening it. Declared by the
|
||||
/// engine via `Capabilities`, never inferred from the URL or the renderer.
|
||||
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
|
||||
/// * `needs_transcoding` - Whether the content needs transcoding
|
||||
/// * `use_html5` - Whether frontend is using HTML5 video element
|
||||
pub fn determine_video_seek_strategy(
|
||||
is_local: bool,
|
||||
seeks_transcoded_in_place: bool,
|
||||
is_hls: bool,
|
||||
needs_transcoding: bool,
|
||||
use_html5: bool,
|
||||
) -> VideoSeekStrategy {
|
||||
@@ -55,15 +53,14 @@ pub fn determine_video_seek_strategy(
|
||||
// native video on routed every transcoded seek into a backend seek that
|
||||
// silently does nothing, and presents as "resume does not work".
|
||||
if needs_transcoding {
|
||||
// Whether a transcode can be seeked in place is a property of the
|
||||
// engine, and the engine states it. This used to be inferred from
|
||||
// `is_hls`, which held only while hls.js was the sole HLS renderer —
|
||||
// and stopped holding the moment mpv became one (DR-238).
|
||||
return match (seeks_transcoded_in_place, use_html5) {
|
||||
(true, true) => VideoSeekStrategy::Html5NativeSeek,
|
||||
(true, false) => VideoSeekStrategy::BackendNativeSeek,
|
||||
(false, true) => VideoSeekStrategy::Html5ReloadStream,
|
||||
(false, false) => VideoSeekStrategy::BackendReloadStream,
|
||||
return if use_html5 {
|
||||
if is_hls {
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
} else {
|
||||
VideoSeekStrategy::Html5ReloadStream
|
||||
}
|
||||
} else {
|
||||
VideoSeekStrategy::BackendReloadStream
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,21 +235,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-transcoded streams seek in place regardless of the engine's
|
||||
/// transcode ability, which only applies to transcodes.
|
||||
/// Test video seek strategy for HLS streams
|
||||
#[test]
|
||||
fn test_seek_strategy_direct_stream() {
|
||||
// HTML5 renders, so the frontend seeks the element
|
||||
fn test_seek_strategy_hls_stream() {
|
||||
// HLS with HTML5 - frontend handles seek, don't call backend
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, false, true),
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
);
|
||||
// The native engine renders, so it seeks
|
||||
// HLS with native backend - backend handles seek
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, false, false),
|
||||
VideoSeekStrategy::BackendNativeSeek
|
||||
);
|
||||
// A transcode an engine says it can move: seek in place
|
||||
// HLS even with needs_transcoding flag - still native seek (HLS supports it)
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, true, true),
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
@@ -269,30 +265,18 @@ mod tests {
|
||||
/// every transcoded seek into a native seek that silently does nothing,
|
||||
/// which presents as "resume does not work".
|
||||
///
|
||||
/// TRACES: UR-040 | DR-238, DR-246 | UT-217
|
||||
/// TRACES: UR-040 | DR-238 | UT-217
|
||||
#[test]
|
||||
fn test_transcoded_seek_follows_the_engines_declared_ability() {
|
||||
// An engine that cannot move a server-side transcode re-opens it,
|
||||
// whichever side is rendering.
|
||||
fn test_seek_strategy_transcoded_hls_native_backend() {
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, false, true, false),
|
||||
determine_video_seek_strategy(false, true, true, false),
|
||||
VideoSeekStrategy::BackendReloadStream
|
||||
);
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, false, true, true),
|
||||
VideoSeekStrategy::Html5ReloadStream
|
||||
);
|
||||
// hls.js can, and says so, so it seeks in place.
|
||||
// The HTML5 side of the same case is unchanged: hls.js seeks in-playlist.
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, true, true),
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
);
|
||||
// The container the stream arrives in no longer decides anything: the
|
||||
// same declared ability gives the same answer on the native side.
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, true, false),
|
||||
VideoSeekStrategy::BackendNativeSeek
|
||||
);
|
||||
}
|
||||
|
||||
/// Test video seek strategy for direct play (non-transcoded) streams
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -377,19 +377,7 @@ fn draw(widget: >k::Box, cr: >k::cairo::Context, state: &Rc<RefCell<SurfaceS
|
||||
if !s.logged_first_frame || s.logged_size != (width, height) {
|
||||
s.logged_first_frame = true;
|
||||
s.logged_size = (width, height);
|
||||
// The allocation *origin* matters as much as its size. A GtkBox is a
|
||||
// no-window widget, so `widget.window()` is the parent's GdkWindow and
|
||||
// the box sits at an offset inside it. `draw_from_gl` composites into
|
||||
// that window; if it does not honour the cairo translation GTK applied
|
||||
// for this widget, the picture lands at the window origin instead of
|
||||
// the widget's — misaligned by exactly this offset, which is the shape
|
||||
// of a letterbox that does not line up.
|
||||
let alloc = widget.allocation();
|
||||
info!(
|
||||
"[VideoSurface] rendering {width}x{height} at widget origin ({}, {}) scale {scale} (texture {texture})",
|
||||
alloc.x(),
|
||||
alloc.y()
|
||||
);
|
||||
info!("[VideoSurface] rendering {width}x{height} (texture {texture})");
|
||||
}
|
||||
|
||||
unsafe {
|
||||
|
||||
@@ -1239,29 +1239,10 @@ 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); 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(),
|
||||
// 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",
|
||||
};
|
||||
|
||||
// Bind the type filter rather than interpolating it: `include_item_types`
|
||||
|
||||
@@ -1230,22 +1230,7 @@ fn build_get_items_endpoint(
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
|
||||
}
|
||||
// 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 {
|
||||
if let Some(sort_by) = &opts.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
|
||||
@@ -1254,7 +1239,7 @@ fn build_get_items_endpoint(
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
|
||||
}
|
||||
if let Some(sort_order) = sort_order {
|
||||
if let Some(sort_order) = &opts.sort_order {
|
||||
endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
|
||||
}
|
||||
if let Some(recursive) = opts.recursive {
|
||||
@@ -2469,15 +2454,8 @@ 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/{}/Stream.{}",
|
||||
"{}/Videos/{}/{}/Subtitles/{}/{}",
|
||||
self.server_url, item_id, media_source_id, stream_index, format
|
||||
)
|
||||
}
|
||||
@@ -3010,7 +2988,6 @@ impl MediaRepository for OnlineRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::MediaKind;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -3026,35 +3003,6 @@ 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.)
|
||||
@@ -4039,70 +3987,6 @@ 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
|
||||
|
||||
@@ -184,45 +184,6 @@ impl StreamSelection {
|
||||
needs_transcoding: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection for an item already sitting in the queue.
|
||||
///
|
||||
/// The queue predates `StreamSelection`: its items carry a URL, an optional
|
||||
/// transport and the older `needs_transcoding` flag. This rebuilds a
|
||||
/// selection from those without re-negotiating with the server, so the
|
||||
/// controller can hand an engine an `OpenRequest` for an item it already
|
||||
/// holds.
|
||||
///
|
||||
/// The transport falls back rather than being sniffed from the URL — the
|
||||
/// substring check is exactly what DR-230 removed. `needs_transcoding` is an
|
||||
/// exact stand-in because every transcode this app requests is HLS (DR-140).
|
||||
///
|
||||
/// TRACES: UR-079, UR-081 | DR-225, DR-245
|
||||
pub fn for_queued_item(
|
||||
url: impl Into<String>,
|
||||
transport: Option<Transport>,
|
||||
needs_transcoding: bool,
|
||||
) -> Self {
|
||||
let transport = transport.unwrap_or(if needs_transcoding {
|
||||
Transport::Hls
|
||||
} else {
|
||||
Transport::Progressive
|
||||
});
|
||||
Self {
|
||||
url: url.into(),
|
||||
transport,
|
||||
playback_kind: if needs_transcoding {
|
||||
PlaybackKind::Transcode
|
||||
} else {
|
||||
PlaybackKind::DirectPlay
|
||||
},
|
||||
rendition: None,
|
||||
available: Vec::new(),
|
||||
media_source_id: None,
|
||||
play_session_id: None,
|
||||
needs_transcoding,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the quality ladder as it applies to a source of a known bitrate.
|
||||
|
||||
@@ -342,36 +342,6 @@ 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.2",
|
||||
"version": "0.10.1",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+10
-48
@@ -145,37 +145,18 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
|
||||
return await TAURI_INVOKE("player_set_audio_track", { streamIndex });
|
||||
},
|
||||
/**
|
||||
* Switch audio track.
|
||||
* Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
||||
* Note: Frontend should handle saving series preferences after this command succeeds
|
||||
*
|
||||
* 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`]:
|
||||
* 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.
|
||||
*
|
||||
* - 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
|
||||
* TRACES: UR-021 | IR-019, DR-024
|
||||
*/
|
||||
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 });
|
||||
@@ -2324,16 +2305,7 @@ export type GetItemsOptions = { startIndex?: number | null; limit?: number | nul
|
||||
*
|
||||
* TRACES: UR-067 | DR-116 | UT-104
|
||||
*/
|
||||
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 }
|
||||
favoritesOnly?: boolean | null }
|
||||
/**
|
||||
* Image options
|
||||
*/
|
||||
@@ -2491,16 +2463,6 @@ 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,239 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -251,6 +251,7 @@
|
||||
function nativeSeekSettling(): boolean {
|
||||
return Date.now() - lastNativeSeekAt < NATIVE_SEEK_SETTLE_MS;
|
||||
}
|
||||
let didStartNativePlayback = $state(false); // Track if we started playback (to know if we should stop on unmount)
|
||||
let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
|
||||
let swipeType = $state<"brightness" | null>(null);
|
||||
let hls: Hls | null = null; // HLS.js instance for streaming HLS content
|
||||
@@ -305,38 +306,18 @@
|
||||
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
|
||||
@@ -600,7 +581,7 @@
|
||||
!shouldHideControls({
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
menuOpen: openMenu !== null,
|
||||
menuOpen: showAudioTrackMenu || showSubtitleMenu || showQualityMenu,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
@@ -1051,6 +1032,7 @@
|
||||
"Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions",
|
||||
);
|
||||
// Backend is kept running but should not play audio since HTML5 element handles playback
|
||||
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
||||
}
|
||||
|
||||
// Register the adapter with the facade so control intents (UI, or a
|
||||
@@ -1116,6 +1098,7 @@
|
||||
|
||||
if (!useHtml5Element) {
|
||||
// Using native backend, subscribe to player events
|
||||
didStartNativePlayback = true; // Track that we started native playback
|
||||
isPlaying = (response.state?.kind ?? response.state) === "playing";
|
||||
// Cleanup happens in the component's top-level onDestroy. Calling
|
||||
// onDestroy() here — after an await — throws lifecycle_outside_component,
|
||||
@@ -1156,6 +1139,7 @@
|
||||
}
|
||||
} else {
|
||||
// For transcoded content, keep backend for seeking
|
||||
didStartNativePlayback = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1289,25 +1273,14 @@
|
||||
}
|
||||
|
||||
// Stop the player when component is destroyed
|
||||
// Unconditional. Leaving the player means nothing should still be playing,
|
||||
// whichever renderer happened to own it.
|
||||
//
|
||||
// This used to be gated on `didStartNativePlayback && !didStopBackendEarly`
|
||||
// — flags describing what *this component* started. A background-audio
|
||||
// handoff swaps the renderer underneath them, so after one they describe a
|
||||
// player that is no longer the one making sound, and the stop was skipped
|
||||
// while the audio stream kept going. It then reappeared in the mini player
|
||||
// as an audio track.
|
||||
//
|
||||
// `playerStop` is idempotent, so calling it when nothing is playing costs a
|
||||
// no-op IPC round trip. That is a far cheaper failure than the alternative.
|
||||
//
|
||||
// TRACES: UR-040, UR-005 | DR-250
|
||||
try {
|
||||
log.debug("Stopping backend player on component unmount");
|
||||
await commands.playerStop();
|
||||
} catch (err) {
|
||||
log.error("Failed to stop backend player:", err);
|
||||
// Skip if we already stopped the backend early (non-transcoded + HTML5)
|
||||
if (didStartNativePlayback && !didStopBackendEarly) {
|
||||
try {
|
||||
log.debug("Stopping backend player on component unmount");
|
||||
await commands.playerStop();
|
||||
} catch (err) {
|
||||
log.error("Failed to stop backend player:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Report stop when component is destroyed (skip for live - no resume tracking)
|
||||
@@ -2066,6 +2039,7 @@
|
||||
transport: targetSelection.transport,
|
||||
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
|
||||
});
|
||||
didStartNativePlayback = true;
|
||||
await playerAdapter?.load(targetSelection.url, {
|
||||
mediaId: media.id,
|
||||
selection: targetSelection,
|
||||
@@ -2373,11 +2347,15 @@
|
||||
}, 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;
|
||||
closeMenu();
|
||||
showAudioTrackMenu = false;
|
||||
|
||||
try {
|
||||
// The BACKEND decides whether the audio-track switch needs a transcode
|
||||
@@ -2430,6 +2408,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleQualityMenu() {
|
||||
showQualityMenu = !showQualityMenu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open the current stream at a different bandwidth ceiling.
|
||||
*
|
||||
@@ -2446,7 +2428,7 @@
|
||||
* TRACES: UR-074, UR-079 | DR-162, DR-226, DR-227
|
||||
*/
|
||||
async function selectQuality(quality: StreamingQuality) {
|
||||
closeMenu();
|
||||
showQualityMenu = false;
|
||||
if (quality === selectedQuality || changingQuality) return;
|
||||
|
||||
changingQuality = true;
|
||||
@@ -2482,6 +2464,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -2524,7 +2510,7 @@
|
||||
async function selectSubtitle(streamIndex: number | null) {
|
||||
log.debug("Selecting subtitle - streamIndex:", streamIndex);
|
||||
selectedSubtitleIndex = streamIndex;
|
||||
closeMenu();
|
||||
showSubtitleMenu = false;
|
||||
|
||||
// For HTML5 video element, update the text tracks
|
||||
if (useHtml5Element) {
|
||||
@@ -2849,155 +2835,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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}
|
||||
<!-- Control buttons -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Play/Pause -->
|
||||
<button
|
||||
@@ -3026,23 +2865,60 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Wraps rather than overflowing: in portrait these icons plus the
|
||||
transport controls are wider than the screen, and the last of them
|
||||
(fullscreen, close) went off the edge. TRACES: UR-066 | DR-256 -->
|
||||
<div class="flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Audio Track Selection -->
|
||||
{#if audioTracks().length > 1}
|
||||
<button
|
||||
onclick={() => toggleMenu("audio")}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label="Select audio track"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<!--
|
||||
@@ -3050,34 +2926,147 @@
|
||||
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-227
|
||||
-->
|
||||
{#if qualityOptions.length > 1}
|
||||
<button
|
||||
onclick={() => toggleMenu("quality")}
|
||||
class="text-white hover:text-gray-300 disabled:opacity-50"
|
||||
disabled={changingQuality}
|
||||
aria-label="Select streaming quality"
|
||||
>
|
||||
<!-- Speedometer: bitrate ceiling -->
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<!-- Subtitle Selection -->
|
||||
{#if subtitleTracks().length > 0}
|
||||
<button
|
||||
onclick={() => toggleMenu("subtitle")}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label="Select subtitles"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<!-- Sleep Timer -->
|
||||
@@ -3103,13 +3092,8 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- 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)}
|
||||
/>
|
||||
<!-- Volume Control -->
|
||||
<VolumeControl size="md" />
|
||||
|
||||
<!-- Picture-in-picture (Android only) -->
|
||||
{#if pipSupported}
|
||||
|
||||
@@ -7,31 +7,16 @@
|
||||
|
||||
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", open, onOpenChange }: Props = $props();
|
||||
let { size = "md" }: Props = $props();
|
||||
|
||||
// On Android, volume is controlled by system volume buttons (not a slider)
|
||||
const isAndroid = platform() === "android";
|
||||
|
||||
let selfOpen = $state(false);
|
||||
const showSlider = $derived(open ?? selfOpen);
|
||||
let showSlider = $state(false);
|
||||
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;
|
||||
@@ -59,7 +44,7 @@
|
||||
}
|
||||
|
||||
function toggleSlider() {
|
||||
setOpen(!showSlider);
|
||||
showSlider = !showSlider;
|
||||
}
|
||||
|
||||
// Icon sizes based on prop (use $derived for reactivity)
|
||||
@@ -121,7 +106,7 @@
|
||||
<!-- Volume Slider (toggle on click) -->
|
||||
{#if showSlider}
|
||||
<div
|
||||
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"
|
||||
class="absolute left-full ml-2 bg-[var(--color-surface)] rounded-lg shadow-lg p-3 z-[70] flex items-center gap-2"
|
||||
role="group"
|
||||
aria-label="Volume controls"
|
||||
>
|
||||
@@ -168,6 +153,9 @@
|
||||
|
||||
<!-- Click outside to close volume slider -->
|
||||
{#if showSlider}
|
||||
<button class="fixed inset-0 z-[65]" onclick={() => setOpen(false)} aria-label="Close volume"
|
||||
<button
|
||||
class="fixed inset-0 z-[65]"
|
||||
onclick={() => (showSlider = 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, MediaKind, SearchResult, Genre } from "$lib/api/types";
|
||||
import type { Library, MediaItem, 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,21 +113,9 @@ 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[];
|
||||
parentKind?: MediaKind;
|
||||
} = {},
|
||||
options: { startIndex?: number; limit?: number; genres?: string[] } = {},
|
||||
) {
|
||||
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
|
||||
|
||||
@@ -141,7 +129,8 @@ function createLibraryStore() {
|
||||
startIndex: options.startIndex ?? 0,
|
||||
limit: options.limit ?? 10000,
|
||||
fields: ["PrimaryImageAspectRatio", "Overview", "MediaStreams"],
|
||||
parentKind: options.parentKind ?? "folder",
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
genres: options.genres,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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,7 +20,6 @@ const KIND_LABELS: Record<MediaKind, string> = {
|
||||
channel: "Channel",
|
||||
liveChannel: "Live TV",
|
||||
channelItem: "Channel",
|
||||
channelFolder: "Channel",
|
||||
folder: "Folder",
|
||||
other: "",
|
||||
};
|
||||
|
||||
@@ -155,7 +155,6 @@
|
||||
case "album":
|
||||
case "artist":
|
||||
case "folder":
|
||||
case "channelFolder":
|
||||
case "playlist":
|
||||
case "channel":
|
||||
// Navigate to detail view
|
||||
|
||||
@@ -171,10 +171,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
await library.loadItems(itemId, { limit: 100 });
|
||||
|
||||
// Ensure cast/crew data is loaded for Movies, Series, and Episodes
|
||||
// Some APIs/caches may not include people data on first load
|
||||
@@ -280,7 +277,6 @@
|
||||
case "album":
|
||||
case "artist":
|
||||
case "folder":
|
||||
case "channelFolder":
|
||||
case "playlist":
|
||||
case "channel":
|
||||
case "movie":
|
||||
|
||||
@@ -174,7 +174,6 @@
|
||||
"series",
|
||||
"season",
|
||||
"folder",
|
||||
"channelFolder",
|
||||
"playlist",
|
||||
"channel",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user