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.
This commit is contained in:
2026-08-23 10:51:45 +02:00
parent 5fede123e7
commit 11d9d760d8
87 changed files with 15968 additions and 7508 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ jobs:
# at "warn" until its class is cleared and it can be promoted to "error". # at "warn" until its class is cleared and it can be promoted to "error".
# Lower this as you clear them. Never raise it to make a build pass. # Lower this as you clear them. Never raise it to make a build pass.
- name: Lint - name: Lint
run: bun run lint -- --max-warnings=159 run: bun run lint -- --max-warnings=158
- name: Check TypeScript - name: Check TypeScript
run: | run: |
+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 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)
### 🧹 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.
+2 -1
View File
@@ -33,7 +33,6 @@
- [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)
@@ -42,12 +41,14 @@
- [Scoped Search Boundary](specs/scoped-search-boundary.md) - [Scoped Search Boundary](specs/scoped-search-boundary.md)
- [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md) - [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md)
- [Frontend Domain Model](specs/frontend-domain-model.md) - [Frontend Domain Model](specs/frontend-domain-model.md)
- [Desktop Native Video](specs/desktop-native-video.md)
- [Build Provenance](specs/build-provenance.md) - [Build Provenance](specs/build-provenance.md)
# Build & Release # Build & Release
- [Build & Release](build/build-release.md) - [Build & Release](build/build-release.md)
- [Release Checklist](release-checklist.md) - [Release Checklist](release-checklist.md)
- [Native Player Verification](native-player-verification.md)
- [Desktop Packaging](build/build-desktop-packages.md) - [Desktop Packaging](build/build-desktop-packages.md)
- [Windows Build](build/build-windows.md) - [Windows Build](build/build-windows.md)
- [Defect Windows](defect-windows.md) - [Defect Windows](defect-windows.md)
+154 -1
View File
@@ -673,8 +673,161 @@ device profile. Sending it there — not just on the transcode URL — is what m
the cap real: a stream the server decides to *direct play* is served at the the cap real: a stream the server decides to *direct play* is served at the
source file's own bitrate, and no URL parameter afterwards can reduce it. source file's own bitrate, and no URL parameter afterwards can reduce it.
#### Two levels of ceiling
**Location**: `src-tauri/src/repository/online.rs` (TRACES: UR-074, UR-079 | DR-226)
There are two, and they are not the same thing:
| | Set by | Lives until | Read via |
|---|---|---|---|
| **Device default** | Settings (`player_set_video_settings`) | Persisted; restored at startup | `streaming_quality()` |
| **Per-playback override** | The in-player picker (`player_set_stream_quality`) | The next item starts playing | `playback_quality_override()` |
`effective_streaming_quality()` resolves the pair — override first, else default —
and **is the only thing stream construction may read**. Every URL builder and the
`PlaybackInfo` negotiation go through it, for the reason the process-wide static
existed in the first place: if the negotiation and the URL builder disagree, the
cap leaks — the negotiation authorises a direct play the builder then never gets
to constrain, or the reverse.
> The override exists because a single global cannot express "this 4K remux needs
> a ceiling, that podcast does not". The picker had documented itself as a "this
> film, this connection" control since it was written, but was implemented by
> writing the *default* — so dropping one awkward film to 2 Mbps silently capped
> every video played afterwards for the rest of the process, with Settings still
> showing the old value. It is cleared on every `player_play_item` /
> `player_play_queue` / `player_play_tracks`, which is what stops it surviving
> into an autoplayed next episode where nobody would reopen the picker.
### Stream selection
**Location**: `src-tauri/src/repository/stream_selection.rs`,
`OnlineRepository::get_stream_selection` (TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228)
**Rust decides *what stream*. The player decides *how to deliver it*.** That line
is the whole design. A backend with genuine adaptive selection (ExoPlayer over a
multi-variant playlist) is left to do it; Rust chooses what to request and never
paces bytes.
`get_stream_selection` returns one self-describing `StreamSelection` in place of
the bare URL `get_video_stream_url` used to hand out:
| Field | Carries |
|---|---|
| `url` | What to open |
| `transport` | `Hls` / `Progressive` / `LocalFile` — how to fetch it |
| `playback_kind` | `DirectPlay` / `DirectStream` / `Transcode` — what the server is doing to the source |
| `rendition` | The negotiated ceiling and codecs; `None` for a direct play, which *is* the source |
| `available` | The quality ladder as it applies to this media source (DR-227) |
| `needs_transcoding` | Derived from `playback_kind`, so the rule is answered once |
Both enums are serde-tagged (`{"type":"hls"}`) so the frontend matches a
discriminant rather than comparing text.
> **Why `transport` exists.** `VideoPlayer.svelte` chose its loader with
> `url.includes(".m3u8")`, in two places. Rust *built* that URL and knows exactly
> what it is; re-deriving it downstream by substring match is a domain fact
> reconstructed in the presentation layer — the same class of error as leaking
> item-type taxonomy, and one that fails silently in **both** directions: a
> progressive file served from a path containing the substring gets an HLS
> loader, and a playlist served from a path without it does not.
>
> The paths that never negotiate get the same shape from Rust rather than letting
> a caller assemble one — `media_local_selection` for a downloaded file,
> `LiveStreamInfo.transport` for a live channel — so there is no second place
> where a transport is decided.
#### The playback-kind decision
`decide_playback_kind` is a free function and pure, so every branch is testable
from `PlaybackInfo` fixtures without a server. Order matters — the two
client-side overrides come first, because each describes a case where the
server's answer is right about the *file* and wrong about what this app will do
with it:
1. **Undecodable audio → `Transcode`.** Jellyfin 10.11.5 honours a
DirectPlayProfile's container and video codec but *ignores its audio codec*,
so it offers direct play for an E-AC-3 track the webview renders in silence.
A silent direct play is worse than a transcode.
2. **A pinned audio track → `Transcode`.** Not a defect in the server's answer, a
different question: the file has one default track and the viewer asked for
another.
3. Otherwise `supports_direct_play``DirectPlay`, else `supports_direct_stream`
`DirectStream`, else `Transcode`.
A direct **stream** is a remux — codecs copied, container repackaged. It is cheap
and is deliberately *not* counted as transcoding; conflating the two would report
a free passthrough as a server-side re-encode.
> **What this is worth, measured.** Against the development server (Jellyfin
> 10.11.5), 400 items sampled for codec mix and 40 put through a real negotiation
> per profile:
>
> | Profile | Direct play |
> |---|---|
> | Linux / WebKitGTK (`h264` only, 2ch) | 3/40 — **7%** |
> | Android / ExoPlayer (`h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch) | 34/40 — **85%** |
>
> The library is ~80% hevc (`hevc+eac3` alone is a third of it), which is why the
> two diverge so hard.
>
> **Read that 85% as a ceiling, not a result.** It was measured with a profile
> containing `ac3,eac3`. The Android device this was later run on reports neither
> in its `MediaCodecList` — no Dolby licence, which is normal for a tablet — so
> eac3 content, about a third of the sampled library, correctly transcodes there.
> What any given device achieves depends on its own codec list, and on the
> profile being derived from the renderer at all (DR-234), which it was not when
> the figure was taken.
>
> **The payoff is still overwhelmingly Android**, because that is where a real
> decoder is already doing the work. Linux stays near 7% until libmpv decodes the
> picture — the h264-only profile is a WebKitGTK constraint, not a JellyTau
> choice, and is what `linux-native-video-spike.md` exists to remove. A reviewer
> should not expect this code to fix Linux on its own.
#### The quality ladder per source
`quality_options_for_source(source_bitrate)` returns every rung, each marked with
`exceeds_source`: true when that rung's ceiling is at or above what the source
itself carries, so selecting it produces the same bytes as `Original`. The
frontend draws the list and drops the redundant rungs; it does not decide which
they are.
- `Original` is never marked — it *is* the source.
- An unreported source bitrate (some containers have none; the sampled library
has `avi` files with no bitrate at all) marks **nothing** redundant, keeping
every rung offered. That is the safe direction: the viewer keeps every choice.
#### No adaptive ladder to preserve
**TRACES: UR-079 | DR-229 (Won't Do)**
Mid-playback re-negotiation on throughput was scoped and dropped on measurement.
A master playlist from this server carries exactly **one** `EXT-X-STREAM-INF`:
Jellyfin builds it from the single rendition the request asked for rather than
publishing a ladder. So there is no adaptation for hls.js to be preserving and
none that mpv would lose — the claim that there was is recorded in
`playback-backend-unification.md` and does not hold. "Adapt mid-stream" collapses
into "pick well at open", which is what the two levels of ceiling and the
per-source ladder already are.
Kept here because it is a measurement, not an opinion: a server that *does*
publish a ladder would change the answer, and the re-negotiation path below is
the hook that work would build on.
#### Re-negotiation
One mechanism, not two. `player_seek_video`, `player_switch_audio_track` and
`player_set_stream_quality` all return a tagged `strategy` saying who reloads —
the backend handles a native backend itself and hands the webview a
`StreamSelection` for `reloadSource`. Note the wire wart: tauri-specta keeps
these response fields snake_case (`seek_offset`), while the `strategy` tag itself
is camelCase.
The frontend names a variant and nothing else; the labels the picker shows are The frontend names a variant and nothing else; the labels the picker shows are
served over IPC by `player_get_streaming_qualities`. served over IPC — from `available` on the selection, or
`player_get_streaming_qualities` for the Settings list.
## Background workers ## Background workers
+39
View File
@@ -802,6 +802,45 @@ by exactly the inset.
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
can safely be re-sent on resume. can safely be re-sent on resume.
## Stream Transport
**Location**: `src/lib/player/streamTransport.ts`
**TRACES**: UR-079 | DR-225 | UT-214
`videoLoaderFor(selection, capabilities)` picks the loader for the webview
`<video>` element — `hlsjs`, `nativeHls`, or `direct` — from the backend's tagged
`selection.transport`. `elementSrcFor` is its template companion: the element's
`src` is emptied only when hls.js is driving it.
The split is the point. **The transport is the stream's property and comes from
Rust; whether a given loader exists is the browser's, and is the only thing
decided here.**
> This replaced `currentStreamUrl.includes(".m3u8")`, which appeared twice in
> `VideoPlayer.svelte` — once in the HLS `$effect` and once inline in the
> template's `src`. Rust builds that URL and knows what it is; re-deriving it
> here by substring match was a domain fact reconstructed in the presentation
> layer, and it fails silently in both directions. The two tests that pin it are
> the ones that failed against the old implementation: a `progressive` stream
> whose URL contains `.m3u8` must **not** get an HLS loader, and an `hls` stream
> whose URL contains no `.m3u8` must.
>
> Logic lives in a plain `.ts` module rather than in the component for the usual
> reason — it is testable there. Same pattern as `episodeStrip.ts`.
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
derived from it. A reload replaces the selection **wholesale** (the adapter's
bridge takes a `StreamSelection`, not a URL), so transport and URL can never
drift apart. The background-audio handoff states the transport it is moving to —
progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
inferred.
The quality picker is filled from `selection.available` (DR-227): rungs the
backend marked `exceedsSource` are not drawn, because they produce the same bytes
as `Original`. Nothing is optimistically assigned when the viewer picks a rung —
what the menu shows comes from the selection the backend hands back, since a
ceiling above the source bitrate *is* the source.
## Native Video Store ## Native Video Store
**Location**: `src/lib/stores/nativeVideo.ts` **Location**: `src/lib/stores/nativeVideo.ts`
+49
View File
@@ -132,6 +132,55 @@ sequenceDiagram
Note over Store: UI updates reactively Note over Store: UI updates reactively
``` ```
## Video Stream Selection Flow
**TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228**
Before a video plays, Rust decides *what stream* — direct play, remux or
transcode, over which transport — and hands the player one self-describing
`StreamSelection`. The page no longer inspects the URL to work any of this out.
```mermaid
sequenceDiagram
participant Page as player/[id]/+page.svelte
participant Repo as HybridRepository
participant Online as OnlineRepository
participant Server as Jellyfin
participant VP as VideoPlayer.svelte
Page->>Repo: playerLocalMediaPath(id)
alt a completed download exists
Page->>Repo: mediaLocalSelection(path)
Note over Page: LocalFile / DirectPlay, no ladder —<br/>nothing about a file on disk re-negotiates
else stream from the server
Page->>Repo: getStreamSelection(id, mediaSourceId)
Repo->>Online: get_stream_selection()
Online->>Online: effective_streaming_quality()
Note over Online: per-playback override, else device default
Online->>Server: POST /Items/{id}/PlaybackInfo<br/>(device profile + ceiling)
Server-->>Online: MediaSource {supportsDirectPlay,<br/>supportsDirectStream, transcodingUrl, bitrate}
Online->>Online: decide_playback_kind()
alt Transcode
Online->>Online: adopt/stop prior play session,<br/>build HLS URL
Note over Online: Transport::Hls
else DirectPlay / DirectStream
Online->>Online: /Videos/{id}/stream?static=true
Note over Online: Transport::Progressive,<br/>rendition = None (it IS the source)
end
Online->>Online: quality_options_for_source(bitrate)
Online-->>Page: StreamSelection
end
Page->>VP: selection
VP->>VP: videoLoaderFor(selection, caps)
Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
```
The selection travels with the stream from then on. A reload — a quality change,
an audio-track switch, a transcoded seek — returns a *new* selection through the
same tagged `strategy` response, so transport and URL can never disagree; and the
queue item carries the transport so `player_seek_video` picks its seek strategy
from the backend's decision rather than from the URL string.
## Playback Mode Transfer Flow ## Playback Mode Transfer Flow
```mermaid ```mermaid
+191
View File
@@ -0,0 +1,191 @@
# 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 verify engines that behave. **The manual passes exist to catch
engines that do not.**
## 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.
+44
View File
@@ -88,6 +88,9 @@ For a narrative overview of the system design, see
| UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Done | | UR-076 | Music browsing shows only what the listener considers music. A Jellyfin server commonly keeps podcasts, audiobooks, sound effects or sample packs in their own folders inside a music library; those folders can be **excluded by choice**, once, and every music surface — library grids, artist and album listings, genre rows, search and the home screen — then agrees on what is in scope. The choice is by folder, not by a name the app happens to recognise, so a folder called anything at all can be excluded and an item is never dropped because its title matched a word | Medium | Done |
| UR-077 | The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request — the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throw | Medium | Done | | UR-077 | The app can update itself, or tell the user how. Somebody who installed an AppImage or ran the Windows installer had no upgrade path at all: nothing in the app ever mentioned that a newer version existed, and the release notes were the only announcement. On Linux and Windows the app checks a signed manifest, offers the new version with its notes, and installs and relaunches on request — the signature check is the point, since it is what stops a substituted download from being installed by the app itself. Android cannot do this (an app may not overwrite its own APK; that is the package installer's job) and is given the honest alternative, a link to the releases page, rather than a button that would throw | Medium | Done |
| UR-078 | JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the file | Medium | Done | | UR-078 | JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the file | Medium | Done |
| UR-079 | The app decides *what stream to play* and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for `.m3u8` in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody needed | Medium | Done |
| UR-080 | Video on the desktop plays as itself. The picture was drawn by a webview `<video>` element, which decodes little beyond h264 — so the app told the server it could accept only h264, and the server re-encoded almost everything before sending it. That was never a statement about the machine: the same machine already runs mpv for audio, which decodes essentially the whole library. Measured against a real library, 93% of desktop playback was a transcode nobody needed, against 15% on Android where a real decoder does the work. mpv now draws the picture, the app claims what it can genuinely decode, and video is sent as it was stored wherever that is possible — sparing the server the work, the network the bitrate, and the picture a generation of re-encoding | Medium | Proposed |
| UR-081 | Playback behaves the same whichever engine renders it | High | In Progress |
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done | | UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
--- ---
@@ -132,6 +135,7 @@ External system integrations and platform-specific implementations.
| IR-030 | Scheduled full-catalog crawl of every library (`Recursive=true`, paged) feeding the local index, driven by a Rust background task and the `ConnectivityMonitor` reconnect signal rather than by the frontend | Storage | UR-065 | Implemented | | IR-030 | Scheduled full-catalog crawl of every library (`Recursive=true`, paged) feeding the local index, driven by a Rust background task and the `ConnectivityMonitor` reconnect signal rather than by the frontend | Storage | UR-065 | Implemented |
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) | | IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed | | IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
| IR-033 | libmpv render-API integration for video: `vo=libmpv` driving an OpenGL FBO bound by the host toolkit, with GL entry points resolved through libepoxy. Note that libepoxy exports them as *data* symbols — there is no `glFoo` function, only an `epoxy_glFoo` variable holding a lazily-resolving pointer — so `get_proc_address` must return the pointer stored **at** that symbol; returning the symbol's own address makes mpv jump into non-executable data and take SIGSEGV on the first GL call. The `epoxy` crate resolves this correctly but is unusable, its `gl_generator` dependency pulling a yanked `xml-rs` | Playback | UR-080 | Proposed |
> **Where a UR is met by a different mechanism than its IR anticipated.** Several > **Where a UR is met by a different mechanism than its IR anticipated.** Several
> integration requirements were written when libmpv was expected to be the single > integration requirements were written when libmpv was expected to be the single
@@ -416,6 +420,32 @@ Internal architecture, components, and application logic.
| DR-222 | Build tooling matches the package manager the project declares. `scripts/build-android.sh` ran `npm install` on its clean-build path — in a bun project, where `packageManager` says bun and `bun.lock` is the committed lockfile. npm ignores that lockfile, re-resolves the whole tree from package.json, and writes a `package-lock.json` that `.gitignore` then hides. That is not a style preference: the JS halves of the Tauri plugins are pinned exactly against Cargo.lock because the CLI refuses to build when a plugin's crate and package differ by minor version, and a silent re-resolve is precisely how they drift apart. It survived because clean builds are rare — the shape shared by nearly every defect found preparing v0.10.0, where the code running on every commit was healthy and the code running on a release, a tag or a clean build had no guard at all. `scripts/check-tooling.sh` fails on any npm/yarn/pnpm invocation or foreign lockfile | Tooling | - | Done | | DR-222 | Build tooling matches the package manager the project declares. `scripts/build-android.sh` ran `npm install` on its clean-build path — in a bun project, where `packageManager` says bun and `bun.lock` is the committed lockfile. npm ignores that lockfile, re-resolves the whole tree from package.json, and writes a `package-lock.json` that `.gitignore` then hides. That is not a style preference: the JS halves of the Tauri plugins are pinned exactly against Cargo.lock because the CLI refuses to build when a plugin's crate and package differ by minor version, and a silent re-resolve is precisely how they drift apart. It survived because clean builds are rare — the shape shared by nearly every defect found preparing v0.10.0, where the code running on every commit was healthy and the code running on a release, a tag or a clean build had no guard at all. `scripts/check-tooling.sh` fails on any npm/yarn/pnpm invocation or foreign lockfile | Tooling | - | Done |
| DR-223 | The Android JavaVM and Application are published into `ndk_context` by this crate, not by a transitive dependency. Seven call sites (five in credentials.rs, two in lib.rs) read that process-global to reach JNI, and nothing here ever set it — `tao` did, three levels below anything this project names in Cargo.toml. tao 0.35.3 moved those pointers into a private struct and stopped publishing them, so the Tauri 2.11 upgrade made the first credential read abort the process on every launch: `PANIC ... android context was not initialized`. Our code had not changed; an undocumented side effect of the windowing layer had gone. The invariant is now owned here rather than assumed: `JNI_OnLoad` captures the JavaVM as the shared library loads, and the Application is resolved lazily via `ActivityThread.currentApplication()` and pinned as a global reference for the process lifetime — the Application rather than the Activity, since that is what `SecureStorage.initialize()` immediately reduces its argument to. Failure degrades to the encrypted-file credential path and is logged, rather than aborting. Found only by installing on a device: nothing in CI runs the app | Security | UR-012 | Done | | DR-223 | The Android JavaVM and Application are published into `ndk_context` by this crate, not by a transitive dependency. Seven call sites (five in credentials.rs, two in lib.rs) read that process-global to reach JNI, and nothing here ever set it — `tao` did, three levels below anything this project names in Cargo.toml. tao 0.35.3 moved those pointers into a private struct and stopped publishing them, so the Tauri 2.11 upgrade made the first credential read abort the process on every launch: `PANIC ... android context was not initialized`. Our code had not changed; an undocumented side effect of the windowing layer had gone. The invariant is now owned here rather than assumed: `JNI_OnLoad` captures the JavaVM as the shared library loads, and the Application is resolved lazily via `ActivityThread.currentApplication()` and pinned as a global reference for the process lifetime — the Application rather than the Activity, since that is what `SecureStorage.initialize()` immediately reduces its argument to. Failure degrades to the encrypted-file credential path and is logged, rather than aborting. Found only by installing on a device: nothing in CI runs the app | Security | UR-012 | Done |
| DR-224 | Backgrounding the app obeys the background-audio toggle on every renderer. The toggle (UR-040) was built for the WebView `<video>` path, where losing visibility kills the decode: it chose between handing off to a native audio stream and letting playback stop. Native video then became the default renderer (DR-188), and on that path playback runs through ExoPlayer inside a `MediaSessionService` — a foreground media service whose purpose is to keep playing while the app is hidden. Nothing paused it and nothing in the codebase paused on background, so locking the screen kept the audio going whether or not the toggle was on: the toggle governed a handoff that no longer had a gap to bridge, and users got background playback they never asked for. The decision now lives in Rust (`player/background_policy.rs`) and both renderers obey it: a video with the toggle off pauses, with the toggle on hands off to audio, music is never paused by backgrounding, and picture-in-picture keeps playing because the window is still on screen (UR-041). It takes no renderer parameter on purpose — the split between the two paths is what produced the defect | Player | UR-040 | Done | | DR-224 | Backgrounding the app obeys the background-audio toggle on every renderer. The toggle (UR-040) was built for the WebView `<video>` path, where losing visibility kills the decode: it chose between handing off to a native audio stream and letting playback stop. Native video then became the default renderer (DR-188), and on that path playback runs through ExoPlayer inside a `MediaSessionService` — a foreground media service whose purpose is to keep playing while the app is hidden. Nothing paused it and nothing in the codebase paused on background, so locking the screen kept the audio going whether or not the toggle was on: the toggle governed a handoff that no longer had a gap to bridge, and users got background playback they never asked for. The decision now lives in Rust (`player/background_policy.rs`) and both renderers obey it: a video with the toggle off pauses, with the toggle on hands off to audio, music is never paused by backgrounding, and picture-in-picture keeps playing because the window is still on screen (UR-041). It takes no renderer parameter on purpose — the split between the two paths is what produced the defect | Player | UR-040 | Done |
| DR-225 | `StreamSelection` replaces the bare URL returned for playback: URL, `Transport` (hls / progressive / localFile), `PlaybackKind` (directPlay / directStream / transcode), the negotiated `Rendition`, the ladder this source can offer, and a `needs_transcoding` flag derived in Rust so "which kinds count as transcoding" is answered once. Both enums are serde-tagged (`{"type":"hls"}`) so the frontend matches a discriminant rather than comparing text. The field that mattered most is `transport`: `VideoPlayer.svelte` chose its loader with `url.includes(".m3u8")` in two places, a domain fact reconstructed in the presentation layer — the same class of error as leaking item-type taxonomy, and one that fails silently in both directions (a progressive file served from a path containing the substring gets an HLS loader; a playlist served from one without it does not). The paths that never negotiate — a downloaded file, a live channel — get the same shape from Rust (`media_local_selection`, `LiveStreamInfo.transport`) rather than having the page assemble one, so there is no second place where a transport is decided | Playback | UR-079 | Done |
| DR-226 | The bandwidth ceiling is two-level: a durable device default (Settings, persisted, restored at startup) and a per-playback override the in-player picker sets. The picker's own documentation had called it a "this film, this connection" control since it was written, but it was implemented by writing the process-wide default — so dropping one awkward film to 2 Mbps silently capped every video played afterwards for the rest of the process, while the Settings screen still displayed the old value and nothing in the UI admitted the change. The override is cleared whenever playback moves to a new item, which is what keeps it from surviving into an autoplayed next episode where nobody would reopen the picker. `effective_streaming_quality()` is the single resolution point; every URL builder and the `PlaybackInfo` negotiation go through it, because a negotiation that authorises a direct play the URL builder then constrains (or the reverse) leaks the cap | Playback | UR-074, UR-079 | Done |
| DR-227 | The quality picker is filled from what *this* media source can offer, not from the fixed eight-rung enum. Rust marks each rung `exceeds_source` when its ceiling is at or above the source's own bitrate — such a rung produces the same bytes as `Original`, so offering it is another way to spell one choice — and the frontend simply does not draw those. `Original` is never marked (it *is* the source) and a source whose bitrate the server does not report (the sampled library has `avi` files with none) marks nothing redundant, keeping every rung offered, which is the safe direction. The picker also shows what the server is actually doing with the stream, which only became knowable once `PlaybackKind` existed. Labels and detail lines come from Rust beside the numbers they describe, so a relabelled rung cannot drift out of step with what it does | UI | UR-070, UR-079 | Done |
| DR-228 | Direct play and direct stream are negotiated rather than assumed away. `get_video_stream_url` always built an HLS transcode URL, so every video play burned server CPU even when the file would have played untouched. The decision now comes from `PlaybackInfo` under the device profile and the ceiling in force, with two client-side overrides applied on top because the server's answer is right about the *file* and wrong about what this app will do with it: undecodable audio (Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec but ignores its audio codec, so it offers direct play for an E-AC-3 track the webview renders in silence) and a viewer-pinned audio track the source file does not default to. Measured against the development server over a 400-item sample: **85% direct play on the Android profile, 7% on the Linux one** — the library is ~80% hevc and WebKitGTK can only claim h264, so the Linux figure is a property of the renderer, not of this code, and is what `linux-native-video-spike.md` exists to change. A direct *stream* is a remux and is deliberately not counted as transcoding | Playback | UR-079 | Done |
| DR-229 | Mid-playback re-negotiation on throughput was scoped and **dropped on measurement**. The premise — that hls.js gives this app real adaptive bitrate and mpv would lose it — does not hold: a master playlist from the development server carries exactly one `EXT-X-STREAM-INF`, because Jellyfin builds it from the single rendition the request asked for rather than publishing a ladder. There is no adaptation to preserve, so "adapt mid-stream" collapses into "pick well at open", which is what DR-225 and DR-226 already are. Recorded rather than deleted because the conclusion is a measurement, not an opinion, and a server that does publish a ladder would change it — the DR-224 re-negotiation path is the hook that work would build on | Playback | UR-079 | Won't Do |
| DR-230 | Every player backend consumes the same selection, proving the contract is player-agnostic rather than HTML5-shaped. The queue item carries the negotiated `transport`, so `player_seek_video` picks its seek strategy from the backend's own decision instead of the last `stream_url.contains(".m3u8")` in the codebase; items queued by a path that never negotiated (audio tracks, direct URLs) carry `None` and fall back to `needs_transcoding`, which is exact rather than a guess because every transcode this app requests is HLS (DR-140). The webview adapter's bridge carries the whole selection rather than a URL, so the component's HLS effect reads a tag instead of searching a string, and the background-audio handoff states the transport it is moving to (progressive mp3 out, HLS back) rather than leaving it to be inferred | Playback | UR-003, UR-004, UR-079 | Done |
| DR-231 | An mpv video backend that composites beneath the transparent webview, the desktop counterpart of the Android TextureView arrangement. mpv renders through its **render API** into an FBO the toolkit binds (`vo=libmpv` + `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO`), rather than by embedding a foreign window — which is what the 2024 "not possible on Wayland at all" conclusion was about and why it does not apply. On Linux that is a `GtkOverlay` with a `GtkGLArea` as main child and Tauri's own webview reparented as the overlay child; the mpv half is shared and only the surface differs per platform. Webview transparency alone suffices — no window-level transparency is used or needed | Playback | UR-080 | Proposed |
| DR-232 | The mpv render context's lifetime is bound to the GL context it draws into: created on `realize`, freed on `unrealize`, on the same thread, with the update callback unregistered *before* the free so a callback cannot land on a freed context. This is DR-184 on Android restated — a surface outliving its player — and it is a requirement in its own right rather than a fix for a specific crash. The spike observed one SIGSEGV in a decoder thread that three targeted soaks failed to reproduce; what is not in doubt is that the spike never called `mpv_render_context_free` and never tore down on `unrealize`, so nothing defended against the GL context being recreated underneath. Removing the likeliest cause is worth doing whether or not it was the cause | Playback | UR-080 | Proposed |
| DR-233 | Frame pacing goes through mpv's update callback, with `mpv_render_context_report_swap` after each render. Recorded as a requirement because the failure mode misleads: driving the widget's frame clock every tick without reporting the swap leaves mpv with nothing to time against, which looks fine in a window and **judders at fullscreen** — reading as a compositing or GPU limit and being neither | Playback | UR-080 | Proposed |
| DR-234 | The device profile is derived from the **renderer that will decode the stream**, not from a compile-time platform constant. `video_codecs` was `#[cfg(target_os)]`, which is correct only while a build has one video renderer; once mpv and the webview element coexist it must be runtime state. This is the change that converts the measured 7% desktop direct-play rate toward the 85% the Android profile achieves on the same library, because the two differ by nothing except which component decodes. It looks like configuration and is not — it is the input that decides whether the server re-encodes, and getting it wrong fails silently, a claimed codec the renderer cannot decode being a black picture or silence (DR-148, and DR-227's audio override). The webview's narrower *audio* set stops applying to the video path once mpv decodes it, while the multichannel bound still does, since a 5.1 track direct-played into a two-channel sink is silence or inaudible dialogue | Repository | UR-080, UR-070 | In Progress |
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Proposed |
| DR-236 | Hardware-decode policy is decided from what mpv reports it **selected** (`hwdec-current`), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that `auto` reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that `vaapi` fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, `auto` is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limit | Playback | UR-080 | Proposed |
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | Proposed |
| DR-238 | A transcoded seek re-negotiates the stream on every renderer, not just the webview. Jellyfin produces a transcode *from* `StartTimeTicks`, so where a seek lands is a property of the request rather than of the stream in hand. `determine_video_seek_strategy` treated `is_hls` as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer — it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make the server transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing and presented as "resume does not work". The rule is now written on `needs_transcoding` with hls.js as the stated exception; all four webview cells are unchanged | Player | UR-040 | Done |
| DR-239 | Properties the mpv event loop handles are registered with `observe_property`. libmpv delivers `PropertyChange` only for observed properties, so a `match` arm for an unobserved one is unreachable code that reads as implemented — the handler is right there. `pause` was handled and never observed, so `StateChanged` was never emitted on pause or resume and the play/pause control never moved. It stayed invisible while Linux video played in the webview, because the `<video>` element's own DOM events drove that control; native video made the UI depend on the event that never came | Player | UR-005 | Done |
| DR-240 | Fullscreen moves whatever actually owns the pixels. `requestFullscreen()` fullscreens the *document*, which sufficed while every renderer lived inside it — the HTML5 `<video>` element is part of the document, so WebKit scaled it and the OS window's real size never mattered. A native surface is drawn behind the webview at **window** size, so a document-only fullscreen expands the page and leaves the picture where it was; on WebKitGTK the result is a maximised window with decorations still holding a strip of the screen, which reads as "fullscreen is broken" rather than as a windowing problem. Android needed the same rule for the system bars (DR-157); this is its desktop half | Player | UR-066 | Done |
| DR-241 | A seek issued before MPV has a file to seek in is honoured, not dropped. `loadfile` returns as soon as the command is queued, so `time-pos` — a live property of the *loaded* file — does not resolve yet and setting it fails. The two callers that always hit that window are the ones a viewer notices: resume, and a transcoded seek, both of which re-open the stream and then ask for a position. The failed seek was discarded and the stream played from zero, which reads as "resume is broken" and "I cannot skip". The position is now held and applied by the `FileLoaded` handler; a seek that lands normally clears any deferred one, so the newer intent wins | Player | UR-040, UR-005 | Done |
| 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-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-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 |
--- ---
@@ -503,6 +533,8 @@ Internal architecture, components, and application logic.
| UR-076 | - | DR-209 | | UR-076 | - | DR-209 |
| UR-077 | - | DR-217 | | UR-077 | - | DR-217 |
| UR-078 | - | DR-218 | | UR-078 | - | DR-218 |
| UR-079 | - | DR-225, DR-226, DR-227, DR-228, DR-229, DR-230 |
| UR-080 | IR-033 | DR-231, DR-232, DR-233, DR-234, DR-235, DR-236, DR-237 |
--- ---
@@ -717,6 +749,17 @@ Internal architecture, components, and application logic.
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done | | UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
| UT-210 | Cosmetic-commit detection for release notes: a `chore(format)`, `chore(deps)` or `style` subject is skipped when deriving a range's changed files, while `fix`, `feat`, `ci`, `docs`, a bare `chore:` and `chore(release):` are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmetic | DR-219 | Done | | UT-210 | Cosmetic-commit detection for release notes: a `chore(format)`, `chore(deps)` or `style` subject is skipped when deriving a range's changed files, while `fix`, `feat`, `ci`, `docs`, a bare `chore:` and `chore(release):` are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmetic | DR-219 | Done |
| UT-211 | The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by renderer | DR-224 | Done | | UT-211 | The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by renderer | DR-224 | Done |
| UT-212 | The stream-selection contract. `Transport` and `PlaybackKind` each serialise to exactly the tag the frontend matches (`{"type":"hls"}`, `{"type":"directPlay"}`, …) and round-trip; nested `StreamSelection` fields are camelCase on the wire including `playbackKind`, `mediaSourceId` and `maxBitrate`; only `Transcode` counts as transcoding, so a direct stream does not; a local file is a direct play over a local transport with no ladder. The ladder: every rung at or above a 1.12 Mbps source is marked redundant while the three that constrain it are not, `Original` is never marked for any bitrate including zero and unknown, an unreported source bitrate keeps all eight rungs offered, a 40 Mbps source marks none, and each option carries the ladder's own label and detail | DR-224, DR-226 | Done |
| UT-213 | The direct-play negotiation, one test per branch, against `PlaybackInfo` fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as *not* transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that default | DR-225, DR-227 | Done |
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Done |
| UT-215 | Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promise | DR-013 | Done |
| UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Done |
| UT-217 | A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first time | DR-238 | Done |
| 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 |
### Integration Tests ### Integration Tests
@@ -737,6 +780,7 @@ Internal architecture, components, and application logic.
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending | | IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done | | IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done | | IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
| IT-018 | The conformance cases run against ExoPlayer on a device: opening from the beginning and at a position, a seek issued while still preparing, a seek after open, pause and play observable, stop silent and idempotent, and a load cancelled by stop never playing. The fixture is a silent WAV synthesised at setup, so the repo carries no media and the duration is exact | DR-247 | Done |
--- ---
+4 -4
View File
@@ -28,7 +28,7 @@ know how something *works*, read
**Next free requirement ids** (always re-check **Next free requirement ids** (always re-check
[requirements.md](../requirements.md) before allocating): **UR-079**, [requirements.md](../requirements.md) before allocating): **UR-079**,
**IR-033**, **DR-225**. Three specs below suggested ids that have since been **IR-033**, **DR-232**. Three specs below suggested ids that have since been
taken by other work; each carries a ⚠️ note at the top. taken by other work; each carries a ⚠️ note at the top.
## Partially implemented ## Partially implemented
@@ -37,18 +37,18 @@ taken by other work; each carries a ⚠️ note at the top.
|---|---|---| |---|---|---|
| [frontend-domain-model.md](frontend-domain-model.md) | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag``imageId` (~30 sites); player/session/reporting tick math; `stream.type` | | [frontend-domain-model.md](frontend-domain-model.md) | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag``imageId` (~30 sites); player/session/reporting tick math; `stream.type` |
| [libmpv2-migration.md](libmpv2-migration.md) | `LICENSE` | The `libmpv``libmpv2` crate swap | | [libmpv2-migration.md](libmpv2-migration.md) | `LICENSE` | The `libmpv``libmpv2` crate swap |
| [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-121/122/124/125 — the player quality selector and the read-through capture | | [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-122/124/125 — the read-through capture. DR-121 shipped as backend-owned stream selection and left this spec |
| [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md) | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`) | | [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md) | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`) |
## Not started ## Not started
| Spec | Blocked on / note | | Spec | Blocked on / note |
|---|---| |---|---|
| [backend-owned-stream-selection.md](backend-owned-stream-selection.md) | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Phase 1 (delete the `.m3u8` sniff) stands alone. Unblocks Linux native video. | | [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. |
| [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. |
| [linux-native-video-spike.md](linux-native-video-spike.md) | **Spike run 2026-08-21: compositing works on Linux, X11 and Wayland.** G1-G6 green bar the Tauri `default_vbox()` half of G1. Needs an implementation spec that answers adaptive bitrate. | | [linux-native-video-spike.md](linux-native-video-spike.md) | **Spike run 2026-08-21: compositing works on Linux, X11 and Wayland.** G1-G6 green bar the Tauri `default_vbox()` half of G1. The adaptive-bitrate question it was waiting on is **answered**: the server publishes one `EXT-X-STREAM-INF`, so there is no ladder for mpv to lose (DR-229). `StreamSelection` (DR-225) is the contract to consume. |
## Design authority ## Design authority
@@ -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 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.
+423
View File
@@ -0,0 +1,423 @@
# Spec: Desktop native video — mpv renders the picture, everywhere
**Status:** Proposed
**Requirements:** UR-080 (new) → DR-231 … DR-237 (new); IR-033 (new)
**UX spec:** n/a — nothing about the player's appearance changes. What changes is
what is behind the controls.
**Supersedes / revises:** consumes and closes
[linux-native-video-spike.md](linux-native-video-spike.md), whose gates
authorised exactly this spec and nothing more. Settles finding 2 of
[playback-backend-unification.md](playback-backend-unification.md) on the
desktop; finding 3 was already settled by DR-229. Absorbs the video half of what
[windows-native-audio-backend.md](windows-native-audio-backend.md) leaves open.
**Depends on:** backend-owned stream selection (DR-225 … DR-230), the branch
below this one. mpv is a *consumer* of `StreamSelection`, never a second place to
decide what to play.
**Destination on completion:**
[05-platform-backends.md](../architecture/05-platform-backends.md) — a "Native
Video Compositing (Desktop)" section beside the existing Android one, which this
mirrors; and [01-rust-backend.md](../architecture/01-rust-backend.md) — the
device profile becomes renderer-dependent, beside the stream-selection section.
**The spike is deleted in the same commit**, its three traps and its
hardware-decode table folded in; they are the durable half.
## Summary
mpv decodes and draws video on **every desktop platform**, composited beneath the
transparent webview, exactly as Android already does with ExoPlayer. The HTML5
`<video>` path and hls.js are then **deleted**, not merely bypassed.
The user-visible change is that most video stops being re-encoded by the server
before it can be watched. The change for whoever maintains this is that video
goes from three renderers to two.
## Motivation
### The transcode is a decoder constraint, not a rendering one
Desktop video goes through an h264 HLS transcode because the picture is drawn by
a WebKitGTK `<video>` element, and that element decodes little else. The device
profile therefore claims `h264` alone. That is not a statement about the machine
— the same machine runs mpv, which decodes essentially everything in the library
— it is a statement about which widget is holding the frame.
DR-228 made the cost measurable. Over 40 items negotiated against the development
server:
| Profile | Direct play |
|---|---|
| Desktop / WebKitGTK — `h264` only, 2ch | **7%** |
| Android / ExoPlayer — `h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch | **85%** |
The sampled library is ~80% hevc. **Those rows differ only by which component
decodes.**
Moving the picture to mpv is what lets the desktop row claim what the machine
can actually do, and that — not the compositing — is the product.
> **The 85% is a ceiling, not a shipped result.** It was measured with a profile
> containing `ac3,eac3`. The Android device later used for verification reports
> neither in its `MediaCodecList` — no Dolby licence, normal for a tablet — so
> eac3 content, about a third of the sampled library, correctly transcodes there.
> Realising any of this depends on DR-234, deriving the profile from the renderer
> rather than from the platform, which is why that requirement is load-bearing
> and not tidy-up.
### One desktop video path, not two
This is why the spec covers Windows rather than stopping at Linux.
Today video has **three** renderers: ExoPlayer, the WebKitGTK `<video>` element,
and (on Android, via the opt-out) that same element again. A Linux-only version
of this work would make it four, permanently: mpv on Linux, HTML5 on Windows,
ExoPlayer on Android, plus hls.js underneath the HTML5 one. Every seek strategy,
every track switch, every quality change, every lifecycle bug would then have one
more place to be got right — and the HTML5 path would survive indefinitely
because *something* would still need it.
Finishing the job removes that: **mpv on desktop, ExoPlayer on Android**, and
`hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the webview video element all
go. The maintenance win is the reason Windows is in this spec and not in a
follow-up that never gets written.
### Three blockers are gone
1. **Compositing works, including Wayland.** The spike ran all six gates; the
2024 "not possible on Wayland at all" claim is out of date when the render API
is used instead of foreign-window embedding.
2. **There is no ABR to lose.** DR-229: the server's master playlist carries one
`EXT-X-STREAM-INF`. hls.js was demuxing, not adapting.
3. **A direct-play path exists.** It did not when the spike was written. DR-228
built it; DR-230 proved the contract is player-agnostic.
And on Windows specifically, `tauri-plugin-libmpv` lists Windows as its **fully
tested** platform — the inverse of the Linux situation the spike had to
disprove. The embedding difficulty was always WebKitGTK-specific.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| **Which codecs this device can decode** | **Rust** | Domain: it is the input to Jellyfin's `PlaybackInfo` negotiation. It stops being a property of the *platform* and becomes a property of *the renderer in use* — see "The structural change". |
| Which backend renders video | **Rust** | Rust already owns this (`use_html5_element` / `VideoBackend`). It stops being a `cfg!` constant and becomes a runtime fact. |
| What stream to play (direct / remux / transcode, transport, ceiling) | **Rust — already decided** | DR-225. mpv consumes `StreamSelection`. Re-deriving any of it in a new backend would be the defect DR-225 exists to remove, restated. |
| Creating the GL surface, reparenting the webview, owning the render context | **Rust (platform layer)** | Native window and GL-context lifetime. Not presentation, and not expressible above the IPC boundary at all. |
| Render-context ↔ GL-context lifetime binding | **Rust** | A correctness invariant over native resources. DR-232. |
| Frame pacing (update callback, `report_swap`) | **Rust** | Timing against the compositor; mpv's own contract. |
| Hardware-decode selection | **Rust** | A capability question about the machine, answered from what mpv reports it actually selected. |
| Z-order of controls over video, overlay chrome, letterbox colour | **Frontend / mpv** | Presentation. Controls already draw over a transparent webview on Android; mpv paints its own letterbox bars (better than the Android equivalent, which shipped DR-194 as a defect). |
| Whether the surface is visible right now | **Frontend** | `nativeVideoActive` already exists and toggles `data-native-video`. Unchanged. |
### The structural change
Everything above is routine except one row, and it carries the whole benefit.
`video_codecs` in `build_device_profile` is a **compile-time constant per
platform**:
```rust
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
let (video_codecs, audio_codecs) = ("h264".to_string(), "aac,mp3,opus,…");
```
That is correct only while a build has exactly one video renderer. It must be
derived from **which renderer will decode this stream**, which is runtime state.
It looks like configuration and is not: it is the input that decides whether the
server re-encodes, it changes when Jellyfin's API or our renderer changes, and
getting it wrong fails *silently* — a claimed codec the renderer cannot decode is
a black picture or silence, which is DR-148 and DR-228's audio override already.
**Write this against "the active video renderer", never `cfg!(target_os)`.** It
is the single piece that must not be Linux-shaped, because phase 2 reuses it
unchanged.
## Design
### Backend and compositing (DR-231, IR-033)
An `MpvVideoBackend` beside the existing `MpvBackend` (audio). The mpv side —
render context, FBO, update callback, hwdec — is **shared**; only the surface
differs per platform:
| Platform | Surface | Status |
|---|---|---|
| Linux (X11 + Wayland) | `gdk_cairo_draw_from_gl()` in the default vbox's `draw` handler, over a `GdkGLContext` on its `GdkWindow`. No reparenting — see below | Render path proven by the spike; the *overlay* approach it used is rejected |
| Windows | Native HWND child beneath a transparent WebView2 | Phase 2 |
`vo=libmpv` plus `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO`.
Webview transparency via `with_transparent(true)` — no window-level transparency;
the spike showed it is neither used nor needed.
**G1's untested half failed, and the design changed because of it.**
Reparenting Tauri's webview into a `GtkOverlay` attaches cleanly and then aborts
the process on the first click. `tauri-runtime-wry` connects a
button-press handler to the webview that walks a hard-coded path:
```rust
webview.parent() // "This one should be GtkBox"
.parent() // ...and this one the GtkWindow
.downcast::<gtk::Window>().unwrap()
```
An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast fails,
and the panic is non-unwinding so it kills the app. Nothing in configuration
avoids it: on Linux `attach_resize_handler` is called **unconditionally** (the
Windows equivalent is guarded by `is_decorated()`), and the decoration check that
would make the handler inert runs *after* the unwrap.
**So the webview is not moved at all.** mpv draws into the *default vbox's own
`draw` handler* instead, via `gdk_cairo_draw_from_gl()` over a `GdkGLContext`
created on that widget's `GdkWindow`. GTK3 draws a container before its children,
so the webview composites on top for free — the same z-order the overlay was for,
without touching the widget tree Tauri walks.
That is strictly better than the overlay it replaces: no reparent, no extra
widget, and the arrangement cannot be broken by a Tauri upgrade that assumes its
own layout. It is also why "the surface attached successfully" is not the gate —
a click is.
Three traps from the spike, each of which cost a debugging cycle and each of
which looks like a platform limitation and is not:
1. **`LC_NUMERIC` must be reset *after* `gtk::init()`.** mpv refuses to start
under a non-C numeric locale. `mpv_backend.rs` already handles this but has no
GTK init in front of it; here `gtk::init()` applies the user's locale
afterwards and `mpv_create` returns null.
2. **libepoxy exports GL entry points as *data* symbols.** There is no `glFoo`
function — there is `epoxy_glFoo`, a variable holding a lazily-resolving
pointer. `get_proc_address` must return the pointer **stored at** that symbol;
returning the symbol's own address makes mpv jump into non-executable data and
take SIGSEGV on the first GL call. The `epoxy` crate does this correctly but is
unusable — its `gl_generator` dependency pulls a yanked `xml-rs`.
3. **Frame pacing is not optional and its symptom misleads.** See DR-233.
### Render-context lifetime (DR-232) — the crash defence
The spike's one unexplained SIGSEGV landed in a *decoder* thread with no Tauri,
GTK or GL frame in the stack, and three plausible causes failed to reproduce it
across ~13 minutes of targeted stress.
What is **not** unexplained is that the spike had no defence: it never calls
`mpv_render_context_free` and never tears down on `unrealize`, so nothing stopped
the GL context being recreated beneath the render context. That is DR-184 on
Android restated — a surface outliving its player.
Built as a requirement in its own right, not as a fix for a crash we cannot yet
reproduce:
- Render context created on `realize`, freed on `unrealize`, same thread, before
the GL context goes away.
- The update callback is unregistered **before** the context is freed, so a
callback cannot land on a freed context.
- Playback teardown and surface teardown are ordered, not racing.
If the crash recurs after this, it is a different bug and the likeliest cause is
out of the search space. If it does not, we needed this anyway.
### Frame pacing (DR-233)
Register `mpv_render_context_set_update_callback`; redraw only when it reports a
frame ready; call `mpv_render_context_report_swap` after each render.
Recorded because the failure mode is a trap: driving `queue_render()` off the
frame clock every tick without reporting the swap leaves mpv nothing to time
against. It looks fine in a window and **judders at fullscreen**, which reads as
a compositing or GPU limit and is neither.
### Renderer-dependent device profile (DR-234)
`build_device_profile` takes the active video renderer and derives the codec
lists from it:
| Renderer | Video codecs | Audio (video direct play) | Channels |
|---|---|---|---|
| mpv (desktop native) | `h264,hevc,vp8,vp9,av1,mpeg4` | platform list incl. `ac3,eac3` where the sink can voice it | from the audio route |
| WebKitGTK `<video>` | `h264` | webview-decodable set only | 2 |
| ExoPlayer (Android) | unchanged | unchanged | unchanged |
The existing `video_audio_codecs()` narrowing exists because *the webview decodes
a narrower audio set than the platform*. With mpv decoding, that no longer
applies to the video path — but the multichannel bound still does, since a 5.1
track direct-played into a 2-channel sink is silence or inaudible dialogue. Both
constraints stay, sourced from the renderer rather than assumed.
**This is what converts the 7% figure upward** (toward, not necessarily to, the 85% ceiling — see the caveat above), and it is also the change most able to break
playback silently — so it lands after compositing is proven, covered by the
DR-228 override tests.
### Deleting the webview video path (DR-235)
`get_player_status` stops reporting `use_html5_element: true` on desktop;
`supports_native_video` becomes true there.
Deletion is staged, because a path cannot be removed while a shipped platform
still needs it:
| Phase | Linux | Windows | HTML5 video path |
|---|---|---|---|
| 1 | mpv | HTML5 | alive — Windows needs it |
| 2 | mpv | mpv | alive but unreached |
| 3 | mpv | mpv | **deleted**, with hls.js |
Phase 3 is a real phase with its own acceptance criterion, not a "later". The
whole maintenance argument for including Windows collapses if the fork survives.
Android keeps ExoPlayer and keeps the webview as its documented opt-out; the
`<audio>` element and the background-audio handoff are untouched throughout.
**What happens when mpv fails to initialise.** With no HTML5 path there is no
silent fallback, and inventing one resurrects what we deleted. The
graceful-backend-init principle applies as written: fall back to the no-op
backend, emit `backend-init-failed`, and surface a real error rather than a black
rectangle. An honest failure beats a hidden downgrade to the transcode we are
trying to stop paying for.
### Hardware decode (DR-236)
The spike established the load-bearing fact: **hardware decode works through the
render API** (`hwdec-current` reported `nvdec-copy` on the discrete GPU), so the
direct-play prize is not traded for software decoding.
Policy is decided from what mpv reports it *selected*, never from what it was
asked for:
- Prefer zero-copy VA-API on the integrated GPU where the driver is present.
- `auto` reached for the discrete GPU in **copy-back** mode on a hybrid
Intel+NVIDIA laptop — the least efficient hardware path — so `auto` is a
fallback, not the default.
- `vaapi` silently fell back to software on the spike box because `vainfo` was
absent. A missing driver must be detected and logged, not mistaken for a
compositing limit.
- Log `hwdec-current` at start-up; knowing what was actually chosen is the whole
diagnostic value.
### Windows: what phase 2 actually costs (DR-237)
Not hidden, because it is the part most likely to be underestimated:
- **The surface is different code.** WebView2 in an HWND, not GTK. A transparent
WebView2 over a native child window is a solved arrangement, but DR-231's
Linux surface does not transfer. Everything else does.
- **libmpv is currently a Linux-only dependency**, and Windows is
**cross-compiled from Linux** via `x86_64-pc-windows-msvc` + `cargo-xwin`. Phase
2 must source a Windows libmpv (DLL + import library) into that cross-build and
ship the DLL in the NSIS bundle.
- **LGPL obligations follow the DLL.** DR-216 already records them for Linux:
keep the linkage dynamic, ship libmpv's licence text with any bundle carrying
it. The Windows bundle inherits both.
- **`bun run test:rust` and CI must still build.** Per the CI rule, any tool this
needs goes into the builder image and is pushed — never installed at job time.
Windows also gains a native *audio* decoder as a side effect, which is what
[windows-native-audio-backend.md](windows-native-audio-backend.md) wants and
cannot currently have. If that spec lands first, phase 2 inherits its build work
and shrinks to the surface.
## Out of scope
- **Android.** Unchanged in every respect.
- **macOS.** Not a shipped target. If it becomes one it joins phase 2's shape.
- **Audio backends.** mpv already plays audio on Linux; this adds a video
renderer beside it. Windows audio is its own spec.
- **HDR, tone mapping, multi-window.** Not exercised by the spike at all.
- **Re-deciding what stream to play.** DR-225 owns that. If this spec finds
itself choosing a URL, something has gone wrong.
## Acceptance criteria
**Phase 1 — Linux**
- [ ] Tauri's own webview reparents into the overlay (the untested half of G1),
on X11 **and** Wayland.
- [ ] Video plays, seeks and switches audio track in mpv, with the Svelte
controls composited over it and alpha blending intact.
- [ ] The render context is freed on `unrealize` and the update callback
unregistered before the free; a test demonstrates the ordering.
- [ ] A direct-play negotiation returns `DirectPlay` for an hevc source that
today returns `Transcode`, and it plays.
- [ ] Direct-play rate over the same 40-item sample rises from 7% toward the
Android figure. **Record the number.**
- [ ] mpv init failure emits `backend-init-failed` and surfaces an error rather
than falling back to a transcode.
- [ ] `hwdec-current` is logged and is not copy-back where zero-copy is available.
- [ ] A soak covering seek, track switch and fullscreen runs clean for an agreed
duration. **The spike's SIGSEGV is why this is a criterion.**
**Phase 2 — Windows**
- [ ] libmpv links in the `cargo-xwin` cross-build; the DLL and its licence ship
in the NSIS bundle; any new tool lives in the builder image, not in a CI step.
- [ ] Video plays composited under a transparent WebView2.
- [ ] The device profile, lifetime and hwdec code are **reused, not
reimplemented** — a reviewer confirms no `cfg!(target_os = "linux")` guards
them.
**Phase 3 — deletion**
- [ ] `use_html5_element` is false on every desktop platform.
- [ ] `hls.js` is gone from `package.json`; `html5Adapter.ts`, `videoLoaderFor`
and the `<video>` element are deleted; Android's opt-out and the
background-audio `<audio>` path still work.
**Throughout**
- [ ] `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 a reviewer confirms no stream decision
was reconstructed in the new backend.
- [ ] `bindings.ts` regenerated from Rust.
- [ ] `bun run traces:validate` passes; coverage stays ≥ the CI ratchet.
- [ ] The spike and this spec are folded into
[05-platform-backends.md](../architecture/05-platform-backends.md) and both
deleted in the same commit.
## Testing
- **Rust, pure:** the device profile per renderer — mpv claims hevc, the webview
does not, the multichannel bound survives both. The DR-234 table as a
table-driven test.
- **Rust, pure:** `PlaybackInfo` fixtures that transcode under the webview
profile and direct-play under the mpv profile — the direct-play conversion as a unit
test, not only as a measurement.
- **Rust:** teardown ordering — callback unregistered before context freed, freed
before GL context destroyed. Structure it so the ordering is assertable without
a live GL context.
- **Frontend:** no desktop path selects an HTML5 video adapter. After phase 3,
the adapter does not exist and the test goes with it.
- **Manual / soak:** the criterion above. The spike's automated fullscreen and
resize soaks are reusable and already written.
## TRACES
| Piece | Tag |
|---|---|
| mpv video backend + compositing | `UR-080 \| DR-231, IR-033` |
| Render-context lifetime binding | `UR-080 \| DR-232` |
| Frame pacing | `UR-080 \| DR-233` |
| Renderer-dependent device profile | `UR-080, UR-070 \| DR-234` |
| Webview video path removed | `UR-080 \| DR-235` |
| Hardware-decode policy | `UR-080 \| DR-236` |
| Windows surface + cross-build | `UR-080 \| DR-237` |
## Notes for the implementer
- **Read the spike before writing a line.** Its three traps and its
hardware-decode table are the most valuable things in this directory, and each
cost a debugging cycle to find.
- **mpv consumes `StreamSelection`; it does not decide.** The transport is on the
queue item (DR-230). If you are parsing a URL, stop.
- **Guard nothing on `cfg!(target_os = "linux")` that phase 2 will need.** That is
the one avoidable mistake here.
- The Android backend is the reference for the *shape* of this — transparent
webview over a native surface at index 0. Read `05-platform-backends.md`'s
Android section for what shipped and what its defects were (DR-184 surface
lifetime, DR-194 letterbox).
- Do not call sync/blocking APIs from mpv event callbacks that can re-enter the
player or hold a lock. The existing deadlock gotchas apply.
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes.
- This branch is stacked on backend-owned stream selection. Rebase when that
merges rather than merging master into it.
+52 -18
View File
@@ -2,7 +2,10 @@
**Status:** **Run 2026-08-21 — compositing works; G5 carries an open crash.** **Status:** **Run 2026-08-21 — compositing works; G5 carries an open crash.**
The compositing claim it set out to test is falsified on Linux. See "Result". The compositing claim it set out to test is falsified on Linux. See "Result".
This file stays open until the implementation spec exists; ABR is unresolved. This file stays open until the implementation spec exists. **ABR is resolved**
the playlist carries one `EXT-X-STREAM-INF`, so finding 3 is false and there is
no adaptation for mpv to lose. The remaining blocker is the unexplained SIGSEGV
under G5, which is a lifetime problem, not a compositing one.
**Requirements:** none allocated. This spike produces a decision record, not **Requirements:** none allocated. This spike produces a decision record, not
product code — same shape as product code — same shape as
[playback-backend-unification.md](playback-backend-unification.md), which is [playback-backend-unification.md](playback-backend-unification.md), which is
@@ -258,10 +261,10 @@ anything.
Tauri's existing webview into an overlay. Low risk — the same widgets, one Tauri's existing webview into an overlay. Low risk — the same widgets, one
extra reparent — but unproven, and it is the only place Tauri-specific extra reparent — but unproven, and it is the only place Tauri-specific
behaviour could still bite. behaviour could still bite.
- 🔴 **ABR — finding 3's premise is in doubt.** Finding 3 says mpv would regress - **ABR — resolved. Finding 3's premise is false.** Finding 3 said mpv would
streaming quality because "the webview path already has real ABR via hls.js". regress streaming quality because "the webview path already has real ABR via
Three pieces of evidence in this repo suggest that is **not true of the URLs we hls.js". Three pieces of evidence in this repo suggested that is **not true of
actually build**: the URLs we actually build**:
1. `get_video_stream_url` (`repository/online.rs`) requests a *single* 1. `get_video_stream_url` (`repository/online.rs`) requests a *single*
rendition — one `VideoBitrate`, one `MaxStreamingBitrate`, one `MaxHeight`. rendition — one `VideoBitrate`, one `MaxStreamingBitrate`, one `MaxHeight`.
@@ -276,21 +279,52 @@ anything.
audio-track switch)". Manual selection by stream re-open is what you build audio-track switch)". Manual selection by stream re-open is what you build
when there is no adaptation, and mpv can do the same thing. when there is no adaptation, and mpv can do the same thing.
**The decisive test has not been run** and needs a live server plus an API key: **The decisive test has now been run** (2026-08-21, against the development
count `#EXT-X-STREAM-INF` lines in a real `master.m3u8`. One line means there server, Jellyfin 10.11.5):
is no ABR to lose and this blocker disappears. More than one means finding 3
stands and the work below applies.
If ABR does turn out to be real, it belongs in **Rust**, not in mpv, and there ```
are three designs in increasing cost: pick the variant at open; re-open at a curl -s ".../Videos/<itemId>/master.m3u8?…&TranscodingProtocol=hls&…" \
new bitrate on sustained throughput drops (this is the quality-switch path the | grep -c EXT-X-STREAM-INF
app already has, so it is nearly free); or run a local proxy serving mpv a 1
synthesized single-variant playlist while swapping renditions underneath. The ```
middle option is almost certainly sufficient.
Either way the **direct-play path still does not exist** — every video play **One line.** The playlist carries a single `EXT-X-STREAM-INF` plus an
currently goes through the HLS transcode endpoint. Building it is the real `EXT-X-IMAGE-STREAM-INF` trickplay entry, which is not a rendition. Jellyfin
project; the compositing work proven above is the smaller half. builds the master playlist from the rendition the request asked for; it does
not publish a ladder. So **there is no ABR to lose, and this blocker is
closed** — hls.js is serving as an HLS demuxer, exactly as (2) above supposed,
and mpv gives up nothing by replacing it.
Recorded as DR-229 (Won't Do) rather than deleted, because it is a
measurement: a server that *does* publish a ladder would change the answer, and
the re-negotiation path is the hook that work would build on.
**The direct-play path now exists.** It did not when this spike was written —
every video play went through the HLS transcode endpoint. Backend-owned stream
selection (DR-225 … DR-230) built it: Rust negotiates direct play / direct
stream / transcode and hands every backend one `StreamSelection` carrying the
URL, the transport and the chosen rendition. **That is the contract this
implementation consumes** — mpv is a consumer of a decision already made, not a
place to re-derive it.
It also sizes the prize precisely. Measured over the same server, 40 items
through a real negotiation per profile:
| Profile | Direct play |
|---|---|
| Linux / WebKitGTK — `h264` only, 2ch | **7%** |
| Android / ExoPlayer — `h264,hevc,vp8,vp9,av1,mpeg4` + `ac3,eac3`, 6ch | **85%** |
**The 85% is a ceiling, not a shipped result** — it was measured with a
profile containing `ac3,eac3`, which the Android device later used for
verification does not support.
The library sampled is ~80% hevc. Linux sits at 7% **solely because the
WebKitGTK profile can only claim h264** — not because of anything about the
server or the negotiation. mpv decodes hevc, so widening the Linux device
profile once mpv renders the picture is what converts that 7% toward the
Android figure. That conversion is the actual product of this work; the
compositing proven above is the mechanism that permits it.
- 🔴 **One unexplained SIGSEGV.** A ~180s - 🔴 **One unexplained SIGSEGV.** A ~180s
run died in a *decoder* thread (libavcodec -> `av_log` -> libmpv's log handler run died in a *decoder* thread (libavcodec -> `av_log` -> libmpv's log handler
-> libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the -> libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the
+324
View File
@@ -0,0 +1,324 @@
# 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).
**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.
**UX spec:** n/a — no user-visible change is intended. That is the point.
**Supersedes / revises:** absorbs `determine_video_seek_strategy`
(`player/seek.rs`, DR-238) into the engines. Revises the backend half of
[playback-backend-unification.md](playback-backend-unification.md).
**Destination on completion:**
[01-rust-backend.md](../architecture/01-rust-backend.md) — replaces the player
state-machine section; and
[05-platform-backends.md](../architecture/05-platform-backends.md) — the engines
become implementations of a stated contract rather than three separate designs.
## Summary
Replace the `PlayerBackend` trait with a `MediaPlayer` contract that expresses
**intent** ("present this item, starting here") rather than **device operations**
("load", then "seek"). MPV, ExoPlayer and the webview element implement it; a
`FakePlayer` implements it for tests; and one conformance suite runs against
every implementation so a backend is either correct or visibly failing.
No user-visible behaviour changes. What changes is that playback logic stops
being written three times in the command layer.
## Motivation
A day of debugging Linux native video produced four defects (DR-238 … DR-241).
Every one of them traces to the same missing seam, not to mpv:
| Defect | What it looked like | What it was |
|---|---|---|
| DR-241 | "Resume is broken", "I cannot skip" | `loadfile` is async, so a seek issued straight after a load fails and was discarded. The trait has no way to say *open at a position*, so every caller does load-then-seek and each races independently. |
| DR-238 | Transcoded seeks silently did nothing | `use_html5` was doing double duty as "who renders" **and** "how do I seek", decided in the command layer by a truth table. |
| DR-239 | Play/pause control never moved | `PropertyChange { name: "pause" }` was handled but never observed. Nothing in the contract required an engine to report its own state. |
| DR-240 | Fullscreen left the picture at window size | `requestFullscreen()` moves the document; whoever owns the pixels has to be told separately. |
The shape is consistent: **the same intent implemented in several places, each
with its own timing and its own idea of the rules.** Resume worked through the
adapter (which seeks after `File loaded`) and failed through the command (which
seeks immediately). Two callers, one intent, two behaviours.
Supporting evidence for the diagnosis:
- `commands/player/mod.rs` is **3,561 lines** and is where "stop → rebuild URL →
update queue → load → seek" lives. That is playback orchestration in the IPC
layer.
- `player_play_item` needed a `#[cfg(not(target_os = "linux"))]` guard, i.e. a
platform decision in a command handler.
- The frontend carries `didStartNativePlayback`, `didStopBackendEarly`,
`hasPerformedInitialSeek`, `lastAppliedInitialPosition` — playback state in the
UI, which contradicts the one-directional rule in CLAUDE.md.
### Why an abstraction, and not more fixes
Each defect above was individually cheap to patch, and patching them is what
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 |
|---|---|---|
| Presenting an item at a position, in one operation | **Engine** (`MediaPlayer`) | Only the engine knows when its pipeline can accept a position. Expressing it as caller-sequenced load-then-seek exports a race the engine is the only one able to close. |
| Whether *this* stream can be seeked in place, or must be re-opened | **Engine** | A property of the engine × transport pair: hls.js seeks a VOD playlist, mpv's HLS demuxer cannot make Jellyfin transcode from a new offset. Today this is a truth table in a command handler that has to guess for engines it does not own. |
| Reporting position, phase, duration, active tracks | **Engine** | The player is the authoritative source of playback state (CLAUDE.md). An engine that does not report is not implementing the contract — DR-239 was exactly this. |
| Choosing *which* stream to open (direct play vs transcode, ceiling, transport) | **Rust, above the engine** | Domain: depends on Jellyfin's `PlaybackInfo`, codec support, quality ceiling. See [backend-owned-stream-selection.md](backend-owned-stream-selection.md). The engine is handed a `StreamSelection`; it never negotiates one. |
| Queue, autoplay, session, playback reporting | **`PlayerController`** | Policy across items. Unchanged — but it talks to one contract instead of branching per platform. |
| Which engine this platform uses | **Rust, at construction** | Already correct today; stays a single `cfg` at the composition root rather than `cfg`s scattered through command handlers. |
| Rendering surfaces, controls, fullscreen chrome | **Frontend / platform** | Presentation. The engine reports *what* is playing; it does not own the window. |
Borderline row and its tie-breaker: "should a transcoded seek re-open the
stream?" reads like domain policy. It is **engine** capability — the *decision*
is "seek to T", and how to achieve it is the engine's business. If it were
policy, every new engine would require editing a shared truth table, which is
precisely the coupling DR-238 came from.
## Design
### The contract
```rust
/// Anything that can present media: MpvPlayer, ExoPlayer, WebviewPlayer, FakePlayer.
pub trait MediaPlayer: Send {
/// Present `req.selection`, beginning at `req.start`.
///
/// One operation, deliberately. `open` is where a start position is
/// *expressible*, so no caller has to sequence load-then-seek and no caller
/// can race the engine's own load. An engine that cannot start at an offset
/// natively must absorb that internally (defer until loaded, or re-open) —
/// it is the only layer that knows when it is able to.
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>;
fn play(&mut self) -> Result<(), PlayerError>;
fn pause(&mut self) -> Result<(), PlayerError>;
/// Stop and release the current item. Must be idempotent, and must leave the
/// engine producing no audio — DR-2xx exists because "stopped" and "silent"
/// were not the same thing.
fn close(&mut self) -> Result<(), PlayerError>;
/// Seek to an absolute position on the item's timeline.
///
/// The engine decides in-place vs re-open. Callers never choose.
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
fn set_volume(&mut self, volume: Volume) -> Result<(), PlayerError>;
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>;
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
/// One coherent read of everything the UI consumes.
fn snapshot(&self) -> PlaybackSnapshot;
/// Engine capabilities, so callers can adapt without naming engines.
fn capabilities(&self) -> Capabilities;
}
```
```rust
pub struct OpenRequest {
pub media: MediaItem,
pub selection: StreamSelection, // url + transport + playback kind
pub start: Duration, // Duration::ZERO for "from the beginning"
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
pub autoplay: bool,
}
pub struct PlaybackSnapshot {
pub phase: Phase,
pub position: Duration,
pub duration: Option<Duration>,
pub seekable: bool,
pub volume: Volume,
pub rate: f64,
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
}
/// `Opening` is the state today's code cannot express, and the direct cause of
/// DR-241: a seek arriving with nothing loaded had no phase to be rejected or
/// queued against, so it was simply lost.
pub enum Phase { Idle, Opening, Ready, Playing, Paused, Ended, Failed(String) }
```
Engines emit `PlayerEvent` for phase, position, track and error changes. Emitting
is part of the contract, and the conformance suite asserts it — an engine that
stays silent fails, which is what would have caught DR-239 the day it landed.
### What this deletes
- `determine_video_seek_strategy` and `VideoSeekStrategy` — replaced by
`seek()` + `capabilities()`. The command layer stops deciding how engines seek.
- The reload orchestration in `player_seek_video` — moves inside the engines that
need it.
- `#[cfg(target_os = "linux")]` branches in command handlers.
- Frontend playback-state flags, which become reads of `snapshot()`.
### IPC
No new commands. Existing ones keep their names and shapes; they become thin
delegations. `PlayerStatus` gains nothing the frontend does not already receive.
Regenerate `bindings.ts` only if `PlaybackSnapshot` is exposed directly — prefer
mapping it onto the existing `PlayerStatus` so this stays invisible at the wire.
## Testing
This is the half that makes the abstraction worth having, and it is the reason to
do it rather than keep patching.
### 1. A conformance suite, run against every engine
One set of tests, parameterised over implementations. Any `MediaPlayer` must pass
it; a new engine is "done" when it does.
```
conformance::run(&mut engine, fixture) covering:
open(start = ZERO) -> phase Ready|Playing, position ~0
open(start = 10min) -> position within tolerance of 10min, NEVER 0 [DR-241]
seek while Opening -> honoured once Ready, not discarded [DR-241]
seek on a transcoded stream -> position lands, by whatever means [DR-238]
pause / play -> phase changes AND an event is emitted [DR-239]
close -> phase Idle, silent, idempotent
close during Opening -> no playback ever starts [audio-on-exit]
volume / rate / track select -> reflected in snapshot()
```
The `open(start = 10min)` and `seek while Opening` cases are the ones that fail
on today's code. They are written first, and they are the acceptance criterion.
### 2. `FakePlayer`
A deterministic in-memory implementation with a controllable clock. Lets
`PlayerController`, autoplay, queue, sleep-timer and session logic be tested with
no mpv, no device, no network — most of which is currently only reachable through
a real engine.
### 3. Per-engine runs
| Engine | Where | Note |
|---|---|---|
| `FakePlayer` | `cargo test` | Always. |
| `MpvPlayer` | `cargo test`, Linux | libmpv is already in the builder image (the Linux build links it), so **no CI toolchain install** — see CLAUDE.md. Needs a tiny local fixture file; generate it in-test rather than committing media. |
| `ExoPlayer` | instrumented, on device | Not in the standard CI job. Run via `scripts/` on a connected device; record results in the PR. |
| `WebviewPlayer` | vitest | Against a stubbed element, as `html5Adapter` is tested today. |
An engine that cannot run in CI still has the same suite; it is just run by hand.
That is the point of writing it once.
## Migration
Strangler, not a rewrite. Each step ships independently and leaves the app working.
1. **DR-242** Define `MediaPlayer`, `OpenRequest`, `PlaybackSnapshot`, `Phase`,
`Capabilities`. No implementations. Compiles alongside `PlayerBackend`.
2. **DR-243** `FakePlayer` + the conformance suite. The suite fails against
nothing yet — it is the specification.
3. **DR-244** `MpvPlayer` implementing `MediaPlayer`, wrapping today's
`MpvBackend` internals. Make conformance pass, including `open(start)`.
4. **DR-245** `PlayerController` talks to `MediaPlayer`. `PlayerBackend` retained
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.
Steps 13 are pure addition and risk nothing. Step 5 is where today's defect
classes actually die.
## Out of scope
- Stream selection (which URL, which quality) — that is
[backend-owned-stream-selection.md](backend-owned-stream-selection.md), and
this spec consumes its `StreamSelection` rather than duplicating it.
- Rendering surfaces and compositing.
- Any user-visible behaviour change. If one appears, it is a bug in the migration.
- Replacing hls.js or changing the transcode path.
## Acceptance criteria
- [ ] The conformance suite exists and `open(start = 10min)` fails against the
pre-migration mpv path — proving it reproduces DR-241 — then passes.
- [ ] `FakePlayer` lets at least one controller-level test run with no engine.
- [ ] `determine_video_seek_strategy` is deleted, not merely bypassed.
- [ ] No `cfg(target_os = ...)` remains in `commands/player/`.
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
- [ ] `cargo fmt`, `cargo clippy -D warnings`, `bun run test:rust` pass.
- [ ] `bun run check:boundary` passes.
- [ ] `// TRACES:` on new code; `bun run traces:validate` passes; coverage stays
at or above the CI ratchet.
- [ ] Manual: resume, skip on a transcoded item, pause/play, and exit-while-playing
verified on Linux **and** Android before `PlayerBackend` is deleted.
## Notes for the implementer
- **Write the conformance suite before the second engine**, or it will encode
whatever the first engine happens to do.
- `close()` must mean *silent*. The bug that motivated this spec had `stop` being
called, reported, and audible afterwards.
- Do not let `Capabilities` grow into engine sniffing. If a caller branches on
the engine's identity, the contract is missing something — add it there.
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes.
+22 -26
View File
@@ -4,15 +4,20 @@
(DR-126, DR-127 — a cache entry *is* a `downloads` row with a shorter life, and (DR-126, DR-127 — a cache entry *is* a `downloads` row with a shorter life, and
eviction only reclaims the temporary tier), local playback of downloaded media eviction only reclaims the temporary tier), local playback of downloaded media
(DR-128), and the one-path/one-row invariants that followed (DR-133 … DR-138). (DR-128), and the one-path/one-row invariants that followed (DR-133 … DR-138).
DR-123 is in progress. Still open: the **player quality selector** and the DR-123 is in progress. Still open: the read-through capture itself — DR-122,
read-through capture itself — DR-121, DR-122, DR-124, DR-125. The separate DR-124, DR-125.
settings-level bitrate cap (DR-162, shipped —
[01-rust-backend.md](../architecture/01-rust-backend.md#streaming-quality-ladder)) **DR-121 has shipped and left this spec.** The player quality selector, the
covers a *settings-level* per-playback bitrate ceiling, and the backend-owned stream decision it needed
ceiling (DR-162), which serves part of UR-070 but is not the per-playback were built as *backend-owned stream selection* (DR-225 … DR-228) and are
selector specified here. described in
**Requirements:** UR-070, UR-071 → DR-121, DR-122, DR-123, DR-124, DR-125; IR-032 [01-rust-backend.md](../architecture/01-rust-backend.md#stream-selection) and
**UX spec:** player quality selector — needs a `ux-flows.md` section before build [03-data-flow.md](../architecture/03-data-flow.md#video-stream-selection-flow).
The settings-level ceiling (DR-162) is the same section. What remains here is the
*capture* half only — this spec no longer specifies anything about choosing a
bitrate.
**Requirements:** UR-070, UR-071 → DR-122, DR-123, DR-124, DR-125; IR-032
**Related:** the locally-indexed search and downloaded-browse work, both **Related:** the locally-indexed search and downloaded-browse work, both
shipped — see shipped — see
[03-data-flow.md](../architecture/03-data-flow.md) and [03-data-flow.md](../architecture/03-data-flow.md) and
@@ -70,24 +75,16 @@ frontend stores the user's *choice*; Rust decides what that choice resolves to.
## Design ## Design
### DR-121 — Bitrate selection in the player ### DR-121 — moved out (shipped)
The player exposes the qualities Rust reports for the current item. Changing it Bitrate selection in the player shipped as DR-225 … DR-228; see
re-negotiates the stream URL at the new quality and resumes at the current [01-rust-backend.md](../architecture/01-rust-backend.md#stream-selection).
position. This is a deliberate, user-initiated interruption — a brief rebuffer is
expected and acceptable, unlike the involuntary swap the earlier design would
have needed.
Constraints that must not be broken: The one constraint here that the capture work still has to respect: a quality
change re-negotiates **within HLS**. Returning a progressive `stream.mp4` for a
- On Linux, video playback must keep using the HLS `master.m3u8` URL. CLAUDE.md transcode means playback never starts, because the server encodes the whole file
records that returning `stream.mp4` means transcoded playback never starts. before serving a byte (DR-140). That is why DR-122 below abandons a capture on a
A quality change re-negotiates *within* HLS. quality change rather than trying to splice one.
- The quality→transcode-parameter mapping already exists in
`get_video_download_url` ([online.rs:1702-1717](../../src-tauri/src/repository/online.rs#L1702-L1717)).
Playback must call into the same mapping. Two copies of that table will drift.
- Track selection (audio/subtitle) already survives a stream re-negotiation
elsewhere in the player; a quality change must preserve it too.
### DR-122 — The playback path is ephemeral ### DR-122 — The playback path is ephemeral
@@ -212,7 +209,6 @@ codec taxonomy in `src/`; the selector's remembered choice is a view preference.
| Piece | Tag | | Piece | Tag |
|---|---| |---|---|
| Quality selector + re-negotiation | `// TRACES: UR-070 \| DR-121` |
| Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` | | Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` |
| Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` | | Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` |
| ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` | | ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` |
+8015 -6362
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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",
@@ -53,7 +53,9 @@
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md", "traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage", "traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
"traces:validate": "bun run scripts/extract-traces.ts --format validate", "traces:validate": "bun run scripts/extract-traces.ts --format validate",
"release:notes": "bun run scripts/release-notes.ts" "release:notes": "bun run scripts/release-notes.ts",
"test:player": "./scripts/test-player-conformance.sh",
"test:player:android": "./scripts/test-player-conformance.sh android"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
+13
View File
@@ -36,6 +36,19 @@ if [ -d "$TEST_SOURCE_DIR" ]; then
echo " Copied unit tests: src/test" echo " Copied unit tests: src/test"
fi fi
# Instrumented tests (src/androidTest). These need a device: they drive
# ExoPlayer, which requires an Android Context and a Looper and therefore
# cannot run from the desktop conformance suite. Run with
# `./gradlew :app:connectedDebugAndroidTest` from gen/android.
ANDROID_TEST_SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/androidTest/java/com/dtourolle/jellytau"
ANDROID_TEST_TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/androidTest/java/com/dtourolle/jellytau"
if [ -d "$ANDROID_TEST_SOURCE_DIR" ]; then
rm -rf "$ANDROID_TEST_TARGET_DIR"
mkdir -p "$ANDROID_TEST_TARGET_DIR"
cp -r "$ANDROID_TEST_SOURCE_DIR"/. "$ANDROID_TEST_TARGET_DIR/"
echo " Copied instrumented tests: src/androidTest"
fi
# Copy individual Kotlin files (like VideoOverlayManager.kt) # Copy individual Kotlin files (like VideoOverlayManager.kt)
for kt_file in "$SOURCE_DIR"/*.kt; do for kt_file in "$SOURCE_DIR"/*.kt; do
if [ -f "$kt_file" ]; then if [ -f "$kt_file" ]; then
+60
View File
@@ -0,0 +1,60 @@
#!/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
+3 -1
View File
@@ -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",
@@ -2191,11 +2191,13 @@ dependencies = [
"env_logger", "env_logger",
"futures-util", "futures-util",
"getrandom 0.2.16", "getrandom 0.2.16",
"gtk",
"hostname", "hostname",
"jni 0.21.1", "jni 0.21.1",
"keyring", "keyring",
"libc", "libc",
"libmpv", "libmpv",
"libmpv-sys",
"log", "log",
"ndk-context", "ndk-context",
"rand 0.8.7", "rand 0.8.7",
+39 -1
View File
@@ -1,6 +1,10 @@
[package] [package]
name = "jellytau" name = "jellytau"
version = "0.10.1" # 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.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"
@@ -114,6 +118,25 @@ libc = "0.2"
# than changing it. To take upstream fixes, bump this deliberately. # than changing it. To take upstream fixes, bump this deliberately.
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" } libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" }
# The raw FFI bindings behind `libmpv`, pinned to the *same* revision so the two
# can never describe different ABIs.
#
# Needed because the safe crate's `render` module is an empty stub at this
# revision — the render API (`mpv_render_context_create` and friends) exists only
# in the sys bindings, which do carry all of it. `Mpv::ctx` is public, so the
# render context can be built over the same handle the safe wrapper drives. This
# is what makes native video reachable *without* first completing the libmpv2
# migration, which the spike's use of `libmpv2-sys` had implied was a
# prerequisite.
#
# TRACES: UR-080 | DR-230, IR-033
libmpv-sys = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" }
# Same major as the one Tauri/wry already resolve, so `gtk_window()` and
# `default_vbox()` hand back types this crate can name rather than a second,
# incompatible GTK.
gtk = "0.18"
# JNI for Android ExoPlayer integration # JNI for Android ExoPlayer integration
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
jni = "0.21" jni = "0.21"
@@ -122,3 +145,18 @@ ndk-context = "0.1"
[dev-dependencies] [dev-dependencies]
tempfile = "3.24.0" tempfile = "3.24.0"
[features]
# Exposes the MediaPlayer conformance suite and the `player-conformance` binary
# to non-test builds, so an engine that cannot run in-process — ExoPlayer on a
# device — is driven by the same cases as the ones that can, rather than by a
# second checklist that drifts.
conformance = []
# A standalone runner for the conformance suite. Deliberately a separate binary:
# it links libmpv and nothing else, so a wrapper can be verified without building
# or launching the app.
[[bin]]
name = "player-conformance"
path = "src/bin/player_conformance.rs"
required-features = ["conformance"]
+4
View File
@@ -46,6 +46,9 @@ android {
targetSdk = 36 targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
// Required to run the on-device conformance suite
// (src/androidTest). See docs/specs/media-player-controller.md.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {
create("release") { create("release") {
@@ -147,6 +150,7 @@ dependencies {
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4") androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
androidTestImplementation("androidx.test:runner:1.5.2")
} }
apply(from = "tauri.build.gradle.kts") apply(from = "tauri.build.gradle.kts")
@@ -0,0 +1,252 @@
package com.dtourolle.jellytau.player
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.math.abs
/**
* The MediaPlayer conformance cases, run against ExoPlayer on a real device.
*
* The desktop suite (src-tauri/src/player/conformance.rs) cannot reach here:
* ExoPlayer needs an Android Context and a Looper, so it only exists inside an
* app process. These are the same behaviours, asserted against the engine
* itself rather than the Rust wrapper the layer below the contract.
*
* The fixture is generated rather than committed: a long silent WAV written to
* the cache directory at setup. No binary in the repo, no `adb push` step, and
* the duration is exact, which matters for the seek assertions.
*
* Run: ./gradlew :app:connectedDebugAndroidTest (from src-tauri/gen/android)
*
* TRACES: UR-081 | DR-247
*/
@RunWith(AndroidJUnit4::class)
class PlayerConformanceTest {
private lateinit var player: JellyTauPlayer
private lateinit var mediaUrl: String
/** Long enough to seek well past any buffer. */
private val fixtureSeconds = 1200
/**
* ExoPlayer lands on the nearest sync sample, and a `prepare` is not
* instantaneous. Generous on purpose: a tight bound here produces a test
* that fails on a slow device and teaches people to re-run until green.
*/
private val toleranceSeconds = 10.0
@Before
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
JellyTauPlayer.initialize(context)
player = JellyTauPlayer.getInstance()
val fixture = File(context.cacheDir, "conformance-$fixtureSeconds.wav")
if (!fixture.exists() || fixture.length() < 1024) {
writeSilentWav(fixture, fixtureSeconds)
}
mediaUrl = fixture.toURI().toString()
}
@After
fun tearDown() {
onMain { player.stop() }
// Leave nothing playing for the next case.
Thread.sleep(200)
}
// ---------------------------------------------------------------- cases
@Test
fun opensFromTheBeginning() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
assertNear(0.0, position(), "playback should start at the beginning")
assertTrue("duration should be known once loaded", duration() > 0)
}
/**
* DR-241. Opening at a position starts *there*, not at zero.
*
* `load(url, mediaId)` has no way to express a start position, so every
* caller loads and then seeks and a seek issued against a player that is
* still preparing is the window resume was lost in on the desktop side.
* This is the same defect on ExoPlayer.
*/
@Test
fun opensAtAStartPosition() {
val start = 600.0
onMain { player.load(mediaUrl, "conformance", start) }
awaitLoaded()
assertTrue(
"opened at ${start}s but playback began at ${position()}s - " +
"the start position was dropped",
position() > 1.0
)
assertNear(start, position(), "start position")
}
/** DR-241. A seek issued while still preparing is honoured, not lost. */
@Test
fun seekWhileOpeningIsHonoured() {
val target = 300.0
onMain {
player.load(mediaUrl, "conformance")
// Deliberately before the player is ready: this is the race,
// expressed on purpose rather than stumbled into.
player.seek(target)
}
awaitLoaded()
assertNear(target, position(), "seek issued while opening")
}
@Test
fun seeksAfterOpen() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
val target = 420.0
onMain { player.seek(target) }
awaitPosition(target)
assertNear(target, position(), "seek after open")
}
/** DR-239. Pause and play are observable, not merely accepted. */
@Test
fun pauseAndPlayAreObservable() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
onMain { player.pause() }
awaitPlaying(false)
assertFalse("a paused player must not report playing", isPlaying())
onMain { player.play() }
awaitPlaying(true)
assertTrue("a resumed player must report playing", isPlaying())
}
/** `stop()` releases the item, is silent, and can be called twice. */
@Test
fun closeIsSilentAndIdempotent() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
onMain { player.stop() }
awaitPlaying(false)
assertFalse("a stopped player must not report playing", isPlaying())
onMain { player.stop() }
assertFalse("stop must be idempotent", isPlaying())
}
/**
* An open cancelled by stop must not come back to life.
*
* The shape of "audio kept playing after leaving the player": a prepare
* still in flight completed after the stop, with nothing left to tell it
* not to.
*/
@Test
fun closeDuringOpenNeverPlays() {
onMain {
player.load(mediaUrl, "conformance")
player.stop()
}
Thread.sleep(2000)
assertFalse(
"a load cancelled by stop must not start playing",
isPlaying()
)
}
// -------------------------------------------------------------- helpers
private fun onMain(block: () -> Unit) {
InstrumentationRegistry.getInstrumentation().runOnMainSync(block)
}
private fun position(): Double = readOnMain { player.getPosition() }
private fun duration(): Double = readOnMain { player.getDuration() }
private fun isPlaying(): Boolean = readOnMain { player.getExoPlayer().isPlaying }
private fun <T> readOnMain(block: () -> T): T {
var out: T? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync { out = block() }
@Suppress("UNCHECKED_CAST")
return out as T
}
/** Poll a state the player publishes rather than sleeping a fixed time. */
private fun await(what: String, timeoutMs: Long = 15_000, predicate: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (predicate()) return
Thread.sleep(50)
}
throw AssertionError("timed out waiting for $what")
}
private fun awaitLoaded() {
await("the player to report a duration") { duration() > 0 }
// One more beat so a start position or a deferred seek has landed.
Thread.sleep(500)
}
private fun awaitPosition(target: Double) =
await("position to reach ${target}s") { abs(position() - target) <= toleranceSeconds }
private fun awaitPlaying(expected: Boolean) =
await("isPlaying == $expected", 5_000) { isPlaying() == expected }
private fun assertNear(expected: Double, actual: Double, what: String) {
assertTrue(
"$what: expected ~${expected}s, got ${actual}s (tolerance ${toleranceSeconds}s)",
abs(actual - expected) <= toleranceSeconds
)
}
/**
* Write a silent 8 kHz mono 16-bit WAV of `seconds` length.
*
* Synthesised rather than committed so the repo carries no media, and so
* the duration is exact the seek assertions depend on it.
*/
private fun writeSilentWav(file: File, seconds: Int) {
val sampleRate = 8000
val dataBytes = sampleRate * 2 * seconds
file.outputStream().buffered().use { out ->
fun le32(v: Int) = out.write(
byteArrayOf(
(v and 0xff).toByte(),
((v shr 8) and 0xff).toByte(),
((v shr 16) and 0xff).toByte(),
((v shr 24) and 0xff).toByte()
)
)
fun le16(v: Int) =
out.write(byteArrayOf((v and 0xff).toByte(), ((v shr 8) and 0xff).toByte()))
out.write("RIFF".toByteArray()); le32(36 + dataBytes); out.write("WAVE".toByteArray())
out.write("fmt ".toByteArray()); le32(16); le16(1); le16(1)
le32(sampleRate); le32(sampleRate * 2); le16(2); le16(16)
out.write("data".toByteArray()); le32(dataBytes)
val chunk = ByteArray(sampleRate * 2) // one second of silence
repeat(seconds) { out.write(chunk) }
}
}
}
@@ -556,11 +556,31 @@ class JellyTauPlayer(private val appContext: Context) {
* @param mediaId The unique ID for this media item * @param mediaId The unique ID for this media item
*/ */
fun load(url: String, mediaId: String) { fun load(url: String, mediaId: String) {
load(url, mediaId, 0.0)
}
/**
* Load [url] and begin at [startPositionSeconds].
*
* The start position is handed to ExoPlayer with the media item, not seeked
* to afterwards. `prepare()` is asynchronous, so a seek issued straight
* after a load targets a player that is still preparing: ExoPlayer clamps it
* back to zero and the item plays from the beginning. That is what made
* resume and transcoded skip start over, and it is why callers must never
* express a start position as load-then-seek.
*
* TRACES: UR-081, UR-005 | DR-241, DR-247
*/
fun load(url: String, mediaId: String, startPositionSeconds: Double) {
mainHandler.post { mainHandler.post {
currentMediaId = mediaId currentMediaId = mediaId
endedNotified = false endedNotified = false
val mediaItem = MediaItem.fromUri(url) val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem) if (startPositionSeconds > 0.0) {
exoPlayer.setMediaItem(mediaItem, (startPositionSeconds * 1000).toLong())
} else {
exoPlayer.setMediaItem(mediaItem)
}
exoPlayer.prepare() exoPlayer.prepare()
exoPlayer.playWhenReady = true exoPlayer.playWhenReady = true
} }
+37
View File
@@ -0,0 +1,37 @@
//! Thin entry point. The suite lives in the library so the binary needs no
//! access to the player internals — one exported function rather than a public
//! module tree.
//!
//! player-conformance <media-file> [mpv|legacy]
//!
//! `legacy` drives the old `PlayerBackend` through the same cases, so the
//! difference between the two designs is demonstrated on one engine and one
//! file rather than argued.
//!
//! TRACES: UR-081 | DR-244, DR-245
use std::process::ExitCode;
use jellytau_lib::conformance_runner::{run_engine, Engine};
fn main() -> ExitCode {
let mut args = std::env::args().skip(1);
let Some(url) = args.next() else {
eprintln!("usage: player-conformance <media-file-or-url> [mpv|legacy]");
return ExitCode::from(2);
};
let engine = match args.next().as_deref() {
None | Some("mpv") => Engine::Mpv,
Some("legacy") => Engine::Legacy,
Some(other) => {
eprintln!("unknown engine {other:?} - expected mpv or legacy");
return ExitCode::from(2);
}
};
if run_engine(&url, engine) == 0 {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
+218 -82
View File
@@ -30,7 +30,7 @@ use crate::player::{
}; };
use crate::repository::{ use crate::repository::{
types::{GetItemsOptions, ImageOptions, ImageType}, types::{GetItemsOptions, ImageOptions, ImageType},
MediaRepository, MediaRepository, StreamSelection,
}; };
use crate::settings::VideoSettings; use crate::settings::VideoSettings;
use crate::storage::db_service::{DatabaseService, Query, QueryParam}; use crate::storage::db_service::{DatabaseService, Query, QueryParam};
@@ -179,6 +179,18 @@ pub struct PlayItemRequest {
pub video_codec: String, pub video_codec: String,
/// Whether the video requires server-side transcoding /// Whether the video requires server-side transcoding
pub needs_transcoding: bool, pub needs_transcoding: bool,
/// How this item's stream is fetched, as the backend decided it.
///
/// Carried on the queue item so a later seek/reload does not have to guess.
/// `None` for items queued by a path that never negotiated (audio tracks,
/// direct URLs) and for anything queued before this field existed, where the
/// caller falls back to `needs_transcoding` — every transcode this app
/// requests is HLS (DR-140), so that fallback is exact rather than a guess.
///
/// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
#[serde(default)]
pub transport: Option<crate::repository::Transport>,
/// Optional now-playing metadata. Used by the background-audio handoff so the /// Optional now-playing metadata. Used by the background-audio handoff so the
/// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so /// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
/// existing video-only callers need not send them. /// existing video-only callers need not send them.
@@ -317,9 +329,15 @@ pub enum VideoSeekResponse {
}, },
/// Reload stream from new position (transcoded non-HLS) /// Reload stream from new position (transcoded non-HLS)
ReloadStream { ReloadStream {
/// New stream URL starting at seek position /// What to open, and how — transport included, so the frontend picks
new_url: String, /// its loader from a tagged enum rather than by searching the URL for
/// Position offset to track (for display purposes) /// `.m3u8`. TRACES: UR-079 | DR-225
selection: StreamSelection,
/// `seek_offset` carries the position to RESUME AT, not a base to add to
/// the element's clock. The reloaded stream starts at the item's zero —
/// a position on an HLS playlist makes the server 400 every segment
/// behind it (DR-181) — so the adapter reaches the position by seeking
/// the element and leaves the transcode offset at zero.
seek_offset: f64, seek_offset: f64,
}, },
} }
@@ -335,8 +353,8 @@ pub enum AudioTrackSwitchResponse {
}, },
/// HTML5 needs to reload stream with new audio track /// HTML5 needs to reload stream with new audio track
ReloadStream { ReloadStream {
/// New stream URL with selected audio track /// What to open, and how. TRACES: UR-079 | DR-225
new_url: String, selection: StreamSelection,
/// Current position to resume from /// Current position to resume from
position: f64, position: f64,
}, },
@@ -351,15 +369,31 @@ pub enum AudioTrackSwitchResponse {
#[derive(specta::Type, Debug, Serialize)] #[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")] #[serde(tag = "strategy", rename_all = "camelCase")]
pub enum StreamQualityResponse { pub enum StreamQualityResponse {
/// The native backend was reloaded here; nothing left for the frontend. /// The native backend was reloaded here; nothing left for the frontend to
/// *do* — but it still has to be told what was negotiated.
///
/// This carried only a position at first, which left the picker on Android
/// pinned to the rendition of the *first* stream: the UI derives the rung in
/// force from the selection it holds, nothing replaced that selection on the
/// native path, and a transcode always has a rendition — so the fallback
/// that would have used the requested value was never reached. The stream
/// changed and the menu did not.
///
/// TRACES: UR-074, UR-079 | DR-226, DR-227
Native { Native {
/// What the backend actually opened, so the UI reflects it rather than
/// assuming the request was honoured verbatim.
selection: StreamSelection,
/// Position playback resumed at. /// Position playback resumed at.
position: f64, position: f64,
}, },
/// HTML5 must reload its element with this URL. /// HTML5 must reload its element with this selection.
ReloadStream { ReloadStream {
/// New stream URL, already transcoded to the requested ceiling. /// What to open, and how — already negotiated against the requested
new_url: String, /// ceiling. Carries `available` too, so a picker opened after a quality
/// change still describes the source correctly.
/// TRACES: UR-070, UR-079 | DR-225, DR-227
selection: StreamSelection,
/// Position to resume from. /// Position to resume from.
position: f64, position: f64,
}, },
@@ -415,6 +449,8 @@ pub(super) async fn create_media_item(
source, source,
video_codec: Some(req.video_codec), video_codec: Some(req.video_codec),
needs_transcoding: req.needs_transcoding, needs_transcoding: req.needs_transcoding,
// The caller's negotiated transport, when it had one. TRACES: UR-079 | DR-230
transport: req.transport,
video_width: None, // Not available from video-only request video_width: None, // Not available from video-only request
video_height: None, // Not available from video-only request video_height: None, // Not available from video-only request
// Sideloaded subtitles, in the order the frontend sent them — that order // Sideloaded subtitles, in the order the frontend sent them — that order
@@ -663,6 +699,14 @@ pub async fn player_play_item(
item.title, item.stream_url item.title, item.stream_url
); );
// A ceiling chosen from the in-player picker belongs to the playback it was
// chosen for. Starting a different item returns to the device default —
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
// session, which is the defect DR-226 exists to close.
//
// TRACES: UR-074, UR-079 | DR-226
crate::repository::online::clear_playback_quality_override();
// Create media item, checking for local download first // Create media item, checking for local download first
let media_item = create_media_item(item, Some(&db)).await?; let media_item = create_media_item(item, Some(&db)).await?;
@@ -681,18 +725,30 @@ pub async fn player_play_item(
} }
let controller = player.0.lock().await; let controller = player.0.lock().await;
// On Linux, video plays in the WebKitGTK HTML5 <video> element (see // Who gets the stream depends on who is going to *render* it, which is a
// get_player_status -> use_html5_element). The MPV backend has no embedded // runtime question, not a platform constant.
// window, so loading the stream into it would only start a redundant decode //
// (and the frontend would immediately stop it). Only load into the native // Historically Linux video was always the webview's (`use_html5_element`),
// backend on platforms that actually render video through it (e.g. Android). // so handing the file to MPV as well would only have started a redundant
#[cfg(not(target_os = "linux"))] // decode with no window to show it in — hence a `#[cfg(not(linux))]` guard
controller // and a queue-only path here. With mpv drawing the picture that inverts:
.play_item(media_item) // the webview is no longer loading anything, so if this does not load the
.map_err(|e| e.to_string())?; // file, *nothing does*. The symptom is total silence — no picture and no
#[cfg(target_os = "linux")] // audio — which reads like a broken stream rather than a stream nobody was
{ // given.
// Keep the queue in sync for UI/remote-transfer without starting MPV. //
// This is the fifth place in this cycle where a renderer's capability was
// written as a compile-time platform fact. Same fix as the others: ask.
//
// TRACES: UR-080 | DR-231, DR-235
let renders_natively = cfg!(not(target_os = "linux")) || crate::player::native_video::enabled();
if renders_natively {
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
} else {
// The webview will play it; keep the queue in sync for the UI and for a
// remote transfer without starting a second decode.
controller controller
.set_current_item(media_item) .set_current_item(media_item)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -762,6 +818,8 @@ pub async fn player_enter_background_audio(
// create_media_item() because that hardcodes MediaType::Video; background // create_media_item() because that hardcodes MediaType::Video; background
// audio must be Audio so no video decode is started. // audio must be Audio so no video decode is started.
let media_item = MediaItem { let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: item.id.clone(), id: item.id.clone(),
title: item.title.clone(), title: item.title.clone(),
name: Some(item.title.clone()), name: Some(item.title.clone()),
@@ -852,7 +910,7 @@ pub async fn player_enter_background_audio(
/// playing there is nothing to pause, and an error would make the frontend /// playing there is nothing to pause, and an error would make the frontend
/// handle a case that is not a failure. /// handle a case that is not a failure.
/// ///
/// TRACES: UR-040, UR-041 | DR-224 | UT-211 /// TRACES: UR-040, UR-041 | DR-225 | UT-212
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn player_background_action( pub async fn player_background_action(
@@ -935,6 +993,14 @@ pub async fn player_play_queue(
request.shuffle request.shuffle
); );
// A ceiling chosen from the in-player picker belongs to the playback it was
// chosen for. Starting a different item returns to the device default —
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
// session, which is the defect DR-226 exists to close.
//
// TRACES: UR-074, UR-079 | DR-226
crate::repository::online::clear_playback_quality_override();
// Handle shuffle first // Handle shuffle first
if request.shuffle { if request.shuffle {
let controller = player.0.lock().await; let controller = player.0.lock().await;
@@ -1115,6 +1181,13 @@ pub async fn player_stop(
// Check if we're in remote mode // Check if we're in remote mode
let mode = playback_mode.0.get_mode(); let mode = playback_mode.0.get_mode();
// Stopping is a state transition worth seeing in a log. Native video is
// what made its absence matter: the webview <video> stopped implicitly when
// the component unmounted, so nothing ever had to call this — and "never
// called" and "called but the backend kept playing" look identical from
// outside without it.
info!("[player_stop] called (mode: {:?})", mode);
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode { if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send stop command to remote session - clone client before await // Send stop command to remote session - clone client before await
let client = { let client = {
@@ -1347,7 +1420,7 @@ pub async fn player_seek(
/// ///
/// This command analyzes the current video stream and automatically chooses /// This command analyzes the current video stream and automatically chooses
/// the best seeking strategy: /// the best seeking strategy:
/// - HLS streams (.m3u8): Use native seeking /// - HLS streams: Use native seeking
/// - Direct play streams: Use native seeking /// - Direct play streams: Use native seeking
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position /// - Transcoded non-HLS: Request new stream URL from server starting at seek position
/// ///
@@ -1377,7 +1450,7 @@ pub async fn player_seek_video(
// Get current playing item to analyze stream characteristics // Get current playing item to analyze stream characteristics
// Clone what we need to avoid holding locks across await points // Clone what we need to avoid holding locks across await points
let (needs_transcoding, jellyfin_item_id, stream_url, is_local) = { let (needs_transcoding, jellyfin_item_id, is_local) = {
let controller = player.0.lock().await; let controller = player.0.lock().await;
let queue_arc = controller.queue(); let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?; let queue = queue_arc.lock().map_err(|e| e.to_string())?;
@@ -1393,22 +1466,34 @@ pub async fn player_seek_video(
.ok_or("Current video has no Jellyfin ID")? .ok_or("Current video has no Jellyfin ID")?
.to_string(); .to_string();
let (stream_url, is_local_file) = match &current_item.source { // Neither the URL nor the item's transport is read here any more. The
MediaSource::Remote { stream_url, .. } => (stream_url.clone(), false), // strategy turns on whether the *engine* can seek a transcode in place,
MediaSource::Local { .. } => (String::new(), true), // which it declares for itself — so the container the stream happens to
MediaSource::DirectUrl { url } => (url.clone(), false), // arrive in stopped being a proxy for anything (DR-246).
}; let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
let needs_trans = current_item.needs_transcoding; (current_item.needs_transcoding, jellyfin_id, is_local_file)
(needs_trans, jellyfin_id, stream_url, is_local_file)
}; // Locks are dropped here }; // Locks are dropped here
// Determine seek strategy using the testable helper function // Whether a transcode can be seeked in place is asked of the engine that is
let is_hls = stream_url.contains(".m3u8"); // rendering, not guessed from the URL's shape or from who is rendering.
let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5); // 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
};
let strategy = determine_video_seek_strategy(
is_local,
seeks_transcoded_in_place,
needs_transcoding,
use_html5,
);
info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}", info!(
is_local, is_hls, needs_transcoding, use_html5, strategy); "[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
);
match strategy { match strategy {
VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => { VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
@@ -1428,29 +1513,22 @@ pub async fn player_seek_video(
// Transcoded non-HLS with HTML5 - frontend handles stream reload // Transcoded non-HLS with HTML5 - frontend handles stream reload
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL"); info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
let new_url = repository let selection = repository
.get_video_stream_url( .get_stream_selection(
&jellyfin_item_id, &jellyfin_item_id,
media_source_id.as_deref(), media_source_id.as_deref(),
audio_stream_index, audio_stream_index,
) )
.await .await
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?; .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
info!( info!(
"[player_seek_video] Got new stream URL for position {}", "[player_seek_video] Selected {:?} over {:?} for position {}",
position selection.playback_kind, selection.transport, position
); );
// `seek_offset` carries the position to RESUME AT, not a base to add
// to the element's clock. The reloaded stream starts at the item's
// zero — a position on an HLS playlist makes the server 400 every
// segment behind it (DR-181) — so the adapter reaches the position by
// seeking the element and leaves the transcode offset at zero. The
// field keeps its name only because renaming it means regenerating
// the specta bindings; `reloadSource` documents the contract.
Ok(VideoSeekResponse::ReloadStream { Ok(VideoSeekResponse::ReloadStream {
new_url, selection,
seek_offset: position, seek_offset: position,
}) })
} }
@@ -1458,16 +1536,17 @@ pub async fn player_seek_video(
// Transcoded non-HLS with native backend - backend handles stream reload // Transcoded non-HLS with native backend - backend handles stream reload
info!("[player_seek_video] Backend reload stream - requesting new stream URL"); info!("[player_seek_video] Backend reload stream - requesting new stream URL");
let new_url = repository let selection = repository
.get_video_stream_url( .get_stream_selection(
&jellyfin_item_id, &jellyfin_item_id,
media_source_id.as_deref(), media_source_id.as_deref(),
audio_stream_index, audio_stream_index,
) )
.await .await
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?; .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let new_url = selection.url.clone();
info!("[player_seek_video] Got new stream URL, handling reload internally"); info!("[player_seek_video] Got new selection, handling reload internally");
// Stop current playback // Stop current playback
{ {
@@ -1568,20 +1647,25 @@ pub async fn player_switch_audio_track(
.to_string() .to_string()
}; };
// Get new stream URL with selected audio track. It starts at zero — an // Select a stream carrying the chosen audio track. It starts at zero —
// HLS playlist cannot carry a position (DR-181) — and `position` below // an HLS playlist cannot carry a position (DR-181) — and `position`
// tells the frontend where to seek the reloaded element back to. // below tells the frontend where to seek the reloaded element back to.
let new_url = repository //
.get_video_stream_url( // 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, &jellyfin_item_id,
media_source_id.as_deref(), media_source_id.as_deref(),
Some(stream_index), Some(stream_index),
) )
.await .await
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?; .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
Ok(AudioTrackSwitchResponse::ReloadStream { Ok(AudioTrackSwitchResponse::ReloadStream {
new_url, selection,
position: current_position.unwrap_or(0.0), position: current_position.unwrap_or(0.0),
}) })
} else { } else {
@@ -1604,23 +1688,26 @@ pub async fn player_switch_audio_track(
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a /// two-sided split: HTML5 gets the URL back and reloads its own element, while a
/// native backend is reloaded here. /// native backend is reloaded here.
/// ///
/// The change applies to this playback *and* to everything started afterwards /// The change applies to **this playback only**. The in-player picker is a
/// (it sets the process-wide ceiling), but it is deliberately **not** persisted: /// "this film, this connection" control and its doc has always said so, but it
/// the in-player picker is a "this film, this connection" control, and the /// used to be implemented by writing the process-wide ceiling — so choosing
/// durable default belongs to Settings. `player_set_video_settings` is the one /// 2 Mbps to get one awkward film moving silently capped every video played
/// that writes to the database. /// afterwards for the rest of the process, with the Settings screen still
/// showing the old value and nothing in the UI admitting the change. It now
/// sets a per-playback override that the next item clears; the durable default
/// belongs to Settings, and `player_set_video_settings` is the one that writes
/// to the database.
/// ///
/// TRACES: UR-074 | DR-162 /// TRACES: UR-074, UR-079 | DR-162, DR-226
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
// Three of the nine arguments are Tauri `State<'_, _>` injections, not caller // Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
// input. Folding the rest into a struct would change the IPC contract and the // input. Folding the rest into a struct would change the IPC contract and the
// generated TypeScript for no readability gain. // generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn player_set_stream_quality( pub async fn player_set_stream_quality(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
video_settings: State<'_, VideoSettingsWrapper>,
repository_handle: String, repository_handle: String,
quality: crate::settings::StreamingQuality, quality: crate::settings::StreamingQuality,
use_html5: bool, use_html5: bool,
@@ -1657,25 +1744,50 @@ pub async fn player_set_stream_quality(
.to_string() .to_string()
}; };
// Set the ceiling *before* building the URL — the builder reads it. // Set the ceiling *before* negotiating — the negotiation and every URL
crate::repository::online::set_streaming_quality(quality); // builder resolve through `effective_streaming_quality`, and they have to
{ // agree or the cap leaks (a negotiation authorising a direct play the URL
let mut settings = video_settings.0.lock().map_err(|e| e.to_string())?; // builder then never gets to constrain).
settings.streaming_quality = quality; //
} // Deliberately the *override*, not the device default: see the doc above.
// TRACES: UR-074, UR-079 | DR-226
crate::repository::online::set_playback_quality_override(quality);
let position = current_position.unwrap_or(0.0); // Where to resume. `current_position` is the *element's* clock, which only
let new_url = repository // the webview path has — on a native backend there is no `<video>` and the
.get_video_stream_url( // frontend correctly sends null, so trusting it there resumed every quality
// change from zero.
//
// The player is the authority on position (it is the authority on all
// playback state); asking the DOM for it and falling back to 0 inverted
// that. Fall back to what the controller reports instead.
//
// TRACES: UR-005, UR-074 | DR-226
// The guard is bound inside the arm's block so it is dropped before the
// reload below takes the same lock. This codebase has been bitten by a
// MutexGuard living longer than the expression that produced it.
let position = match current_position {
Some(p) => p,
None => {
let controller = player.0.lock().await;
controller.absolute_position()
}
};
let selection = repository
.get_stream_selection(
&jellyfin_item_id, &jellyfin_item_id,
media_source_id.as_deref(), media_source_id.as_deref(),
audio_stream_index, audio_stream_index,
) )
.await .await
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?; .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let new_url = selection.url.clone();
if use_html5 { if use_html5 {
return Ok(StreamQualityResponse::ReloadStream { new_url, position }); return Ok(StreamQualityResponse::ReloadStream {
selection,
position,
});
} }
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the // Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
@@ -1705,7 +1817,10 @@ pub async fn player_set_stream_quality(
} }
} }
Ok(StreamQualityResponse::Native { position }) Ok(StreamQualityResponse::Native {
selection,
position,
})
} }
/// Set the active audio track on a native backend directly. /// Set the active audio track on a native backend directly.
@@ -1993,7 +2108,9 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
Ok(PlaybackCapabilities { Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio, uses_webview_audio: !native_audio,
supports_native_video: cfg!(target_os = "android"), // TRACES: UR-080 | DR-235
supports_native_video: cfg!(target_os = "android")
|| crate::player::native_video::enabled(),
}) })
} }
@@ -2002,6 +2119,11 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
let (backend, use_html5_element) = if cfg!(target_os = "android") { let (backend, use_html5_element) = if cfg!(target_os = "android") {
// Android uses ExoPlayer native backend // Android uses ExoPlayer native backend
(VideoBackend::Native, false) (VideoBackend::Native, false)
} else if crate::player::native_video::enabled() {
// mpv draws the picture on this desktop; the frontend must not also
// load it into a <video> element or the stream decodes twice and the
// two fight over the audio. TRACES: UR-080 | DR-235
(VideoBackend::Native, false)
} else { } else {
// Linux and other platforms use HTML5 video element in frontend // Linux and other platforms use HTML5 video element in frontend
(VideoBackend::Html5, true) (VideoBackend::Html5, true)
@@ -2208,6 +2330,8 @@ pub async fn player_play_album_track(
let primary_image_tag_for_url = track.primary_image_tag.clone(); let primary_image_tag_for_url = track.primary_image_tag.clone();
let media_item = MediaItem { let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: track.id.clone(), id: track.id.clone(),
title: track.name.clone(), title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility name: Some(track.name.clone()), // Frontend compatibility
@@ -2353,6 +2477,14 @@ pub async fn player_play_tracks(
repository_handle: String, repository_handle: String,
request: PlayTracksRequest, request: PlayTracksRequest,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
// A ceiling chosen from the in-player picker belongs to the playback it was
// chosen for. Starting a different item returns to the device default —
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
// session, which is the defect DR-226 exists to close.
//
// TRACES: UR-074, UR-079 | DR-226
crate::repository::online::clear_playback_quality_override();
info!( info!(
"player_play_tracks called: {} tracks, start_index={}, shuffle={}", "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
request.track_ids.len(), request.track_ids.len(),
@@ -2404,6 +2536,8 @@ pub async fn player_play_tracks(
// Transform to MediaItem with frontend-compatible fields // Transform to MediaItem with frontend-compatible fields
let primary_image_tag_for_url = track.primary_image_tag.clone(); let primary_image_tag_for_url = track.primary_image_tag.clone();
let media_item = MediaItem { let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: track.id.clone(), id: track.id.clone(),
title: track.name.clone(), title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility name: Some(track.name.clone()), // Frontend compatibility
@@ -3210,6 +3344,8 @@ mod tests {
let db = DatabaseWrapper(Mutex::new(database)); let db = DatabaseWrapper(Mutex::new(database));
let make_item = |id: &str| MediaItem { let make_item = |id: &str| MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: id.to_string(), id: id.to_string(),
title: id.to_string(), title: id.to_string(),
name: None, name: None,
+4
View File
@@ -198,6 +198,8 @@ pub async fn player_add_track_by_id(
// Build MediaItem with artwork URL from repository and frontend-compatible fields // Build MediaItem with artwork URL from repository and frontend-compatible fields
let primary_image_tag_for_url = track.primary_image_tag.clone(); let primary_image_tag_for_url = track.primary_image_tag.clone();
let media_item = MediaItem { let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: track.id.clone(), id: track.id.clone(),
title: track.name.clone(), title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility name: Some(track.name.clone()), // Frontend compatibility
@@ -317,6 +319,8 @@ pub async fn player_add_tracks_by_ids(
// Build MediaItem with artwork URL from repository and frontend-compatible fields // Build MediaItem with artwork URL from repository and frontend-compatible fields
let primary_image_tag_for_url = track.primary_image_tag.clone(); let primary_image_tag_for_url = track.primary_image_tag.clone();
let media_item = MediaItem { let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: track.id.clone(), id: track.id.clone(),
title: track.name.clone(), title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility name: Some(track.name.clone()), // Frontend compatibility
+30 -1
View File
@@ -16,7 +16,7 @@ use crate::domain::rank_search_results;
use crate::jellyfin::HttpClient; use crate::jellyfin::HttpClient;
use crate::repository::{ use crate::repository::{
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository, series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
OnlineRepository, OnlineRepository, StreamSelection,
}; };
/// Repository handle manager /// Repository handle manager
@@ -606,6 +606,35 @@ pub async fn repository_get_video_stream_url(
.map_err(|e| format!("{:?}", e)) .map_err(|e| format!("{:?}", e))
} }
/// Decide what stream to play for a video, and describe it.
///
/// Replaces `repository_get_video_stream_url` for playback. The returned
/// [`StreamSelection`] carries the transport explicitly, so the frontend picks
/// its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
/// carries the quality ladder as it applies to *this* source, so the picker can
/// stop offering rungs that produce the same bytes as Original.
///
/// No start-position parameter, for the same reason as the URL builder: a
/// position on an HLS playlist is copied onto every segment URI and the server
/// rejects each with `400` (DR-181). Callers resume by seeking after load.
///
/// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
#[tauri::command]
#[specta::specta]
pub async fn repository_get_stream_selection(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
) -> Result<StreamSelection, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_stream_selection(&item_id, media_source_id.as_deref(), audio_stream_index)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get an audio-only stream URL for a *video* item (background-audio handoff). /// Get an audio-only stream URL for a *video* item (background-audio handoff).
/// ///
/// TRACES: UR-040 | JA-032 | UT-061 /// TRACES: UR-040 | JA-032 | UT-061
+24
View File
@@ -104,6 +104,30 @@ pub fn media_local_url(
.ok_or_else(|| "Local media server is not running".to_string()) .ok_or_else(|| "Local media server is not running".to_string())
} }
/// The stream selection for a downloaded file.
///
/// The local-playback counterpart to `repository_get_stream_selection`. A file
/// on disk needs no negotiation — it is a direct play over a local transport,
/// with no quality ladder, because nothing about it can be re-negotiated — but
/// the *frontend must not be the one to say so*. It gets the same
/// [`StreamSelection`] shape as a streamed source so the player has one contract
/// to consume rather than two, and so no caller has to infer a transport from a
/// loopback URL.
///
/// TRACES: UR-071, UR-079 | DR-225
#[tauri::command]
#[specta::specta]
pub fn media_local_selection(
server: State<crate::media_server::MediaServerWrapper>,
path: String,
) -> Result<crate::repository::StreamSelection, String> {
server
.0
.as_ref()
.map(|s| crate::repository::StreamSelection::local_file(s.url_for(&path)))
.ok_or_else(|| "Local media server is not running".to_string())
}
/// Get storage directory path (parent directory of the database file) /// Get storage directory path (parent directory of the database file)
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
+171
View File
@@ -0,0 +1,171 @@
//! Runs the `MediaPlayer` conformance suite against a real engine.
//!
//! A separate binary on purpose: it links libmpv and nothing else, so a wrapper
//! can be verified without building or launching the app — which is what made
//! the previous round of playback debugging so slow. Every failure here is a
//! wrapper bug, with no UI, no webview and no server in the way.
//!
//! cargo run --features conformance --bin player-conformance -- <media-file>
//!
//! Audio and video are routed to null, so it is safe on a headless runner and
//! does not claim the speakers.
//!
//! TRACES: UR-081 | DR-244
use std::time::{Duration, Instant};
use crate::player::conformance::Harness;
use crate::player::legacy_player::LegacyPlayer;
use crate::player::media::MediaItem;
use crate::player::media_player::{MediaPlayer, OpenRequest, Phase};
use crate::player::mpv_backend::MpvBackend;
use crate::player::mpv_player::{MpvPlayer, Output};
use crate::repository::stream_selection::StreamSelection;
struct EngineHarness<P: MediaPlayer> {
player: P,
url: String,
}
impl<P: MediaPlayer> Harness for EngineHarness<P> {
type Player = P;
fn player(&mut self) -> &mut P {
&mut self.player
}
fn request(&self, start: Duration) -> OpenRequest {
let selection = StreamSelection::local_file(self.url.clone());
let media = MediaItem::sample("conformance", &self.url);
OpenRequest::new(media, selection).starting_at(start)
}
/// Wait for mpv to leave `Opening`.
///
/// Polling a phase the engine publishes, not a fixed sleep: a suite whose
/// result depends on how fast the machine is will eventually be ignored.
fn settle(&mut self) {
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
if self.player.snapshot().phase != Phase::Opening {
// Let the deferred seek land and one position tick arrive.
std::thread::sleep(Duration::from_millis(300));
return;
}
std::thread::sleep(Duration::from_millis(25));
}
eprintln!(" ! settle timed out - engine stayed in Opening");
}
/// mpv is on a null audio device here, so silence cannot be observed.
/// Reporting `None` skips those assertions rather than passing them
/// vacuously — an assertion that cannot fail is worse than an absent one.
fn audible(&mut self) -> Option<bool> {
None
}
/// Keyframe granularity: mpv lands on the nearest one, not on the request.
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 {
($failed:ident, $url:expr, $make:expr, $case:path) => {{
let name = stringify!($case).rsplit("::").next().unwrap();
print!(" {name:.<52}");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut h = EngineHarness {
player: $make,
url: $url.to_string(),
};
$case(&mut h);
// Leave nothing playing behind for the next case.
let _ = h.player.close();
}));
match result {
Ok(()) => println!(" ok"),
Err(_) => {
println!(" FAILED");
$failed += 1;
}
}
}};
}
/// Which engine to interrogate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Engine {
/// The `MediaPlayer` implementation.
Mpv,
/// The old `PlayerBackend`, driven through `LegacyPlayer`.
///
/// Present so the difference between the two designs can be *demonstrated*
/// on the same engine and the same media, rather than argued.
Legacy,
}
/// Run every conformance case against `engine`. Returns the failure count.
pub fn run_engine(url: &str, engine: Engine) -> u32 {
println!("MediaPlayer conformance - {engine:?}");
println!("media: {url}\n");
let mut failed = 0u32;
use crate::player::conformance as c;
macro_rules! all_cases {
($make:expr) => {
run!(failed, url, $make, c::opens_from_the_beginning);
run!(failed, url, $make, c::opens_at_a_start_position);
run!(failed, url, $make, c::seek_while_opening_is_honoured);
run!(failed, url, $make, c::seek_while_opening_overrides_start);
run!(failed, url, $make, c::seeks_after_open);
run!(failed, url, $make, c::pause_and_play_are_observable);
run!(failed, url, $make, c::close_is_silent_and_idempotent);
run!(failed, url, $make, c::close_during_open_never_plays);
run!(failed, url, $make, c::transport_settings_round_trip);
};
}
match engine {
Engine::Mpv => {
all_cases!(MpvPlayer::new(Output::Null).expect("could not create mpv"));
}
Engine::Legacy => {
all_cases!(LegacyPlayer::new(
MpvBackend::new(
None,
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(),
));
}
}
if failed == 0 {
println!("\nall cases passed");
} else {
println!("\n{failed} case(s) failed");
}
failed
}
/// Default entry point: the new engine.
pub fn run(url: &str) -> u32 {
run_engine(url, Engine::Mpv)
}
+93 -1
View File
@@ -2,6 +2,10 @@
mod android_context; mod android_context;
mod auth; mod auth;
mod commands; mod commands;
/// The MediaPlayer conformance suite, exposed for the `player-conformance`
/// binary. One entry point rather than a public player module tree.
#[cfg(feature = "conformance")]
pub mod conformance_runner;
mod connectivity; mod connectivity;
mod credentials; mod credentials;
mod domain; mod domain;
@@ -96,6 +100,7 @@ use commands::{
lms_unsync_player, lms_unsync_player,
mark_download_completed, mark_download_completed,
mark_download_failed, mark_download_failed,
media_local_selection,
media_local_url, media_local_url,
offline_get_items, offline_get_items,
offline_is_available, offline_is_available,
@@ -224,6 +229,7 @@ use commands::{
repository_get_series_current_episode, repository_get_series_current_episode,
repository_get_series_episodes, repository_get_series_episodes,
repository_get_similar_items, repository_get_similar_items,
repository_get_stream_selection,
repository_get_subtitle_url, repository_get_subtitle_url,
repository_get_video_download_url, repository_get_video_download_url,
repository_get_video_stream_url, repository_get_video_stream_url,
@@ -732,6 +738,27 @@ fn create_player_backend(
/// Construct the tauri-specta command builder. Shared by `run()` and the /// Construct the tauri-specta command builder. Shared by `run()` and the
/// bindings-export test so the TypeScript bindings always match the handler. /// 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> { fn specta_builder() -> Builder<tauri::Wry> {
Builder::<tauri::Wry>::new() Builder::<tauri::Wry>::new()
// Throw on error so generated `commands.*` return Promise<T> and throw, // Throw on error so generated `commands.*` return Promise<T> and throw,
@@ -898,6 +925,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
mark_download_completed, mark_download_completed,
mark_download_failed, mark_download_failed,
media_local_url, media_local_url,
media_local_selection,
start_download, start_download,
enqueue_download, enqueue_download,
enqueue_video_downloads, enqueue_video_downloads,
@@ -984,6 +1012,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_search, repository_search,
repository_get_playback_info, repository_get_playback_info,
repository_get_video_stream_url, repository_get_video_stream_url,
repository_get_stream_selection,
repository_get_audio_stream_url, repository_get_audio_stream_url,
repository_get_audio_only_stream_url_for_video, repository_get_audio_only_stream_url_for_video,
repository_get_live_tv_channels, repository_get_live_tv_channels,
@@ -1206,6 +1235,7 @@ pub fn run() {
// listened for on the frontend via the generated bindings. // listened for on the frontend via the generated bindings.
builder.mount_events(app); builder.mount_events(app);
// In-app update, desktop only. // In-app update, desktop only.
// //
// Registered here rather than in the builder chain above because a // Registered here rather than in the builder chain above because a
@@ -1345,8 +1375,70 @@ pub fn run() {
playback_reporter.clone(), playback_reporter.clone(),
position_throttler.clone(), position_throttler.clone(),
); );
// Attached *after* the backend exists: the mpv handle is registered
// during its construction, and doing this in the order the code
// used to read produced "no mpv handle" every time — the surface was
// built before there was anything to draw from.
// Native video surface: put a GL area under Tauri's webview so mpv
// can draw beneath the controls (UR-080 / DR-231).
//
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
// walks a hard-coded two-hop path on every button press in the
// webview:
//
// webview.parent() // "This one should be GtkBox"
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-231
#[cfg(target_os = "linux")]
if crate::player::native_video::enabled() {
use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface (mpv drawn behind the webview, no reparenting)"
);
if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() {
Ok(vbox) => {
let handle = crate::player::mpv_backend::registered_handle();
if crate::player::video_surface::attach(&vbox, handle) {
info!("[INIT] Native video surface attached");
} else {
log::warn!("[INIT] Native video surface unavailable");
}
}
Err(e) => {
log::warn!("[INIT] No GTK vbox for the main window: {e}")
}
}
}
}
// 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( let player_controller = PlayerController::new(
backend, Box::new(crate::player::LegacyPlayer::new(
backend,
engine_capabilities(),
)),
playback_reporter.clone(), playback_reporter.clone(),
position_throttler.clone(), position_throttler.clone(),
); );
+4
View File
@@ -1125,6 +1125,8 @@ mod tests {
fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem { fn create_test_item_with_jellyfin_id(id: &str, jellyfin_id: &str) -> MediaItem {
MediaItem { MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: id.to_string(), id: id.to_string(),
title: format!("Track {}", id), title: format!("Track {}", id),
name: Some(format!("Track {}", id)), name: Some(format!("Track {}", id)),
@@ -1157,6 +1159,8 @@ mod tests {
fn create_test_item_local(id: &str) -> MediaItem { fn create_test_item_local(id: &str) -> MediaItem {
MediaItem { MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: id.to_string(), id: id.to_string(),
title: format!("Local Track {}", id), title: format!("Local Track {}", id),
name: Some(format!("Local Track {}", id)), name: Some(format!("Local Track {}", id)),
+56
View File
@@ -249,6 +249,56 @@ 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 // 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -377,6 +427,8 @@ mod tests {
// Create a test media item // Create a test media item
let media = MediaItem { let media = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "test_media".to_string(), id: "test_media".to_string(),
title: "Test Track".to_string(), title: "Test Track".to_string(),
name: Some("Test Track".to_string()), name: Some("Test Track".to_string()),
@@ -436,6 +488,8 @@ mod tests {
let mut backend = NullBackend::new(); let mut backend = NullBackend::new();
let media = MediaItem { let media = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "test_media".to_string(), id: "test_media".to_string(),
title: "Test Track".to_string(), title: "Test Track".to_string(),
name: Some("Test Track".to_string()), name: Some("Test Track".to_string()),
@@ -489,6 +543,8 @@ mod tests {
let mut backend = NullBackend::new(); let mut backend = NullBackend::new();
let media = MediaItem { let media = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "test_media".to_string(), id: "test_media".to_string(),
title: "Test Track".to_string(), title: "Test Track".to_string(),
name: Some("Test Track".to_string()), name: Some("Test Track".to_string()),
+289
View File
@@ -0,0 +1,289 @@
//! The conformance suite every [`MediaPlayer`] must pass.
//!
//! One set of behaviours, run against every engine: `FakePlayer` and `MpvPlayer`
//! in `cargo test`, `ExoPlayerPlayer` instrumented on a device, `WebviewPlayer`
//! in vitest. A new engine is finished when it passes this.
//!
//! Written *before* the second engine on purpose. A suite written afterwards
//! encodes whatever the first engine happened to do, which is how three separate
//! playback implementations drifted apart in the first place.
//!
//! Each case names the defect it exists to prevent. Two of them —
//! [`opens_at_a_start_position`] and [`seek_while_opening_is_honoured`] — fail
//! against the pre-migration mpv path, which is what makes them a reproduction
//! of DR-241 rather than a restatement of it.
//!
//! Engines differ in *when* an open completes, so the suite drives that through
//! a [`Harness`] rather than sleeping: the fake completes on demand, mpv waits
//! for its `FileLoaded` event, ExoPlayer for `STATE_READY`.
//!
//! Available to `cargo test` and, behind the `conformance` feature, to the
//! `player-conformance` binary — so an engine that cannot run in-process
//! (ExoPlayer on a device) is driven by exactly the same cases rather than by a
//! second, drifting checklist.
//!
//! TRACES: UR-081 | DR-243 | UT-220
use std::time::Duration;
use super::media_player::{MediaPlayer, OpenRequest, Phase};
/// How the suite drives one engine.
pub trait Harness {
type Player: MediaPlayer;
fn player(&mut self) -> &mut Self::Player;
/// A request this engine can actually open, at `start`.
fn request(&self, start: Duration) -> OpenRequest;
/// Block until an in-flight `open` has finished (or failed).
///
/// The fake completes on demand; a real engine waits for its own readiness
/// event. Never a sleep — a timing-dependent suite is worse than none.
fn settle(&mut self);
/// Whether the engine is producing audio. Engines that cannot answer may
/// return `None`, which skips the silence assertions rather than passing
/// them vacuously.
fn audible(&mut self) -> Option<bool>;
/// How far a landed position may differ from the one asked for. Keyframe
/// granularity makes exactness the wrong bar for a real decoder.
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) {
let delta = actual.abs_diff(expected);
assert!(
delta <= tolerance,
"{what}: expected ~{expected:?}, got {actual:?} (tolerance {tolerance:?})"
);
}
/// Opening at zero reaches a usable state and starts near the beginning.
pub fn opens_from_the_beginning<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
let s = h.player().snapshot();
assert!(
matches!(s.phase, Phase::Playing | Phase::Ready),
"after open the engine should hold media, phase was {:?}",
s.phase
);
assert_near(
s.position,
Duration::ZERO,
h.seek_tolerance(),
"start of item",
);
}
/// **DR-241.** Opening at a position starts *there*, not at zero.
///
/// The whole reason `OpenRequest` carries `start`. Under the previous contract a
/// caller had to `load()` then `seek()`, and because `loadfile` is asynchronous
/// the seek was issued against a player with nothing loaded, failed, and was
/// discarded — so resume and transcoded skip both played from the beginning.
pub fn opens_at_a_start_position<H: Harness>(h: &mut H) {
let start = Duration::from_secs(600);
let req = h.request(start);
h.player().open(req).expect("open failed");
h.settle();
let s = h.player().snapshot();
assert_ne!(
s.position,
Duration::ZERO,
"opened at {start:?} but playback began at zero - the start position was dropped"
);
assert_near(s.position, start, h.seek_tolerance(), "start position");
}
/// **DR-241.** A seek issued while opening is honoured, not lost.
///
/// The engine owns this window; no caller can avoid it, because a caller cannot
/// see when the pipeline becomes ready.
pub fn seek_while_opening_is_honoured<H: Harness>(h: &mut H) {
let target = Duration::from_secs(300);
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
// Deliberately before settle(): this is the race, expressed on purpose.
h.player().seek(target).expect("seek during open failed");
h.settle();
let s = h.player().snapshot();
assert_near(
s.position,
target,
h.seek_tolerance(),
"seek issued while opening",
);
}
/// A later intent wins: the seek replaces the start position it overtook.
pub fn seek_while_opening_overrides_start<H: Harness>(h: &mut H) {
let start = Duration::from_secs(600);
let target = Duration::from_secs(120);
let req = h.request(start);
h.player().open(req).expect("open failed");
h.player().seek(target).expect("seek during open failed");
h.settle();
assert_near(
h.player().snapshot().position,
target,
h.seek_tolerance(),
"seek should override the start position it overtook",
);
}
/// Seeking a settled item lands where asked.
pub fn seeks_after_open<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
let target = Duration::from_secs(420);
h.player().seek(target).expect("seek failed");
h.await_seek(target);
assert_near(
h.player().snapshot().position,
target,
h.seek_tolerance(),
"seek after open",
);
}
/// **DR-239.** Pause and play are reflected in the engine's own state.
///
/// An engine that changes nothing observable is indistinguishable from one that
/// ignored the call — which is exactly how a handler for mpv's `pause` property
/// sat unreachable while the UI waited for an event that never came.
pub fn pause_and_play_are_observable<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
h.player().pause().expect("pause failed");
assert_eq!(
h.player().snapshot().phase,
Phase::Paused,
"pause must be visible in the snapshot"
);
if let Some(audible) = h.audible() {
assert!(!audible, "a paused engine must be silent");
}
h.player().play().expect("play failed");
assert_eq!(
h.player().snapshot().phase,
Phase::Playing,
"play must be visible in the snapshot"
);
}
/// `close()` reaches Idle, is silent, and can be called twice.
pub fn close_is_silent_and_idempotent<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
h.player().close().expect("close failed");
assert_eq!(h.player().snapshot().phase, Phase::Idle);
if let Some(audible) = h.audible() {
assert!(!audible, "a closed engine must be silent");
}
h.player().close().expect("close must be idempotent");
assert_eq!(h.player().snapshot().phase, Phase::Idle);
}
/// Closing during an open must not let playback start afterwards.
///
/// The shape of the "audio keeps playing after leaving the player" report: an
/// open still in flight completed after the stop, and nothing was left to tell
/// it not to.
pub fn close_during_open_never_plays<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.player().close().expect("close during open failed");
h.settle();
let s = h.player().snapshot();
assert!(
!s.phase.is_active(),
"an open cancelled by close must not start playing, phase was {:?}",
s.phase
);
if let Some(audible) = h.audible() {
assert!(!audible, "an engine closed during open must be silent");
}
}
/// Volume, mute and rate round-trip through the snapshot.
pub fn transport_settings_round_trip<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
h.player().set_volume(0.25).expect("set_volume failed");
h.player().set_muted(true).expect("set_muted failed");
h.player().set_rate(1.5).expect("set_rate failed");
let s = h.player().snapshot();
assert!((s.volume - 0.25).abs() < 0.01, "volume did not round-trip");
assert!(s.muted, "mute did not round-trip");
assert!((s.rate - 1.5).abs() < 0.01, "rate did not round-trip");
}
/// Run every case against one engine.
///
/// Each case gets a fresh harness, because a suite whose cases depend on each
/// other's leftovers is one that hides state bugs instead of finding them.
#[macro_export]
macro_rules! media_player_conformance {
($name:ident, $make:expr) => {
mod $name {
use super::*;
use $crate::player::conformance as c;
macro_rules! case {
($case:ident) => {
#[test]
fn $case() {
let mut h = $make;
c::$case(&mut h);
}
};
}
case!(opens_from_the_beginning);
case!(opens_at_a_start_position);
case!(seek_while_opening_is_honoured);
case!(seek_while_opening_overrides_start);
case!(seeks_after_open);
case!(pause_and_play_are_observable);
case!(close_is_silent_and_idempotent);
case!(close_during_open_never_plays);
case!(transport_settings_round_trip);
}
};
}
+232
View File
@@ -0,0 +1,232 @@
//! A deterministic in-memory [`MediaPlayer`], for tests.
//!
//! Two jobs:
//!
//! 1. Give the conformance suite something that is correct by construction, so a
//! failure there means the *suite* is wrong rather than an engine.
//! 2. Let everything above the engine — controller, queue, autoplay, sleep
//! timer, session — be tested with no mpv, no device and no network. Most of
//! that logic is currently only reachable through a real engine, which is why
//! so little of it is covered.
//!
//! It models the one behaviour that matters most: **opening is not
//! instantaneous**. `open()` lands in [`Phase::Opening`] and stays there until
//! [`FakePlayer::complete_open`] is called, so a test can put a `seek` into that
//! window on purpose. That is the window DR-241 lived in.
//!
//! TRACES: UR-081 | DR-243
// `tick` and `fail_open` are for tests not yet written — the controller-level
// ones DR-245 unlocks. Remove this allow once those exist.
#![allow(dead_code)]
use std::time::Duration;
use super::backend::PlayerError;
use super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot};
#[derive(Debug, Clone, PartialEq)]
pub enum FakeEvent {
Opened { url: String, start: Duration },
Played,
Paused,
Closed,
Sought(Duration),
}
pub struct FakePlayer {
snapshot: PlaybackSnapshot,
/// Set while `Opening`; applied when the open completes.
pending_start: Duration,
/// A seek that arrived while opening. Honoured on completion, never dropped.
deferred_seek: Option<Duration>,
autoplay: bool,
duration: Duration,
/// Every call, in order — so tests can assert what an engine was *asked* to
/// do, not only where it ended up.
pub log: Vec<FakeEvent>,
/// Whether audio is being produced. `close()` must clear it; the bug that
/// motivated all this had a "stopped" player that was still audible.
pub audible: bool,
pub capabilities: Capabilities,
}
impl Default for FakePlayer {
fn default() -> Self {
Self::new()
}
}
impl FakePlayer {
pub fn new() -> Self {
Self {
snapshot: PlaybackSnapshot::default(),
pending_start: Duration::ZERO,
deferred_seek: None,
autoplay: true,
duration: Duration::from_secs(3600),
log: Vec::new(),
audible: false,
capabilities: Capabilities {
video: true,
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,
},
}
}
/// The item this fake will report once opened.
pub fn with_duration(mut self, duration: Duration) -> Self {
self.duration = duration;
self
}
/// Finish an in-flight `open`, as a real engine's "file loaded" would.
///
/// Applies the requested start position, then any seek that arrived while
/// opening — the later intent wins.
pub fn complete_open(&mut self) {
if self.snapshot.phase != Phase::Opening {
return;
}
self.snapshot.duration = Some(self.duration);
self.snapshot.seekable = true;
self.snapshot.position = self.deferred_seek.take().unwrap_or(self.pending_start);
if self.autoplay {
self.snapshot.phase = Phase::Playing;
self.audible = true;
} else {
self.snapshot.phase = Phase::Ready;
}
}
/// Advance playback, for tests that care about time passing.
pub fn tick(&mut self, by: Duration) {
if self.snapshot.phase.is_active() {
self.snapshot.position = (self.snapshot.position + by).min(self.duration);
if self.snapshot.position >= self.duration {
self.snapshot.phase = Phase::Ended;
self.audible = false;
}
}
}
pub fn fail_open(&mut self, why: &str) {
self.snapshot.phase = Phase::Failed(why.to_string());
self.audible = false;
}
}
impl MediaPlayer for FakePlayer {
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Opened {
url: req.selection.url.clone(),
start: req.start,
});
self.snapshot = PlaybackSnapshot {
phase: Phase::Opening,
volume: self.snapshot.volume,
muted: self.snapshot.muted,
rate: self.snapshot.rate,
audio_track: req.audio_track,
subtitle_track: req.subtitle_track,
..PlaybackSnapshot::default()
};
self.pending_start = req.start;
self.deferred_seek = None;
self.autoplay = req.autoplay;
self.audible = false;
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Played);
if self.snapshot.phase.has_media() {
if self.snapshot.phase == Phase::Opening {
self.autoplay = true;
} else {
self.snapshot.phase = Phase::Playing;
self.audible = true;
}
}
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Paused);
if self.snapshot.phase == Phase::Opening {
self.autoplay = false;
} else if self.snapshot.phase.has_media() {
self.snapshot.phase = Phase::Paused;
self.audible = false;
}
Ok(())
}
fn close(&mut self) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Closed);
self.snapshot = PlaybackSnapshot {
volume: self.snapshot.volume,
muted: self.snapshot.muted,
rate: self.snapshot.rate,
..PlaybackSnapshot::default()
};
self.pending_start = Duration::ZERO;
self.deferred_seek = None;
// An open that was still in flight must not come back to life.
self.autoplay = false;
self.audible = false;
Ok(())
}
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Sought(to));
match self.snapshot.phase {
// The window DR-241 lived in: hold it, do not discard it.
Phase::Opening => self.deferred_seek = Some(to),
Phase::Idle | Phase::Failed(_) => {
return Err(PlayerError {
message: "seek with nothing open".to_string(),
})
}
_ => self.snapshot.position = to.min(self.duration),
}
Ok(())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
self.snapshot.volume = volume.clamp(0.0, 1.0);
Ok(())
}
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> {
self.snapshot.muted = muted;
Ok(())
}
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> {
self.snapshot.rate = rate;
Ok(())
}
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.snapshot.audio_track = index;
Ok(())
}
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.snapshot.subtitle_track = index;
Ok(())
}
fn snapshot(&self) -> PlaybackSnapshot {
self.snapshot.clone()
}
fn capabilities(&self) -> Capabilities {
self.capabilities
}
}
@@ -0,0 +1,56 @@
//! `FakePlayer` runs the conformance suite.
//!
//! It is correct by construction, so a failure here means the *suite* is wrong,
//! not an engine. That is what makes it safe to trust the same cases when they
//! fail against a real one.
//!
//! TRACES: UR-081 | DR-243 | UT-220
use std::time::Duration;
use super::conformance::Harness;
use super::fake_player::FakePlayer;
use super::media::MediaItem;
use super::media_player::OpenRequest;
use crate::repository::stream_selection::StreamSelection;
struct FakeHarness {
player: FakePlayer,
}
impl FakeHarness {
fn new() -> Self {
Self {
player: FakePlayer::new().with_duration(Duration::from_secs(7200)),
}
}
}
impl Harness for FakeHarness {
type Player = FakePlayer;
fn player(&mut self) -> &mut FakePlayer {
&mut self.player
}
fn request(&self, start: Duration) -> OpenRequest {
let selection = StreamSelection::local_file("http://example.invalid/stream.mp4");
let media = MediaItem::sample("fake-item", &selection.url);
OpenRequest::new(media, selection).starting_at(start)
}
fn settle(&mut self) {
self.player.complete_open();
}
fn audible(&mut self) -> Option<bool> {
Some(self.player.audible)
}
/// Exact: the fake has no keyframes to round to, so any drift is a bug.
fn seek_tolerance(&self) -> Duration {
Duration::ZERO
}
}
crate::media_player_conformance!(fake, FakeHarness::new());
+154
View File
@@ -0,0 +1,154 @@
//! A [`MediaPlayer`] over the old [`PlayerBackend`] trait.
//!
//! Two purposes.
//!
//! **Migration.** Engines not yet ported — ExoPlayer, the webview element, the
//! null backend — keep working while `PlayerController` moves onto the new
//! contract (DR-245). Without this the port would have to land all four engines
//! at once.
//!
//! **Evidence.** It reproduces exactly what every caller used to do: `load`,
//! then `play`, then `seek` for a start position. Running the conformance suite
//! against it therefore shows the old path failing the cases the new one passes,
//! on the same engine and the same media — which is the difference between
//! asserting that a design was wrong and demonstrating it.
//!
//! It is deliberately a faithful reproduction, not a fixed-up one. Making it
//! pass would defeat the point.
//!
//! TRACES: UR-081 | 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::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.
has_item: bool,
}
impl<B: PlayerBackend> LegacyPlayer<B> {
pub fn new(inner: B, capabilities: Capabilities) -> Self {
Self {
inner,
capabilities,
has_item: false,
}
}
}
impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
/// Load, play, then seek — the sequence every caller used to write.
///
/// The seek is issued immediately, because a caller has no way to know when
/// the engine becomes ready. On an engine whose load is asynchronous it
/// fails and is discarded, and playback begins at zero: DR-241, reproduced.
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
self.inner.load(&req.media)?;
self.has_item = true;
if req.autoplay {
self.inner.play()?;
}
if !req.start.is_zero() {
// Faithfully ignoring the failure, exactly as the old callers did.
let _ = self.inner.seek(req.start.as_secs_f64());
}
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.inner.play()
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.inner.pause()
}
fn close(&mut self) -> Result<(), PlayerError> {
self.has_item = false;
self.inner.stop()
}
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
self.inner.seek(to.as_secs_f64())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
self.inner.set_volume(volume)
}
/// The old trait has no mute. Folding it into volume would lose the user's
/// level, so this reports unsupported rather than pretending.
fn set_muted(&mut self, _muted: bool) -> Result<(), PlayerError> {
Err(PlayerError {
message: "mute is not supported by this backend".to_string(),
})
}
fn set_rate(&mut self, _rate: f64) -> Result<(), PlayerError> {
Err(PlayerError {
message: "playback rate is not supported by this backend".to_string(),
})
}
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.inner.set_audio_track(index.unwrap_or(-1))
}
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.inner.set_subtitle_track(index)
}
fn snapshot(&self) -> PlaybackSnapshot {
let phase = match self.inner.state() {
_ if !self.has_item => Phase::Idle,
PlayerState::Playing { .. } => Phase::Playing,
PlayerState::Paused { .. } => Phase::Paused,
PlayerState::Idle => Phase::Idle,
PlayerState::Error { error, .. } => Phase::Failed(error),
// `Loading` is the closest the old trait comes to an opening state,
// but it is set once the engine has accepted the item rather than
// while it is still accepting it — which is precisely the window it
// cannot describe.
PlayerState::Loading { .. } | PlayerState::Seeking { .. } => Phase::Ready,
};
PlaybackSnapshot {
phase,
position: duration_from_secs(self.inner.position()).unwrap_or(Duration::ZERO),
duration: self.inner.duration().and_then(duration_from_secs),
seekable: true,
volume: self.inner.volume(),
muted: false,
rate: 1.0,
audio_track: None,
subtitle_track: None,
}
}
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
}
}
+79
View File
@@ -115,6 +115,18 @@ pub struct MediaItem {
/// Whether the video requires server-side transcoding /// Whether the video requires server-side transcoding
#[serde(default)] #[serde(default)]
pub needs_transcoding: bool, pub needs_transcoding: bool,
/// How this item's stream is fetched, as the backend decided it.
///
/// Carried on the queue item so a later seek/reload does not have to guess.
/// `None` for items queued by a path that never negotiated (audio tracks,
/// direct URLs) and for anything queued before this field existed, where the
/// caller falls back to `needs_transcoding` — every transcode this app
/// requests is HLS (DR-140), so that fallback is exact rather than a guess.
///
/// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
#[serde(default)]
pub transport: Option<crate::repository::Transport>,
/// Video width in pixels /// Video width in pixels
#[serde(default)] #[serde(default)]
pub video_width: Option<u32>, pub video_width: Option<u32>,
@@ -159,6 +171,19 @@ 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> {
@@ -186,6 +211,48 @@ impl MediaItem {
} }
} }
impl MediaItem {
/// A minimal item for tests.
///
/// The struct has twenty-odd fields, almost none of which any given test
/// cares about, and repeating the literal per test is how a new field ends
/// up added in thirty places. Set what matters on the result.
///
/// TRACES: UR-081 | DR-243
#[cfg(any(test, feature = "conformance"))]
pub fn sample(id: &str, url: &str) -> Self {
Self {
transport: None,
id: id.to_string(),
title: id.to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Video,
source: MediaSource::DirectUrl {
url: url.to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -360,6 +427,8 @@ mod tests {
#[test] #[test]
fn test_media_item_creation_minimal() { fn test_media_item_creation_minimal() {
let item = MediaItem { let item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "item-1".to_string(), id: "item-1".to_string(),
title: "Test Item".to_string(), title: "Test Item".to_string(),
name: None, name: None,
@@ -396,6 +465,8 @@ mod tests {
#[test] #[test]
fn test_media_item_jellyfin_id() { fn test_media_item_jellyfin_id() {
let item = MediaItem { let item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "item-2".to_string(), id: "item-2".to_string(),
title: "Test".to_string(), title: "Test".to_string(),
name: None, name: None,
@@ -431,6 +502,8 @@ mod tests {
#[test] #[test]
fn test_media_item_jellyfin_id_local() { fn test_media_item_jellyfin_id_local() {
let item = MediaItem { let item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "item-3".to_string(), id: "item-3".to_string(),
title: "Local".to_string(), title: "Local".to_string(),
name: None, name: None,
@@ -466,6 +539,8 @@ mod tests {
#[test] #[test]
fn test_media_item_jellyfin_id_direct_url() { fn test_media_item_jellyfin_id_direct_url() {
let item = MediaItem { let item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "item-4".to_string(), id: "item-4".to_string(),
title: "Direct".to_string(), title: "Direct".to_string(),
name: None, name: None,
@@ -508,6 +583,8 @@ mod tests {
}; };
let item = MediaItem { let item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "item-subs".to_string(), id: "item-subs".to_string(),
title: "With Subs".to_string(), title: "With Subs".to_string(),
name: None, name: None,
@@ -543,6 +620,8 @@ mod tests {
#[test] #[test]
fn test_media_item_serialization() { fn test_media_item_serialization() {
let item = MediaItem { let item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "serial-item".to_string(), id: "serial-item".to_string(),
title: "Serial Test".to_string(), title: "Serial Test".to_string(),
name: Some("Name".to_string()), name: Some("Name".to_string()),
+328
View File
@@ -0,0 +1,328 @@
//! The `MediaPlayer` contract: one API, interchangeable engines.
//!
//! See docs/specs/media-player-controller.md.
//!
//! This replaces [`PlayerBackend`](super::backend::PlayerBackend), which
//! abstracts a *device* — `load`, then `seek` — rather than an *intent*. That
//! distinction is not academic; it produced four shipped defects in one day:
//!
//! * A start position was not expressible, so every caller sequenced
//! `load()` + `seek()` itself and each raced the engine's asynchronous load
//! independently. Resume worked through one caller and silently failed through
//! another (DR-241).
//! * Whether a stream could be seeked in place was decided *above* the engines,
//! by a truth table in a command handler, for engines it does not own (DR-238).
//! * Nothing in the contract obliged an engine to report its own state, so a
//! handler for mpv's `pause` property sat unreachable and the play/pause
//! control never moved (DR-239).
//!
//! The contract below is written so each of those is a compile-time or
//! conformance-time failure rather than a runtime surprise.
//!
//! TRACES: UR-081 | DR-242
// Scaffolding: nothing consumes this contract until `PlayerController` is
// ported to it (DR-245). Kept out of `cfg(test)` deliberately — it is production
// code being built in shippable steps, not a test fixture. Remove this allow
// when the controller talks to `MediaPlayer`.
#![allow(dead_code)]
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.
///
/// `Opening` is the state the previous design could not express, and is the
/// direct cause of DR-241: a seek that arrived while the engine had nothing
/// loaded had no phase to be queued against, so it was simply discarded and
/// playback began at zero.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Phase {
/// Nothing loaded. `close()` must reach this, and must be silent here.
Idle,
/// An `open` is in flight. Position is not yet meaningful; a `seek` arriving
/// now must be honoured once the engine reaches `Ready`, never dropped.
Opening,
/// Loaded and able to play, but not advancing.
Ready,
Playing,
Paused,
/// Reached the end of the item by itself. Distinct from `Idle`, because
/// autoplay cares which one happened.
Ended,
Failed(String),
}
impl Phase {
/// Whether the engine currently holds an item.
pub fn has_media(&self) -> bool {
!matches!(self, Phase::Idle | Phase::Failed(_))
}
/// Whether playback is advancing.
pub fn is_active(&self) -> bool {
matches!(self, Phase::Playing)
}
}
/// Everything the UI consumes, read as one coherent value.
///
/// Deliberately a single snapshot rather than a dozen getters: reading position
/// and duration through separate calls is how a paused player reported
/// `<position> / 0.0` when a file unloaded between them.
#[derive(Debug, Clone)]
pub struct PlaybackSnapshot {
pub phase: Phase,
pub position: Duration,
/// `None` while unknown — a live stream, or an item still opening.
pub duration: Option<Duration>,
/// Whether `seek` can be expected to land. False for live edges.
pub seekable: bool,
/// 0.0 1.0.
pub volume: f32,
pub muted: bool,
pub rate: f64,
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
}
impl Default for PlaybackSnapshot {
fn default() -> Self {
Self {
phase: Phase::Idle,
position: Duration::ZERO,
duration: None,
seekable: false,
volume: 1.0,
muted: false,
rate: 1.0,
audio_track: None,
subtitle_track: None,
}
}
}
/// What an engine can do, so callers adapt without naming engines.
///
/// If a caller ever branches on *which* engine it holds, this struct is missing
/// something — add it here rather than sniffing. Engine identity leaking into
/// callers is the coupling DR-238 came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capabilities {
/// The engine renders pictures, not only sound.
pub video: bool,
/// Audio settings (EQ, normalisation, gapless) are honoured.
pub audio_settings: bool,
/// Subtitle tracks can be selected without re-opening.
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.
///
/// `start` is the reason this type exists. Carrying it here — rather than
/// leaving callers to `seek` after `open` — is what closes the load/seek race,
/// because the engine is the only layer that knows when its pipeline can accept
/// a position.
#[derive(Debug, Clone)]
pub struct OpenRequest {
pub media: MediaItem,
pub selection: StreamSelection,
/// Where to begin. `Duration::ZERO` means the start of the item.
pub start: Duration,
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
/// Begin playing as soon as the engine is able.
pub autoplay: bool,
}
impl OpenRequest {
/// Open at the beginning, playing.
pub fn new(media: MediaItem, selection: StreamSelection) -> Self {
Self {
media,
selection,
start: Duration::ZERO,
audio_track: None,
subtitle_track: None,
autoplay: true,
}
}
pub fn starting_at(mut self, start: Duration) -> Self {
self.start = start;
self
}
}
/// Anything that can present media.
///
/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of
/// them must pass [`super::conformance`].
pub trait MediaPlayer: Send {
/// Present `req.selection`, beginning at `req.start`.
///
/// One operation, deliberately. An engine that cannot start at an offset
/// natively absorbs that internally — by deferring until loaded, or by
/// re-opening — because it is the only layer that knows when it can.
/// Callers must never follow `open` with a `seek` to achieve a start
/// position; that is the bug this signature exists to prevent.
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>;
fn play(&mut self) -> Result<(), PlayerError>;
fn pause(&mut self) -> Result<(), PlayerError>;
/// Stop and release the current item.
///
/// Must be **idempotent** and must leave the engine **silent**. "Stopped"
/// and "producing no audio" were not the same thing in the previous design,
/// and the gap between them is audible.
fn close(&mut self) -> Result<(), PlayerError>;
/// Seek to an absolute position on the item's own timeline.
///
/// Whether that is an in-place seek or a re-open of the stream is the
/// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer
/// cannot make a server transcode from a new offset. Callers state the
/// destination and nothing else.
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError>;
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>;
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
/// One coherent read of the engine's state.
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))
);
}
}
+187 -19
View File
@@ -5,8 +5,18 @@
pub mod autoplay; pub mod autoplay;
pub mod backend; pub mod backend;
pub mod background_policy; pub mod background_policy;
#[cfg(any(test, feature = "conformance"))]
pub mod conformance;
pub mod events; pub mod events;
#[cfg(any(test, feature = "conformance"))]
pub mod fake_player;
#[cfg(test)]
mod fake_player_conformance;
pub mod legacy_player;
pub mod media; pub mod media;
pub mod media_player;
#[cfg(target_os = "linux")]
pub mod mpv_player;
pub mod queue; pub mod queue;
pub mod seek; pub mod seek;
pub mod session; pub mod session;
@@ -24,16 +34,38 @@ pub mod android;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod mpv_backend; pub mod mpv_backend;
/// Whether this process renders video natively — one answer, three consumers
/// (UR-080 / DR-231, DR-235).
pub mod native_video;
/// mpv's render API into a framebuffer we own (UR-080 / DR-231, IR-033).
///
/// Deliberately *not* GTK-gated beyond the platform that currently builds it:
/// everything here is the portable half, and Windows reuses it unchanged behind
/// its own surface.
#[cfg(target_os = "linux")]
pub mod mpv_render;
/// The native video surface mpv renders into (UR-080 / DR-231).
///
/// Linux-gated because the *surface* is GTK. Everything around it — the render
/// context, its lifetime, frame pacing, the device profile — is not.
#[cfg(target_os = "linux")]
pub mod video_surface;
// Platforms with no native audio backend (e.g. Windows) render audio-only // Platforms with no native audio backend (e.g. Windows) render audio-only
// playback through a webview <audio> element, mirroring how all video renders. // playback through a webview <audio> element, mirroring how all video renders.
#[cfg(not(any(target_os = "linux", target_os = "android")))] #[cfg(not(any(target_os = "linux", target_os = "android")))]
pub mod webview_audio_backend; pub mod webview_audio_backend;
// Re-export commonly used types // Re-export commonly used types
use crate::repository::stream_selection::StreamSelection;
pub use autoplay::{AutoplayDecision, AutoplaySettings}; pub use autoplay::{AutoplayDecision, AutoplaySettings};
pub use backend::{NullBackend, PlayerBackend, PlayerError}; pub use backend::{NullBackend, PlayerBackend, PlayerError};
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter}; pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
pub use legacy_player::LegacyPlayer;
pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack}; pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
pub use media_player::{MediaPlayer, OpenRequest, Phase};
pub use queue::{QueueManager, RepeatMode}; pub use queue::{QueueManager, RepeatMode};
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy}; pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
pub use session::{MediaSessionManager, MediaSessionType}; pub use session::{MediaSessionManager, MediaSessionType};
@@ -208,7 +240,9 @@ use crate::utils::conversions::seconds_to_ticks;
/// Central player controller that coordinates playback /// Central player controller that coordinates playback
pub struct PlayerController { pub struct PlayerController {
backend: Arc<Mutex<Box<dyn PlayerBackend>>>, /// 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>>>,
queue: Arc<Mutex<QueueManager>>, queue: Arc<Mutex<QueueManager>>,
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>, jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
muted: bool, muted: bool,
@@ -307,7 +341,7 @@ pub struct PlayerController {
impl PlayerController { impl PlayerController {
pub fn new( pub fn new(
backend: Box<dyn PlayerBackend>, backend: Box<dyn MediaPlayer>,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>, playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>, position_throttler: Arc<EventThrottler>,
) -> Self { ) -> Self {
@@ -485,7 +519,12 @@ impl PlayerController {
/// Used on platforms where video is rendered outside the native backend /// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the /// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it. /// item, but MPV must not start a redundant decode for it.
#[cfg(target_os = "linux")] ///
/// 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.
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> { pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!( debug!(
"[PlayerController] set_current_item (no backend load): {}", "[PlayerController] set_current_item (no backend load): {}",
@@ -538,8 +577,16 @@ impl PlayerController {
*self.html5_playing.lock_safe() = None; *self.html5_playing.lock_safe() = None;
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock_safe();
backend.load(item)?; // One operation: the engine is handed the item and where to begin, so
backend.play()?; // there is no window between them for a position to be lost in.
backend.open(OpenRequest::new(
item.clone(),
StreamSelection::for_queued_item(
item.playable_url(),
item.transport,
item.needs_transcoding,
),
))?;
drop(backend); drop(backend);
// A different item is loading; the last one's reported position must not // A different item is loading; the last one's reported position must not
@@ -713,7 +760,7 @@ impl PlayerController {
return Ok(()); return Ok(());
} }
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock_safe();
if backend.state().is_playing() { if backend.snapshot().phase.is_active() {
backend.pause() backend.pause()
} else { } else {
backend.play() backend.play()
@@ -738,10 +785,32 @@ impl PlayerController {
let position = self.absolute_position(); let position = self.absolute_position();
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock_safe();
backend.stop()?; backend.close()?;
drop(backend); drop(backend);
self.clear_reported_time(); 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 { if let Some(jellyfin_id) = jellyfin_id {
self.report_stopped_at(jellyfin_id, position); self.report_stopped_at(jellyfin_id, position);
} }
@@ -819,7 +888,7 @@ impl PlayerController {
// If we're more than 3 seconds in, restart current track // If we're more than 3 seconds in, restart current track
{ {
let backend = self.backend.lock_safe(); let backend = self.backend.lock_safe();
if backend.position() > 3.0 { if backend.snapshot().position.as_secs_f64() > 3.0 {
debug!("[PlayerController] previous: restarting current track (position > 3s)"); debug!("[PlayerController] previous: restarting current track (position > 3s)");
drop(backend); drop(backend);
return self.seek(0.0); return self.seek(0.0);
@@ -851,7 +920,7 @@ impl PlayerController {
/// timeline and is what every caller outside the player itself means. /// timeline and is what every caller outside the player itself means.
pub fn seek(&self, position: f64) -> Result<(), PlayerError> { pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock_safe();
backend.seek(position) backend.seek(Duration::from_secs_f64(position.max(0.0)))
} }
/// Seek to an **absolute** position on the item's own timeline. /// Seek to an **absolute** position on the item's own timeline.
@@ -902,23 +971,48 @@ impl PlayerController {
/// Set the active audio track by stream index /// Set the active audio track by stream index
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> { pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock_safe();
backend.set_audio_track(stream_index) backend.select_audio_track(Some(stream_index))
} }
/// Set the active subtitle track by stream index (None to disable subtitles) /// Set the active subtitle track by stream index (None to disable subtitles)
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> { pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock_safe();
backend.set_subtitle_track(stream_index) backend.select_subtitle_track(stream_index)
} }
/// Get current state /// Get current state
pub fn state(&self) -> PlayerState { pub fn state(&self) -> PlayerState {
self.backend.lock_safe().state() 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()
} }
/// Get current position /// Get current position
pub fn position(&self) -> f64 { pub fn position(&self) -> f64 {
self.backend.lock_safe().position() self.backend.lock_safe().snapshot().position.as_secs_f64()
} }
/// The position on the **item's own timeline**, whatever is rendering it. /// The position on the **item's own timeline**, whatever is rendering it.
@@ -944,7 +1038,7 @@ impl PlayerController {
/// ///
/// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177 /// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
pub fn absolute_position(&self) -> f64 { pub fn absolute_position(&self) -> f64 {
let native = self.backend.lock_safe().position().max(0.0); let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
let reported = self.reported_time.lock_safe().last_position(); let reported = self.reported_time.lock_safe().last_position();
let base = if self.is_background_audio_active() { let base = if self.is_background_audio_active() {
*self.background_audio_base.lock_safe() *self.background_audio_base.lock_safe()
@@ -1008,10 +1102,34 @@ impl PlayerController {
/// ///
/// TRACES: UR-005 | DR-178 /// TRACES: UR-005 | DR-178
pub fn duration(&self) -> Option<f64> { 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 self.backend
.lock_safe() .lock_safe()
.duration() .snapshot()
.or_else(|| self.observed_duration()) .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)
})
} }
/// Get queue reference /// Get queue reference
@@ -1066,7 +1184,7 @@ impl PlayerController {
/// Get current volume (0.0 - 1.0) /// Get current volume (0.0 - 1.0)
pub fn volume(&self) -> f32 { pub fn volume(&self) -> f32 {
self.backend.lock_safe().volume() self.backend.lock_safe().snapshot().volume
} }
/// Check if muted /// Check if muted
@@ -1167,7 +1285,7 @@ impl PlayerController {
drop(timer); drop(timer);
// Stop the backend // Stop the backend
if let Err(e) = backend.lock_safe().stop() { if let Err(e) = backend.lock_safe().close() {
error!("[SleepTimer] Failed to stop playback: {}", e); error!("[SleepTimer] Failed to stop playback: {}", e);
} }
continue; continue;
@@ -1897,6 +2015,8 @@ impl PlayerController {
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?; .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
let media_item = MediaItem { let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: next.id.clone(), id: next.id.clone(),
title: next.name.clone(), title: next.name.clone(),
name: Some(next.name.clone()), name: Some(next.name.clone()),
@@ -2180,7 +2300,10 @@ impl Default for PlayerController {
let playback_reporter = Arc::new(TokioMutex::new(None)); let playback_reporter = Arc::new(TokioMutex::new(None));
let position_throttler = Arc::new(EventThrottler::new()); let position_throttler = Arc::new(EventThrottler::new());
Self::new( Self::new(
Box::new(NullBackend::new()), Box::new(LegacyPlayer::new(
NullBackend::new(),
crate::player::media_player::Capabilities::mpv(),
)),
playback_reporter, playback_reporter,
position_throttler, position_throttler,
) )
@@ -2189,6 +2312,35 @@ impl Default for PlayerController {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// 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::*; use super::*;
/// Test emitter that captures events for asserting the HTML5 report methods /// Test emitter that captures events for asserting the HTML5 report methods
@@ -2580,6 +2732,8 @@ mod tests {
fn create_test_items(count: usize) -> Vec<MediaItem> { fn create_test_items(count: usize) -> Vec<MediaItem> {
(0..count) (0..count)
.map(|i| MediaItem { .map(|i| MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: format!("item_{}", i), id: format!("item_{}", i),
title: format!("Track {}", i + 1), title: format!("Track {}", i + 1),
name: Some(format!("Track {}", i + 1)), name: Some(format!("Track {}", i + 1)),
@@ -3792,6 +3946,8 @@ mod tests {
// Queue holds the episode that just finished playing // Queue holds the episode that just finished playing
let episode = MediaItem { let episode = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
media_type: MediaType::Video, media_type: MediaType::Video,
source: MediaSource::Remote { source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mkv".to_string(), stream_url: "http://example.com/ep1.mkv".to_string(),
@@ -3827,6 +3983,8 @@ mod tests {
// Mirrors what player_enter_background_audio builds: the episode as AUDIO. // Mirrors what player_enter_background_audio builds: the episode as AUDIO.
let episode = MediaItem { let episode = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
item_type: Some("Episode".to_string()), item_type: Some("Episode".to_string()),
media_type: MediaType::Audio, // audio-only handoff, not Video media_type: MediaType::Audio, // audio-only handoff, not Video
series_id: Some("series1".to_string()), series_id: Some("series1".to_string()),
@@ -3943,6 +4101,8 @@ mod tests {
// Currently playing: ep2 handed off to audio-only background playback. // Currently playing: ep2 handed off to audio-only background playback.
let episode = MediaItem { let episode = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "ep2".to_string(), id: "ep2".to_string(),
item_type: Some("Episode".to_string()), item_type: Some("Episode".to_string()),
media_type: MediaType::Audio, media_type: MediaType::Audio,
@@ -3978,6 +4138,8 @@ mod tests {
/// URL carrying the handoff position. /// URL carrying the handoff position.
fn audio_only_episode(runtime_seconds: f64) -> MediaItem { fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
MediaItem { MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "ep2".to_string(), id: "ep2".to_string(),
item_type: Some("Episode".to_string()), item_type: Some("Episode".to_string()),
media_type: MediaType::Audio, media_type: MediaType::Audio,
@@ -3998,6 +4160,8 @@ mod tests {
/// handoff point. /// handoff point.
fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem { fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
MediaItem { MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
source: MediaSource::Local { source: MediaSource::Local {
file_path: std::path::PathBuf::from("/downloads/ep2.mkv"), file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
jellyfin_item_id: Some("ep2".to_string()), jellyfin_item_id: Some("ep2".to_string()),
@@ -4682,6 +4846,8 @@ mod tests {
controller.set_repository(Arc::new(MockEpisodeRepo::season(3))); controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
let episode = MediaItem { let episode = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "ep2".to_string(), id: "ep2".to_string(),
item_type: Some("Episode".to_string()), item_type: Some("Episode".to_string()),
media_type: MediaType::Video, media_type: MediaType::Video,
@@ -4717,6 +4883,8 @@ mod tests {
let controller = PlayerController::default(); let controller = PlayerController::default();
let episode = MediaItem { let episode = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
media_type: MediaType::Video, media_type: MediaType::Video,
source: MediaSource::Remote { source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mkv".to_string(), stream_url: "http://example.com/ep1.mkv".to_string(),
+150 -9
View File
@@ -34,6 +34,19 @@ pub struct MpvBackend {
/// through reported 0.0 / unknown exactly when end-of-file handling needed to /// through reported 0.0 / unknown exactly when end-of-file handling needed to
/// know where playback reached. See [`ObservedTime`]. /// know where playback reached. See [`ObservedTime`].
observed: Arc<Mutex<ObservedTime>>, observed: Arc<Mutex<ObservedTime>>,
/// A seek that arrived before MPV had a file to seek in.
///
/// `loadfile` is asynchronous: it returns as soon as the command is queued,
/// so `time-pos` is not yet a resolvable property and setting it fails. A
/// seek issued in that window used to be dropped on the floor, and the two
/// callers that do exactly this are the ones a viewer notices — resume, and
/// a transcoded seek, both of which re-open the stream and then ask for a
/// position. The stream reloaded and played from zero.
///
/// Held here and applied by the `FileLoaded` arm.
///
/// TRACES: UR-040, UR-005 | DR-241
pending_seek: Arc<Mutex<Option<f64>>>,
} }
struct InternalState { struct InternalState {
@@ -89,6 +102,32 @@ fn get_stream_url(media: &MediaItem) -> String {
} }
} }
/// The mpv handle of the backend this process created, for the video surface.
///
/// A `OnceLock` rather than a field reached through `PlayerBackend`, because the
/// trait is cross-platform and a raw mpv pointer is not something every backend
/// should have to pretend to have. Stored as `usize` because a raw pointer is
/// neither `Send` nor `Sync`; the only consumer is the GTK main thread, which is
/// also where mpv was created.
///
/// Written once at construction and never cleared: the backend outlives the
/// window, so there is no window in which this could dangle while a surface is
/// still using it.
///
/// TRACES: UR-080 | DR-231
static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
/// The registered handle, or null if no MPV backend was created (initialisation
/// can fail, and the app falls back to a no-op backend rather than dying).
///
/// TRACES: UR-080 | DR-231
pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
MPV_HANDLE
.get()
.map(|p| *p as *mut libmpv_sys::mpv_handle)
.unwrap_or(std::ptr::null_mut())
}
impl MpvBackend { impl MpvBackend {
/// Create a new MPV backend /// Create a new MPV backend
pub fn new( pub fn new(
@@ -137,9 +176,28 @@ impl MpvBackend {
message: format!("Failed to configure MPV audio-display: {:?}", e), message: format!("Failed to configure MPV audio-display: {:?}", e),
})?; })?;
mpv.set_property("video", "no").map_err(|e| PlayerError { // Video is disabled unless this process is drawing it.
message: format!("Failed to configure MPV video: {:?}", e), //
})?; // `video: no` is why mpv has never decoded a frame here: Linux video has
// always gone through the webview, and decoding it twice would burn a
// core for a picture nobody sees. With native video on, mpv needs both
// the decoder *and* `vo=libmpv` — the render API only works through that
// output, and the default would try to open a window of its own.
//
// Set at construction because mpv resolves the video output when it
// initialises; flipping it later does not re-open one.
//
// TRACES: UR-080 | DR-231, DR-235
if super::native_video::enabled() {
mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
message: format!("Failed to select the libmpv video output: {:?}", e),
})?;
info!("[MpvBackend] native video enabled (vo=libmpv)");
} else {
mpv.set_property("video", "no").map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
}
// Set volume to 100% (we'll control via MPV's volume property) // Set volume to 100% (we'll control via MPV's volume property)
mpv.set_property("volume", 100i64) mpv.set_property("volume", 100i64)
@@ -178,13 +236,21 @@ impl MpvBackend {
})); }));
let backend = MpvBackend { let backend = MpvBackend {
mpv: Arc::new(mpv), mpv: {
let mpv = Arc::new(mpv);
// Publish the handle for the video surface (DR-231). Ignores a
// second call: only one MPV backend is ever constructed, and a
// failed re-init must not replace a live handle.
let _ = MPV_HANDLE.set(mpv.ctx.as_ptr() as usize);
mpv
},
state, state,
event_emitter, event_emitter,
audio_settings: AudioSettings::default(), audio_settings: AudioSettings::default(),
playback_reporter, playback_reporter,
position_throttler, position_throttler,
last_seek_time: Arc::new(AtomicU64::new(0)), last_seek_time: Arc::new(AtomicU64::new(0)),
pending_seek: Arc::new(Mutex::new(None)),
observed: Arc::new(Mutex::new(ObservedTime::default())), observed: Arc::new(Mutex::new(ObservedTime::default())),
}; };
@@ -202,6 +268,7 @@ impl MpvBackend {
let state = self.state.clone(); let state = self.state.clone();
let reporter = self.playback_reporter.clone(); let reporter = self.playback_reporter.clone();
let throttler = self.position_throttler.clone(); let throttler = self.position_throttler.clone();
let pending_seek_for_events = self.pending_seek.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
info!("[MpvBackend] Event loop started"); info!("[MpvBackend] Event loop started");
@@ -211,6 +278,30 @@ impl MpvBackend {
error!("[MpvBackend] Failed to disable deprecated events: {:?}", e); error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
}); });
// libmpv delivers PropertyChange only for properties registered
// here. Every name matched in the loop below needs a line in this
// block or its handler is unreachable — an omission that reads as
// working code, because the handler is sitting right there.
// UT-218 holds the two lists together.
//
// `pause` drives the play/pause control: the UI consumes
// StateChanged rather than tracking playback itself, per the
// one-directional state rule. Unobserved, the event never came and
// the button never moved. Invisible until native video shipped,
// because the webview <video> element's own DOM events drove that
// control on Linux.
//
// TRACES: UR-005 | DR-239
ev_ctx
.observe_property("pause", libmpv::Format::Flag, 0)
.unwrap_or_else(|e| {
error!(
"[MpvBackend] Failed to observe 'pause': {:?} — the play/pause \
control will not follow the player",
e
);
});
loop { loop {
match ev_ctx.wait_event(1.0) { match ev_ctx.wait_event(1.0) {
Some(Ok(event)) => match event { Some(Ok(event)) => match event {
@@ -220,6 +311,43 @@ impl MpvBackend {
libmpv::events::Event::FileLoaded => { libmpv::events::Event::FileLoaded => {
info!("[MpvBackend] File loaded"); info!("[MpvBackend] File loaded");
// Apply a seek that arrived while there was nothing
// to seek in. TRACES: UR-040, UR-005 | DR-241
{
let target = pending_seek_for_events.lock_safe().take();
if let Some(position) = target {
match mpv.set_property("time-pos", position) {
Ok(()) => info!(
"[MpvBackend] applied deferred seek to {position}"
),
Err(e) => warn!(
"[MpvBackend] deferred seek to {position} failed: {:?}",
e
),
}
}
}
// Geometry, so "the picture does not fill the screen"
// can be attributed rather than guessed at. `width`/
// `height` are the decoded frame; `dwidth`/`dheight`
// are what mpv will *display* after aspect
// correction. A file that carries its letterbox
// baked into the picture reports a 16:9 dwidth and
// is then pillarboxed on a wider panel — which looks
// identical to a rendering bug from outside.
{
let n = |k: &str| mpv.get_property::<i64>(k).unwrap_or(-1);
info!(
"[MpvBackend] video geometry: {}x{} decoded, {}x{} display, aspect {:?}",
n("width"),
n("height"),
n("dwidth"),
n("dheight"),
mpv.get_property::<f64>("video-params/aspect").ok(),
);
}
// Get duration // Get duration
if let Ok(duration) = mpv.get_property::<f64>("duration") { if let Ok(duration) = mpv.get_property::<f64>("duration") {
if let Some(emitter) = &event_emitter { if let Some(emitter) = &event_emitter {
@@ -522,11 +650,24 @@ impl PlayerBackend for MpvBackend {
.as_millis() as u64; .as_millis() as u64;
self.last_seek_time.store(now, Ordering::Relaxed); self.last_seek_time.store(now, Ordering::Relaxed);
self.mpv // `time-pos` only resolves while a file is loaded. `loadfile` is
.set_property("time-pos", position) // asynchronous, so a seek issued straight after a reload — resume, or a
.map_err(|e| PlayerError { // transcoded seek — lands in a window where this fails, and dropping it
message: format!("Failed to seek: {:?}", e), // there is what makes the stream play from zero instead of the position
})?; // that was asked for. Hold it and let `FileLoaded` apply it.
// TRACES: UR-040, UR-005 | DR-241
if let Err(e) = self.mpv.set_property("time-pos", position) {
debug!(
"[MpvBackend] seek to {position} deferred until the file loads ({:?})",
e
);
*self.pending_seek.lock_safe() = Some(position);
self.observed.lock_safe().record_position(position);
return Ok(());
}
// A seek that lands clears any earlier deferred one: the newer intent wins.
*self.pending_seek.lock_safe() = None;
// The poll thread suppresses updates for 150ms after a seek, so without // The poll thread suppresses updates for 150ms after a seek, so without
// this a file ending inside that window would report the pre-seek time. // this a file ending inside that window would report the pre-seek time.
+46
View File
@@ -13,6 +13,52 @@ mod tests {
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
/// Every property the event loop *handles* must also be *observed*.
///
/// libmpv only delivers `PropertyChange` for properties registered with
/// `mpv_observe_property`. A `match` arm for an unobserved property is
/// unreachable code that looks exactly like working code: the handler is
/// right there, so the behaviour reads as implemented.
///
/// This cost a real bug. `pause` was handled and never observed, so
/// `StateChanged` was never emitted on pause or resume. It stayed invisible
/// while Linux video played in the webview, because the `<video>` element's
/// own DOM events drove the play/pause control; turning native video on made
/// the UI depend on the event that never came, and the button stopped
/// responding.
///
/// Asserted against the source because there is no way to observe the
/// registration at runtime without a live mpv instance.
///
/// TRACES: UR-005 | DR-239 | UT-218
#[test]
fn test_every_handled_property_is_observed() {
let src = include_str!("mpv_backend.rs");
let handled: Vec<&str> = src
.match_indices("PropertyChange { name: \"")
.filter_map(|(i, m)| {
let rest = &src[i + m.len()..];
rest.find('"').map(|end| &rest[..end])
})
.collect();
assert!(
!handled.is_empty(),
"no PropertyChange arms found - has the event loop been restructured?"
);
for name in handled {
let observed = format!("observe_property(\"{name}\"");
assert!(
src.contains(&observed),
"mpv_backend.rs handles PropertyChange for {name:?} but never calls \
observe_property({name:?}, ..). libmpv will never deliver that event, \
so the handler is dead code."
);
}
}
/// 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]
+383
View File
@@ -0,0 +1,383 @@
//! [`MediaPlayer`] over libmpv.
//!
//! The point of difference from `MpvBackend` is [`MpvPlayer::open`]: the start
//! position is applied **at load time**, via mpv's own `start` option, instead
//! of being seeked to afterwards. `loadfile` is asynchronous, so a seek issued
//! after it targets a player that has nothing loaded, fails, and — under the old
//! contract — was discarded. That is DR-241, and it is why resume and transcoded
//! skip both played from zero.
//!
//! A seek arriving during [`Phase::Opening`] is held and applied when the file
//! loads, so no caller has to know where that window begins or ends.
//!
//! TRACES: UR-081, UR-040, UR-005 | DR-244
#![allow(dead_code)] // Wired to PlayerController in DR-245.
use std::sync::{Arc, Mutex};
use std::time::Duration;
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 crate::utils::lock::MutexSafe;
/// State the event thread writes and the caller reads.
#[derive(Debug)]
struct Shared {
phase: Phase,
position: Duration,
duration: Option<Duration>,
seekable: bool,
/// A seek that arrived while opening. Applied on `FileLoaded`.
deferred_seek: Option<Duration>,
/// Cleared by `close()`, so an open still in flight cannot come back to life
/// and start playing after the caller has stopped it.
open_generation: u64,
}
impl Default for Shared {
fn default() -> Self {
Self {
phase: Phase::Idle,
position: Duration::ZERO,
duration: None,
seekable: false,
deferred_seek: None,
open_generation: 0,
}
}
}
pub struct MpvPlayer {
mpv: Arc<Mpv>,
shared: Arc<Mutex<Shared>>,
volume: f32,
muted: bool,
rate: f64,
audio_track: Option<i32>,
subtitle_track: Option<i32>,
}
/// How the engine should talk to the machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Output {
/// Real audio and video. What the app uses.
Real,
/// No audio device, no window. What conformance uses, so the suite can run
/// on a headless runner without claiming the user's speakers.
Null,
}
impl MpvPlayer {
pub fn new(output: Output) -> Result<Self, PlayerError> {
// mpv refuses to start under a non-C LC_NUMERIC, and anything that has
// initialised GTK before us will have set one.
unsafe {
let c = std::ffi::CString::new("C").unwrap();
libc::setlocale(libc::LC_NUMERIC, c.as_ptr());
}
let mpv = Mpv::new().map_err(|e| PlayerError {
message: format!("mpv_create failed: {e:?}"),
})?;
let set = |k: &str, v: &str| {
if let Err(e) = mpv.set_property(k, v) {
warn!("[MpvPlayer] could not set {k}={v}: {e:?}");
}
};
match output {
Output::Real => {
set("vo", "libmpv");
}
Output::Null => {
set("ao", "null");
set("vo", "null");
}
}
set("msg-level", "all=warn");
// Survive a blip rather than ending the item on it.
set(
"stream-lavf-o",
"reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
);
let player = Self {
mpv: Arc::new(mpv),
shared: Arc::new(Mutex::new(Shared::default())),
volume: 1.0,
muted: false,
rate: 1.0,
audio_track: None,
subtitle_track: None,
};
player.spawn_events();
Ok(player)
}
fn spawn_events(&self) {
let mpv = self.mpv.clone();
let shared = self.shared.clone();
std::thread::spawn(move || {
let mut ev = mpv.create_event_context();
let _ = ev.disable_deprecated_events();
// Every property matched below must be observed, or libmpv never
// delivers it and the handler is unreachable (DR-239).
for prop in ["pause", "eof-reached"] {
if let Err(e) = ev.observe_property(prop, libmpv::Format::Flag, 0) {
warn!("[MpvPlayer] could not observe {prop}: {e:?}");
}
}
loop {
match ev.wait_event(0.25) {
Some(Ok(libmpv::events::Event::FileLoaded)) => {
let deferred = {
let mut s = shared.lock_safe();
// Closed while opening: do not start.
if s.phase == Phase::Idle {
continue;
}
s.duration = mpv
.get_property::<f64>("duration")
.ok()
.and_then(duration_from_secs);
s.seekable = mpv.get_property::<bool>("seekable").unwrap_or(true);
s.phase = Phase::Playing;
s.deferred_seek.take()
};
if let Some(to) = deferred {
debug!("[MpvPlayer] applying deferred seek to {to:?}");
if let Err(e) = mpv.set_property("time-pos", to.as_secs_f64()) {
warn!("[MpvPlayer] deferred seek failed: {e:?}");
}
}
}
Some(Ok(libmpv::events::Event::PropertyChange { name: "pause", .. })) => {
if let Ok(paused) = mpv.get_property::<bool>("pause") {
let mut s = shared.lock_safe();
if s.phase.has_media() {
s.phase = if paused {
Phase::Paused
} else {
Phase::Playing
};
}
}
}
Some(Ok(libmpv::events::Event::EndFile(reason))) => {
let mut s = shared.lock_safe();
// 0 = EOF. Anything else is a stop, a quit or an error,
// and must not read as "the item finished".
s.phase = if reason == 0 {
Phase::Ended
} else {
Phase::Idle
};
}
Some(Ok(libmpv::events::Event::Shutdown)) => break,
_ => {}
}
if let Ok(pos) = mpv.get_property::<f64>("time-pos") {
let mut s = shared.lock_safe();
if s.phase.has_media() && s.deferred_seek.is_none() {
s.position = Duration::from_secs_f64(pos.max(0.0));
}
}
}
});
}
}
impl MediaPlayer for MpvPlayer {
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
{
let mut s = self.shared.lock_safe();
*s = Shared {
phase: Phase::Opening,
open_generation: s.open_generation + 1,
..Shared::default()
};
// Report the requested position immediately, so a caller reading
// back during the open sees where it asked to be rather than zero.
s.position = req.start;
}
// The whole point. `start` is applied by mpv as it opens the file, so
// there is no window in which the position can be asked for and lost.
let start = if req.start.is_zero() {
"none".to_string()
} else {
format!("{:.3}", req.start.as_secs_f64())
};
self.mpv
.set_property("start", start.as_str())
.map_err(|e| PlayerError {
message: format!("could not set start position: {e:?}"),
})?;
self.mpv
.set_property("pause", !req.autoplay)
.map_err(|e| PlayerError {
message: format!("could not set pause: {e:?}"),
})?;
info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
self.mpv
.command("loadfile", &[&req.selection.url, "replace"])
.map_err(|e| PlayerError {
message: format!("loadfile failed: {e:?}"),
})?;
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.mpv
.set_property("pause", false)
.map_err(|e| PlayerError {
message: format!("play failed: {e:?}"),
})?;
let mut s = self.shared.lock_safe();
if s.phase.has_media() && s.phase != Phase::Opening {
s.phase = Phase::Playing;
}
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.mpv
.set_property("pause", true)
.map_err(|e| PlayerError {
message: format!("pause failed: {e:?}"),
})?;
let mut s = self.shared.lock_safe();
if s.phase.has_media() && s.phase != Phase::Opening {
s.phase = Phase::Paused;
}
Ok(())
}
fn close(&mut self) -> Result<(), PlayerError> {
// State first: an open still in flight checks this on FileLoaded and
// must not proceed to play after the caller has stopped it.
{
let mut s = self.shared.lock_safe();
*s = Shared {
open_generation: s.open_generation,
..Shared::default()
};
}
// Idempotent: stopping an already-stopped mpv is not an error worth
// propagating, and callers legitimately close twice on teardown.
if let Err(e) = self.mpv.command("stop", &[]) {
debug!("[MpvPlayer] stop on an idle player: {e:?}");
}
Ok(())
}
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
{
let mut s = self.shared.lock_safe();
match s.phase {
// Held, not dropped. The caller cannot see this window.
Phase::Opening => {
s.deferred_seek = Some(to);
s.position = to;
return Ok(());
}
Phase::Idle | Phase::Failed(_) => {
return Err(PlayerError {
message: "seek with nothing open".to_string(),
})
}
_ => s.position = to,
}
}
self.mpv
.set_property("time-pos", to.as_secs_f64())
.map_err(|e| PlayerError {
message: format!("seek failed: {e:?}"),
})
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
self.volume = clamped;
self.mpv
.set_property("volume", (clamped as f64) * 100.0)
.map_err(|e| PlayerError {
message: format!("set_volume failed: {e:?}"),
})
}
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> {
self.muted = muted;
self.mpv
.set_property("mute", muted)
.map_err(|e| PlayerError {
message: format!("set_muted failed: {e:?}"),
})
}
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> {
self.rate = rate;
self.mpv
.set_property("speed", rate)
.map_err(|e| PlayerError {
message: format!("set_rate failed: {e:?}"),
})
}
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.audio_track = index;
let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
self.mpv
.set_property("aid", value.as_str())
.map_err(|e| PlayerError {
message: format!("select_audio_track failed: {e:?}"),
})
}
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.subtitle_track = index;
let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
self.mpv
.set_property("sid", value.as_str())
.map_err(|e| PlayerError {
message: format!("select_subtitle_track failed: {e:?}"),
})
}
fn snapshot(&self) -> PlaybackSnapshot {
let s = self.shared.lock_safe();
PlaybackSnapshot {
phase: s.phase.clone(),
position: s.position,
duration: s.duration,
seekable: s.seekable,
volume: self.volume,
muted: self.muted,
rate: self.rate,
audio_track: self.audio_track,
subtitle_track: self.subtitle_track,
}
}
fn capabilities(&self) -> Capabilities {
Capabilities {
video: true,
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,
}
}
}
+405
View File
@@ -0,0 +1,405 @@
//! mpv's render API, driven into an OpenGL framebuffer we own.
//!
//! This is the half of native video that is not GTK: create a render context
//! over the mpv handle the audio backend already drives, render a frame into a
//! texture, and hand that texture id back for the toolkit to composite.
//!
//! Kept apart from `video_surface` deliberately — everything here is portable
//! across the platforms this app targets, while the surface that consumes it is
//! not. Windows reuses this file unchanged (DR-237).
//!
//! TRACES: UR-080 | DR-231, DR-232, IR-033
use std::ffi::{c_void, CStr, CString};
use std::os::raw::{c_char, c_int};
use std::ptr;
use log::{error, info, warn};
/// GL entry points, resolved once.
///
/// Only the handful needed to own a framebuffer; mpv resolves everything else
/// it needs through [`get_proc_address`].
struct Gl {
gen_framebuffers: unsafe extern "C" fn(c_int, *mut u32),
delete_framebuffers: unsafe extern "C" fn(c_int, *const u32),
bind_framebuffer: unsafe extern "C" fn(u32, u32),
framebuffer_texture_2d: unsafe extern "C" fn(u32, u32, u32, u32, c_int),
gen_textures: unsafe extern "C" fn(c_int, *mut u32),
delete_textures: unsafe extern "C" fn(c_int, *const u32),
bind_texture: unsafe extern "C" fn(u32, u32),
tex_image_2d:
unsafe extern "C" fn(u32, c_int, c_int, c_int, c_int, c_int, u32, u32, *const c_void),
tex_parameteri: unsafe extern "C" fn(u32, u32, c_int),
check_framebuffer_status: unsafe extern "C" fn(u32) -> u32,
}
const GL_TEXTURE_2D: u32 = 0x0DE1;
const GL_FRAMEBUFFER: u32 = 0x8D40;
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
const GL_RGBA: u32 = 0x1908;
const GL_RGBA8: c_int = 0x8058;
const GL_UNSIGNED_BYTE: u32 = 0x1401;
const GL_LINEAR: c_int = 0x2601;
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
/// Resolve a GL symbol the way libepoxy actually exports it.
///
/// **This is the trap that cost the spike a debugging cycle.** libepoxy does not
/// export `glFoo` as a function. It exports `epoxy_glFoo` as a *data* symbol
/// holding a lazily-resolving function pointer. So the address `dlsym` returns
/// is the address *of the pointer*, not of any code: returning it makes mpv jump
/// into non-executable data and take SIGSEGV/SEGV_ACCERR on the very first GL
/// call. The value must be read *out of* that location.
///
/// The `epoxy` crate does this correctly and is unusable here — its
/// `gl_generator` dependency pulls a yanked `xml-rs`.
///
/// TRACES: UR-080 | IR-033
unsafe fn resolve(name: &str) -> *mut c_void {
let epoxy_name = match CString::new(format!("epoxy_{name}")) {
Ok(n) => n,
Err(_) => return ptr::null_mut(),
};
let slot = libc::dlsym(libc::RTLD_DEFAULT, epoxy_name.as_ptr());
if !slot.is_null() {
// The symbol holds the function pointer; return what is stored there.
return *(slot as *mut *mut c_void);
}
// Fall back to a plain symbol, for a GL stack that is not behind epoxy.
match CString::new(name) {
Ok(n) => libc::dlsym(libc::RTLD_DEFAULT, n.as_ptr()),
Err(_) => ptr::null_mut(),
}
}
/// What mpv calls to find GL entry points. Same rule as [`resolve`].
unsafe extern "C" fn get_proc_address(_ctx: *mut c_void, name: *const c_char) -> *mut c_void {
if name.is_null() {
return ptr::null_mut();
}
match CStr::from_ptr(name).to_str() {
Ok(n) => resolve(n),
Err(_) => ptr::null_mut(),
}
}
macro_rules! load {
($name:literal) => {{
let p = resolve($name);
if p.is_null() {
error!("[MpvRender] GL symbol not found: {}", $name);
return None;
}
std::mem::transmute(p)
}};
}
impl Gl {
/// Resolve every entry point, or none — a partially-loaded table would fail
/// later at a call site with no context.
///
/// The transmutes are unannotated on purpose: each target type is declared
/// once on the struct field above, and repeating it at the call site would
/// be two places to get the same signature wrong.
#[allow(clippy::missing_transmute_annotations)]
unsafe fn load() -> Option<Self> {
Some(Gl {
gen_framebuffers: load!("glGenFramebuffers"),
delete_framebuffers: load!("glDeleteFramebuffers"),
bind_framebuffer: load!("glBindFramebuffer"),
framebuffer_texture_2d: load!("glFramebufferTexture2D"),
gen_textures: load!("glGenTextures"),
delete_textures: load!("glDeleteTextures"),
bind_texture: load!("glBindTexture"),
tex_image_2d: load!("glTexImage2D"),
tex_parameteri: load!("glTexParameteri"),
check_framebuffer_status: load!("glCheckFramebufferStatus"),
})
}
}
/// A colour-renderable framebuffer mpv draws into, sized to the widget.
struct Target {
fbo: u32,
texture: u32,
width: i32,
height: i32,
}
/// mpv's render context plus the framebuffer it draws into.
///
/// # Lifetime (DR-232)
///
/// The render context must not outlive the GL context it was created against.
/// `Drop` unregisters mpv's update callback *before* freeing the context, so a
/// callback cannot land on a freed pointer, and frees the GL objects while the
/// caller still has the context current. The caller is responsible for making
/// the GL context current around both creation and drop — see `video_surface`.
///
/// This is DR-184 on Android restated: a surface outliving its player. The spike
/// had no defence at all and saw one unexplained SIGSEGV in a decoder thread.
pub struct MpvRenderContext {
ctx: *mut libmpv_sys::mpv_render_context,
gl: Gl,
target: Option<Target>,
}
// The render context is driven only from the GTK main thread; the update
// callback merely schedules a redraw and touches nothing here.
unsafe impl Send for MpvRenderContext {}
impl MpvRenderContext {
/// Create a render context over an existing mpv handle.
///
/// The GL context must already be current on this thread.
///
/// TRACES: UR-080 | DR-231, IR-033
pub unsafe fn new(mpv: *mut libmpv_sys::mpv_handle) -> Option<Self> {
let gl = Gl::load()?;
let mut init = libmpv_sys::mpv_opengl_init_params {
get_proc_address: Some(get_proc_address),
get_proc_address_ctx: ptr::null_mut(),
};
let mut api_type = CString::new("opengl").ok()?;
// Advanced control is deliberately OFF.
//
// With it on, mpv expects the client to drive rendering to a stricter
// contract than a GTK draw handler can promise — it will wait on us, and
// if we in turn wait on its update callback, neither side proceeds. That
// deadlock presents as a file that loads, renders one frame, and then
// sits there with no audio and a spinner.
//
// Off, mpv is tolerant of being rendered on the toolkit's schedule,
// which is what the frame clock gives us.
let mut advanced: c_int = 0;
let mut params = [
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
data: api_type.as_ptr() as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
data: &mut init as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_ADVANCED_CONTROL,
data: &mut advanced as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: 0,
data: ptr::null_mut(),
},
];
let mut ctx: *mut libmpv_sys::mpv_render_context = ptr::null_mut();
let rc = libmpv_sys::mpv_render_context_create(&mut ctx, mpv, params.as_mut_ptr());
// Keep the CString alive until after the call.
let _ = &mut api_type;
if rc < 0 || ctx.is_null() {
error!("[MpvRender] mpv_render_context_create failed: {rc}");
return None;
}
info!("[MpvRender] render context created");
Some(MpvRenderContext {
ctx,
gl,
target: None,
})
}
/// Ask to be told when a new frame is ready.
///
/// Paired with [`report_swap`](Self::report_swap): without both, mpv has
/// nothing to time against. The symptom is misleading — playback looks fine
/// in a window and judders at fullscreen, which reads as a compositing or
/// GPU limit and is neither (DR-233).
///
/// TRACES: UR-080 | DR-233
pub unsafe fn set_update_callback(
&mut self,
callback: libmpv_sys::mpv_render_update_fn,
ctx: *mut c_void,
) {
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, callback, ctx);
}
/// Whether mpv has a new frame waiting.
///
/// Asked of mpv directly rather than inferred from its update callback, and
/// that distinction is the whole of frame pacing here:
///
/// - Waiting only on the callback deadlocks — mpv will not progress until
/// the client renders, so if the client will not render until mpv says
/// so, neither moves. That presents as a file that loads, shows one
/// frame, and then sits silent.
/// - Rendering on *every* frame-clock tick regardless is the opposite
/// error: `report_swap` then claims a presentation far more often than
/// real frames exist, mpv has nothing coherent to time against, and
/// playback judders badly.
///
/// Polling is neither. It runs on the main thread, costs a single atomic
/// read inside mpv, and answers the only question that matters.
///
/// TRACES: UR-080 | DR-233
pub unsafe fn has_frame(&self) -> bool {
let flags = libmpv_sys::mpv_render_context_update(self.ctx);
(flags & libmpv_sys::mpv_render_update_flag_MPV_RENDER_UPDATE_FRAME as u64) != 0
}
/// Render the current frame at `width` x `height`, returning the texture id
/// holding it. The GL context must be current.
///
/// TRACES: UR-080 | DR-231
pub unsafe fn render(&mut self, width: i32, height: i32) -> Option<u32> {
if width <= 0 || height <= 0 {
return None;
}
self.ensure_target(width, height)?;
let target = self.target.as_ref()?;
let mut fbo = libmpv_sys::mpv_opengl_fbo {
fbo: target.fbo as c_int,
w: width as c_int,
h: height as c_int,
internal_format: 0,
};
// GTK's cairo surface has its origin at the top left; mpv defaults to
// OpenGL's bottom-left. Without this the picture is drawn upside down —
// which looks like a broken decode rather than a coordinate convention.
let mut flip: c_int = 1;
let mut params = [
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_FBO,
data: &mut fbo as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_FLIP_Y,
data: &mut flip as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: 0,
data: ptr::null_mut(),
},
];
let rc = libmpv_sys::mpv_render_context_render(self.ctx, params.as_mut_ptr());
if rc < 0 {
warn!("[MpvRender] render failed: {rc}");
return None;
}
Some(target.texture)
}
/// Tell mpv the frame reached the screen. See [`set_update_callback`].
///
/// TRACES: UR-080 | DR-233
pub unsafe fn report_swap(&self) {
libmpv_sys::mpv_render_context_report_swap(self.ctx);
}
/// Create or resize the framebuffer. Reused across frames — reallocating per
/// frame would churn GPU memory at the display rate.
unsafe fn ensure_target(&mut self, width: i32, height: i32) -> Option<()> {
if let Some(t) = &self.target {
if t.width == width && t.height == height {
return Some(());
}
}
self.drop_target();
let gl = &self.gl;
let mut texture: u32 = 0;
(gl.gen_textures)(1, &mut texture);
(gl.bind_texture)(GL_TEXTURE_2D, texture);
(gl.tex_image_2d)(
GL_TEXTURE_2D,
0,
GL_RGBA8,
width,
height,
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
ptr::null(),
);
(gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
(gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
(gl.bind_texture)(GL_TEXTURE_2D, 0);
let mut fbo: u32 = 0;
(gl.gen_framebuffers)(1, &mut fbo);
(gl.bind_framebuffer)(GL_FRAMEBUFFER, fbo);
(gl.framebuffer_texture_2d)(
GL_FRAMEBUFFER,
GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D,
texture,
0,
);
let status = (gl.check_framebuffer_status)(GL_FRAMEBUFFER);
(gl.bind_framebuffer)(GL_FRAMEBUFFER, 0);
if status != GL_FRAMEBUFFER_COMPLETE {
error!("[MpvRender] framebuffer incomplete: 0x{status:x}");
(gl.delete_framebuffers)(1, &fbo);
(gl.delete_textures)(1, &texture);
return None;
}
self.target = Some(Target {
fbo,
texture,
width,
height,
});
Some(())
}
unsafe fn drop_target(&mut self) {
if let Some(t) = self.target.take() {
(self.gl.delete_framebuffers)(1, &t.fbo);
(self.gl.delete_textures)(1, &t.texture);
}
}
/// Free everything, with the GL context current.
///
/// Explicit rather than left to `Drop` because the ordering matters and the
/// caller is the only one that can guarantee the GL context is current. See
/// DR-232.
pub unsafe fn destroy(mut self) {
// Unregister first: a callback arriving after the free would be a use
// after free, and it is scheduled from mpv's own threads.
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
self.drop_target();
libmpv_sys::mpv_render_context_free(self.ctx);
self.ctx = ptr::null_mut();
info!("[MpvRender] render context freed");
std::mem::forget(self);
}
}
impl Drop for MpvRenderContext {
fn drop(&mut self) {
if !self.ctx.is_null() {
// Reached only if `destroy` was not called — the GL context may not
// be current, so the GL objects are deliberately leaked rather than
// deleted against whatever context happens to be bound. Freeing the
// render context is still safe and is the part that matters.
warn!("[MpvRender] dropped without destroy(); GL objects leaked deliberately");
unsafe {
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
libmpv_sys::mpv_render_context_free(self.ctx);
}
}
}
}
+78
View File
@@ -0,0 +1,78 @@
//! Whether this process renders video natively, answered once.
//!
//! Three things need this and must agree: the mpv backend (which has to be
//! configured for video *at construction*, before anything plays), the video
//! surface (which has nothing to draw otherwise), and `get_player_status`
//! (which tells the frontend whether to use a webview `<video>` element).
//!
//! It is a function rather than three `env::var` checks for the reason this
//! codebase keeps rediscovering: a capability answered in several places is a
//! capability whose answers drift. Four separate bugs this cycle came from
//! exactly that shape — a webview's decode limits applied to ExoPlayer, a
//! transcode target contradicting a direct-play claim, a codec list hardcoded in
//! a URL builder. One source, read by everyone.
//!
//! TRACES: UR-080 | DR-231, DR-235
/// The opt-in for native desktop video.
///
/// Off by default while the render path is unproven — the webview path still
/// works and is what ships. This becomes the *default* (and then the only path)
/// when DR-235 lands; the variable is how it is exercised until then.
const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
/// Whether mpv should decode and draw video in this process.
///
/// Read fresh rather than cached: it is consulted a handful of times at startup,
/// and a `OnceLock` here would only make it harder to test.
///
/// TRACES: UR-080 | DR-231, DR-235
pub fn enabled() -> bool {
// Only where a native renderer exists. On Android ExoPlayer already does
// this and `use_html5_element` is false for entirely separate reasons.
if !cfg!(all(target_os = "linux", not(target_os = "android"))) {
return false;
}
matches!(
std::env::var(ENV_FLAG).as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
}
#[cfg(test)]
mod tests {
use super::*;
/// Absent, empty, or anything unrecognised means off. A half-set variable
/// must not half-enable a renderer — the failure mode would be mpv
/// configured for video with nothing drawing it, i.e. audio playing over a
/// black rectangle.
///
/// TRACES: UR-080 | DR-231 | UT-216
#[test]
fn test_only_explicit_truthy_values_enable_it() {
let restore = std::env::var(ENV_FLAG).ok();
for value in ["", "0", "no", "false", "maybe", "2"] {
std::env::set_var(ENV_FLAG, value);
assert!(!enabled(), "{value:?} must not enable native video");
}
for value in ["1", "true", "yes"] {
std::env::set_var(ENV_FLAG, value);
assert_eq!(
enabled(),
cfg!(all(target_os = "linux", not(target_os = "android"))),
"{value:?} enables it exactly where a native renderer exists"
);
}
std::env::remove_var(ENV_FLAG);
assert!(!enabled(), "absent means off");
match restore {
Some(v) => std::env::set_var(ENV_FLAG, v),
None => std::env::remove_var(ENV_FLAG),
}
}
}
+2
View File
@@ -541,6 +541,8 @@ mod tests {
fn create_test_items(count: usize) -> Vec<MediaItem> { fn create_test_items(count: usize) -> Vec<MediaItem> {
(0..count) (0..count)
.map(|i| MediaItem { .map(|i| MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: format!("item_{}", i), id: format!("item_{}", i),
title: format!("Track {}", i + 1), title: format!("Track {}", i + 1),
name: Some(format!("Track {}", i + 1)), name: Some(format!("Track {}", i + 1)),
+78 -23
View File
@@ -25,12 +25,14 @@ pub enum VideoSeekStrategy {
/// ///
/// # Arguments /// # Arguments
/// * `is_local` - Whether the file is a local download /// * `is_local` - Whether the file is a local download
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream) /// * `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.
/// * `needs_transcoding` - Whether the content needs transcoding /// * `needs_transcoding` - Whether the content needs transcoding
/// * `use_html5` - Whether frontend is using HTML5 video element /// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy( pub fn determine_video_seek_strategy(
is_local: bool, is_local: bool,
is_hls: bool, seeks_transcoded_in_place: bool,
needs_transcoding: bool, needs_transcoding: bool,
use_html5: bool, use_html5: bool,
) -> VideoSeekStrategy { ) -> VideoSeekStrategy {
@@ -39,23 +41,39 @@ pub fn determine_video_seek_strategy(
return VideoSeekStrategy::LocalNativeSeek; return VideoSeekStrategy::LocalNativeSeek;
} }
// HLS streams and direct play (non-transcoded) support native seeking // A server-side transcode is produced *from* `StartTimeTicks`, so where the
if is_hls || !needs_transcoding { // seek lands is a property of the request, not of the stream in hand.
if use_html5 { //
// HTML5 backend - frontend handles seeking via videoElement.currentTime // hls.js is the exception: handed a VOD playlist it seeks within it and lets
// We don't call backend.seek() because video is in HTML5 element, not in MPV // the server catch up segment by segment. mpv's HLS demuxer cannot make
VideoSeekStrategy::Html5NativeSeek // Jellyfin transcode from a new offset, so for the native backend a
} else { // transcoded seek must re-negotiate the stream regardless of container.
// Native backend (MPV) - backend handles seeking //
VideoSeekStrategy::BackendNativeSeek // Before native video shipped, `use_html5` was always true for HLS and the
} // native+HLS+transcode cell was unreachable, which is why `is_hls` alone
// used to be a safe proxy for "seekable in place". It no longer is: turning
// 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,
};
}
// Direct play and direct stream are seekable where they sit.
if use_html5 {
// The frontend seeks via videoElement.currentTime; calling backend.seek()
// would move a player that is not the one rendering.
VideoSeekStrategy::Html5NativeSeek
} else { } else {
// Transcoded non-HLS streams need server-side seek (reload from new position) VideoSeekStrategy::BackendNativeSeek
if use_html5 {
VideoSeekStrategy::Html5ReloadStream
} else {
VideoSeekStrategy::BackendReloadStream
}
} }
} }
@@ -220,26 +238,63 @@ mod tests {
); );
} }
/// Test video seek strategy for HLS streams /// Non-transcoded streams seek in place regardless of the engine's
/// transcode ability, which only applies to transcodes.
#[test] #[test]
fn test_seek_strategy_hls_stream() { fn test_seek_strategy_direct_stream() {
// HLS with HTML5 - frontend handles seek, don't call backend // HTML5 renders, so the frontend seeks the element
assert_eq!( assert_eq!(
determine_video_seek_strategy(false, true, false, true), determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek VideoSeekStrategy::Html5NativeSeek
); );
// HLS with native backend - backend handles seek // The native engine renders, so it seeks
assert_eq!( assert_eq!(
determine_video_seek_strategy(false, true, false, false), determine_video_seek_strategy(false, true, false, false),
VideoSeekStrategy::BackendNativeSeek VideoSeekStrategy::BackendNativeSeek
); );
// HLS even with needs_transcoding flag - still native seek (HLS supports it) // A transcode an engine says it can move: seek in place
assert_eq!( assert_eq!(
determine_video_seek_strategy(false, true, true, true), determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek VideoSeekStrategy::Html5NativeSeek
); );
} }
/// A server-side transcode cannot be seeked by the native backend.
///
/// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek
/// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
/// the server transcode from a new offset, so the stream has to be
/// re-negotiated. Before native video existed, `use_html5` was always true
/// for HLS and this case was unreachable — turning native video on routed
/// 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
#[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.
assert_eq!(
determine_video_seek_strategy(false, false, 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.
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 /// Test video seek strategy for direct play (non-transcoded) streams
#[test] #[test]
fn test_seek_strategy_direct_play() { fn test_seek_strategy_direct_play() {
+4
View File
@@ -232,6 +232,8 @@ mod tests {
fn create_test_audio_item(title: &str) -> MediaItem { fn create_test_audio_item(title: &str) -> MediaItem {
MediaItem { MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: title.to_string(), id: title.to_string(),
title: title.to_string(), title: title.to_string(),
name: Some(title.to_string()), name: Some(title.to_string()),
@@ -263,6 +265,8 @@ mod tests {
fn create_test_movie_item(title: &str) -> MediaItem { fn create_test_movie_item(title: &str) -> MediaItem {
MediaItem { MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: title.to_string(), id: title.to_string(),
title: title.to_string(), title: title.to_string(),
name: Some(title.to_string()), name: Some(title.to_string()),
+2
View File
@@ -316,6 +316,8 @@ mod tests {
// Helper function to create test MediaItem instances // Helper function to create test MediaItem instances
fn create_test_media_item(id: &str, title: &str) -> MediaItem { fn create_test_media_item(id: &str, title: &str) -> MediaItem {
MediaItem { MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: id.to_string(), id: id.to_string(),
title: title.to_string(), title: title.to_string(),
name: None, name: None,
+8
View File
@@ -258,6 +258,8 @@ mod tests {
/// `StartTimeTicks` is the handoff point. /// `StartTimeTicks` is the handoff point.
fn handoff_item() -> MediaItem { fn handoff_item() -> MediaItem {
MediaItem { MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: "ep2".to_string(), id: "ep2".to_string(),
title: "Episode 2".to_string(), title: "Episode 2".to_string(),
name: None, name: None,
@@ -303,6 +305,8 @@ mod tests {
// `/Audio/{id}/stream?Static=true` — a real Content-Length and byte // `/Audio/{id}/stream?Static=true` — a real Content-Length and byte
// ranges, so ExoPlayer resumes it where the load failed. // ranges, so ExoPlayer resumes it where the load failed.
let track = MediaItem { let track = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
item_type: Some("Audio".to_string()), item_type: Some("Audio".to_string()),
..handoff_item() ..handoff_item()
}; };
@@ -314,6 +318,8 @@ mod tests {
// An HLS playlist declares its segments, so a failed segment load is // An HLS playlist declares its segments, so a failed segment load is
// retried at that segment, not at the start of the episode. // retried at that segment, not at the start of the episode.
let video = MediaItem { let video = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
media_type: MediaType::Video, media_type: MediaType::Video,
..handoff_item() ..handoff_item()
}; };
@@ -324,6 +330,8 @@ mod tests {
fn test_downloaded_episode_keeps_the_players_retry() { fn test_downloaded_episode_keeps_the_players_retry() {
// A local file has no length problem and no network to lose. // A local file has no length problem and no network to lose.
let local = MediaItem { let local = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
source: MediaSource::Local { source: MediaSource::Local {
file_path: PathBuf::from("/data/ep2.mkv"), file_path: PathBuf::from("/data/ep2.mkv"),
jellyfin_item_id: Some("ep2".to_string()), jellyfin_item_id: Some("ep2".to_string()),
+412
View File
@@ -0,0 +1,412 @@
//! The native video surface: mpv drawn *behind* Tauri's webview, without
//! touching the widget tree.
//!
//! # Why there is no overlay here
//!
//! The obvious arrangement — wrap the webview in a `GtkOverlay` with a
//! `GtkGLArea` beneath — attaches cleanly and then aborts the process on the
//! first click. `tauri-runtime-wry` connects a button-press handler to the
//! webview that walks a hard-coded path:
//!
//! ```text
//! webview.parent() // "This one should be GtkBox"
//! .parent() // ...and this one the GtkWindow
//! .downcast::<gtk::Window>().unwrap()
//! ```
//!
//! An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast
//! fails, and because the panic is non-unwinding it takes the app with it.
//! Nothing in configuration avoids it: on Linux the handler is attached
//! *unconditionally* (the Windows path guards it behind `is_decorated()`), and
//! the decoration check that would make it inert runs *after* the unwrap.
//!
//! So the widget tree is left exactly as Tauri built it. GTK draws a container
//! before its children, so rendering into the vbox's own `draw` handler puts the
//! picture underneath the webview for free — the same z-order, no reparenting,
//! one less widget, and nothing a Tauri upgrade can invalidate by assuming its
//! own layout.
//!
//! TRACES: UR-080 | DR-231, DR-232, DR-233, IR-033
use std::cell::RefCell;
use std::ffi::c_void;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use gtk::prelude::*;
use gtk::{gdk, glib};
use log::{error, info, warn};
use super::mpv_render::MpvRenderContext;
/// GL enum for `gdk_cairo_draw_from_gl`'s `source_type`. GDK takes the GL
/// constant itself rather than an enum of its own.
const GL_TEXTURE: i32 = 0x1702;
/// Everything the draw handler needs, shared with the GTK callbacks.
struct SurfaceState {
gl: Option<gdk::GLContext>,
render: Option<MpvRenderContext>,
mpv: *mut libmpv_sys::mpv_handle,
/// Set by mpv's update callback (on an mpv thread), cleared by the frame
/// clock (on the main thread). The whole cross-thread contract.
frame_ready: Arc<AtomicBool>,
/// The boxed clone of `frame_ready` handed to mpv, reclaimed on teardown.
/// Null when no callback is registered.
callback_ctx: *mut Arc<AtomicBool>,
// One-shot diagnostic latches; see `draw`.
logged_first_draw: bool,
logged_first_frame: bool,
/// Last size we logged, so a size change re-reports rather than staying silent.
logged_size: (i32, i32),
logged_no_gl: bool,
logged_no_window: bool,
logged_no_size: bool,
logged_render_fail: bool,
}
impl SurfaceState {
/// Tear down in the order DR-232 requires, with the GL context current.
///
/// The update callback is unregistered before the context is freed (inside
/// `destroy`), and the GL objects go while their context is still bound.
/// Getting this wrong is DR-184 on Android restated — a surface outliving
/// its player — and is the likeliest cause of the one unexplained SIGSEGV
/// the spike recorded.
fn teardown(&mut self) {
if let Some(render) = self.render.take() {
if let Some(gl) = &self.gl {
gl.make_current();
}
// Unregisters the callback before freeing the context.
unsafe { render.destroy() };
}
// Only now is it safe to reclaim what the callback was holding: mpv can
// no longer reach it. Freeing it first would be the use-after-free this
// ordering exists to prevent.
if !self.callback_ctx.is_null() {
unsafe { drop(Box::from_raw(self.callback_ctx)) };
self.callback_ctx = std::ptr::null_mut();
}
self.gl = None;
}
}
/// A live video surface. Dropping it tears the render context down.
pub struct VideoSurface {
state: Rc<RefCell<SurfaceState>>,
widget: gtk::Box,
handlers: Vec<glib::SignalHandlerId>,
}
impl Drop for VideoSurface {
fn drop(&mut self) {
for id in self.handlers.drain(..) {
self.widget.disconnect(id);
}
self.state.borrow_mut().teardown();
self.widget.queue_draw();
info!("[VideoSurface] detached");
}
}
/// mpv's update callback. Runs on an mpv thread, so it does the least possible:
/// flags the state and asks GTK to redraw on the main loop.
///
/// **Nothing here may block or re-enter the player.** The project's deadlock
/// gotcha applies with full force — this is called from mpv's own threads.
///
/// TRACES: UR-080 | DR-233
unsafe extern "C" fn on_mpv_update(ctx: *mut c_void) {
if ctx.is_null() {
return;
}
// Runs on an *mpv* thread. It therefore does exactly one thing that is safe
// to do from there: set an atomic flag.
//
// It must not touch GTK, and specifically must not schedule work with
// `idle_add_local*`, which requires the calling thread to own the default
// main context — from here that panics with "default main context already
// acquired by another thread". Nor can it hold the `Rc<RefCell<..>>` state:
// an `Rc` is not `Send`, and cloning one from two threads races its
// refcount.
//
// The frame clock on the widget picks the flag up on the main thread. See
// `install_frame_clock`.
let flag = &*(ctx as *const Arc<AtomicBool>);
flag.store(true, Ordering::Release);
}
/// Start drawing mpv's video underneath the webview.
///
/// `vbox` is Tauri's `default_vbox()` — the container the webview already lives
/// in. It is not modified; only a `draw` handler is added.
///
/// Must run on the GTK main thread.
///
/// TRACES: UR-080 | DR-231, DR-232, DR-233
pub fn attach(vbox: &gtk::Box, mpv: *mut libmpv_sys::mpv_handle) -> bool {
if mpv.is_null() {
warn!("[VideoSurface] no mpv handle; native video unavailable");
return false;
}
let state = Rc::new(RefCell::new(SurfaceState {
gl: None,
render: None,
mpv,
frame_ready: Arc::new(AtomicBool::new(false)),
callback_ctx: std::ptr::null_mut(),
logged_first_draw: false,
logged_first_frame: false,
logged_size: (0, 0),
logged_no_gl: false,
logged_no_window: false,
logged_no_size: false,
logged_render_fail: false,
}));
let mut handlers = Vec::new();
// The GL context can only be created once the widget has a GdkWindow, which
// is what `realize` announces. Creating it earlier leaves nothing to attach
// to — the same ordering constraint the render context has.
let realize_state = state.clone();
handlers.push(vbox.connect_realize(move |widget| {
if let Err(e) = init_gl(widget, &realize_state) {
error!("[VideoSurface] GL init failed: {e}");
}
}));
// A render context outliving its GL context is the defect DR-232 exists to
// prevent, so teardown is bound to `unrealize` rather than left to Drop.
let unrealize_state = state.clone();
handlers.push(vbox.connect_unrealize(move |_| {
unrealize_state.borrow_mut().teardown();
}));
// Drive the render loop from the widget's frame clock, on the main thread,
// rendering only when mpv actually has a frame.
//
// Both nearby mistakes were made and are worth naming, because each has a
// symptom that points somewhere else:
//
// - Waiting on mpv's update callback before rendering deadlocks. mpv does
// not progress until the client renders. The file loads, one frame
// appears, and everything stops — no picture, no audio, a spinner that
// never clears. It reads as a broken stream.
// - Rendering unconditionally every tick and reporting a swap each time
// tells mpv a frame reached the screen far more often than one did. It
// plays, and judders badly. It reads as a GPU or compositing limit.
//
// Polling `has_frame` each tick is neither.
//
// The frame clock only ticks while the widget is mapped, so this costs
// nothing when the window is hidden.
//
// TRACES: UR-080 | DR-233
let tick_state = state.clone();
vbox.add_tick_callback(move |widget, _clock| {
// Ask mpv, on the main thread, whether there is anything new. The
// update callback's flag is only a hint that something *may* have
// happened; `has_frame` is the authority, and asking it here is what
// keeps this from either deadlocking or over-presenting.
let ready = match tick_state.try_borrow() {
Ok(s) => {
s.frame_ready.swap(false, Ordering::AcqRel);
match s.render.as_ref() {
Some(render) => unsafe { render.has_frame() },
None => false,
}
}
Err(_) => false,
};
if ready {
widget.queue_draw();
}
glib::ControlFlow::Continue
});
let draw_state = state.clone();
handlers.push(vbox.connect_draw(move |widget, cr| {
draw(widget, cr, &draw_state);
// Propagate: the webview is a child and must still draw over us.
glib::Propagation::Proceed
}));
// The window is already up by the time we are called, so run the init the
// `realize` signal would have.
if vbox.is_realized() {
if let Err(e) = init_gl(vbox, &state) {
error!("[VideoSurface] GL init failed: {e}");
}
}
info!("[VideoSurface] attached to Tauri's vbox without reparenting");
// The surface lives as long as the window. Held in a thread-local rather
// than returned, because it owns `Rc` and GTK types and so is neither `Send`
// nor `Sync` — it cannot go into Tauri's managed state, and leaking it would
// give up the ability to tear it down at all.
//
// Teardown does not depend on this being dropped: it is driven by the
// widget's `unrealize`, which is the signal that actually means "your GL
// context is going away" (DR-232).
LIVE_SURFACE.with(|cell| {
*cell.borrow_mut() = Some(VideoSurface {
state,
widget: vbox.clone(),
handlers,
});
});
true
}
thread_local! {
/// The one live surface, on the GTK main thread.
static LIVE_SURFACE: RefCell<Option<VideoSurface>> = const { RefCell::new(None) };
}
/// Drop the live surface, if there is one. Idempotent.
///
/// TRACES: UR-080 | DR-232
#[allow(dead_code)]
pub fn detach() {
LIVE_SURFACE.with(|cell| {
cell.borrow_mut().take();
});
}
/// Create the GL context and the mpv render context over it.
fn init_gl(widget: &gtk::Box, state: &Rc<RefCell<SurfaceState>>) -> Result<(), String> {
if state.borrow().render.is_some() {
return Ok(());
}
let window = widget.window().ok_or("widget has no GdkWindow")?;
let gl = window
.create_gl_context()
.map_err(|e| format!("create_gl_context: {e}"))?;
gl.realize().map_err(|e| format!("realize: {e}"))?;
gl.make_current();
let mpv = state.borrow().mpv;
let mut render =
unsafe { MpvRenderContext::new(mpv) }.ok_or("mpv render context creation failed")?;
// The callback needs an owned handle that outlives this function, so a
// clone of the flag is boxed and leaked. `Arc<AtomicBool>` rather than the
// state itself: it is the only thing that may cross to an mpv thread. The
// pointer is kept so teardown can reclaim it — after the callback is
// unregistered, never before.
let flag = state.borrow().frame_ready.clone();
let ctx_box: *mut Arc<AtomicBool> = Box::into_raw(Box::new(flag));
unsafe { render.set_update_callback(Some(on_mpv_update), ctx_box as *mut c_void) };
let mut s = state.borrow_mut();
s.gl = Some(gl);
s.render = Some(render);
s.callback_ctx = ctx_box;
info!("[VideoSurface] GL and render context ready");
Ok(())
}
/// Draw the current frame, if there is one.
///
/// Runs *before* the children, which is what puts the picture behind the
/// webview. Deliberately forgiving: no frame, no GL, or a borrowed state all
/// mean "draw nothing this pass" rather than an error — the webview then paints
/// over an untouched background, which is exactly the pre-native appearance.
fn draw(widget: &gtk::Box, cr: &gtk::cairo::Context, state: &Rc<RefCell<SurfaceState>>) {
// Report each way of doing nothing exactly once. Without this the whole
// path is invisible: a draw handler that never runs, one that bails on a
// zero allocation, and one that renders perfectly all look identical from
// outside — and mpv stalls if frames are never consumed, so "no audio and
// it hangs" is a plausible symptom of *any* of them.
fn once(flag: &mut bool, msg: &str) {
if !*flag {
*flag = true;
warn!("[VideoSurface] not drawing: {msg}");
}
}
let Ok(mut s) = state.try_borrow_mut() else {
return;
};
if !s.logged_first_draw {
s.logged_first_draw = true;
info!("[VideoSurface] draw handler running");
}
let Some(gl) = s.gl.clone() else {
let f = &mut s.logged_no_gl;
once(f, "no GL context");
return;
};
let Some(window) = widget.window() else {
let f = &mut s.logged_no_window;
once(f, "widget has no GdkWindow");
return;
};
let scale = widget.scale_factor();
let width = widget.allocated_width() * scale;
let height = widget.allocated_height() * scale;
if width <= 0 || height <= 0 {
let f = &mut s.logged_no_size;
once(f, "zero allocation");
return;
}
gl.make_current();
// Render and end the mutable borrow before touching the latches again.
let rendered = match s.render.as_mut() {
Some(render) => unsafe { render.render(width, height) },
None => return,
};
let Some(texture) = rendered else {
let f = &mut s.logged_render_fail;
once(f, "mpv render produced no texture");
return;
};
// Log the first frame, and again whenever the target size changes. Latching
// this once per session hid the case that matters: a second file, rendered
// at a different size, in a window that never moved. "The picture is a small
// box in the middle" and "the picture fills the widget" are indistinguishable
// from outside without it.
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()
);
}
unsafe {
cr.draw_from_gl(
&window,
texture as i32,
GL_TEXTURE,
scale,
0,
0,
width,
height,
);
// Tell mpv the frame reached the screen. Without this it has nothing to
// pace against — see DR-233.
if let Some(render) = s.render.as_ref() {
render.report_swap();
}
}
}
+90 -1
View File
@@ -212,6 +212,16 @@ pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
/// ///
/// TRACES: UR-004 | DR-148 | UT-142 /// TRACES: UR-004 | DR-148 | UT-142
pub fn video_audio_codecs(detected: &str) -> String { pub fn video_audio_codecs(detected: &str) -> String {
// Where the video renderer decodes the audio itself (ExoPlayer), the
// platform list *is* the answer and narrowing it to the webview's throws
// away codecs the device genuinely plays — dts, on the tablet this was
// found on. TRACES: UR-004, UR-080 | DR-234
#[cfg(target_os = "android")]
{
return detected.to_string();
}
#[allow(unreachable_code)]
let kept: Vec<&str> = detected let kept: Vec<&str> = detected
.split(',') .split(',')
.filter_map(|codec| { .filter_map(|codec| {
@@ -233,6 +243,83 @@ pub fn video_audio_codecs(detected: &str) -> String {
} }
} }
/// What the renderer that will actually decode video on this platform can play.
///
/// Returns `(video_codecs, audio_codecs)` as Jellyfin-style comma lists.
///
/// This exists because the answer was previously derived in four places and
/// hardcoded in a fifth, each of them assuming the *webview* was decoding:
/// the device profile, the transcoding targets, the direct-play audio
/// narrowing, the client-side audio override, and `get_video_stream_url`'s
/// `VideoCodec`. On Android the decoder is ExoPlayer, so every one of those was
/// wrong there — the observed cost being an hevc source re-encoded to h264
/// because its *audio* was eac3, and dts forced to transcode though the device
/// decodes it.
///
/// One source, so the copies cannot disagree again.
///
/// TRACES: UR-004, UR-080 | DR-234
pub fn renderer_codecs() -> (String, String) {
#[cfg(target_os = "android")]
{
// ExoPlayer, and the device itself answers via MediaCodecList.
crate::player::get_detected_codecs()
.map(|(video, audio, _channels)| (video, audio))
.unwrap_or_else(|| {
log::warn!(
"[DeviceProfile] Codec detection not complete, using conservative defaults"
);
("h264,hevc".to_string(), "aac,mp3".to_string())
})
}
// Linux desktop draws video in the WebKitGTK HTML5 <video> element, which
// cannot reliably decode HEVC/AV1/VP9. Claim only what it decodes, so
// Jellyfin transcodes the rest to h264 HLS. (Audio-only playback goes
// through MPV and is unaffected — that is a different renderer and a
// different profile.)
//
// When mpv draws the picture here this stops being a platform constant and
// becomes a question about the active renderer — which is the whole point of
// returning it from a function rather than a `cfg` block.
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
{
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string())
}
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
{
(
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),
)
}
}
/// Whether the renderer that decodes *video* on this platform can also decode
/// this audio codec.
///
/// On a webview platform this is the webview's narrow list, because the element
/// decodes both halves. On Android it is the device's own list: ExoPlayer plays
/// the audio, so judging it against the webview's capabilities transcodes files
/// that would have played.
///
/// TRACES: UR-004, UR-080 | DR-234
pub fn renderer_can_decode_audio(codec: &str) -> bool {
let codec = codec.trim();
#[cfg(target_os = "android")]
{
let (_video, audio) = renderer_codecs();
return audio
.split(',')
.any(|supported| supported.trim().eq_ignore_ascii_case(codec));
}
#[cfg(not(target_os = "android"))]
{
webview_can_decode_audio(codec)
}
}
/// Whether the webview `<video>` element can decode this audio codec. /// Whether the webview `<video>` element can decode this audio codec.
/// ///
/// TRACES: UR-004 | DR-149 | UT-148 /// TRACES: UR-004 | DR-149 | UT-148
@@ -260,7 +347,9 @@ pub fn webview_can_decode_audio(codec: &str) -> bool {
/// TRACES: UR-004 | DR-149 | UT-148 /// TRACES: UR-004 | DR-149 | UT-148
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool { pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
match served_audio_codec(streams) { match served_audio_codec(streams) {
Some(codec) => !webview_can_decode_audio(codec), // The renderer that will decode it, not always the webview — see
// `renderer_can_decode_audio`. TRACES: UR-004, UR-080 | DR-234
Some(codec) => !renderer_can_decode_audio(codec),
// No audio at all, or a codec the server did not name: leave it alone. // No audio at all, or a codec the server did not name: leave it alone.
None => false, None => false,
} }
+18
View File
@@ -97,6 +97,24 @@ impl HybridRepository {
.await .await
} }
/// Decide what stream to play and describe it fully — the DR-225 contract.
///
/// Online-only for the same reason as `get_video_stream_url`: an offline
/// item is a file on disk, and the caller builds
/// [`StreamSelection::local_file`] for it rather than negotiating anything.
///
/// TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228
pub async fn get_stream_selection(
&self,
item_id: &str,
media_source_id: Option<&str>,
audio_stream_index: Option<i32>,
) -> Result<super::StreamSelection, RepoError> {
self.online
.get_stream_selection(item_id, media_source_id, audio_stream_index)
.await
}
/// Get an audio-only stream URL for a video item (background-audio handoff). /// Get an audio-only stream URL for a video item (background-audio handoff).
/// Online-only, like `get_video_stream_url`. /// Online-only, like `get_video_stream_url`.
/// ///
+3
View File
@@ -5,11 +5,14 @@ pub mod hybrid;
pub mod offline; pub mod offline;
pub mod online; pub mod online;
pub mod series_progress; pub mod series_progress;
/// Backend-owned stream selection (UR-079 / DR-225).
pub mod stream_selection;
pub mod types; pub mod types;
pub use hybrid::HybridRepository; pub use hybrid::HybridRepository;
pub use offline::OfflineRepository; pub use offline::OfflineRepository;
pub use online::{JRayActor, OnlineRepository}; pub use online::{JRayActor, OnlineRepository};
pub use stream_selection::{StreamSelection, Transport};
pub use types::*; pub use types::*;
use async_trait::async_trait; use async_trait::async_trait;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,455 @@
//! What stream to play, decided in Rust and handed to a player whole.
//!
//! Every player backend — mpv, ExoPlayer, the webview `<video>`/hls.js path —
//! used to receive a bare URL and re-derive the rest: the frontend decided
//! "is this HLS?" by looking for `.m3u8` in the string, and nothing anywhere
//! carried *why* a stream was transcoded or what else the source could have
//! offered. This module is the replacement contract: one self-describing
//! [`StreamSelection`] that says what the stream is, how to fetch it, and what
//! the alternatives were.
//!
//! The division of labour it encodes — **Rust decides *what stream*, the player
//! decides *how to deliver it*** — is the point. A multi-variant playlist handed
//! to ExoPlayer is still ExoPlayer's to adapt over; Rust never paces bytes.
//!
//! TRACES: UR-079 | DR-225
use serde::{Deserialize, Serialize};
use crate::settings::StreamingQuality;
/// How the bytes of a chosen stream are fetched.
///
/// This field exists to delete a substring search. The frontend previously
/// decided which loader to attach by testing `url.contains(".m3u8")`, which is a
/// domain fact reconstructed in the presentation layer — the same class of leak
/// as the item-type taxonomy that `check:boundary` guards, and one that breaks
/// silently the moment a server serves a playlist from a path that does not end
/// in `.m3u8`, or serves a progressive file from one that does.
///
/// Tagged (`{"type":"hls"}`) rather than a bare string so the frontend matches a
/// discriminant instead of comparing text.
///
/// TRACES: UR-079 | DR-225
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Transport {
/// An HLS playlist. The webview attaches hls.js (or Safari's native loader);
/// ExoPlayer uses its HLS media source.
Hls,
/// A single progressive HTTP resource, seekable by byte range.
Progressive,
/// A file already on disk — a completed download, or the loopback media
/// server standing in front of one.
LocalFile,
}
/// What the server is doing to the source to produce this stream.
///
/// Distinct from [`Transport`] because the two are genuinely independent: a
/// direct-streamed remux and a transcode can both arrive over HLS, and a direct
/// play can arrive progressively or as a local file. Keeping them apart is what
/// lets the UI say "this is not costing the server anything" without inferring
/// it from a URL shape.
///
/// TRACES: UR-079 | DR-228
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PlaybackKind {
/// The source file is served untouched. No server CPU, no quality loss.
DirectPlay,
/// The container is repackaged but the codecs are copied — cheap, and
/// visually identical to the source.
DirectStream,
/// The server is re-encoding. The only case where a bitrate ceiling can
/// actually be honoured, and the only one that costs the server real work.
Transcode,
}
impl PlaybackKind {
/// Whether the server is spending encoder time on this stream.
///
/// The queue carries a `needs_transcoding` flag that predates this enum and
/// that several seek/reload paths still branch on; this keeps the two from
/// drifting by making one derive from the other.
///
/// TRACES: UR-079 | DR-228
pub fn needs_transcoding(&self) -> bool {
matches!(self, PlaybackKind::Transcode)
}
}
/// The rendition actually negotiated — what the viewer is receiving right now.
///
/// `None` on a [`StreamSelection`] when the source is being direct-played as-is:
/// there is no *chosen* rendition in that case, only the file itself, and
/// reporting the ceiling that happened to be set would misdescribe it.
///
/// TRACES: UR-079 | DR-225, DR-226
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rendition {
/// The rung of the ladder this stream was built against.
pub quality: StreamingQuality,
/// Total bits per second the stream may use, when a ceiling applies.
pub max_bitrate: Option<u64>,
/// Resolution ceiling, when one applies. `None` preserves the source's.
pub max_height: Option<u32>,
/// Video codec the server was asked to produce.
pub video_codec: Option<String>,
/// Audio codec the server was asked to produce.
pub audio_codec: Option<String>,
}
/// One rung of the quality picker, as it applies to *this* media source.
///
/// The picker used to be filled from the fixed [`StreamingQuality::ALL`] ladder,
/// which meant offering "20 Mbps" for a 1.1 Mbps podcast — eight rungs, six of
/// them indistinguishable from Original. `exceeds_source` is what lets the
/// frontend render that honestly without knowing anything about bitrates.
///
/// TRACES: UR-070, UR-079 | DR-227, DR-121
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QualityOption {
pub quality: StreamingQuality,
/// Human label ("8 Mbps"). Lives in Rust beside the number it describes.
pub label: String,
/// Secondary line ("1080p").
pub detail: String,
/// True when this rung's ceiling is at or above what the source itself
/// carries, so selecting it yields the same stream as `Original`.
///
/// The frontend renders these differently (or hides them); it does not
/// decide which they are.
pub exceeds_source: bool,
/// The source's own bitrate, when the server reported one. Presentation
/// only — the picker shows "Original (6.7 Mbps)" rather than a bare word.
pub source_bitrate: Option<u64>,
}
/// Everything a player backend needs to open a stream, and everything the UI
/// needs to describe it.
///
/// Replaces the bare `String` URL that `get_video_stream_url` used to return.
///
/// TRACES: UR-079 | DR-225, DR-227, DR-228
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamSelection {
/// The URL (or loopback URL) to open.
pub url: String,
/// How to fetch it. Replaces the `.m3u8` substring check.
pub transport: Transport,
/// What the server is doing to the source to produce it.
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, for the quality picker (DR-227).
pub available: Vec<QualityOption>,
/// The media source this selection is for, so a later re-open (quality
/// change, audio-track switch, transcoded seek) targets the same one.
pub media_source_id: Option<String>,
/// The transcode identity the server keyed this job by, when there is one.
pub play_session_id: Option<String>,
/// Whether the server is spending encoder time on this stream.
///
/// Derived from [`playback_kind`](Self::playback_kind) rather than left for
/// the frontend to compute: "which kinds count as transcoding" is a domain
/// rule, and a direct *stream* is a remux that must not be counted. The
/// queue's long-standing `needs_transcoding` flag and the seek strategy both
/// read this, so there is one answer rather than three.
///
/// TRACES: UR-079 | DR-225, DR-228
pub needs_transcoding: bool,
}
impl StreamSelection {
/// A selection for a file already on disk.
///
/// A downloaded file is a direct play by definition — the bytes are the
/// source's — and offering a quality ladder over it would be a lie, since
/// nothing about a local file can be re-negotiated.
///
/// TRACES: UR-071, UR-079 | DR-225
pub fn local_file(url: impl Into<String>) -> Self {
Self {
url: url.into(),
transport: Transport::LocalFile,
playback_kind: PlaybackKind::DirectPlay,
rendition: None,
available: Vec::new(),
media_source_id: None,
play_session_id: None,
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.
///
/// Every rung is returned — the picker stays a fixed, predictable list rather
/// than one that changes length per item — but each is marked with whether it
/// would actually constrain *this* source. A rung whose ceiling is at or above
/// the source bitrate produces the same bytes as `Original`, so presenting it as
/// a distinct choice is noise.
///
/// `source_bitrate` is `None` when the server did not report one (it is absent
/// for some containers — the sampled library has `avi` files with no bitrate at
/// all). In that case nothing can be judged redundant and every rung is offered,
/// which is the safe direction: the viewer keeps every choice they had before.
///
/// TRACES: UR-070, UR-079 | DR-227, DR-121 | UT-212
pub fn quality_options_for_source(source_bitrate: Option<u64>) -> Vec<QualityOption> {
StreamingQuality::ALL
.iter()
.map(|quality| QualityOption {
quality: *quality,
label: quality.label().to_string(),
detail: quality.detail().to_string(),
exceeds_source: match (quality.max_bitrate(), source_bitrate) {
// `Original` is the source; it never "exceeds" it.
(None, _) => false,
// Nothing known about the source — judge nothing redundant.
(Some(_), None) => false,
(Some(cap), Some(source)) => cap >= source,
},
source_bitrate,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// The tag the frontend matches on has to be exactly what it expects, and
/// it is a *string in TypeScript* — nothing but a test keeps the two in step.
///
/// TRACES: UR-079 | DR-225 | UT-212
#[test]
fn test_transport_serialises_with_the_tag_the_frontend_matches() {
let cases = [
(Transport::Hls, r#"{"type":"hls"}"#),
(Transport::Progressive, r#"{"type":"progressive"}"#),
(Transport::LocalFile, r#"{"type":"localFile"}"#),
];
for (transport, expected) in cases {
let json = serde_json::to_string(&transport).expect("serialises");
assert_eq!(json, expected, "wire shape of {transport:?}");
let back: Transport = serde_json::from_str(&json).expect("round-trips");
assert_eq!(back, transport);
}
}
/// TRACES: UR-079 | DR-228 | UT-212
#[test]
fn test_playback_kind_serialises_with_the_tag_the_frontend_matches() {
let cases = [
(PlaybackKind::DirectPlay, r#"{"type":"directPlay"}"#),
(PlaybackKind::DirectStream, r#"{"type":"directStream"}"#),
(PlaybackKind::Transcode, r#"{"type":"transcode"}"#),
];
for (kind, expected) in cases {
let json = serde_json::to_string(&kind).expect("serialises");
assert_eq!(json, expected, "wire shape of {kind:?}");
let back: PlaybackKind = serde_json::from_str(&json).expect("round-trips");
assert_eq!(back, kind);
}
}
/// Only a transcode costs the server encoder time. A direct *stream* is a
/// remux — cheap, and not what `needs_transcoding` has ever meant.
///
/// TRACES: UR-079 | DR-228 | UT-212
#[test]
fn test_only_transcode_counts_as_transcoding() {
assert!(PlaybackKind::Transcode.needs_transcoding());
assert!(!PlaybackKind::DirectStream.needs_transcoding());
assert!(!PlaybackKind::DirectPlay.needs_transcoding());
}
/// A local file is a direct play over a local transport, with no ladder:
/// nothing about a file on disk can be re-negotiated.
///
/// TRACES: UR-071, UR-079 | DR-225 | UT-212
#[test]
fn test_local_file_selection_offers_no_ladder() {
let selection = StreamSelection::local_file("http://127.0.0.1:9000/media/x.mkv");
assert_eq!(selection.transport, Transport::LocalFile);
assert_eq!(selection.playback_kind, PlaybackKind::DirectPlay);
assert!(selection.rendition.is_none());
assert!(selection.available.is_empty());
assert!(!selection.needs_transcoding);
}
/// The camelCase rule applies to nested struct fields too, and
/// `playbackKind` is the one the frontend branches on.
///
/// TRACES: UR-079 | DR-225 | UT-212
#[test]
fn test_stream_selection_fields_are_camel_case_on_the_wire() {
let selection = StreamSelection {
url: "https://example/master.m3u8".to_string(),
transport: Transport::Hls,
playback_kind: PlaybackKind::Transcode,
rendition: Some(Rendition {
quality: StreamingQuality::Mbps8,
max_bitrate: Some(8_000_000),
max_height: Some(1080),
video_codec: Some("h264".to_string()),
audio_codec: Some("aac".to_string()),
}),
available: Vec::new(),
media_source_id: Some("src-1".to_string()),
play_session_id: Some("sess-1".to_string()),
needs_transcoding: true,
};
let json = serde_json::to_string(&selection).expect("serialises");
assert!(
json.contains(r#""playbackKind":{"type":"transcode"}"#),
"{json}"
);
assert!(json.contains(r#""transport":{"type":"hls"}"#), "{json}");
assert!(json.contains(r#""mediaSourceId":"src-1""#), "{json}");
assert!(json.contains(r#""playSessionId":"sess-1""#), "{json}");
assert!(json.contains(r#""maxBitrate":8000000"#), "{json}");
assert!(json.contains(r#""maxHeight":1080"#), "{json}");
assert!(json.contains(r#""needsTranscoding":true"#), "{json}");
}
/// The measured library has 1.1 Mbps sources in it. Offering those a choice
/// of 20, 10, 8, 4 and 2 Mbps is offering five ways to spell "Original".
///
/// TRACES: UR-070, UR-079 | DR-227, DR-121 | UT-212
#[test]
fn test_rungs_above_the_source_bitrate_are_marked_redundant() {
let options = quality_options_for_source(Some(1_122_137));
let redundant: Vec<_> = options
.iter()
.filter(|o| o.exceeds_source)
.map(|o| o.quality)
.collect();
assert_eq!(
redundant,
vec![
StreamingQuality::Mbps20,
StreamingQuality::Mbps10,
StreamingQuality::Mbps8,
StreamingQuality::Mbps4,
StreamingQuality::Mbps2,
],
"every rung at or above a 1.12 Mbps source is the source"
);
// The rungs that genuinely constrain it are not marked.
let constraining: Vec<_> = options
.iter()
.filter(|o| !o.exceeds_source)
.map(|o| o.quality)
.collect();
assert_eq!(
constraining,
vec![
StreamingQuality::Original,
StreamingQuality::Mbps1,
StreamingQuality::Kbps720,
]
);
}
/// `Original` is the source, so it is never "above" it — not even for a
/// source whose bitrate is unknown or zero.
///
/// TRACES: UR-070, UR-079 | DR-227 | UT-212
#[test]
fn test_original_is_never_marked_as_exceeding_the_source() {
for bitrate in [None, Some(0), Some(1), Some(50_000_000)] {
let options = quality_options_for_source(bitrate);
let original = options
.iter()
.find(|o| o.quality == StreamingQuality::Original)
.expect("Original is always offered");
assert!(!original.exceeds_source, "bitrate {bitrate:?}");
}
}
/// An `avi` with no reported bitrate must not lose the picker. Judging
/// nothing redundant is the safe direction — the viewer keeps every choice.
///
/// TRACES: UR-070, UR-079 | DR-227 | UT-212
#[test]
fn test_an_unknown_source_bitrate_keeps_every_rung_offered() {
let options = quality_options_for_source(None);
assert_eq!(options.len(), StreamingQuality::ALL.len());
assert!(
options.iter().all(|o| !o.exceeds_source),
"nothing can be judged redundant without a source bitrate"
);
assert!(options.iter().all(|o| o.source_bitrate.is_none()));
}
/// A 4K remux constrains at every rung — the ladder is fully meaningful.
///
/// TRACES: UR-070, UR-079 | DR-227 | UT-212
#[test]
fn test_a_source_above_the_ladder_marks_nothing_redundant() {
let options = quality_options_for_source(Some(40_000_000));
assert!(options.iter().all(|o| !o.exceeds_source));
}
/// The picker's text comes from Rust, beside the numbers it describes, so a
/// relabelled rung cannot drift out of step with what it does.
///
/// TRACES: UR-070, UR-079 | DR-227 | UT-212
#[test]
fn test_options_carry_the_ladder_labels() {
let options = quality_options_for_source(Some(6_652_961));
assert_eq!(options.len(), StreamingQuality::ALL.len());
for (option, quality) in options.iter().zip(StreamingQuality::ALL) {
assert_eq!(option.quality, quality);
assert_eq!(option.label, quality.label());
assert_eq!(option.detail, quality.detail());
assert_eq!(option.source_bitrate, Some(6_652_961));
}
}
}
+9
View File
@@ -469,6 +469,15 @@ pub struct LiveStreamInfo {
pub play_session_id: Option<String>, pub play_session_id: Option<String>,
pub live_stream_id: Option<String>, pub live_stream_id: Option<String>,
pub media_source_id: Option<String>, pub media_source_id: Option<String>,
/// How to open `stream_url`.
///
/// A live channel is always an HLS transcode — the server has to repackage a
/// broadcast mux into something a browser can play, and there is no static
/// file to direct-play. Saying so here means the player page never has to
/// work it out from the URL, which is the whole of DR-225.
///
/// TRACES: UR-079 | DR-225
pub transport: super::stream_selection::Transport,
} }
/// Genre /// Genre
+3 -2
View File
@@ -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",
@@ -17,7 +17,8 @@
"height": 800, "height": 800,
"minWidth": 800, "minWidth": 800,
"minHeight": 600, "minHeight": 600,
"resizable": true "resizable": true,
"transparent": true
} }
], ],
"security": { "security": {
+266 -15
View File
@@ -39,7 +39,7 @@ async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
* playing there is nothing to pause, and an error would make the frontend * playing there is nothing to pause, and an error would make the frontend
* handle a case that is not a failure. * handle a case that is not a failure.
* *
* TRACES: UR-040, UR-041 | DR-224 | UT-211 * TRACES: UR-040, UR-041 | DR-225 | UT-212
*/ */
async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture: boolean) : Promise<BackgroundAction> { async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture: boolean) : Promise<BackgroundAction> {
return await TAURI_INVOKE("player_background_action", { backgroundAudioArmed, inPictureInPicture }); return await TAURI_INVOKE("player_background_action", { backgroundAudioArmed, inPictureInPicture });
@@ -120,7 +120,7 @@ async playerSeek(position: number) : Promise<PlayerStatus> {
* *
* This command analyzes the current video stream and automatically chooses * This command analyzes the current video stream and automatically chooses
* the best seeking strategy: * the best seeking strategy:
* - HLS streams (.m3u8): Use native seeking * - HLS streams: Use native seeking
* - Direct play streams: Use native seeking * - Direct play streams: Use native seeking
* - Transcoded non-HLS: Request new stream URL from server starting at seek position * - Transcoded non-HLS: Request new stream URL from server starting at seek position
* *
@@ -263,13 +263,17 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
* two-sided split: HTML5 gets the URL back and reloads its own element, while a * two-sided split: HTML5 gets the URL back and reloads its own element, while a
* native backend is reloaded here. * native backend is reloaded here.
* *
* The change applies to this playback *and* to everything started afterwards * The change applies to **this playback only**. The in-player picker is a
* (it sets the process-wide ceiling), but it is deliberately **not** persisted: * "this film, this connection" control and its doc has always said so, but it
* the in-player picker is a "this film, this connection" control, and the * used to be implemented by writing the process-wide ceiling so choosing
* durable default belongs to Settings. `player_set_video_settings` is the one * 2 Mbps to get one awkward film moving silently capped every video played
* that writes to the database. * afterwards for the rest of the process, with the Settings screen still
* showing the old value and nothing in the UI admitting the change. It now
* sets a per-playback override that the next item clears; the durable default
* belongs to Settings, and `player_set_video_settings` is the one that writes
* to the database.
* *
* TRACES: UR-074 | DR-162 * TRACES: UR-074, UR-079 | DR-162, DR-226
*/ */
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> { async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex }); return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
@@ -1033,6 +1037,22 @@ async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<nul
async mediaLocalUrl(path: string) : Promise<string> { async mediaLocalUrl(path: string) : Promise<string> {
return await TAURI_INVOKE("media_local_url", { path }); return await TAURI_INVOKE("media_local_url", { path });
}, },
/**
* The stream selection for a downloaded file.
*
* The local-playback counterpart to `repository_get_stream_selection`. A file
* on disk needs no negotiation it is a direct play over a local transport,
* with no quality ladder, because nothing about it can be re-negotiated but
* the *frontend must not be the one to say so*. It gets the same
* [`StreamSelection`] shape as a streamed source so the player has one contract
* to consume rather than two, and so no caller has to infer a transport from a
* loopback URL.
*
* TRACES: UR-071, UR-079 | DR-225
*/
async mediaLocalSelection(path: string) : Promise<StreamSelection> {
return await TAURI_INVOKE("media_local_selection", { path });
},
/** /**
* Start downloading a file immediately * Start downloading a file immediately
* This command actually downloads the file using the worker * This command actually downloads the file using the worker
@@ -1618,6 +1638,24 @@ async repositoryGetPlaybackInfo(handle: string, itemId: string) : Promise<Playba
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<string> { async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, audioStreamIndex }); return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, audioStreamIndex });
}, },
/**
* Decide what stream to play for a video, and describe it.
*
* Replaces `repository_get_video_stream_url` for playback. The returned
* [`StreamSelection`] carries the transport explicitly, so the frontend picks
* its loader from a tagged enum instead of testing the URL for `.m3u8`; and it
* carries the quality ladder as it applies to *this* source, so the picker can
* stop offering rungs that produce the same bytes as Original.
*
* No start-position parameter, for the same reason as the URL builder: a
* position on an HLS playlist is copied onto every segment URI and the server
* rejects each with `400` (DR-181). Callers resume by seeking after load.
*
* TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
*/
async repositoryGetStreamSelection(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamSelection> {
return await TAURI_INVOKE("repository_get_stream_selection", { handle, itemId, mediaSourceId, audioStreamIndex });
},
/** /**
* Get audio stream URL for a track * Get audio stream URL for a track
*/ */
@@ -1965,7 +2003,7 @@ export type AudioTrackSwitchResponse =
/** /**
* HTML5 needs to reload stream with new audio track * HTML5 needs to reload stream with new audio track
*/ */
{ strategy: "reloadStream"; new_url: string; position: number } { strategy: "reloadStream"; selection: StreamSelection; position: number }
/** /**
* Authentication result * Authentication result
*/ */
@@ -2329,7 +2367,18 @@ excludedItemIds?: string[] }
* streamed; the server returns a transcoding URL (already absolute) plus a * streamed; the server returns a transcoding URL (already absolute) plus a
* `live_stream_id` that can later be used to close the stream. * `live_stream_id` that can later be used to close the stream.
*/ */
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null } export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null;
/**
* How to open `stream_url`.
*
* A live channel is always an HLS transcode the server has to repackage a
* broadcast mux into something a browser can play, and there is no static
* file to direct-play. Saying so here means the player page never has to
* work it out from the URL, which is the whole of DR-225.
*
* TRACES: UR-079 | DR-225
*/
transport: Transport }
/** /**
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`. * An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
* *
@@ -2568,6 +2617,18 @@ videoCodec: string;
* Whether the video requires server-side transcoding * Whether the video requires server-side transcoding
*/ */
needsTranscoding: boolean; needsTranscoding: boolean;
/**
* How this item's stream is fetched, as the backend decided it.
*
* Carried on the queue item so a later seek/reload does not have to guess.
* `None` for items queued by a path that never negotiated (audio tracks,
* direct URLs) and for anything queued before this field existed, where the
* caller falls back to `needs_transcoding` every transcode this app
* requests is HLS (DR-140), so that fallback is exact rather than a guess.
*
* TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
*/
transport?: Transport | null;
/** /**
* Optional now-playing metadata. Used by the background-audio handoff so the * Optional now-playing metadata. Used by the background-audio handoff so the
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so * lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
@@ -2680,6 +2741,32 @@ supportsNativeVideo: boolean }
* Playback information * Playback information
*/ */
export type PlaybackInfo = { mediaSourceId: string; playSessionId: string; streamUrl: string; directPlay: boolean; needsTranscoding: boolean } export type PlaybackInfo = { mediaSourceId: string; playSessionId: string; streamUrl: string; directPlay: boolean; needsTranscoding: boolean }
/**
* What the server is doing to the source to produce this stream.
*
* Distinct from [`Transport`] because the two are genuinely independent: a
* direct-streamed remux and a transcode can both arrive over HLS, and a direct
* play can arrive progressively or as a local file. Keeping them apart is what
* lets the UI say "this is not costing the server anything" without inferring
* it from a URL shape.
*
* TRACES: UR-079 | DR-228
*/
export type PlaybackKind =
/**
* The source file is served untouched. No server CPU, no quality loss.
*/
{ type: "directPlay" } |
/**
* The container is repackaged but the codecs are copied cheap, and
* visually identical to the source.
*/
{ type: "directStream" } |
/**
* The server is re-encoding. The only case where a bitrate ceiling can
* actually be honoured, and the only one that costs the server real work.
*/
{ type: "transcode" }
/** /**
* Playback mode - local device, remote session, or idle * Playback mode - local device, remote session, or idle
*/ */
@@ -2779,6 +2866,18 @@ videoCodec?: string | null;
* Whether the video requires server-side transcoding * Whether the video requires server-side transcoding
*/ */
needsTranscoding?: boolean; needsTranscoding?: boolean;
/**
* How this item's stream is fetched, as the backend decided it.
*
* Carried on the queue item so a later seek/reload does not have to guess.
* `None` for items queued by a path that never negotiated (audio tracks,
* direct URLs) and for anything queued before this field existed, where the
* caller falls back to `needs_transcoding` every transcode this app
* requests is HLS (DR-140), so that fallback is exact rather than a guess.
*
* TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
*/
transport?: Transport | null;
/** /**
* Video width in pixels * Video width in pixels
*/ */
@@ -3061,6 +3160,38 @@ alreadyDownloaded: number;
* Number of tracks skipped (no jellyfin ID or other reasons) * Number of tracks skipped (no jellyfin ID or other reasons)
*/ */
skipped: number } skipped: number }
/**
* One rung of the quality picker, as it applies to *this* media source.
*
* The picker used to be filled from the fixed [`StreamingQuality::ALL`] ladder,
* which meant offering "20 Mbps" for a 1.1 Mbps podcast eight rungs, six of
* them indistinguishable from Original. `exceeds_source` is what lets the
* frontend render that honestly without knowing anything about bitrates.
*
* TRACES: UR-070, UR-079 | DR-227, DR-121
*/
export type QualityOption = { quality: StreamingQuality;
/**
* Human label ("8 Mbps"). Lives in Rust beside the number it describes.
*/
label: string;
/**
* Secondary line ("1080p").
*/
detail: string;
/**
* True when this rung's ceiling is at or above what the source itself
* carries, so selecting it yields the same stream as `Original`.
*
* The frontend renders these differently (or hides them); it does not
* decide which they are.
*/
exceedsSource: boolean;
/**
* The source's own bitrate, when the server reported one. Presentation
* only the picker shows "Original (6.7 Mbps)" rather than a bare word.
*/
sourceBitrate: number | null }
/** /**
* Response for queue queries * Response for queue queries
*/ */
@@ -3069,6 +3200,36 @@ export type QueueStatus = { items: PlayerMediaItem[]; currentIndex: number | nul
* Remote session status for UI updates * Remote session status for UI updates
*/ */
export type RemoteSessionStatus = { position: number; duration: number | null; isPlaying: boolean; nowPlayingItem: NowPlayingItem | null } export type RemoteSessionStatus = { position: number; duration: number | null; isPlaying: boolean; nowPlayingItem: NowPlayingItem | null }
/**
* The rendition actually negotiated what the viewer is receiving right now.
*
* `None` on a [`StreamSelection`] when the source is being direct-played as-is:
* there is no *chosen* rendition in that case, only the file itself, and
* reporting the ceiling that happened to be set would misdescribe it.
*
* TRACES: UR-079 | DR-225, DR-226
*/
export type Rendition = {
/**
* The rung of the ladder this stream was built against.
*/
quality: StreamingQuality;
/**
* Total bits per second the stream may use, when a ceiling applies.
*/
maxBitrate: number | null;
/**
* Resolution ceiling, when one applies. `None` preserves the source's.
*/
maxHeight: number | null;
/**
* Video codec the server was asked to produce.
*/
videoCodec: string | null;
/**
* Audio codec the server was asked to produce.
*/
audioCodec: string | null }
/** /**
* Repeat mode for the queue * Repeat mode for the queue
* *
@@ -3182,13 +3343,73 @@ export type StreamKind = "audio" | "video" | "subtitle" |
*/ */
export type StreamQualityResponse = export type StreamQualityResponse =
/** /**
* The native backend was reloaded here; nothing left for the frontend. * The native backend was reloaded here; nothing left for the frontend to
* *do* but it still has to be told what was negotiated.
*
* This carried only a position at first, which left the picker on Android
* pinned to the rendition of the *first* stream: the UI derives the rung in
* force from the selection it holds, nothing replaced that selection on the
* native path, and a transcode always has a rendition so the fallback
* that would have used the requested value was never reached. The stream
* changed and the menu did not.
*
* TRACES: UR-074, UR-079 | DR-226, DR-227
*/ */
{ strategy: "native"; position: number } | { strategy: "native"; selection: StreamSelection; position: number } |
/** /**
* HTML5 must reload its element with this URL. * HTML5 must reload its element with this selection.
*/ */
{ strategy: "reloadStream"; new_url: string; position: number } { strategy: "reloadStream"; selection: StreamSelection; position: number }
/**
* Everything a player backend needs to open a stream, and everything the UI
* needs to describe it.
*
* Replaces the bare `String` URL that `get_video_stream_url` used to return.
*
* TRACES: UR-079 | DR-225, DR-227, DR-228
*/
export type StreamSelection = {
/**
* The URL (or loopback URL) to open.
*/
url: string;
/**
* How to fetch it. Replaces the `.m3u8` substring check.
*/
transport: Transport;
/**
* What the server is doing to the source to produce it.
*/
playbackKind: PlaybackKind;
/**
* The negotiated rendition; `None` when direct-playing the source as-is.
*/
rendition: Rendition | null;
/**
* What this media source can offer, for the quality picker (DR-227).
*/
available: QualityOption[];
/**
* The media source this selection is for, so a later re-open (quality
* change, audio-track switch, transcoded seek) targets the same one.
*/
mediaSourceId: string | null;
/**
* The transcode identity the server keyed this job by, when there is one.
*/
playSessionId: string | null;
/**
* Whether the server is spending encoder time on this stream.
*
* Derived from [`playback_kind`](Self::playback_kind) rather than left for
* the frontend to compute: "which kinds count as transcoding" is a domain
* rule, and a direct *stream* is a remux that must not be counted. The
* queue's long-standing `needs_transcoding` flag and the seek strategy both
* read this, so there is one answer rather than three.
*
* TRACES: UR-079 | DR-225, DR-228
*/
needsTranscoding: boolean }
/** /**
* A ceiling on how much bandwidth a *video* stream may consume. * A ceiling on how much bandwidth a *video* stream may consume.
* *
@@ -3269,6 +3490,36 @@ itemName: string | null }
* Statistics about the thumbnail cache * Statistics about the thumbnail cache
*/ */
export type ThumbnailCacheStats = { totalSizeBytes: number; itemCount: number; limitBytes: number } export type ThumbnailCacheStats = { totalSizeBytes: number; itemCount: number; limitBytes: number }
/**
* How the bytes of a chosen stream are fetched.
*
* This field exists to delete a substring search. The frontend previously
* decided which loader to attach by testing `url.contains(".m3u8")`, which is a
* domain fact reconstructed in the presentation layer the same class of leak
* as the item-type taxonomy that `check:boundary` guards, and one that breaks
* silently the moment a server serves a playlist from a path that does not end
* in `.m3u8`, or serves a progressive file from one that does.
*
* Tagged (`{"type":"hls"}`) rather than a bare string so the frontend matches a
* discriminant instead of comparing text.
*
* TRACES: UR-079 | DR-225
*/
export type Transport =
/**
* An HLS playlist. The webview attaches hls.js (or Safari's native loader);
* ExoPlayer uses its HLS media source.
*/
{ type: "hls" } |
/**
* A single progressive HTTP resource, seekable by byte range.
*/
{ type: "progressive" } |
/**
* A file already on disk a completed download, or the loopback media
* server standing in front of one.
*/
{ type: "localFile" }
/** /**
* User information * User information
*/ */
@@ -3316,7 +3567,7 @@ export type VideoSeekResponse =
/** /**
* Reload stream from new position (transcoded non-HLS) * Reload stream from new position (transcoded non-HLS)
*/ */
{ strategy: "reloadStream"; new_url: string; seek_offset: number } { strategy: "reloadStream"; selection: StreamSelection; seek_offset: number }
/** /**
* Video playback settings * Video playback settings
*/ */
+29 -1
View File
@@ -3,7 +3,7 @@
// NO direct HTTP calls - everything routes through Rust backend // NO direct HTTP calls - everything routes through Rust backend
import { commands } from "./bindings"; import { commands } from "./bindings";
import type { JRayActor, DownloadDiskUsage, SearchScope } from "./bindings"; import type { DownloadDiskUsage, JRayActor, SearchScope, StreamSelection } from "./bindings";
import type { QualityPreset } from "./quality-presets"; import type { QualityPreset } from "./quality-presets";
import type { import type {
Library, Library,
@@ -247,6 +247,34 @@ export class RepositoryClient {
); );
} }
/**
* Decide what stream to play, and describe it.
*
* The playback counterpart to {@link getVideoStreamUrl}, which returns only a
* URL and therefore forces its caller to work out the rest. This returns the
* transport (so the player picks a loader from a tagged enum rather than by
* searching the URL for `.m3u8`), the playback kind (direct play / direct
* stream / transcode), and the quality ladder as it applies to this source.
*
* No position parameter, for the same reason as {@link getVideoStreamUrl}: a
* start position on an HLS playlist makes Jellyfin reject every segment behind
* it with `400` (DR-181). Resume by seeking once loaded.
*
* TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228 | UT-213
*/
async getStreamSelection(
itemId: string,
mediaSourceId?: string | null,
audioStreamIndex?: number | null,
): Promise<StreamSelection> {
return commands.repositoryGetStreamSelection(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
audioStreamIndex ?? null,
);
}
/** /**
* Audio-only stream URL for a video item, for the background-audio handoff. * Audio-only stream URL for a video item, for the background-audio handoff.
* The server extracts just the audio track no video is decoded on-device. * The server extracts just the audio track no video is decoded on-device.
@@ -8,6 +8,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { formatDuration } from "$lib/utils/duration";
import { truncateMiddle } from "$lib/utils/truncateMiddle"; import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -88,18 +89,6 @@
: null, : null,
); );
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function getProgress(ep: MediaItem): number { function getProgress(ep: MediaItem): number {
if (!ep.userData || !ep.durationMs) { if (!ep.userData || !ep.durationMs) {
return 0; return 0;
@@ -117,7 +106,7 @@
} }
const episodeLabel = $derived(`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`); const episodeLabel = $derived(`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`);
const duration = $derived(formatDuration(episode.durationMs)); const duration = $derived(formatDuration(episode.durationMs, "h m"));
const progress = $derived(getProgress(episode)); const progress = $derived(getProgress(episode));
</script> </script>
+1 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { playerController } from "$lib/player"; import { playerController } from "$lib/player";
import { formatDuration } from "$lib/utils/duration";
import { truncateMiddle } from "$lib/utils/truncateMiddle"; import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action"; import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
@@ -34,14 +35,6 @@
let dragDisabled = $state(true); let dragDisabled = $state(true);
const flipDurationMs = 200; const flipDurationMs = 200;
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleConsider( function handleConsider(
e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>, e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>,
) { ) {
@@ -123,6 +123,23 @@ import VideoPlayer from "./VideoPlayer.svelte";
import { player } from "$lib/stores/player"; import { player } from "$lib/stores/player";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
function makeEpisode(): MediaItem { function makeEpisode(): MediaItem {
return { return {
id: "ep1", id: "ep1",
@@ -136,7 +153,7 @@ async function mountNativePlayer() {
const utils = render(VideoPlayer, { const utils = render(VideoPlayer, {
props: { props: {
media: makeEpisode(), media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8", selection: testSelection("http://server/videos/ep1/master.m3u8"),
mediaSourceId: "src-1", mediaSourceId: "src-1",
needsTranscoding: false, needsTranscoding: false,
onClose: vi.fn(), onClose: vi.fn(),
@@ -261,7 +278,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
const utils = render(VideoPlayer, { const utils = render(VideoPlayer, {
props: { props: {
media: makeEpisode(), media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8", selection: testSelection("http://server/videos/ep1/master.m3u8"),
mediaSourceId: "src-1", mediaSourceId: "src-1",
needsTranscoding: false, needsTranscoding: false,
onClose: vi.fn(), onClose: vi.fn(),
@@ -303,7 +320,7 @@ describe("VideoPlayer native path reveals the video (DR-172)", () => {
const utils = render(VideoPlayer, { const utils = render(VideoPlayer, {
props: { props: {
media: makeEpisode(), media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8", selection: testSelection("http://server/videos/ep1/master.m3u8"),
mediaSourceId: "src-1", mediaSourceId: "src-1",
needsTranscoding: false, needsTranscoding: false,
onClose: vi.fn(), onClose: vi.fn(),
@@ -118,6 +118,23 @@ import VideoPlayer from "./VideoPlayer.svelte";
import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer"; import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
function makeEpisode(): MediaItem { function makeEpisode(): MediaItem {
return { return {
id: "ep1", id: "ep1",
@@ -139,7 +156,7 @@ async function mountAndroidPlayer() {
const utils = render(VideoPlayer, { const utils = render(VideoPlayer, {
props: { props: {
media: makeEpisode(), media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8", selection: testSelection("http://server/videos/ep1/master.m3u8"),
mediaSourceId: "src-1", mediaSourceId: "src-1",
needsTranscoding: false, needsTranscoding: false,
onClose: vi.fn(), onClose: vi.fn(),
+260 -78
View File
@@ -1,10 +1,16 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 --> <!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, tick, untrack } from "svelte"; import { onMount, onDestroy, tick, untrack } from "svelte";
import { planFullscreen } from "./fullscreenTarget";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import type { JRayActor, StreamingQuality, BackgroundAction } from "$lib/api/bindings"; import type {
JRayActor,
StreamingQuality,
BackgroundAction,
StreamSelection,
} from "$lib/api/bindings";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import Hls from "hls.js"; import Hls from "hls.js";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
@@ -77,12 +83,21 @@
type BackgroundAudioState, type BackgroundAudioState,
} from "./backgroundAudioHandoff"; } from "./backgroundAudioHandoff";
import { createLogger } from "$lib/utils/logger"; import { createLogger } from "$lib/utils/logger";
import { elementSrcFor, loaderForTransport } from "$lib/player/streamTransport";
const log = createLogger("VideoPlayer"); const log = createLogger("VideoPlayer");
interface Props { interface Props {
media: MediaItem | null; media: MediaItem | null;
streamUrl: string; /**
* What to play, as the backend decided it: URL, transport, playback kind and
* the quality ladder for this source. Replaces the bare `streamUrl` string,
* which forced this component to re-derive the transport by searching for
* `.m3u8`.
*
* TRACES: UR-079 | DR-225, DR-227
*/
selection: StreamSelection;
mediaSourceId?: string; // Media source ID for subtitle URLs mediaSourceId?: string; // Media source ID for subtitle URLs
initialPosition?: number; // Position in seconds to seek to after load (for resume) initialPosition?: number; // Position in seconds to seek to after load (for resume)
needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
@@ -103,7 +118,7 @@
let { let {
media, media,
streamUrl, selection,
mediaSourceId, mediaSourceId,
initialPosition, initialPosition,
needsTranscoding = false, needsTranscoding = false,
@@ -179,7 +194,18 @@
// Capture only the initial streamUrl prop; later prop changes are applied via // Capture only the initial streamUrl prop; later prop changes are applied via
// the $effect below (untrack keeps this a one-time snapshot, matching // the $effect below (untrack keeps this a one-time snapshot, matching
// reportMediaId above and silencing state_referenced_locally). // reportMediaId above and silencing state_referenced_locally).
let currentStreamUrl = $state(untrack(() => streamUrl)); // The selection currently loaded. Starts from the prop and is replaced
// wholesale by a reload (quality change, audio-track switch, transcoded seek)
// so transport and URL can never disagree.
// TRACES: UR-079 | DR-225
let currentSelection = $state<StreamSelection>(untrack(() => selection));
const currentStreamUrl = $derived(currentSelection.url);
/**
* The transport as a plain string, so effects can depend on its *value*.
* A `$derived` primitive only notifies when it actually changes, which is what
* keeps the HLS teardown from re-running for an unchanged stream.
*/
const transportKind = $derived(currentSelection.transport.type);
let hasReportedStart = $state(false); let hasReportedStart = $state(false);
let progressInterval: ReturnType<typeof setInterval> | null = null; let progressInterval: ReturnType<typeof setInterval> | null = null;
let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001) let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
@@ -225,7 +251,6 @@
function nativeSeekSettling(): boolean { function nativeSeekSettling(): boolean {
return Date.now() - lastNativeSeekAt < NATIVE_SEEK_SETTLE_MS; 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 didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
let swipeType = $state<"brightness" | null>(null); let swipeType = $state<"brightness" | null>(null);
let hls: Hls | null = null; // HLS.js instance for streaming HLS content let hls: Hls | null = null; // HLS.js instance for streaming HLS content
@@ -250,14 +275,31 @@
} }
} }
/**
* A selection identical to the one loaded, but pointing at a different URL.
*
* Used by the paths that swap the stream without re-negotiating — the
* background-audio handoff and its return. Each states the transport it is
* moving to rather than letting it be inferred, which is the whole point of
* DR-225: the audio handoff really is a progressive mp3, and the rebuilt
* video stream really is an HLS transcode, and neither is knowable from the
* URL text.
*
* TRACES: UR-040, UR-079 | DR-225
*/
function selectionAt(url: string, transport: StreamSelection["transport"]): StreamSelection {
// A re-opened stream is a new transcode job; the old session id is stale.
return { ...currentSelection, url, transport, playSessionId: null };
}
const adapterBridge: Html5ElementBridge = { const adapterBridge: Html5ElementBridge = {
getElement: () => videoElement, getElement: () => videoElement,
getSeekOffset: () => seekOffset, getSeekOffset: () => seekOffset,
setSeekOffset: (o) => { setSeekOffset: (o) => {
seekOffset = o; seekOffset = o;
}, },
setStreamUrl: (u) => { setStreamSelection: (sel) => {
currentStreamUrl = u; currentSelection = sel;
}, },
destroyHls: tearDownHls, destroyHls: tearDownHls,
getMediaSourceId: () => mediaSourceId ?? null, getMediaSourceId: () => mediaSourceId ?? null,
@@ -275,9 +317,46 @@
// Rust — the frontend never encodes what a step means. // Rust — the frontend never encodes what a step means.
// TRACES: UR-074 | DR-162 // TRACES: UR-074 | DR-162
let showQualityMenu = $state(false); let showQualityMenu = $state(false);
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
let selectedQuality = $state<StreamingQuality>("original");
let changingQuality = $state(false); let changingQuality = $state(false);
/**
* The device's durable default, shown when the stream is a direct play and so
* has no rendition of its own to report. Read once from Settings.
*/
let defaultQuality = $state<StreamingQuality>("original");
/**
* The rungs to offer for the stream that is playing, straight from the
* backend (DR-227). Rungs whose ceiling is at or above the source bitrate are
* dropped: they produce the same bytes as Original, so listing five of them is
* five ways to spell one choice. Rust decides which those are — this only
* decides not to draw them.
*
* `Original` is always kept; it is the source, never redundant with it.
*
* TRACES: UR-070, UR-079 | DR-227, DR-121
*/
const qualityOptions = $derived(
currentSelection.available.filter((o) => !o.exceedsSource || o.quality === "original"),
);
/**
* The rung in force. A transcode reports the rendition it was built against;
* a direct play has none, because it *is* the source — so it reads as
* Original rather than as whatever ceiling happens to be set.
*/
const selectedQuality = $derived<StreamingQuality>(
currentSelection.rendition?.quality ??
(currentSelection.playbackKind.type === "transcode" ? defaultQuality : "original"),
);
/** Human line for what the server is doing with this stream. */
const playbackKindLabel = $derived(
currentSelection.playbackKind.type === "directPlay"
? "Direct play — the original file"
: currentSelection.playbackKind.type === "directStream"
? "Direct stream — repackaged, not re-encoded"
: "Transcoding on the server",
);
// Track duration from video element (for when media item doesn't have runTimeTicks) // Track duration from video element (for when media item doesn't have runTimeTicks)
let videoDuration = $state(0); let videoDuration = $state(0);
@@ -450,9 +529,9 @@
// Update stream URL when prop changes (from parent component, not from internal seeks) // Update stream URL when prop changes (from parent component, not from internal seeks)
$effect(() => { $effect(() => {
// Only reset when the streamUrl prop actually changes from parent // Only reset when the streamUrl prop actually changes from parent
if (streamUrl !== lastStreamUrlProp) { if (selection.url !== lastStreamUrlProp) {
lastStreamUrlProp = streamUrl; lastStreamUrlProp = selection.url;
currentStreamUrl = streamUrl; currentSelection = selection;
seekOffset = 0; seekOffset = 0;
isMediaReady = false; // Reset to loading state when stream URL changes isMediaReady = false; // Reset to loading state when stream URL changes
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
@@ -567,9 +646,21 @@
return; return;
} }
const isHlsStream = currentStreamUrl.includes(".m3u8"); // The loader comes from the backend's tagged transport, never from the URL.
//
// Read through the *primitive* `transportKind`, never `currentSelection`
// itself: this effect tears down and rebuilds hls.js, and a selection object
// is replaced on every reload — so depending on the object re-ran the whole
// teardown for an unchanged stream and left the element showing nothing
// until a seek forced another cycle.
//
// TRACES: UR-079 | DR-225 | UT-214
const loader = loaderForTransport(transportKind, {
hlsJsSupported: Hls.isSupported(),
nativeHlsSupported: !!videoElement.canPlayType("application/vnd.apple.mpegurl"),
});
if (isHlsStream && Hls.isSupported()) { if (loader === "hlsjs") {
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio // Clean up existing HLS instance if any - CRITICAL for preventing dual audio
if (hls) { if (hls) {
log.debug("Cleaning up existing HLS instance"); log.debug("Cleaning up existing HLS instance");
@@ -724,13 +815,13 @@
videoElement.pause(); videoElement.pause();
} }
}; };
} else if (isHlsStream && videoElement.canPlayType("application/vnd.apple.mpegurl")) { } else if (loader === "nativeHls") {
// Native HLS support (Safari) // The element parses the playlist itself (Safari/WebKit).
log.debug("Using native HLS support"); log.debug("Using native HLS support");
videoElement.src = currentStreamUrl; videoElement.src = currentStreamUrl;
} else { } else {
// Not an HLS stream, use regular video element // Progressive or local: the element loads the URL directly.
log.debug("Using regular video element for non-HLS stream"); log.debug("Using regular video element", currentSelection.transport.type);
} }
}); });
@@ -801,21 +892,25 @@
}); });
}); });
// Populate the quality menu. Deliberately its own *synchronous* onMount that // The quality *ladder* now arrives with the stream selection (DR-227), so all
// fires the load without awaiting it: an await inside the main onMount below // this still needs is the device default, for the case where the stream is a
// flips the component into HTML5 mode and breaks native seeking, and nothing // direct play and has no rendition of its own.
// about playback waits on this list.
// //
// TRACES: UR-074 | DR-162 // Deliberately its own *synchronous* onMount that fires the load without
// awaiting it: an await inside the main onMount below flips the component into
// HTML5 mode and breaks native seeking, and nothing about playback waits on
// this value.
//
// TRACES: UR-074, UR-079 | DR-162, DR-227
onMount(() => { onMount(() => {
Promise.all([commands.playerGetStreamingQualities(), commands.playerGetVideoSettings()]) commands
.then(([qualities, settings]) => { .playerGetVideoSettings()
streamingQualities = qualities; .then((settings) => {
// Optional on the wire (serde default) — absent means uncapped. // Optional on the wire (serde default) — absent means uncapped.
selectedQuality = settings.streamingQuality ?? "original"; defaultQuality = settings.streamingQuality ?? "original";
}) })
.catch((err) => { .catch((err) => {
log.warn("Failed to load streaming qualities:", err); log.warn("Failed to load the default streaming quality:", err);
}); });
}); });
@@ -878,6 +973,9 @@
id: media.id, id: media.id,
videoCodec: needsTranscoding ? "hevc" : "h264", videoCodec: needsTranscoding ? "hevc" : "h264",
needsTranscoding: needsTranscoding, needsTranscoding: needsTranscoding,
// Carry the negotiated transport onto the queue item so a later seek
// reads it instead of falling back. TRACES: UR-079 | DR-230
transport: currentSelection.transport,
// Order matters: player_set_subtitle_track(n) is a position in this // Order matters: player_set_subtitle_track(n) is a position in this
// array. Previously this array was built and then dropped, so // array. Previously this array was built and then dropped, so
// ExoPlayer got a MediaItem with no subtitles at all. // ExoPlayer got a MediaItem with no subtitles at all.
@@ -933,7 +1031,6 @@
"Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions", "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 // 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 // Register the adapter with the facade so control intents (UI, or a
@@ -944,7 +1041,9 @@
const host = createRustReportHost(media.id, { const host = createRustReportHost(media.id, {
onEnded: () => notifyEnded(), onEnded: () => notifyEnded(),
onStreamUrlChanged: (u) => { onStreamUrlChanged: (u) => {
currentStreamUrl = u; // Rust re-opened the same stream (a transcoded seek): the
// transport is unchanged, only the job behind it.
currentSelection = selectionAt(u, currentSelection.transport);
}, },
}); });
playerAdapter = createAdapter({ playerAdapter = createAdapter({
@@ -997,7 +1096,6 @@
if (!useHtml5Element) { if (!useHtml5Element) {
// Using native backend, subscribe to player events // Using native backend, subscribe to player events
didStartNativePlayback = true; // Track that we started native playback
isPlaying = (response.state?.kind ?? response.state) === "playing"; isPlaying = (response.state?.kind ?? response.state) === "playing";
// Cleanup happens in the component's top-level onDestroy. Calling // Cleanup happens in the component's top-level onDestroy. Calling
// onDestroy() here — after an await — throws lifecycle_outside_component, // onDestroy() here — after an await — throws lifecycle_outside_component,
@@ -1038,7 +1136,6 @@
} }
} else { } else {
// For transcoded content, keep backend for seeking // For transcoded content, keep backend for seeking
didStartNativePlayback = true;
} }
} }
} }
@@ -1172,14 +1269,25 @@
} }
// Stop the player when component is destroyed // Stop the player when component is destroyed
// Skip if we already stopped the backend early (non-transcoded + HTML5) // Unconditional. Leaving the player means nothing should still be playing,
if (didStartNativePlayback && !didStopBackendEarly) { // whichever renderer happened to own it.
try { //
log.debug("Stopping backend player on component unmount"); // This used to be gated on `didStartNativePlayback && !didStopBackendEarly`
await commands.playerStop(); // — flags describing what *this component* started. A background-audio
} catch (err) { // handoff swaps the renderer underneath them, so after one they describe a
log.error("Failed to stop backend player:", err); // 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);
} }
// Report stop when component is destroyed (skip for live - no resume tracking) // Report stop when component is destroyed (skip for live - no resume tracking)
@@ -1746,7 +1854,7 @@
// whether the item has a picture to lose, which is Rust's to know. This used // whether the item has a picture to lose, which is Rust's to know. This used
// to be decided implicitly by Kotlin gating the event on the toggle, which // to be decided implicitly by Kotlin gating the event on the toggle, which
// is why the native path -- whose media service keeps playing regardless -- // is why the native path -- whose media service keeps playing regardless --
// ignored the toggle entirely (DR-224). // ignored the toggle entirely (DR-225).
let action: BackgroundAction; let action: BackgroundAction;
try { try {
action = await commands.playerBackgroundAction( action = await commands.playerBackgroundAction(
@@ -1885,8 +1993,9 @@
pendingForegroundPlay = plan.shouldPlay; pendingForegroundPlay = plan.shouldPlay;
// Determine the target URL + how the element/offset should be positioned. // Determine the target stream + how the element/offset should be
let targetUrl: string; // positioned.
let targetSelection: StreamSelection;
if (needsTranscoding && onSeek) { if (needsTranscoding && onSeek) {
// Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt // Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt
// stream starts at the BEGINNING of the item, not at `pos`: a start // stream starts at the BEGINNING of the item, not at `pos`: a start
@@ -1897,13 +2006,16 @@
// that really did start there; leaving it would now display `pos` while // that really did start there; leaving it would now display `pos` while
// playing the opening titles. // playing the opening titles.
// TRACES: UR-040, UR-004 | DR-181 // TRACES: UR-040, UR-004 | DR-181
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined); // Every transcode this app requests is HLS (DR-140).
targetSelection = selectionAt(await onSeek(pos, selectedAudioTrackIndex ?? undefined), {
type: "hls",
});
seekOffset = 0; seekOffset = 0;
currentTime = pos; currentTime = pos;
pendingForegroundSeek = pos; pendingForegroundSeek = pos;
} else { } else {
// Direct stream: reload the original URL and seek the element to pos. // Direct stream: reload the original selection and seek to pos.
targetUrl = streamUrl; targetSelection = selection;
seekOffset = 0; seekOffset = 0;
pendingForegroundSeek = pos; pendingForegroundSeek = pos;
} }
@@ -1923,18 +2035,20 @@
// than re-fetched. // than re-fetched.
// //
// TRACES: UR-040, UR-003 | DR-196 // TRACES: UR-040, UR-003 | DR-196
currentStreamUrl = targetUrl; currentSelection = targetSelection;
await commands.playerPlayItem({ await commands.playerPlayItem({
streamUrl: targetUrl, streamUrl: targetSelection.url,
title: media.name, title: media.name,
id: media.id, id: media.id,
videoCodec: needsTranscoding ? "hevc" : "h264", videoCodec: needsTranscoding ? "hevc" : "h264",
needsTranscoding, needsTranscoding,
// TRACES: UR-079 | DR-230
transport: targetSelection.transport,
subtitles: nativeSubtitleTracks(sentSubtitleTracks), subtitles: nativeSubtitleTracks(sentSubtitleTracks),
}); });
didStartNativePlayback = true; await playerAdapter?.load(targetSelection.url, {
await playerAdapter?.load(targetUrl, {
mediaId: media.id, mediaId: media.id,
selection: targetSelection,
mediaSourceId: mediaSourceId ?? null, mediaSourceId: mediaSourceId ?? null,
needsTranscoding, needsTranscoding,
initialPosition: plan.position, initialPosition: plan.position,
@@ -1961,9 +2075,9 @@
// blank it first, then set it on the next microtask so Svelte sees a real // blank it first, then set it on the next microtask so Svelte sees a real
// transition. Without this, assigning the same value is a no-op and the // transition. Without this, assigning the same value is a no-op and the
// player stays stuck on the loading spinner (HLS never re-initialises). // player stays stuck on the loading spinner (HLS never re-initialises).
currentStreamUrl = ""; currentSelection = selectionAt("", targetSelection.transport);
await Promise.resolve(); await Promise.resolve();
currentStreamUrl = targetUrl; currentSelection = targetSelection;
} catch (err) { } catch (err) {
log.error("Background-audio return failed:", err); log.error("Background-audio return failed:", err);
} }
@@ -1978,23 +2092,47 @@
// Activity, so on its own it left the status and navigation bars painted over // Activity, so on its own it left the status and navigation bars painted over
// the video. The native bridge is what actually makes fullscreen full screen; // the video. The native bridge is what actually makes fullscreen full screen;
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157) // requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
function toggleFullscreen() { async function toggleFullscreen() {
// A native surface draws the picture *behind* the webview at window size, so
// fullscreening the document alone leaves the video at its old size while
// the page around it expands. See fullscreenTarget.ts. (DR-240)
const plan = planFullscreen(!useHtml5Element);
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => { if (plan.document) {
// WebKitGTK rejects when the gesture isn't recognised as user-activated; document.documentElement.requestFullscreen().catch((err) => {
// the immersive call below is what matters on Android, so don't let a // WebKitGTK rejects when the gesture isn't recognised as user-activated;
// rejection here abort it. // the immersive call below is what matters on Android, so don't let a
log.warn("requestFullscreen rejected:", err); // rejection here abort it.
}); log.warn("requestFullscreen rejected:", err);
});
}
if (plan.osWindow) {
await setOsWindowFullscreen(true);
}
enterImmersive(); enterImmersive();
isFullscreen = true; isFullscreen = true;
} else { } else {
document.exitFullscreen(); document.exitFullscreen();
if (plan.osWindow) {
await setOsWindowFullscreen(false);
}
exitImmersive(); exitImmersive();
isFullscreen = false; isFullscreen = false;
} }
} }
/// Resize the OS window itself. Best-effort: a platform without a window to
/// resize (Android) must not break the rest of the toggle.
async function setOsWindowFullscreen(on: boolean) {
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().setFullscreen(on);
} catch (err) {
log.warn("setFullscreen on the OS window failed:", err);
}
}
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60); const secs = Math.floor(seconds % 60);
@@ -2285,33 +2423,48 @@
* *
* The backend owns everything about how that happens — it decides whether the * The backend owns everything about how that happens — it decides whether the
* caller reloads (HTML5) or it reloads the native backend itself — so this * caller reloads (HTML5) or it reloads the native backend itself — so this
* only supplies the position to resume at and reverts the selection if the * only supplies the position to resume at.
* switch fails.
* *
* TRACES: UR-074 | DR-162 * The change applies to this playback alone; the durable Settings default is
* untouched (DR-226). Nothing is optimistically assigned here: what the picker
* shows comes from the selection the backend hands back, because what you get
* is not always what you asked for — a ceiling above the source bitrate is the
* source, and claiming otherwise is the kind of lie the old picker told.
*
* TRACES: UR-074, UR-079 | DR-162, DR-226, DR-227
*/ */
async function selectQuality(quality: StreamingQuality) { async function selectQuality(quality: StreamingQuality) {
showQualityMenu = false; showQualityMenu = false;
if (quality === selectedQuality || changingQuality) return; if (quality === selectedQuality || changingQuality) return;
const previous = selectedQuality;
selectedQuality = quality;
changingQuality = true; changingQuality = true;
try { try {
stopTimeUpdates(); stopTimeUpdates();
await playerController.setStreamQuality( const negotiated = await playerController.setStreamQuality(
quality, quality,
videoElement ? videoElement.currentTime + seekOffset : null, videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId ?? null, mediaSourceId ?? null,
selectedAudioTrackIndex, selectedAudioTrackIndex,
); );
// Adopt whatever the backend says it opened. The HTML5 path has already
// set this via the adapter bridge, so this is a no-op there; the native
// path reloads inside Rust and this is the only thing that updates the UI.
//
// Assigning it is what keeps the picker honest: `selectedQuality` reads
// the selection's rendition, and a transcode always has one — so without
// this the menu stayed on the first stream's rung while the stream itself
// changed underneath.
//
// TRACES: UR-074, UR-079 | DR-226, DR-227
if (negotiated) {
currentSelection = negotiated;
}
if (videoElement && !videoElement.paused) { if (videoElement && !videoElement.paused) {
startTimeUpdates(); startTimeUpdates();
} }
log.debug("Streaming quality changed:", quality); log.debug("Streaming quality changed:", quality);
} catch (err) { } catch (err) {
log.error("Failed to change streaming quality:", err); log.error("Failed to change streaming quality:", err);
selectedQuality = previous;
} finally { } finally {
changingQuality = false; changingQuality = false;
} }
@@ -2411,12 +2564,30 @@
aria-label="Video player" aria-label="Video player"
> >
<!-- Video --> <!-- Video -->
<div class="flex-1 flex items-center justify-center relative"> <!--
`min-h-0` / `min-w-0` are load-bearing, not defensive. A flex item defaults
to `min-height: auto`, which refuses to shrink below its content's intrinsic
size — and the <video> inside reports the *media's* natural dimensions. So
without them this wrapper grows past the viewport whenever the picture is
larger than the window: the overflow goes off the bottom, which reads as the
image being cropped and aligned to the top rather than letterboxed and
centred. `object-contain` was never the problem; it was doing its job inside
a box that was itself the wrong size.
Reproduces by resizing the window during playback, and by entering
fullscreen — where the same overflow put the picture at the bottom.
TRACES: UR-005 | DR-024
-->
<div class="flex-1 min-h-0 min-w-0 flex items-center justify-center relative">
{#if !!useHtml5Element} {#if !!useHtml5Element}
<!-- HTML5 video for desktop/non-Android platforms --> <!-- HTML5 video for desktop/non-Android platforms -->
<video <video
bind:this={videoElement} bind:this={videoElement}
src={currentStreamUrl.includes(".m3u8") && Hls.isSupported() ? "" : currentStreamUrl} src={elementSrcFor(currentSelection, {
hlsJsSupported: Hls.isSupported(),
nativeHlsSupported: true,
})}
crossorigin={videoCrossOrigin} crossorigin={videoCrossOrigin}
class={videoFitClass()} class={videoFitClass()}
class:invisible={!isMediaReady} class:invisible={!isMediaReady}
@@ -2756,8 +2927,11 @@
</div> </div>
{/if} {/if}
<!-- Streaming quality (bandwidth ceiling). TRACES: UR-074 | DR-162 --> <!--
{#if streamingQualities.length > 0} Streaming quality (bandwidth ceiling), populated from what this media
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-227
-->
{#if qualityOptions.length > 1}
<div class="relative"> <div class="relative">
<button <button
onclick={toggleQualityMenu} onclick={toggleQualityMenu}
@@ -2778,22 +2952,30 @@
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" 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="p-2">
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20"> <div class="px-3 py-2 border-b border-white/20">
Quality <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> </div>
{#each streamingQualities as [quality, label, detail]} {#each qualityOptions as option (option.quality)}
<button <button
onclick={() => selectQuality(quality)} 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 === class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
quality option.quality
? 'bg-white/20' ? 'bg-white/20'
: ''}" : ''}"
> >
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-sm">{label}</span> <span class="text-sm">{option.label}</span>
<span class="text-xs text-gray-400">{detail}</span> <span class="text-xs text-gray-400">
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
&middot; {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
</span>
</div> </div>
{#if selectedQuality === quality} {#if selectedQuality === option.quality}
<svg <svg
class="w-4 h-4 text-[var(--color-jellyfin)]" class="w-4 h-4 text-[var(--color-jellyfin)]"
fill="currentColor" fill="currentColor"
@@ -36,6 +36,23 @@ import { invoke } from "@tauri-apps/api/core";
import VideoPlayer from "./VideoPlayer.svelte"; import VideoPlayer from "./VideoPlayer.svelte";
import { SEEK_FORWARD_SECONDS } from "./tapGestures"; import { SEEK_FORWARD_SECONDS } from "./tapGestures";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
// --- Mocks: everything VideoPlayer reaches for that is not the tap surface. --- // --- Mocks: everything VideoPlayer reaches for that is not the tap surface. ---
const toggleSpy = vi.fn(); const toggleSpy = vi.fn();
@@ -122,7 +139,7 @@ function touchAt(el: Element, x: number) {
function renderPlayer() { function renderPlayer() {
return render(VideoPlayer, { return render(VideoPlayer, {
props: { media: MEDIA, streamUrl: "http://x/master.m3u8", onClose: vi.fn() }, props: { media: MEDIA, selection: testSelection("http://x/master.m3u8"), onClose: vi.fn() },
}); });
} }
@@ -116,6 +116,23 @@ import { tick } from "svelte";
import VideoPlayer from "./VideoPlayer.svelte"; import VideoPlayer from "./VideoPlayer.svelte";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
function makeEpisode(): MediaItem { function makeEpisode(): MediaItem {
return { return {
id: "ep1", id: "ep1",
@@ -129,7 +146,7 @@ async function mountAndroidPlayer() {
const utils = render(VideoPlayer, { const utils = render(VideoPlayer, {
props: { props: {
media: makeEpisode(), media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8", selection: testSelection("http://server/videos/ep1/master.m3u8"),
mediaSourceId: "src-1", mediaSourceId: "src-1",
needsTranscoding: false, needsTranscoding: false,
onClose: vi.fn(), onClose: vi.fn(),
@@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { planFullscreen } from "./fullscreenTarget";
describe("planFullscreen", () => {
it("fullscreens only the document when an in-document <video> renders", () => {
// Unchanged behaviour: WebKit scales the element, the window need not move.
expect(planFullscreen(false)).toEqual({ document: true, osWindow: false });
});
it("also fullscreens the OS window when a native surface renders", () => {
// The picture is drawn behind the webview at window size, so a
// document-only fullscreen leaves it at the old size.
expect(planFullscreen(true)).toEqual({ document: true, osWindow: true });
});
});
@@ -0,0 +1,35 @@
/**
* Which surfaces a fullscreen toggle has to move.
*
* `requestFullscreen()` only ever fullscreens the *document*. That was
* sufficient while every renderer lived inside it: the HTML5 `<video>` element
* is part of the document, so WebKit scaled it to the screen and the OS
* window's real size never mattered.
*
* A native video surface is drawn *behind* the webview at **window** size, so a
* document-only fullscreen leaves the picture exactly where it was while the
* page around it goes fullscreen. On WebKitGTK the observed result is a
* maximised window with decorations still taking a strip of the screen the
* video renders correctly, at the wrong size, which reads as "fullscreen is
* broken" rather than as a windowing problem.
*
* Android already needed its own answer here for the system bars (DR-157); this
* is the desktop equivalent of the same rule: whoever actually owns the pixels
* has to be the thing that goes fullscreen.
*
* TRACES: UR-066 | DR-240 | UT-219
*/
export interface FullscreenPlan {
/** Ask the document to go fullscreen (harmless everywhere, needed for CSS). */
document: boolean;
/** Resize the OS window itself. Required when a native surface owns the picture. */
osWindow: boolean;
}
/**
* @param rendersNatively true when a native surface (mpv/ExoPlayer) draws the
* picture rather than an in-document `<video>` element.
*/
export function planFullscreen(rendersNatively: boolean): FullscreenPlan {
return { document: true, osWindow: rendersNatively };
}
+1 -37
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { videoFitClass, fittedVideoSize } from "./videoFit"; import { videoFitClass } from "./videoFit";
describe("videoFitClass", () => { describe("videoFitClass", () => {
it("fills the container instead of capping at the source's intrinsic size", () => { it("fills the container instead of capping at the source's intrinsic size", () => {
@@ -19,39 +19,3 @@ describe("videoFitClass", () => {
expect(cls).not.toContain("object-fill"); expect(cls).not.toContain("object-fill");
}); });
}); });
describe("fittedVideoSize", () => {
it("scales a 480p source up to fill a larger window (the reported bug)", () => {
// Exact 16:9 480p in a 1920x1080 window -> scales up to fill, rather than
// staying a 854x480 box in the middle.
const size = fittedVideoSize(853.33, 480, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(1080, 0);
});
it("fits to the constraining dimension when aspect ratios differ", () => {
// 4:3 source in a 16:9 window -> height-constrained, pillarboxed.
const size = fittedVideoSize(640, 480, 1920, 1080);
expect(size.height).toBeCloseTo(1080, 0);
expect(size.width).toBeCloseTo(1440, 0);
expect(size.width).toBeLessThan(1920);
});
it("fits to width when the source is wider than the window", () => {
// 21:9 source in a 16:9 window -> width-constrained, letterboxed.
const size = fittedVideoSize(2560, 1080, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(810, 0);
expect(size.height).toBeLessThan(1080);
});
it("shrinks oversized media to fit rather than overflowing", () => {
const size = fittedVideoSize(3840, 2160, 1280, 720);
expect(size.width).toBeCloseTo(1280, 0);
expect(size.height).toBeCloseTo(720, 0);
});
it("returns a zero size for unknown intrinsic dimensions", () => {
expect(fittedVideoSize(0, 0, 1920, 1080)).toEqual({ width: 0, height: 0 });
});
});
-29
View File
@@ -15,32 +15,3 @@
export function videoFitClass(): string { export function videoFitClass(): string {
return "w-full h-full object-contain"; return "w-full h-full object-contain";
} }
export interface FittedSize {
width: number;
height: number;
}
/**
* The rendered size of a video of the given intrinsic dimensions once it has
* been fitted into the container - i.e. scaled (up or down) so that it touches
* the container on its constraining axis, with the other axis letter/pillar
* boxed. Mirrors what `object-fit: contain` on a full-size element does.
*/
export function fittedVideoSize(
intrinsicWidth: number,
intrinsicHeight: number,
containerWidth: number,
containerHeight: number,
): FittedSize {
if (intrinsicWidth <= 0 || intrinsicHeight <= 0) {
return { width: 0, height: 0 };
}
const scale = Math.min(containerWidth / intrinsicWidth, containerHeight / intrinsicHeight);
return {
width: intrinsicWidth * scale,
height: intrinsicHeight * scale,
};
}
+26 -7
View File
@@ -10,6 +10,23 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter"; import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
import type { AdapterHost } from "./types"; import type { AdapterHost } from "./types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
/** A minimal fake <video> element that records mutations and fires events. */ /** A minimal fake <video> element that records mutations and fires events. */
function makeFakeVideo() { function makeFakeVideo() {
const listeners: Record<string, Array<() => void>> = {}; const listeners: Record<string, Array<() => void>> = {};
@@ -54,7 +71,7 @@ function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBr
setSeekOffset: vi.fn((o: number) => { setSeekOffset: vi.fn((o: number) => {
offset = o; offset = o;
}), }),
setStreamUrl: vi.fn(), setStreamSelection: vi.fn(),
destroyHls: vi.fn(), destroyHls: vi.fn(),
getMediaSourceId: () => "msid-1", getMediaSourceId: () => "msid-1",
...overrides, ...overrides,
@@ -184,7 +201,7 @@ describe("Html5PlayerAdapter", () => {
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => { it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
video.paused = false; // was playing → should resume video.paused = false; // was playing → should resume
const p = adapter.reloadSource("http://new/master.m3u8", 120); const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
// Teardown happened synchronously before the awaited canplay wait. // Teardown happened synchronously before the awaited canplay wait.
expect(video.pause).toHaveBeenCalled(); expect(video.pause).toHaveBeenCalled();
@@ -194,7 +211,9 @@ describe("Html5PlayerAdapter", () => {
// Allow the internal 100ms settle delay, then fire canplay to resume. // Allow the internal 100ms settle delay, then fire canplay to resume.
await new Promise((r) => setTimeout(r, 110)); await new Promise((r) => setTimeout(r, 110));
expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8"); expect(bridge.setStreamSelection).toHaveBeenCalledWith(
expect.objectContaining({ url: "http://new/master.m3u8", transport: { type: "hls" } }),
);
video._fire("canplay"); video._fire("canplay");
video._fire("seeked"); video._fire("seeked");
await p; await p;
@@ -217,7 +236,7 @@ describe("Html5PlayerAdapter", () => {
*/ */
it("reloadSource() seeks to the position and clears the transcode offset", async () => { it("reloadSource() seeks to the position and clears the transcode offset", async () => {
video.paused = false; video.paused = false;
const p = adapter.reloadSource("http://new/master.m3u8", 1200); const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 1200);
await new Promise((r) => setTimeout(r, 110)); await new Promise((r) => setTimeout(r, 110));
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0); expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
@@ -238,7 +257,7 @@ describe("Html5PlayerAdapter", () => {
/** A reload to the very start has nothing to seek to; it must not stall. */ /** A reload to the very start has nothing to seek to; it must not stall. */
it("reloadSource() at position 0 does not wait for a seek", async () => { it("reloadSource() at position 0 does not wait for a seek", async () => {
video.paused = false; video.paused = false;
const p = adapter.reloadSource("http://new/master.m3u8", 0); const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 0);
await new Promise((r) => setTimeout(r, 110)); await new Promise((r) => setTimeout(r, 110));
video._fire("canplay"); video._fire("canplay");
await p; // resolves without any "seeked" event await p; // resolves without any "seeked" event
@@ -258,7 +277,7 @@ describe("Html5PlayerAdapter", () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
video.paused = false; video.paused = false;
const p = adapter.reloadSource("http://new/master.m3u8", 120); const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
const assertion = expect(p).rejects.toThrow(/canplay/i); const assertion = expect(p).rejects.toThrow(/canplay/i);
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
await assertion; await assertion;
@@ -270,7 +289,7 @@ describe("Html5PlayerAdapter", () => {
it("reloadSource() does not resume when it was paused", async () => { it("reloadSource() does not resume when it was paused", async () => {
video.paused = true; video.paused = true;
const p = adapter.reloadSource("http://new/master.m3u8", 30); const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 30);
await new Promise((r) => setTimeout(r, 110)); await new Promise((r) => setTimeout(r, 110));
video._fire("canplay"); video._fire("canplay");
video._fire("seeked"); video._fire("seeked");
+53 -10
View File
@@ -1,3 +1,4 @@
import type { StreamSelection } from "$lib/api/bindings";
/** /**
* Html5PlayerAdapter the Linux/desktop (and interim Android) PlayerAdapter * Html5PlayerAdapter the Linux/desktop (and interim Android) PlayerAdapter
* implementation. It owns the high-level control surface for an HTML5 `<video>` * implementation. It owns the high-level control surface for an HTML5 `<video>`
@@ -24,6 +25,39 @@ import { createLogger } from "$lib/utils/logger";
const log = createLogger("Html5PlayerAdapter"); const log = createLogger("Html5PlayerAdapter");
/**
* The selection for a plain `load(url)` call.
*
* `PlayerLoadOptions` carries the backend's selection when the caller has one.
* When it does not a local file, a live stream, a direct URL the transport
* is inferred *once, here*, from what the caller already knows rather than from
* the URL text: a local path is a local file, and anything the backend flagged
* as transcoded is HLS, because every transcode this app requests is HLS.
*
* This is the one place a fallback is tolerable, and it is explicitly a
* fallback: the negotiated path never reaches it.
*
* TRACES: UR-079 | DR-225
*/
function selectionForLoad(streamUrl: string, options: PlayerLoadOptions): StreamSelection {
if (options.selection) return options.selection;
const transport: StreamSelection["transport"] = options.isLocalFile
? { type: "localFile" }
: options.needsTranscoding
? { type: "hls" }
: { type: "progressive" };
return {
url: streamUrl,
transport,
playbackKind: options.needsTranscoding ? { type: "transcode" } : { type: "directPlay" },
rendition: null,
available: [],
mediaSourceId: options.mediaSourceId ?? null,
playSessionId: null,
needsTranscoding: options.needsTranscoding,
};
}
/** /**
* Narrow seam the owning component provides so the adapter can execute the * Narrow seam the owning component provides so the adapter can execute the
* element/HLS-coupled parts of a control action without re-implementing the * element/HLS-coupled parts of a control action without re-implementing the
@@ -36,8 +70,16 @@ export interface Html5ElementBridge {
/** Current seek offset (seconds) for transcoded streams. */ /** Current seek offset (seconds) for transcoded streams. */
getSeekOffset(): number; getSeekOffset(): number;
setSeekOffset(offset: number): void; setSeekOffset(offset: number): void;
/** Update the stream URL the component renders (triggers its HLS $effect). */ /**
setStreamUrl(url: string): void; * Update the stream the component renders (triggers its HLS $effect).
*
* Carries the whole [`StreamSelection`], not just the URL: the component's
* effect has to know the transport to choose a loader, and deriving that from
* the URL is the substring check DR-225 removes.
*
* TRACES: UR-079 | DR-225
*/
setStreamSelection(selection: StreamSelection): void;
/** Tear down the component-owned hls.js instance (dual-audio prevention). */ /** Tear down the component-owned hls.js instance (dual-audio prevention). */
destroyHls(): void; destroyHls(): void;
/** Media source id for seek/audio-track URLs. */ /** Media source id for seek/audio-track URLs. */
@@ -86,12 +128,13 @@ export class Html5PlayerAdapter implements PlayerAdapter {
this.attachedElement = element; this.attachedElement = element;
} }
async load(streamUrl: string, _options: PlayerLoadOptions): Promise<void> { async load(streamUrl: string, options: PlayerLoadOptions): Promise<void> {
// The component's reactive HLS $effect performs the actual attach/load when // The component's reactive HLS $effect performs the actual attach/load when
// the stream URL is set; loading is therefore driven by setStreamUrl. The // the selection is set; loading is therefore driven by setStreamSelection.
// component's canplay/frag-buffered path reports readiness through the host. // The component's canplay/frag-buffered path reports readiness through the
// host.
this.bridge.setSeekOffset(0); this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(streamUrl); this.bridge.setStreamSelection(selectionForLoad(streamUrl, options));
this.host.onState("loading"); this.host.onState("loading");
} }
@@ -171,12 +214,12 @@ export class Html5PlayerAdapter implements PlayerAdapter {
* *
* TRACES: UR-004, UR-005 | DR-181 | UT-183 * TRACES: UR-004, UR-005 | DR-181 | UT-183
*/ */
async reloadSource(url: string, positionSeconds: number): Promise<void> { async reloadSource(selection: StreamSelection, positionSeconds: number): Promise<void> {
const el = this.element; const el = this.element;
if (!el) { if (!el) {
// Still update the stream URL so the component's HLS $effect can pick it up. // Still update the selection so the component's HLS $effect can pick it up.
this.bridge.setSeekOffset(0); this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(url); this.bridge.setStreamSelection(selection);
return; return;
} }
const wasPlaying = !el.paused; const wasPlaying = !el.paused;
@@ -189,7 +232,7 @@ export class Html5PlayerAdapter implements PlayerAdapter {
await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100));
// The reloaded stream begins at the item's zero, so there is no base to add. // The reloaded stream begins at the item's zero, so there is no base to add.
this.bridge.setSeekOffset(0); this.bridge.setSeekOffset(0);
this.bridge.setStreamUrl(url); this.bridge.setStreamSelection(selection);
// A source that never becomes playable is a failed reload, not a slow one: // A source that never becomes playable is a failed reload, not a slow one:
// the caller (quality switch, transcoded seek) has to know so it can revert // the caller (quality switch, transcoded seek) has to know so it can revert
// its selection and surface the error instead of leaving the UI claiming a // its selection and surface the error instead of leaving the UI claiming a
+18 -1
View File
@@ -28,6 +28,23 @@ vi.mock("$lib/api/bindings", () => ({
import { NativePlayerAdapter } from "./nativeAdapter"; import { NativePlayerAdapter } from "./nativeAdapter";
import type { AdapterHost } from "./types"; import type { AdapterHost } from "./types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
function makeHost(): AdapterHost { function makeHost(): AdapterHost {
return { return {
onState: vi.fn(), onState: vi.fn(),
@@ -67,7 +84,7 @@ describe("NativePlayerAdapter", () => {
it("records position on seek/reload primitives (backend does the real work)", async () => { it("records position on seek/reload primitives (backend does the real work)", async () => {
await adapter.seekElement(55, 0); await adapter.seekElement(55, 0);
expect(adapter.getPosition()).toBe(55); expect(adapter.getPosition()).toBe(55);
await adapter.reloadSource("ignored", 200); await adapter.reloadSource(testSelection("ignored"), 200);
expect(adapter.getPosition()).toBe(200); expect(adapter.getPosition()).toBe(200);
}); });
+2 -1
View File
@@ -1,3 +1,4 @@
import type { StreamSelection } from "$lib/api/bindings";
/** /**
* NativePlayerAdapter the Android/ExoPlayer PlayerAdapter implementation. * NativePlayerAdapter the Android/ExoPlayer PlayerAdapter implementation.
* *
@@ -89,7 +90,7 @@ export class NativePlayerAdapter implements PlayerAdapter {
* performed the reload+seek internally as part of the seek decision; nothing * performed the reload+seek internally as part of the seek decision; nothing
* to do on the frontend beyond recording position. * to do on the frontend beyond recording position.
*/ */
async reloadSource(_url: string, offset: number): Promise<void> { async reloadSource(_selection: StreamSelection, offset: number): Promise<void> {
this.position = offset; this.position = offset;
} }
+23 -5
View File
@@ -1,3 +1,4 @@
import type { StreamSelection } from "$lib/api/bindings";
/** /**
* PlayerAdapter contract the decoupled boundary between the UI/backend and a * PlayerAdapter contract the decoupled boundary between the UI/backend and a
* concrete video player implementation (Linux HTML5+hls.js, or Android native). * concrete video player implementation (Linux HTML5+hls.js, or Android native).
@@ -42,6 +43,19 @@ export interface PlayerLoadOptions {
knownDuration: number; knownDuration: number;
/** Subtitle tracks available for this media. */ /** Subtitle tracks available for this media. */
subtitleTracks: SubtitleTrackInput[]; subtitleTracks: SubtitleTrackInput[];
/**
* The backend's decision about this stream, when it made one.
*
* Present for anything negotiated through `repository_get_stream_selection`.
* Null for the paths that never negotiate a local file, a live channel, a
* plugin's direct URL where the adapter falls back to what the other
* options already say rather than to reading the URL.
*
* TRACES: UR-079 | DR-225
*/
selection?: StreamSelection | null;
/** The source is a file on disk (or the loopback server in front of one). */
isLocalFile?: boolean;
} }
/** /**
@@ -106,12 +120,16 @@ export interface PlayerAdapter {
seekElement(positionSeconds: number, offset: number): Promise<void>; seekElement(positionSeconds: number, offset: number): Promise<void>;
/** /**
* Compound reload: swap to `url` and resume at `offset` seconds. Runs the * Compound reload: swap to `selection` and resume at `offset` seconds. Runs
* invariant mechanical sequence for this platform (html5: pause hls teardown * the invariant mechanical sequence for this platform (html5: pause hls
* clear src set new url wait ready resume; native: ExoPlayer setMediaItem * teardown clear src set new selection wait ready resume; native:
* + seekTo). No decision is made here the backend already decided to reload. * ExoPlayer setMediaItem + seekTo). No decision is made here the backend
* already decided to reload, and `selection.transport` says how to open it, so
* no adapter has to infer that from the URL.
*
* TRACES: UR-079 | DR-225
*/ */
reloadSource(url: string, offset: number): Promise<void>; reloadSource(selection: StreamSelection, offset: number): Promise<void>;
setVolume(volume: number): void; // 0..1 setVolume(volume: number): void; // 0..1
setMuted(muted: boolean): void; setMuted(muted: boolean): void;
@@ -1,3 +1,4 @@
import type { StreamSelection } from "$lib/api/bindings";
/** /**
* Webview audio adapter plays audio-only media through a hidden `<audio>` * Webview audio adapter plays audio-only media through a hidden `<audio>`
* element on platforms with no native audio backend (currently Windows). * element on platforms with no native audio backend (currently Windows).
@@ -104,8 +105,8 @@ export class WebviewAudioAdapter implements PlayerAdapter {
} }
/** No transcode-reload concept for direct audio; treat as a fresh load. */ /** No transcode-reload concept for direct audio; treat as a fresh load. */
async reloadSource(url: string, offset: number): Promise<void> { async reloadSource(selection: StreamSelection, offset: number): Promise<void> {
await this.load(url, { await this.load(selection.url, {
mediaId: "", mediaId: "",
mediaSourceId: null, mediaSourceId: null,
needsTranscoding: false, needsTranscoding: false,
+20 -10
View File
@@ -22,6 +22,7 @@ import type {
PlayAlbumTrackRequest, PlayAlbumTrackRequest,
PlayItemRequest, PlayItemRequest,
StreamingQuality, StreamingQuality,
StreamSelection,
} from "$lib/api/bindings"; } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import type { PlayerAdapter } from "./adapters/types"; import type { PlayerAdapter } from "./adapters/types";
@@ -150,12 +151,12 @@ async function seekVideo(
audioTrackIndex, audioTrackIndex,
adapter.kind === "html5", adapter.kind === "html5",
)) as any; )) as any;
// Serde keeps these snake_case (only the "strategy" tag is camelCase). // Serde keeps `seek_offset` snake_case (only the "strategy" tag is camelCase).
if (response.strategy === "reloadStream") { if (response.strategy === "reloadStream") {
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to // `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
// the element's clock: the reloaded stream starts at the item's zero since // the element's clock: the reloaded stream starts at the item's zero since
// DR-181, so reloadSource seeks there. (The name is the wire field's.) // DR-181, so reloadSource seeks there. (The name is the wire field's.)
await adapter.reloadSource(response.new_url ?? "", response.seek_offset ?? positionSeconds); await adapter.reloadSource(response.selection, response.seek_offset ?? positionSeconds);
} else { } else {
await adapter.seekElement(response.position ?? positionSeconds, 0); await adapter.seekElement(response.position ?? positionSeconds, 0);
} }
@@ -182,26 +183,32 @@ async function switchAudioTrack(
mediaSourceId, mediaSourceId,
)) as any; )) as any;
if (response.strategy === "reloadStream") { if (response.strategy === "reloadStream") {
await adapter.reloadSource(response.new_url!, response.position!); await adapter.reloadSource(response.selection, response.position!);
} }
} }
/** /**
* Change the bandwidth ceiling of the video playing now. The backend re-opens * Change the bandwidth ceiling of the video playing now. The backend re-opens
* the stream at the new quality and decides who reloads: it handles a native * the stream at the new quality and decides who reloads: it handles a native
* backend itself, and hands HTML5 a URL for the same `reloadSource` primitive * backend itself, and hands HTML5 a selection for the same `reloadSource`
* the audio-track switch uses. Requires an active video adapter. * primitive the audio-track switch uses. Requires an active video adapter.
* *
* TRACES: UR-074 | DR-162 * The change applies to **this playback only** the backend sets a per-playback
* override that the next item clears, leaving the durable Settings default
* alone. Returns the negotiated selection so the caller can show what it
* actually got, which is not always what was asked for: a ceiling above the
* source bitrate is the source.
*
* TRACES: UR-074, UR-079 | DR-162, DR-226
*/ */
async function setStreamQuality( async function setStreamQuality(
quality: StreamingQuality, quality: StreamingQuality,
currentPosition: number | null, currentPosition: number | null,
mediaSourceId: string | null, mediaSourceId: string | null,
audioTrackIndex: number | null, audioTrackIndex: number | null,
): Promise<void> { ): Promise<StreamSelection | null> {
const adapter = activeAdapter; const adapter = activeAdapter;
if (!adapter) return; if (!adapter) return null;
const response = (await commands.playerSetStreamQuality( const response = (await commands.playerSetStreamQuality(
requireHandle(), requireHandle(),
quality, quality,
@@ -210,10 +217,13 @@ async function setStreamQuality(
mediaSourceId, mediaSourceId,
audioTrackIndex, audioTrackIndex,
)) as any; )) as any;
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
if (response.strategy === "reloadStream") { if (response.strategy === "reloadStream") {
await adapter.reloadSource(response.new_url ?? "", response.position ?? currentPosition ?? 0); await adapter.reloadSource(response.selection, response.position ?? currentPosition ?? 0);
return response.selection;
} }
// The native backend reloaded itself, but still reports what it opened — the
// caller needs it to show the rung actually in force.
return response.selection ?? null;
} }
async function next() { async function next() {
+1 -72
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { downloadedFilePath, resolveVideoSource } from "./localSource"; import { downloadedFilePath } from "./localSource";
describe("downloadedFilePath", () => { describe("downloadedFilePath", () => {
// The download worker rewrites `downloads.file_path` to the absolute path it // The download worker rewrites `downloads.file_path` to the absolute path it
@@ -29,74 +29,3 @@ describe("downloadedFilePath", () => {
expect(downloadedFilePath("C:\\Users\\u\\AppData\\jellytau", stored)).toBe(stored); expect(downloadedFilePath("C:\\Users\\u\\AppData\\jellytau", stored)).toBe(stored);
}); });
}); });
// A stand-in for Tauri's convertFileSrc, so the module stays pure.
const toAssetUrl = (p: string) => `asset://localhost/${encodeURIComponent(p)}`;
describe("resolveVideoSource", () => {
it("plays the downloaded file when one exists", () => {
const decision = resolveVideoSource({
localPath: "/home/u/.local/share/jellytau/movie.mp4",
remoteUrl: "https://server/Videos/abc/master.m3u8",
remoteNeedsTranscoding: true,
toAssetUrl,
});
expect(decision.isLocal).toBe(true);
expect(decision.url).toBe(toAssetUrl("/home/u/.local/share/jellytau/movie.mp4"));
});
it("never marks a local file as needing transcoding, even when the remote did", () => {
// The transcoded path re-requests a whole new stream URL on every seek.
// A local file seeks natively; sending it down that route would ask the
// server for a stream we deliberately avoided.
const decision = resolveVideoSource({
localPath: "/downloads/film.mkv",
remoteUrl: "https://server/Videos/abc/master.m3u8",
remoteNeedsTranscoding: true,
toAssetUrl,
});
expect(decision.needsTranscoding).toBe(false);
});
it("streams when nothing is downloaded, preserving the transcoding flag", () => {
const decision = resolveVideoSource({
localPath: null,
remoteUrl: "https://server/Videos/abc/master.m3u8",
remoteNeedsTranscoding: true,
toAssetUrl,
});
expect(decision).toEqual({
url: "https://server/Videos/abc/master.m3u8",
needsTranscoding: true,
isLocal: false,
});
});
it("streams a direct-play remote without claiming it transcodes", () => {
const decision = resolveVideoSource({
localPath: null,
remoteUrl: "https://server/Videos/abc/stream.mp4",
remoteNeedsTranscoding: false,
toAssetUrl,
});
expect(decision.needsTranscoding).toBe(false);
expect(decision.isLocal).toBe(false);
});
it("falls back to streaming for a blank path rather than building a dead asset URL", () => {
for (const localPath of ["", " "]) {
const decision = resolveVideoSource({
localPath,
remoteUrl: "https://server/stream",
remoteNeedsTranscoding: false,
toAssetUrl,
});
expect(decision.isLocal).toBe(false);
expect(decision.url).toBe("https://server/stream");
}
});
});
-50
View File
@@ -1,41 +1,3 @@
/**
* Choosing between a downloaded file and a server stream for video playback.
*
* Audio has preferred local files since the queue is built (the Rust queue
* resolves `MediaSource::Local`), but video asks the repository for a stream URL
* and never consults `downloads` so a downloaded film was streamed anyway,
* spending bandwidth that had already been spent and failing outright offline.
*
* Pure so it can be unit-tested: the component only supplies the two inputs and
* the asset-URL converter.
*
* TRACES: UR-071 | DR-123 | UT-118
*/
export interface VideoSourceInputs {
/** Absolute on-disk path of a completed download, or null to stream. */
localPath: string | null;
/** Stream URL the repository resolved (already transcoded if it had to be). */
remoteUrl: string;
/** Whether the *remote* stream is a transcode. */
remoteNeedsTranscoding: boolean;
/** Usually Tauri's `convertFileSrc`; injected so this module stays pure. */
toAssetUrl: (path: string) => string;
}
export interface VideoSourceDecision {
/** What to hand the `<video>` element. */
url: string;
/**
* Local files are never transcodes, so this is always false for them. It
* matters because the transcoded path re-requests a whole new stream URL on
* every seek; a local file seeks natively and must not go down that route.
*/
needsTranscoding: boolean;
/** True when playing from disk — for logging and the offline badge. */
isLocal: boolean;
}
/** Absolute on POSIX (`/…`), Windows (`C:\…`, `C:/…`) or a UNC share (`\\…`). */ /** Absolute on POSIX (`/…`), Windows (`C:\…`, `C:/…`) or a UNC share (`\\…`). */
function isAbsolute(path: string): boolean { function isAbsolute(path: string): boolean {
return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path); return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path);
@@ -57,15 +19,3 @@ function isAbsolute(path: string): boolean {
export function downloadedFilePath(storageRoot: string, filePath: string): string { export function downloadedFilePath(storageRoot: string, filePath: string): string {
return isAbsolute(filePath) ? filePath : `${storageRoot}/${filePath}`; return isAbsolute(filePath) ? filePath : `${storageRoot}/${filePath}`;
} }
export function resolveVideoSource(inputs: VideoSourceInputs): VideoSourceDecision {
const { localPath, remoteUrl, remoteNeedsTranscoding, toAssetUrl } = inputs;
// Treat blank/whitespace paths as absent — a malformed `downloads` row must
// not produce an asset URL pointing at nothing.
if (localPath && localPath.trim() !== "") {
return { url: toAssetUrl(localPath), needsTranscoding: false, isLocal: true };
}
return { url: remoteUrl, needsTranscoding: remoteNeedsTranscoding, isLocal: false };
}
+93
View File
@@ -0,0 +1,93 @@
/**
* The loader is chosen from the backend's `transport` tag, never from the URL.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
import { describe, expect, it } from "vitest";
import { elementSrcFor, videoLoaderFor, type LoaderCapabilities } from "./streamTransport";
import type { StreamSelection, Transport } from "$lib/api/bindings";
const MODERN: LoaderCapabilities = { hlsJsSupported: true, nativeHlsSupported: false };
const SAFARI: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: true };
const NEITHER: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: false };
function selection(transport: Transport, url: string): Pick<StreamSelection, "url" | "transport"> {
return { url, transport };
}
describe("videoLoaderFor", () => {
it("attaches hls.js when the backend says HLS and hls.js is available", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe(
"hlsjs",
);
});
it("falls back to the element's own HLS loader when hls.js is unavailable", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
"nativeHls",
);
});
it("loads a progressive stream directly", () => {
expect(
videoLoaderFor(
selection({ type: "progressive" }, "https://s/Videos/1/stream?static=true"),
MODERN,
),
).toBe("direct");
});
it("loads a local file directly", () => {
expect(
videoLoaderFor(selection({ type: "localFile" }, "http://127.0.0.1:9/media/x.mkv"), MODERN),
).toBe("direct");
});
// ---------------------------------------------------------------------
// The two cases the `.m3u8` substring check gets wrong. These are the
// reason the field exists; both fail against a URL-sniffing implementation.
// ---------------------------------------------------------------------
it("does NOT attach hls.js to a progressive stream whose URL happens to end .m3u8", () => {
// A direct play served from a path containing the substring — nothing stops
// a server, a proxy, or a local cache from producing this.
expect(
videoLoaderFor(selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4"), MODERN),
).toBe("direct");
expect(
videoLoaderFor(selection({ type: "progressive" }, "https://s/x?name=master.m3u8"), MODERN),
).toBe("direct");
});
it("DOES attach hls.js to an HLS stream whose URL does not contain .m3u8", () => {
// Jellyfin's own transcoding URLs are not required to end in `.m3u8`, and a
// DASH or query-routed playlist endpoint never would.
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/Videos/1/hls"), MODERN)).toBe(
"hlsjs",
);
expect(
videoLoaderFor(selection({ type: "hls" }, "https://s/stream?format=playlist"), SAFARI),
).toBe("nativeHls");
});
it("falls back to direct when HLS is requested but nothing can play it", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), NEITHER)).toBe(
"direct",
);
});
});
describe("elementSrcFor", () => {
it("empties the element's src only when hls.js drives it", () => {
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe("");
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
"https://s/master.m3u8",
);
});
it("keeps the src for a progressive stream that looks like a playlist", () => {
const s = selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4");
expect(elementSrcFor(s, MODERN)).toBe("https://s/files/movie.m3u8.mp4");
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* Which loader opens a stream in the webview `<video>` element.
*
* Extracted from `VideoPlayer.svelte` so the decision can be unit-tested the
* same pattern as `episodeStrip.ts` and `TrackList.logic.test.ts`.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
import type { StreamSelection, Transport } from "$lib/api/bindings";
/** How the element should be fed. */
export type VideoLoader =
/** hls.js drives a MediaSource; the element's own `src` stays empty. */
| "hlsjs"
/** The element loads the playlist itself (Safari/WebKit native HLS). */
| "nativeHls"
/** The element loads the URL directly — a progressive file or a local one. */
| "direct";
/** What the running browser can do, passed in so the decision stays pure. */
export interface LoaderCapabilities {
/** `Hls.isSupported()` */
hlsJsSupported: boolean;
/** `video.canPlayType("application/vnd.apple.mpegurl")` was non-empty */
nativeHlsSupported: boolean;
}
/**
* Pick the loader from the backend's tagged `transport`.
*
* This used to read `url.includes(".m3u8")`, in two places in
* `VideoPlayer.svelte`. Rust *builds* that URL and knows exactly what it is;
* re-deriving the answer here by substring match is a domain fact reconstructed
* in the presentation layer the same error as leaking item-type taxonomy, and
* one that fails silently in both directions: a progressive file served from a
* path containing `.m3u8` gets an HLS loader, and a playlist served from a path
* without it does not.
*
* The transport is the *stream's* property; whether a given loader exists is the
* *browser's*. Only the second is decided here.
*/
export function videoLoaderFor(
selection: Pick<StreamSelection, "url" | "transport">,
capabilities: LoaderCapabilities,
): VideoLoader {
return loaderForTransport(selection.transport.type, capabilities);
}
/**
* The same decision, taken from the transport *tag* alone.
*
* Exists because a Svelte `$effect` that reads the whole selection re-runs
* whenever the selection **object** is replaced even with an identical URL and
* transport and the HLS effect's teardown/rebuild is not idempotent: it
* destroys the hls.js instance and reattaches, which leaves the element with no
* video until something forces another cycle. The pre-DR-225 code read a plain
* URL *string*, so re-assigning the same value was a no-op and the effect stayed
* put. Passing primitives restores that.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
export function loaderForTransport(
transport: Transport["type"],
capabilities: LoaderCapabilities,
): VideoLoader {
if (transport !== "hls") {
// Progressive and local files are what the element loads natively. No
// MediaSource, no playlist parsing.
return "direct";
}
if (capabilities.hlsJsSupported) return "hlsjs";
if (capabilities.nativeHlsSupported) return "nativeHls";
// Nothing here can parse a playlist. Handing the URL to the element is very
// likely to fail, but it is the only remaining move and it surfaces a real
// media error rather than silently doing nothing.
return "direct";
}
/** Convenience for the template: does the element's `src` stay empty? */
export function elementSrcFor(
selection: Pick<StreamSelection, "url" | "transport">,
capabilities: LoaderCapabilities,
): string {
return videoLoaderFor(selection, capabilities) === "hlsjs" ? "" : selection.url;
}
export type { Transport };
+44
View File
@@ -64,6 +64,49 @@ function createAuthStore() {
return repository; return repository;
} }
/**
* The repository, waiting for session restore rather than failing the instant
* it is asked.
*
* `getRepository()` throws immediately, which is right for a click handler
* the user is present and an error is honest. It is wrong for anything that
* runs *on mount*: the session is restored asynchronously at startup, so a
* page that loads before that finishes gets "Not connected to a server" and
* shows a fatal error for a session that was about to arrive. The player page
* hit this, where the symptom is a playback error on a perfectly good stream.
*
* Resolves as soon as the repository exists, rejects only if it genuinely has
* not appeared so a real logged-out state still surfaces, just not as a race.
*
* TRACES: UR-002 | DR-013
*/
async function waitForRepository(timeoutMs = 5000): Promise<RepositoryClient> {
if (repository) return repository;
return new Promise<RepositoryClient>((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
unsubscribe();
fn();
};
// Every store change is a chance the session landed. `subscribe` fires
// synchronously on registration, which also covers the case where it
// arrived between the check above and here.
const unsubscribe = subscribe(() => {
if (repository) finish(() => resolve(repository as RepositoryClient));
});
const timer = setTimeout(
() => finish(() => reject(new Error("Not connected to a server"))),
timeoutMs,
);
});
}
/** /**
* Initialize event listeners from Rust backend. * Initialize event listeners from Rust backend.
* These should be called once during app initialization. * These should be called once during app initialization.
@@ -572,6 +615,7 @@ function createAuthStore() {
logout, logout,
clearError, clearError,
getRepository, getRepository,
waitForRepository,
getCurrentSession, getCurrentSession,
getUserId, getUserId,
getServerUrl, getServerUrl,
@@ -0,0 +1,117 @@
/**
* Waiting for the repository rather than racing it.
*
* The defect: the player page asks for the repository *on mount*, but the
* session is restored asynchronously at startup. Losing that race produced
* "Not connected to a server" as a fatal playback error for a stream that was
* perfectly fine.
*
* These test the waiting contract itself rather than the auth store's internals,
* because the contract is the part the player depends on: resolve as soon as it
* exists, still reject when it genuinely is not there, and never settle twice.
*
* TRACES: UR-002, UR-004 | DR-013 | UT-215
*/
import { describe, expect, it, vi } from "vitest";
type Listener = () => void;
/**
* The shape `waitForRepository` is built on: a store you can subscribe to, and
* a value that appears at some later point. Mirrors the real implementation
* without dragging in Tauri.
*/
function makeWaiter() {
let repository: object | null = null;
const listeners = new Set<Listener>();
const subscribe = (fn: Listener) => {
listeners.add(fn);
fn(); // stores fire synchronously on subscribe
return () => listeners.delete(fn);
};
const publish = (value: object | null) => {
repository = value;
listeners.forEach((fn) => fn());
};
async function waitForRepository(timeoutMs = 5000): Promise<object> {
if (repository) return repository;
return new Promise<object>((resolve, reject) => {
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
unsubscribe();
fn();
};
const unsubscribe = subscribe(() => {
if (repository) finish(() => resolve(repository as object));
});
const timer = setTimeout(
() => finish(() => reject(new Error("Not connected to a server"))),
timeoutMs,
);
});
}
return { waitForRepository, publish, listenerCount: () => listeners.size };
}
describe("waitForRepository", () => {
it("resolves immediately when the session is already restored", async () => {
const w = makeWaiter();
const repo = {};
w.publish(repo);
await expect(w.waitForRepository(50)).resolves.toBe(repo);
});
it("resolves when the session arrives later — the race the player lost", async () => {
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(1000);
// Nothing yet; the page has already mounted and asked. Published on a
// microtask rather than a timer: the point is *ordering* (asked before it
// arrived), and a wall-clock delay would make this a race under load.
await Promise.resolve();
w.publish(repo);
await expect(pending).resolves.toBe(repo);
});
it("still rejects when there genuinely is no session", async () => {
vi.useFakeTimers();
const w = makeWaiter();
const pending = w.waitForRepository(500);
const assertion = expect(pending).rejects.toThrow("Not connected to a server");
await vi.advanceTimersByTimeAsync(600);
await assertion;
vi.useRealTimers();
});
it("unsubscribes once settled, so a later change cannot resolve it twice", async () => {
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(1000);
expect(w.listenerCount()).toBe(1);
w.publish(repo);
await pending;
expect(w.listenerCount()).toBe(0);
// A further change must not throw or re-settle.
expect(() => w.publish(null)).not.toThrow();
});
it("does not leave a pending timer that fires after success", async () => {
vi.useFakeTimers();
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(200);
w.publish(repo);
await expect(pending).resolves.toBe(repo);
// If the timeout were still armed it would reject an already-settled
// promise, which surfaces as an unhandled rejection rather than a failure.
await vi.advanceTimersByTimeAsync(500);
vi.useRealTimers();
});
});
+1 -21
View File
@@ -5,7 +5,7 @@
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { formatDuration, formatSecondsDuration } from "./duration"; import { formatDuration } from "./duration";
describe("formatDuration", () => { describe("formatDuration", () => {
it("should format duration from milliseconds (mm:ss format)", () => { it("should format duration from milliseconds (mm:ss format)", () => {
@@ -39,23 +39,3 @@ describe("formatDuration", () => {
expect(formatDuration(9045000, "hh:mm:ss")).toBe("2:30:45"); expect(formatDuration(9045000, "hh:mm:ss")).toBe("2:30:45");
}); });
}); });
describe("formatSecondsDuration", () => {
it("should format duration from seconds (mm:ss format)", () => {
expect(formatSecondsDuration(1)).toBe("0:01");
expect(formatSecondsDuration(60)).toBe("1:00");
expect(formatSecondsDuration(61)).toBe("1:01");
expect(formatSecondsDuration(3661)).toBe("61:01");
});
it("should format duration with hh:mm:ss format", () => {
expect(formatSecondsDuration(3600, "hh:mm:ss")).toBe("1:00:00");
expect(formatSecondsDuration(3661, "hh:mm:ss")).toBe("1:01:01");
expect(formatSecondsDuration(7325, "hh:mm:ss")).toBe("2:02:05");
});
it("should pad minutes and seconds with leading zeros", () => {
expect(formatSecondsDuration(5, "hh:mm:ss")).toBe("0:00:05");
expect(formatSecondsDuration(65, "hh:mm:ss")).toBe("0:01:05");
});
});
+13 -25
View File
@@ -12,11 +12,23 @@
* @param format Format type: "mm:ss" (default) or "hh:mm:ss" * @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string or empty string if no duration * @returns Formatted duration string or empty string if no duration
*/ */
export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string { export function formatDuration(
ms?: number | null,
format: "mm:ss" | "hh:mm:ss" | "h m" = "mm:ss",
): string {
if (!ms) return ""; if (!ms) return "";
const totalSeconds = Math.floor(ms / 1000); const totalSeconds = Math.floor(ms / 1000);
// "1h 23m" / "45m" — the shape a runtime is read at a glance, as opposed to
// the clock shape a *position* is read at. Three components had hand-rolled
// this identically; it belongs here with the other two.
if (format === "h m") {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
if (format === "hh:mm:ss") { if (format === "hh:mm:ss") {
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60); const minutes = Math.floor((totalSeconds % 3600) / 60);
@@ -30,27 +42,3 @@ export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss"
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`; return `${minutes}:${seconds.toString().padStart(2, "0")}`;
} }
/**
* Convert seconds to formatted duration string
* @param seconds Duration in seconds
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string
*/
export function formatSecondsDuration(
seconds: number,
format: "mm:ss" | "hh:mm:ss" = "mm:ss",
): string {
if (format === "hh:mm:ss") {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
// Default "mm:ss" format
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}
+2 -13
View File
@@ -1,6 +1,7 @@
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 --> <!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { formatDuration } from "$lib/utils/duration";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation"; import { navigateBack } from "$lib/utils/navigation";
@@ -250,18 +251,6 @@
// Images now handled by CachedImage component // Images now handled by CachedImage component
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function handleItemClick(clickedItem: MediaItem | Library) { function handleItemClick(clickedItem: MediaItem | Library) {
if (!("kind" in clickedItem)) { if (!("kind" in clickedItem)) {
// Library item - navigate to library // Library item - navigate to library
@@ -534,7 +523,7 @@
> >
{/if} {/if}
{#if item.durationMs} {#if item.durationMs}
<span>{formatDuration(item.durationMs)}</span> <span>{formatDuration(item.durationMs, "h m")}</span>
{/if} {/if}
{#if item.communityRating} {#if item.communityRating}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
+76 -51
View File
@@ -3,8 +3,8 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { downloadedFilePath, resolveVideoSource } from "$lib/player/localSource"; import { downloadedFilePath } from "$lib/player/localSource";
import type { PlayQueueRequest } from "$lib/api/bindings"; import type { PlayQueueRequest, StreamSelection } from "$lib/api/bindings";
import type { MediaItem, MediaKind } from "$lib/api/types"; import type { MediaItem, MediaKind } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { library } from "$lib/stores/library"; import { library } from "$lib/stores/library";
@@ -76,7 +76,15 @@
const hasNext = $derived($hasNextStore); const hasNext = $derived($hasNextStore);
const hasPrevious = $derived($hasPreviousStore); const hasPrevious = $derived($hasPreviousStore);
let currentMedia = $state<MediaItem | null>(null); let currentMedia = $state<MediaItem | null>(null);
let streamUrl = $state<string | null>(null); /**
* What to play, as the backend decided it. Null while still resolving.
*
* Replaces a bare URL string: the transport travels with it, so neither this
* page nor VideoPlayer has to work out whether the URL is a playlist.
*
* TRACES: UR-079 | DR-225
*/
let selection = $state<StreamSelection | null>(null);
let mediaSourceId = $state<string | null>(null); let mediaSourceId = $state<string | null>(null);
let isVideo = $state(false); let isVideo = $state(false);
let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume
@@ -94,7 +102,7 @@
// Which player component to render. Video without a stream URL is "pending" // Which player component to render. Video without a stream URL is "pending"
// (still resolving), never audio — see playerSurface.ts. // (still resolving), never audio — see playerSurface.ts.
const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl })); const surface = $derived(resolvePlayerSurface({ isVideo, streamUrl: selection?.url ?? null }));
onMount(() => { onMount(() => {
// Start position polling (only for audio via MPV backend) // Start position polling (only for audio via MPV backend)
@@ -308,17 +316,17 @@
const fullPath = downloadedFilePath(storagePath, localDownload.filePath); const fullPath = downloadedFilePath(storagePath, localDownload.filePath);
log.debug("loadAndPlay: Full local path:", fullPath); log.debug("loadAndPlay: Full local path:", fullPath);
// Serve the file over the loopback media server rather than the asset
// protocol: the asset protocol answers a range-less request with the
// entire file, so a downloaded film never finished loading. Rust mints
// the URL (it holds the port and the per-session token).
// TRACES: UR-071 | DR-137
const localUrl = await commands.mediaLocalUrl(fullPath);
log.debug("loadAndPlay: Local media URL resolved");
if (isVideo) { if (isVideo) {
// Local video files don't need transcoding and support native seeking // Served over the loopback media server rather than the asset
streamUrl = localUrl; // protocol: the asset protocol answers a range-less request with the
// entire file, so a downloaded film never finished loading. Rust mints
// the URL (it holds the port and the per-session token) and states the
// transport with it.
//
// A downloaded file is a direct play over a local transport, and Rust
// says so rather than this page assuming it.
// TRACES: UR-071 | DR-137, DR-225
selection = await commands.mediaLocalSelection(fullPath);
videoNeedsTranscoding = false; videoNeedsTranscoding = false;
// Use explicit startPosition, or fall back to retrieved progress from database // Use explicit startPosition, or fall back to retrieved progress from database
const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0; const effectivePosition = startPosition ?? retrievedProgressSeconds ?? 0;
@@ -346,7 +354,12 @@
} else { } else {
// Online playback - get playback info from server // Online playback - get playback info from server
isOfflinePlayback = false; isOfflinePlayback = false;
const repo = auth.getRepository(); // Wait for session restore rather than failing on a race: this runs on
// mount, and at startup (or after a hot reload) the repository may be a
// few hundred milliseconds behind. Failing instantly showed "Not
// connected to a server" as a *playback* error for a stream that was
// fine. TRACES: UR-002, UR-004 | DR-013
const repo = await auth.waitForRepository();
if (isLive) { if (isLive) {
// Live TV channels must be "opened" before streaming; the server returns // Live TV channels must be "opened" before streaming; the server returns
@@ -355,7 +368,19 @@
const liveInfo = await repo.openLiveStream(id); const liveInfo = await repo.openLiveStream(id);
log.debug("loadAndPlay: Live stream URL:", liveInfo.streamUrl); log.debug("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
mediaSourceId = liveInfo.mediaSourceId; mediaSourceId = liveInfo.mediaSourceId;
streamUrl = liveInfo.streamUrl; selection = {
url: liveInfo.streamUrl,
// Rust's verdict, not a guess from the URL.
transport: liveInfo.transport,
playbackKind: { type: "transcode" },
rendition: null,
// A live channel has no ladder to offer: there is no source file to
// measure and no rendition to re-negotiate against.
available: [],
mediaSourceId: liveInfo.mediaSourceId,
playSessionId: liveInfo.playSessionId,
needsTranscoding: true,
};
videoNeedsTranscoding = true; videoNeedsTranscoding = true;
videoInitialPosition = 0; videoInitialPosition = 0;
isPlaying = true; isPlaying = true;
@@ -363,46 +388,46 @@
return; return;
} }
log.debug("loadAndPlay: Getting playback info");
const playbackInfo = await repo.getPlaybackInfo(id);
log.debug("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
if (isVideo) { if (isVideo) {
// Playback API now detects HEVC/10-bit and returns transcoded URL when needed
log.debug(
"loadAndPlay: Using video stream, directPlay:",
playbackInfo.directPlay,
"needsTranscoding:",
playbackInfo.needsTranscoding,
);
mediaSourceId = playbackInfo.mediaSourceId;
// Prefer a completed download over streaming. Audio has done this // Prefer a completed download over streaming. Audio has done this
// since the queue is built; video previously always streamed, so a // since the queue is built; video previously always streamed, so a
// downloaded film re-spent bandwidth already spent and would not play // downloaded film re-spent bandwidth already spent and would not play
// at all offline. Rust returns null when nothing is downloaded or the // at all offline. Rust returns null when nothing is downloaded or the
// file has gone, so this falls back to the server on its own. // file has gone, so this falls back to the server on its own.
// TRACES: UR-071 | DR-123 //
// A downloaded file is served over the loopback media server, not the // Checked *first* so the streaming path below negotiates exactly once:
// asset protocol — see DR-137. The URL is minted up front because // asking for a `PlaybackInfo` and then a stream selection meant two
// resolveVideoSource stays pure/synchronous. // negotiations per load, and each one claims a transcode identity and
// TRACES: UR-071 | DR-123, DR-137 // retires the previous — so the server started a job only to be told
// to stop it a moment later. Observed in the log as a pair of
// `[StreamSelection]` lines for one play.
//
// TRACES: UR-071 | DR-123, DR-137, DR-225
const localPath = await commands.playerLocalMediaPath(id); const localPath = await commands.playerLocalMediaPath(id);
const localUrl = localPath ? await commands.mediaLocalUrl(localPath) : null; if (localPath) {
const source = resolveVideoSource({ // A downloaded file is a direct play over a local transport, served
localPath, // by the loopback media server rather than the asset protocol
remoteUrl: playbackInfo.streamUrl, // (DR-137). Its media-source id still comes from the server, since
remoteNeedsTranscoding: playbackInfo.needsTranscoding, // that is what subtitle URLs are keyed by.
toAssetUrl: () => localUrl ?? "", selection = await commands.mediaLocalSelection(localPath);
}); videoNeedsTranscoding = false;
mediaSourceId = (await repo.getPlaybackInfo(id)).mediaSourceId;
streamUrl = source.url; log.debug("loadAndPlay: Playing downloaded file from disk");
videoNeedsTranscoding = source.needsTranscoding; } else {
log.debug( // Rust negotiates direct play vs direct stream vs transcode against
source.isLocal // the device profile and the ceiling in force, and returns the
? "loadAndPlay: Playing downloaded file from disk" // transport and the media-source id with it. This page no longer
: `loadAndPlay: Using stream URL: ${streamUrl}`, // decides — or separately asks for — any of that.
); // TRACES: UR-070, UR-079 | DR-225, DR-227, DR-228
selection = await repo.getStreamSelection(id, null, null);
mediaSourceId = selection.mediaSourceId;
// Rust's own verdict — "which kinds count as transcoding" is a
// domain rule, and a direct *stream* is a remux that does not.
videoNeedsTranscoding = selection.needsTranscoding;
log.debug(
`loadAndPlay: ${selection.playbackKind.type} over ${selection.transport.type}`,
);
}
// Set initial position for the video player to seek to after load. // Set initial position for the video player to seek to after load.
// Use explicit startPosition, or fall back to retrieved progress. // Use explicit startPosition, or fall back to retrieved progress.
@@ -847,10 +872,10 @@
class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin" class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
></div> ></div>
</div> </div>
{:else if surface === "video" && streamUrl} {:else if surface === "video" && selection}
<VideoPlayer <VideoPlayer
media={currentMedia} media={currentMedia}
{streamUrl} {selection}
mediaSourceId={mediaSourceId ?? undefined} mediaSourceId={mediaSourceId ?? undefined}
initialPosition={videoInitialPosition} initialPosition={videoInitialPosition}
needsTranscoding={videoNeedsTranscoding} needsTranscoding={videoNeedsTranscoding}