Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e579d4bff2 | ||
|
|
bb14c66e71 | ||
|
|
7660cf219b | ||
|
|
fd8273824a | ||
|
|
11d9d760d8 |
+102
@@ -9,6 +9,108 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
|||||||
For how long each fixed defect had been shipping before it was found, see
|
For how long each fixed defect had been shipping before it was found, see
|
||||||
[docs/defect-windows.md](docs/defect-windows.md).
|
[docs/defect-windows.md](docs/defect-windows.md).
|
||||||
|
|
||||||
|
## v0.11.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)
|
||||||
|
|
||||||
|
### 🧹 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
|
## v0.10.1
|
||||||
|
|
||||||
A single fix, for something that had been quietly overriding a choice you made.
|
A single fix, for something that had been quietly overriding a choice you made.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
|
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
|
||||||
- [Playback Backend Unification](specs/playback-backend-unification.md)
|
- [Playback Backend Unification](specs/playback-backend-unification.md)
|
||||||
- [Linux Native Video Spike](specs/linux-native-video-spike.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)
|
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
|
||||||
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
|
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
|
||||||
- [libmpv2 Migration](specs/libmpv2-migration.md)
|
- [libmpv2 Migration](specs/libmpv2-migration.md)
|
||||||
|
|||||||
@@ -25,8 +25,19 @@ release mechanics. This covers whether the player is fit to release at all.
|
|||||||
a wrong capability for ExoPlayer (DR-246 follow-up) and a `Duration` panic
|
a wrong capability for ExoPlayer (DR-246 follow-up) and a `Duration` panic
|
||||||
(DR-252). Both were invisible to the test suites.
|
(DR-252). Both were invisible to the test suites.
|
||||||
|
|
||||||
The suites verify engines that behave. **The manual passes exist to catch
|
The suites originally verified only engines that *behave*, which is why both
|
||||||
engines that do not.**
|
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
|
## 1. Automated gates
|
||||||
|
|
||||||
|
|||||||
@@ -446,6 +446,9 @@ Internal architecture, components, and application logic.
|
|||||||
| 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-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-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-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-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -760,6 +763,10 @@ Internal architecture, components, and application logic.
|
|||||||
| 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-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-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-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 |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ taken by other work; each carries a ⚠️ note at the top.
|
|||||||
| Spec | Blocked on / note |
|
| 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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [windows-native-audio-backend.md](windows-native-audio-backend.md) | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken. |
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# 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
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.10.1",
|
"version": "0.11.0",
|
||||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
Generated
+1
-1
@@ -2181,7 +2181,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.10.1"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ name = "jellytau"
|
|||||||
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
||||||
# which `tauri dev` issues — ambiguous.
|
# which `tauri dev` issues — ambiguous.
|
||||||
default-run = "jellytau"
|
default-run = "jellytau"
|
||||||
version = "0.10.1"
|
version = "0.11.0"
|
||||||
description = "A cross-platform Jellyfin client"
|
description = "A cross-platform Jellyfin client"
|
||||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -152,3 +152,107 @@ impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
|
|||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -171,19 +171,6 @@ pub enum MediaSource {
|
|||||||
DirectUrl { url: String },
|
DirectUrl { url: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaItem {
|
|
||||||
/// The URL or path an engine should open.
|
|
||||||
///
|
|
||||||
/// TRACES: UR-081 | DR-245
|
|
||||||
pub fn playable_url(&self) -> String {
|
|
||||||
match &self.source {
|
|
||||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
|
||||||
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().into_owned(),
|
|
||||||
MediaSource::DirectUrl { url } => url.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MediaItem {
|
impl MediaItem {
|
||||||
/// Get the Jellyfin item ID if available
|
/// Get the Jellyfin item ID if available
|
||||||
pub fn jellyfin_id(&self) -> Option<&str> {
|
pub fn jellyfin_id(&self) -> Option<&str> {
|
||||||
@@ -198,10 +185,16 @@ impl MediaItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the playback URL or file path
|
/// The URL or path an engine should open.
|
||||||
///
|
///
|
||||||
/// Only available on Android where ExoPlayer needs direct URL access
|
/// Not gated to Android any more. It was, back when only ExoPlayer needed
|
||||||
#[cfg(target_os = "android")]
|
/// 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
|
||||||
pub fn playback_url(&self) -> String {
|
pub fn playback_url(&self) -> String {
|
||||||
match &self.source {
|
match &self.source {
|
||||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||||
|
|||||||
@@ -582,7 +582,7 @@ impl PlayerController {
|
|||||||
backend.open(OpenRequest::new(
|
backend.open(OpenRequest::new(
|
||||||
item.clone(),
|
item.clone(),
|
||||||
StreamSelection::for_queued_item(
|
StreamSelection::for_queued_item(
|
||||||
item.playable_url(),
|
item.playback_url(),
|
||||||
item.transport,
|
item.transport,
|
||||||
item.needs_transcoding,
|
item.needs_transcoding,
|
||||||
),
|
),
|
||||||
@@ -1995,6 +1995,15 @@ impl PlayerController {
|
|||||||
&self,
|
&self,
|
||||||
next_episode_id: &str,
|
next_episode_id: &str,
|
||||||
) -> Result<(), String> {
|
) -> 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
|
let repo = self
|
||||||
.repository
|
.repository
|
||||||
.lock_safe()
|
.lock_safe()
|
||||||
@@ -2313,6 +2322,78 @@ impl Default for PlayerController {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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
|
/// A duration the engine does not know must fall back to the one the item
|
||||||
/// carries, and zero must count as "does not know".
|
/// carries, and zero must count as "does not know".
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -592,6 +592,14 @@ impl PlayerBackend for MpvBackend {
|
|||||||
// one's "last observed" position.
|
// one's "last observed" position.
|
||||||
self.observed.lock_safe().reset();
|
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
|
// Load the media file
|
||||||
self.mpv
|
self.mpv
|
||||||
.command("loadfile", &[&stream_url])
|
.command("loadfile", &[&stream_url])
|
||||||
@@ -634,6 +642,10 @@ impl PlayerBackend for MpvBackend {
|
|||||||
message: format!("Failed to stop: {:?}", e),
|
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();
|
let mut state = self.state.lock_safe();
|
||||||
state.current_media = None;
|
state.current_media = None;
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,43 @@ 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
|
/// Test that simulates the position update thread spawning async tasks
|
||||||
/// without a Tokio runtime (the bug we just fixed)
|
/// without a Tokio runtime (the bug we just fixed)
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "JellyTau",
|
"productName": "JellyTau",
|
||||||
"version": "0.10.1",
|
"version": "0.11.0",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user