Compare commits

..
Author SHA1 Message Date
dtourolle 40d358ab52 docs: a verification plan for the native player
Not a generic smoke test. Every case exists because something specific went
wrong, and most were found on hardware after the suites were already green.

The sequences are load-bearing. Two defects this cycle only appeared in a
particular order of actions — play, enable background audio, background,
foreground, exit — and testing the same features in any other order found
neither. So the plan asks for that order explicitly rather than listing
"background audio" as a feature to try.

It also states plainly that a green conformance run is not sufficient evidence
to ship, because both regressions introduced during this work passed
conformance and were caught by a person using the app.

Includes a symptom-to-cause table, because none of these presented as their
cause: a dead play/pause button was an unobserved property, a black screen was
a float that could not become a Duration, and a scrub bar with no scale was a
duration of zero being believed.

"Known open" lists what is deliberately unfixed so each gets a decision rather
than a surprise — device-local resume, the unconfirmed handoff swap, and the
broken side-by-side debug install whose own error message advises an uninstall
that would destroy the real app's data.
2026-08-23 10:39:37 +02:00
dtourolle 7e23da46e2 fix(player): a junk duration from an engine must not panic the backend
DR-252, and a regression I introduced in DR-245.

`Duration::from_secs_f64` panics on a negative or non-finite value. The old
PlayerBackend contract passed durations around as a bare Option<f64> and never
promised otherwise, so junk flowed through harmlessly. LegacyPlayer converts
that value to a Duration on the way into the MediaPlayer contract, which turned
it into a hard panic.

ExoPlayer reports C.TIME_UNSET — Long::MIN_VALUE, about -9.2e15 seconds — for
any stream whose length it does not know. That is every background-audio
handoff: /Audio/{id}/universal is a chunked, length-less transcode. So the
panic fired exactly when the handoff started, killed the Rust backend
mid-swap, and left a black screen with no controls.

Caught on the device, in the user's own repro sequence: enable background
audio, background the app, come back. Not by any suite — the conformance cases
run against engines that report sane numbers, and nothing was asking what
happens when one does not.

One guard on the contract now, used by every engine crossing into it, rather
than each adapter deciding for itself. mpv had the same unguarded conversion
for its duration property and would have hit it the moment libmpv reported
something odd.

UT-222 pins the values: TIME_UNSET as seconds, negatives, zero, NaN and both
infinities yield no duration; a real runtime survives.

791 Rust tests, mpv conformance still 9/9, clippy clean both ways.
2026-08-23 10:13:43 +02:00
dtourolle e5b7003489 fix(player): a duration of zero is not a duration
DR-251. Scrubbing was dead on Android because the seek bar had no scale:
every position tick read `<position> / 0.0`.

ExoPlayer reports C.TIME_UNSET until it has resolved a duration, and
JellyTauPlayer.getDuration() maps that to 0.0. So the engine answered
Some(0.0) rather than None, which satisfied every "unknown duration" fallback
in the controller — `observed_duration()` was never consulted, and neither was
the runtime the catalog had carried since long before anything started
decoding.

Zero is now read as "does not know yet" at each step, with a final fallback to
the item's own duration. That fixes it for any engine that cannot answer,
rather than for ExoPlayer specifically.

Red first: the test asserts a controller whose engine reports nothing usable
still reports the queued item's 1800s, and failed with None before the change.

790 Rust tests, clippy clean both ways.
2026-08-23 09:13:21 +02:00
dtourolle 5b5162dd1e fix(player): closing the player stops every renderer, not the believed one
DR-250. The user's diagnosis, and a better fix than modelling the handoff more
carefully: a close that stops only what we believe is playing is fragile by
construction. A close that stops everything is correct whatever the bookkeeping
thinks.

Two places it did not.

