Compare commits

..
Author SHA1 Message Date
dtourolle 7660cf219b docs(specs): restore the stream-selection spec dropped by the squash
Traceability Validation / Check Requirement Traces (push) Waiting to run
Build & Release / Run Tests (push) Waiting to run
Build & Release / Build Linux (push) Blocked by required conditions
Build & Release / Build Windows (push) Blocked by required conditions
Build & Release / Build Android (push) Blocked by required conditions
Build & Release / Create Release (push) Blocked by required conditions
Publish Documentation / Build & publish docs to gitea-pages (push) In progress
CI caught this, which is what it is for: `check-doc-links.sh` failed because
media-player-controller.md links to backend-owned-stream-selection.md twice and
the file was not on master.

My fault, and worth recording how. That spec was written and committed directly
on master early on, before the work moved into a worktree. Squashing the branch
began with `git reset --hard` back to the merge base, which dropped those early
master-only commits — and the squash then brought in a document referencing one
of them.

Nothing was lost: the commits are still reachable, and the file is restored from
1d56517f along with its index entries in docs/specs/README.md and the docs site.

The lesson is narrower than "be careful with reset": a squash whose base is
chosen by hand silently drops anything committed outside the branch being
squashed. Only the link checker noticed, because it is the one gate that reads
across files rather than within them — and it is also the one gate I had not
run locally before pushing.

All CI checks now pass locally: boundary, doc links, tooling, format, lint at
the 158 ceiling, traces validate, coverage 90%.
2026-08-23 11:08:36 +02:00
dtourolle fd8273824a test(player): drive an engine that answers badly
Build & Release / Create Release (push) Blocked by required conditions
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 42s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m46s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m53s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 15m15s
Build & Release / Build Linux (push) Waiting to run
Build & Release / Build Windows (push) Waiting to run
Build & Release / Build Android (push) Waiting to run
Fair criticism: hardware time went into writing a checklist describing what the
tablet found, when it should have gone into making the suites able to find it.
A checklist decays and depends on someone following it. A test does not.

The gap was specific. Every engine the conformance suite drives reports sane
numbers, so it stayed green while a real one took the backend down. The old
PlayerBackend contract is a plain f64 — it never promised finite, never
promised positive, and nothing enforced it.

UT-223 adds the engine that was missing: a HostileBackend answering with
C.TIME_UNSET as seconds, NaN, both infinities, a negative and a zero. Reading a
snapshot must yield no duration and a zero position rather than panicking.
Against the adapter as originally written it fails with

    cannot convert float seconds to Duration: value is negative

which is the exact panic that produced a black screen on the tablet — now
reproduced in 0.00s on a laptop instead of by backgrounding an app.

UT-224 pins the other hardware-only finding: stopping clears an active
background-audio handoff, flag and base offset both. That was verified by
listening to a device, which is not a test.

Both were confirmed to fail against the pre-fix code before being kept.

The verification plan now says to prefer moving cases out of it and into tests,
and that what remains should be what genuinely needs eyes, ears or a display —
not what merely has not been automated yet.

793 Rust tests.
2026-08-23 10:54:49 +02:00
dtourolle 11d9d760d8 feat(player): native video on Linux, and one contract for every player (v0.11.0)
mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.

That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.

Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.

  DR-238/246  a seek routed by the stream's container rather than by what the
              engine could do with it - correct only while one player handled
              those streams, silent the moment another did
  DR-239      a property handled but never observed, so the play/pause button
              waited for an event that could not arrive
  DR-240      fullscreen expanding the document while the window stayed put
  DR-241      a seek issued before the engine had a file, failed, and discarded
              - which is why resume began at zero
  DR-247      a Linux-only gate outliving the caller that made it Linux-only,
              breaking the Android build outright
  DR-250      a stop aimed at whichever renderer bookkeeping believed was in
              charge, missing the one actually making sound
  DR-251      a duration of zero believed, leaving the seek bar no scale
  DR-252      a junk float converted to a Duration, panicking the backend the
              instant a length-less stream appeared

So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.

Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.

Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.

Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.

Squashed from worktree-linux-native-video, which keeps the per-defect history.
2026-08-23 10:51:45 +02:00
12 changed files with 497 additions and 6 deletions
+91
View File
@@ -9,6 +9,97 @@ 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.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)
### 🧹 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.
+1
View File
@@ -33,6 +33,7 @@
- [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)
+13 -2
View File
@@ -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
(DR-252). Both were invisible to the test suites.
The suites verify engines that behave. **The manual passes exist to catch
engines that do not.**
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
+2
View File
@@ -760,6 +760,8 @@ 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-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 |
### Integration Tests
+1
View File
@@ -45,6 +45,7 @@ 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. |
@@ -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 14 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
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.10.1",
"version": "0.11.0",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
+1 -1
View File
@@ -2181,7 +2181,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.10.1"
version = "0.11.0"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -4,7 +4,7 @@ name = "jellytau"
# `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous.
default-run = "jellytau"
version = "0.10.1"
version = "0.11.0"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
+104
View File
@@ -152,3 +152,107 @@ impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
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));
}
}
+39
View File
@@ -2313,6 +2313,45 @@ impl Default for PlayerController {
#[cfg(test)]
mod tests {
/// 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".
///
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.10.1",
"version": "0.11.0",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",