The teardown's stop was gated on `didStartNativePlayback &&
!didStopBackendEarly` — flags describing what *this component* started. A
background-audio handoff swaps the renderer underneath them, so after one they
describe a player that is no longer making sound and the stop was skipped
entirely. The audio stream kept running and the mini player adopted it, which
is exactly why a movie reappeared as an audio track. It is unconditional now;
`playerStop` is idempotent, so the cost of calling it when nothing plays is a
no-op round trip, against the alternative of silently leaving audio running.

And `PlayerController::stop` never cleared the handoff. Leaving the base offset
and the active flag behind lets a later position read be interpreted against a
handoff that no longer exists. Stopping now clears both.

`didStartNativePlayback` had no remaining reader and is deleted rather than
silenced — dead bookkeeping about which renderer was in charge is precisely the
frontend playback state this contract is meant to remove, and the eslint
ratchet caught it going one over.

The underlying unconfirmed state swap is still there and still worth fixing —
it is written up in media-player-controller.md. This makes the symptom
impossible while that lands.

789 Rust tests, 1088 frontend, lint back at 158, clippy clean both ways.
2026-08-23 09:02:30 +02:00
dtourolle 888f0a2a5d docs(specs): the background-audio handoff is an unconfirmed state swap
Diagnosed on a device. The likeliest explanation for "audio keeps playing
after I leave the player", which is the report this line of work started from.

enter_background_audio and exit_background_audio are pure bookkeeping: a
boolean and a base offset. Neither confirms the audio stream opened, nor that
the webview <video> came back. exit_background_audio's own comment says the
element "becomes the player again once it reloads" — a future event nothing
waits for, while the flag calls the swap done the moment it is invoked.

Foreground the app, then leave the player before the element has reloaded, and
the stop is aimed at something that does not exist yet while the audio stream
keeps running. The mini player then adopts a live audio session, which is why a
movie reappears as an audio track and why it is intermittent.

Same defect class as DR-238 … DR-241: state asserted rather than confirmed. It
is what Phase::Opening and the open generation exist for — a handoff is an open
in flight, and a close during one must cancel it. Today the handoff never
reaches an engine as an open at all, which is why
close_during_open_never_plays passes on all four engines while the bug
survives.

Credit where due: the sequence came from the user reproducing it deliberately,
not from the logs.
2026-08-23 08:58:25 +02:00
dtourolle 7d60f7ed9c fix(android): a Linux gate that outlived its caller broke the build
`set_current_item` was `#[cfg(target_os = "linux")]` from when its caller was a
`#[cfg]` branch too. d3ecd8ee correctly replaced that branch with a runtime
question — "does this renderer draw the picture?" — which means the `else` arm
is now compiled on every platform, including ones where it never runs. The gate
stayed, so the Android build stopped compiling at that commit.

It went unnoticed because nothing built for Android afterwards. CI's Android
`cargo check` would have caught it; this branch has never been pushed.

Also adds the widget's allocation origin to the video-surface log. A GtkBox is
a no-window widget, so `widget.window()` is the parent's GdkWindow and the box
sits at an offset inside it; if `draw_from_gl` does not honour the cairo
translation GTK applied, the picture lands at the window origin instead of the
widget's — misaligned by exactly that offset, which is the shape of a letterbox
that does not line up. Logging the origin says whether that is what is
happening before anyone changes the geometry.
2026-08-23 08:46:01 +02:00
dtourolle d952a2ae55 fix(player): ExoPlayer can seek a transcode in place; mpv cannot
A regression I introduced in DR-246 and did not catch, because the capability
was declared once for "native engines" as though being native were the
property that mattered.

It is not. Speaking HLS is. ExoPlayer is a full HLS client: like hls.js it
seeks within the VOD playlist it was handed and lets the server catch up. mpv's
HLS demuxer will not make the server produce segments from a new offset, so it
has to re-open the stream. Grouping them together declared false for both, so
on Android a transcoded seek began re-opening the stream where it previously
seeked in place — the same class of defect DR-238 was about, reintroduced on
the platform I had not exercised.

Capabilities::native() is gone, replaced by mpv() and exoplayer(), and the
composition root chooses per platform through engine_capabilities(). Treating a
category as a proxy for an ability is precisely the inference this design
removes; a helper named after the category invited it straight back in.

Not yet verified on a device. The conformance cases run against JellyTauPlayer
in isolation and do not cover a transcoded seek, PiP, background audio or the
media session — none of which have been exercised since the controller port.
2026-08-23 08:33:23 +02:00
dtourolle 954546434a docs(specs): record what shipped, and where the design bent
DR-242 … DR-247 are in. The spec now says so rather than reading as a proposal
for work that already exists.

One deviation is recorded rather than quietly absorbed: DR-246 called for the
engines to own seek strategy outright, and they cannot — re-negotiating a
stream needs the repository, which sits above them. The engine declares the
ability and the caller acts on it. `determine_video_seek_strategy` therefore
survives, correctly typed over a declared capability instead of over a guess,
because the defect was its input rather than its existence.
2026-08-22 22:23:20 +02:00
dtourolle 9d6b4f819c chore(player): a script for the conformance suite
The desktop runner needed a hand-generated fixture and the Android one needed
`-x :app:rustBuildUniversalDebug`, which nobody was going to remember. Both are
now `bun run test:player` and `bun run test:player:android`.

The fixture is generated on first use rather than committed: no media in the
repo, and an exact duration, which the seek assertions depend on.

The gradle exclusion carries its reason inline — 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`, and the library already in
jniLibs is what the test process loads.
2026-08-22 22:23:20 +02:00
dtourolle 6b3d853442 feat(player): seek strategy follows what the engine says it can do
DR-246. The strategy used to turn on `is_hls` and `use_html5`, decided in a
command handler on behalf of engines it does not own. That 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).

Engines now declare `Capabilities::seeks_transcoded_in_place` — true for
hls.js, which seeks within the VOD playlist it was handed and lets the server
catch up; false for mpv, whose HLS demuxer cannot make the server transcode
from a new offset. The command asks whichever engine is rendering. Adding an
engine no longer means editing a shared truth table.

The item's transport is not read at the seek site any more; the compiler
flagged it unused, which is the URL-shape input finally disappearing.

A deviation from the spec, recorded deliberately: it called for the engine to
own the decision outright. It cannot. Re-negotiating a stream needs the
repository, which sits above the engine, so the engine states the ability and
the caller acts on it. That still removes the defect — nobody guesses on
another component's behalf — without pretending an engine can reach upward.

Also fixes a latent race in the conformance suite, found by running it: the
seek case asserted immediately, which passes on an engine that records the
target when it accepts a seek and races on one that waits for the decoder to
move. `Harness::await_seek` polls instead, the way the Android suite already
did. It failed with machine load rather than with the code, which is the kind
of test that teaches people to re-run until green.

  MpvPlayer     9/9
  LegacyPlayer  8/9 - still only the mute/rate gap in the old trait

789 tests, clippy -D warnings clean with and without the feature.
2026-08-22 22:21:42 +02:00
dtourolle 5fcf58fa78 feat(player): the controller talks to one contract
DR-245. PlayerController now holds a MediaPlayer instead of a PlayerBackend,
and every engine reaches it through that contract.

Deliberately a seam swap, not four rewrites: the existing backends are carried
across by LegacyPlayer, so MPV keeps its EQ and normalisation, ExoPlayer keeps
its media session, and nothing loses a feature to the migration. MpvPlayer
stays available for conformance until it grows the audio-settings half.

The substantive change is at the load site. Where the controller used to call
load() and then play(), it now issues one open() carrying the item and where
to begin — so the window a start position could be lost in is gone from the
controller as well as from the engines.

`state()` maps the engine's Phase back onto PlayerState using the queue, which
is what knows the item. External behaviour is unchanged.

Supporting pieces:

  - The contract gains set_audio_settings/audio_settings as *provided*
    methods. Engines that cannot honour them say so through Capabilities and
    inherit a no-op, rather than every implementation carrying an Ok(()) it
    does not mean.
  - PlayerBackend is implemented for Box<dyn PlayerBackend>, without which the
    boxed engine built at the composition root cannot be handed to anything
    generic over the trait.
  - StreamSelection::for_queued_item rebuilds a selection for an item already
    in the queue, without re-negotiating. The transport falls back rather than
    being sniffed out of the URL — that substring check is what DR-230 removed
    — and needs_transcoding is an exact stand-in because every transcode this
    app requests is HLS (DR-140).
  - default-run = "jellytau". The conformance binary made a bare `cargo run`
    ambiguous, which broke `tauri dev` outright. Caught by running the app
    rather than by any suite, which is the argument for doing both.

789 tests, clippy -D warnings clean with and without the feature.
2026-08-22 22:12:51 +02:00
22 changed files with 865 additions and 96 deletions
+1
View File
@@ -48,6 +48,7 @@
- [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)
+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.
+7 -1
View File
@@ -440,8 +440,12 @@ Internal architecture, components, and application logic.
| DR-242 | The player contract expresses intent, not device operations. `MediaPlayer::open` carries the start position, so no caller sequences load-then-seek and none can race an engine's asynchronous load; `seek` states a destination and leaves in-place-vs-re-open to the engine, which is the only layer that knows its own transport; `snapshot` is one coherent read; and `Phase::Opening` names the window a seek used to be lost in. Replaces `PlayerBackend`, which abstracted a device and required each of the three engines to re-derive the same rules | Player | UR-081 | In Progress | | DR-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-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-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 | `LegacyPlayer` drives the old `PlayerBackend` through the `MediaPlayer` contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purpose | Player | UR-081 | In Progress | | DR-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-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 |
--- ---
@@ -754,6 +758,8 @@ Internal architecture, components, and application logic.
| UT-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done | | UT-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-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-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
+56 -1
View File
@@ -1,6 +1,11 @@
# Spec: MediaPlayer — one controller API, three interchangeable engines # Spec: MediaPlayer — one controller API, three interchangeable engines
**Status:** Proposed **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:** UR-081 (new) → DR-242 … DR-249 (new); IR-034. Re-check
`requirements.md` before allocating — ids moved several times while this was `requirements.md` before allocating — ids moved several times while this was
written. written.
@@ -61,6 +66,47 @@ 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 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.** 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 ## Layer assignment
| Logic / responsibility | Layer | Why it belongs there | | Logic / responsibility | Layer | Why it belongs there |
@@ -226,6 +272,15 @@ Strangler, not a rewrite. Each step ships independently and leaves the app worki
behind an adapter so the other engines keep working. behind an adapter so the other engines keep working.
5. **DR-246** Move seek strategy and reload orchestration out of 5. **DR-246** Move seek strategy and reload orchestration out of
`commands/player/mod.rs` into the engines; delete `seek.rs`'s truth table. `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. 6. **DR-247** `ExoPlayerPlayer`; conformance on device.
7. **DR-248** `WebviewPlayer`; retire the adapter shim. 7. **DR-248** `WebviewPlayer`; retire the adapter shim.
8. **DR-249** Delete `PlayerBackend` and the frontend playback-state flags. 8. **DR-249** Delete `PlayerBackend` and the frontend playback-state flags.
+3 -1
View File
@@ -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",
+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
+4
View File
@@ -1,5 +1,9 @@
[package] [package]
name = "jellytau" name = "jellytau"
# The app. Named explicitly because the crate also builds
# `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous.
default-run = "jellytau"
version = "0.10.1" version = "0.10.1"
description = "A cross-platform Jellyfin client" description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"] authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
+23 -20
View File
@@ -1450,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, is_local, transport) = { 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())?;
@@ -1466,31 +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();
// The URL itself is no longer read here: the seek strategy now comes // Neither the URL nor the item's transport is read here any more. The
// from the item's own `transport`, not from inspecting the string. // strategy turns on whether the *engine* can seek a transcode in place,
// which it declares for itself — so the container the stream happens to
// arrive in stopped being a proxy for anything (DR-246).
let is_local_file = matches!(current_item.source, MediaSource::Local { .. }); 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)
let transport = current_item.transport;
(needs_trans, jellyfin_id, is_local_file, transport)
}; // Locks are dropped here }; // Locks are dropped here
// The transport comes from the backend's own decision, not from searching // Whether a transcode can be seeked in place is asked of the engine that is
// the URL for `.m3u8` — Rust built that URL and knows what it is. Items // rendering, not guessed from the URL's shape or from who is rendering.
// queued without one fall back to `needs_transcoding`, which is exact: // TRACES: UR-040, UR-079 | DR-238, DR-246
// every transcode this app requests is HLS (DR-140). let seeks_transcoded_in_place = {
// let controller = player.0.lock().await;
// TRACES: UR-004, UR-079 | DR-225, DR-230 controller.capabilities().seeks_transcoded_in_place
let is_hls = match transport {
Some(crate::repository::Transport::Hls) => true,
Some(crate::repository::Transport::Progressive)
| Some(crate::repository::Transport::LocalFile) => false,
None => needs_transcoding,
}; };
let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5); 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 => {
+15 -1
View File
@@ -68,6 +68,19 @@ impl<P: MediaPlayer> Harness for EngineHarness<P> {
fn seek_tolerance(&self) -> Duration { fn seek_tolerance(&self) -> Duration {
Duration::from_secs(10) 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 { macro_rules! run {
@@ -138,7 +151,8 @@ pub fn run_engine(url: &str, engine: Engine) -> u32 {
std::sync::Arc::new(tokio::sync::Mutex::new(None)), std::sync::Arc::new(tokio::sync::Mutex::new(None)),
std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()), std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()),
) )
.expect("could not create the legacy backend") .expect("could not create the legacy backend"),
crate::player::media_player::Capabilities::mpv(),
)); ));
} }
} }
+28
View File
@@ -738,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,
@@ -1409,8 +1430,15 @@ pub fn run() {
} }
} }
// Every engine reaches the controller through the one contract.
// `LegacyPlayer` carries the not-yet-ported ones across unchanged,
// so this port swaps a seam rather than four implementations.
// TRACES: UR-081 | DR-245
let player_controller = PlayerController::new( let player_controller = PlayerController::new(
Box::new(crate::player::LegacyPlayer::new(
backend, backend,
engine_capabilities(),
)),
playback_reporter.clone(), playback_reporter.clone(),
position_throttler.clone(), position_throttler.clone(),
); );
+50
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::*;
+12
View File
@@ -53,6 +53,17 @@ pub trait Harness {
fn seek_tolerance(&self) -> Duration { fn seek_tolerance(&self) -> Duration {
Duration::from_secs(5) 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) { fn assert_near(actual: Duration, expected: Duration, tolerance: Duration, what: &str) {
@@ -151,6 +162,7 @@ pub fn seeks_after_open<H: Harness>(h: &mut H) {
let target = Duration::from_secs(420); let target = Duration::from_secs(420);
h.player().seek(target).expect("seek failed"); h.player().seek(target).expect("seek failed");
h.await_seek(target);
assert_near( assert_near(
h.player().snapshot().position, h.player().snapshot().position,
+2
View File
@@ -72,6 +72,8 @@ impl FakePlayer {
audio_settings: true, audio_settings: true,
subtitle_switching: true, subtitle_switching: true,
audio_track_switching: true, audio_track_switching: true,
// The fake honours a seek in any phase, so it can claim this.
seeks_transcoded_in_place: true,
}, },
} }
} }
+24 -16
View File
@@ -18,16 +18,21 @@
//! //!
//! TRACES: UR-081 | DR-245 //! TRACES: UR-081 | DR-245
#![allow(dead_code)] // Consumed when PlayerController is ported (DR-245).
use std::time::Duration; use std::time::Duration;
use super::backend::{PlayerBackend, PlayerError}; use super::backend::{PlayerBackend, PlayerError};
use super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot}; use super::media_player::{
duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
};
use super::state::PlayerState; use super::state::PlayerState;
pub struct LegacyPlayer<B: PlayerBackend> { pub struct LegacyPlayer<B: PlayerBackend> {
inner: B, 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 /// 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 /// can do: it knows an item was handed over, not whether the engine is ready
/// for one. That gap is the whole problem. /// for one. That gap is the whole problem.
@@ -35,16 +40,13 @@ pub struct LegacyPlayer<B: PlayerBackend> {
} }
impl<B: PlayerBackend> LegacyPlayer<B> { impl<B: PlayerBackend> LegacyPlayer<B> {
pub fn new(inner: B) -> Self { pub fn new(inner: B, capabilities: Capabilities) -> Self {
Self { Self {
inner, inner,
capabilities,
has_item: false, has_item: false,
} }
} }
pub fn inner_mut(&mut self) -> &mut B {
&mut self.inner
}
} }
impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> { impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
@@ -124,8 +126,8 @@ impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
}; };
PlaybackSnapshot { PlaybackSnapshot {
phase, phase,
position: Duration::from_secs_f64(self.inner.position().max(0.0)), position: duration_from_secs(self.inner.position()).unwrap_or(Duration::ZERO),
duration: self.inner.duration().map(Duration::from_secs_f64), duration: self.inner.duration().and_then(duration_from_secs),
seekable: true, seekable: true,
volume: self.inner.volume(), volume: self.inner.volume(),
muted: false, muted: false,
@@ -135,12 +137,18 @@ impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
} }
} }
fn capabilities(&self) -> Capabilities { fn set_audio_settings(
Capabilities { &mut self,
video: false, settings: &crate::settings::AudioSettings,
audio_settings: true, ) -> Result<(), PlayerError> {
subtitle_switching: true, self.inner.set_audio_settings(settings)
audio_track_switching: true,
} }
fn audio_settings(&self) -> crate::settings::AudioSettings {
self.inner.audio_settings()
}
fn capabilities(&self) -> Capabilities {
self.capabilities
} }
} }
+13
View File
@@ -171,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> {
+122
View File
@@ -32,6 +32,24 @@ use std::time::Duration;
use super::backend::PlayerError; use super::backend::PlayerError;
use super::media::MediaItem; use super::media::MediaItem;
use crate::repository::stream_selection::StreamSelection; 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. /// What an engine is doing right now.
/// ///
@@ -120,6 +138,68 @@ pub struct Capabilities {
pub subtitle_switching: bool, pub subtitle_switching: bool,
/// Audio tracks can be selected without re-opening. /// Audio tracks can be selected without re-opening.
pub audio_track_switching: bool, 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. /// A request to present an item.
@@ -203,4 +283,46 @@ pub trait MediaPlayer: Send {
fn snapshot(&self) -> PlaybackSnapshot; fn snapshot(&self) -> PlaybackSnapshot;
fn capabilities(&self) -> Capabilities; 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))
);
}
} }
+140 -20
View File
@@ -12,7 +12,6 @@ pub mod events;
pub mod fake_player; pub mod fake_player;
#[cfg(test)] #[cfg(test)]
mod fake_player_conformance; mod fake_player_conformance;
#[cfg(any(test, feature = "conformance"))]
pub mod legacy_player; pub mod legacy_player;
pub mod media; pub mod media;
pub mod media_player; pub mod media_player;
@@ -60,10 +59,13 @@ pub mod video_surface;
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};
@@ -238,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,
@@ -337,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 {
@@ -515,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): {}",
@@ -568,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
@@ -743,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()
@@ -768,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);
} }
@@ -849,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);
@@ -881,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.
@@ -932,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.
@@ -974,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()
@@ -1038,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
@@ -1096,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
@@ -1197,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;
@@ -2212,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,
) )
@@ -2221,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
+7 -2
View File
@@ -21,7 +21,9 @@ use libmpv::Mpv;
use log::{debug, info, warn}; use log::{debug, info, warn};
use super::backend::PlayerError; use super::backend::PlayerError;
use super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot}; use super::media_player::{
duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
};
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
/// State the event thread writes and the caller reads. /// State the event thread writes and the caller reads.
@@ -145,7 +147,7 @@ impl MpvPlayer {
s.duration = mpv s.duration = mpv
.get_property::<f64>("duration") .get_property::<f64>("duration")
.ok() .ok()
.map(Duration::from_secs_f64); .and_then(duration_from_secs);
s.seekable = mpv.get_property::<bool>("seekable").unwrap_or(true); s.seekable = mpv.get_property::<bool>("seekable").unwrap_or(true);
s.phase = Phase::Playing; s.phase = Phase::Playing;
s.deferred_seek.take() s.deferred_seek.take()
@@ -373,6 +375,9 @@ impl MediaPlayer for MpvPlayer {
audio_settings: true, audio_settings: true,
subtitle_switching: true, subtitle_switching: true,
audio_track_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,
} }
} }
} }
+35 -19
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 {
@@ -53,14 +55,15 @@ pub fn determine_video_seek_strategy(
// native video on routed every transcoded seek into a backend seek that // native video on routed every transcoded seek into a backend seek that
// silently does nothing, and presents as "resume does not work". // silently does nothing, and presents as "resume does not work".
if needs_transcoding { if needs_transcoding {
return if use_html5 { // Whether a transcode can be seeked in place is a property of the
if is_hls { // engine, and the engine states it. This used to be inferred from
VideoSeekStrategy::Html5NativeSeek // `is_hls`, which held only while hls.js was the sole HLS renderer —
} else { // and stopped holding the moment mpv became one (DR-238).
VideoSeekStrategy::Html5ReloadStream return match (seeks_transcoded_in_place, use_html5) {
} (true, true) => VideoSeekStrategy::Html5NativeSeek,
} else { (true, false) => VideoSeekStrategy::BackendNativeSeek,
VideoSeekStrategy::BackendReloadStream (false, true) => VideoSeekStrategy::Html5ReloadStream,
(false, false) => VideoSeekStrategy::BackendReloadStream,
}; };
} }
@@ -235,20 +238,21 @@ 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
@@ -265,18 +269,30 @@ mod tests {
/// every transcoded seek into a native seek that silently does nothing, /// every transcoded seek into a native seek that silently does nothing,
/// which presents as "resume does not work". /// which presents as "resume does not work".
/// ///
/// TRACES: UR-040 | DR-238 | UT-217 /// TRACES: UR-040 | DR-238, DR-246 | UT-217
#[test] #[test]
fn test_seek_strategy_transcoded_hls_native_backend() { 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!( assert_eq!(
determine_video_seek_strategy(false, true, true, false), determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream VideoSeekStrategy::BackendReloadStream
); );
// The HTML5 side of the same case is unchanged: hls.js seeks in-playlist. 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!( assert_eq!(
determine_video_seek_strategy(false, true, true, true), determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek 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
+13 -1
View File
@@ -377,7 +377,19 @@ fn draw(widget: &gtk::Box, cr: &gtk::cairo::Context, state: &Rc<RefCell<SurfaceS
if !s.logged_first_frame || s.logged_size != (width, height) { if !s.logged_first_frame || s.logged_size != (width, height) {
s.logged_first_frame = true; s.logged_first_frame = true;
s.logged_size = (width, height); s.logged_size = (width, height);
info!("[VideoSurface] rendering {width}x{height} (texture {texture})"); // 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 { unsafe {
@@ -184,6 +184,45 @@ impl StreamSelection {
needs_transcoding: false, 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. /// Build the quality ladder as it applies to a source of a known bitrate.
+14 -8
View File
@@ -251,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
@@ -1032,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
@@ -1098,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,
@@ -1139,7 +1136,6 @@
} }
} else { } else {
// For transcoded content, keep backend for seeking // For transcoded content, keep backend for seeking
didStartNativePlayback = true;
} }
} }
} }
@@ -1273,15 +1269,26 @@
} }
// 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.
//
// This used to be gated on `didStartNativePlayback && !didStopBackendEarly`
// — flags describing what *this component* started. A background-audio
// handoff swaps the renderer underneath them, so after one they describe a
// player that is no longer the one making sound, and the stop was skipped
// while the audio stream kept going. It then reappeared in the mini player
// as an audio track.
//
// `playerStop` is idempotent, so calling it when nothing is playing costs a
// no-op IPC round trip. That is a far cheaper failure than the alternative.
//
// TRACES: UR-040, UR-005 | DR-250
try { try {
log.debug("Stopping backend player on component unmount"); log.debug("Stopping backend player on component unmount");
await commands.playerStop(); await commands.playerStop();
} catch (err) { } catch (err) {
log.error("Failed to stop backend player:", 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)
if (!isLive && onReportStop && currentTime > 0) { if (!isLive && onReportStop && currentTime > 0) {
@@ -2039,7 +2046,6 @@
transport: targetSelection.transport, transport: targetSelection.transport,
subtitles: nativeSubtitleTracks(sentSubtitleTracks), subtitles: nativeSubtitleTracks(sentSubtitleTracks),
}); });
didStartNativePlayback = true;
await playerAdapter?.load(targetSelection.url, { await playerAdapter?.load(targetSelection.url, {
mediaId: media.id, mediaId: media.id,
selection: targetSelection, selection: targetSelection,