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
dtourolle 20e683d705 feat(android): conformance on a device, and a start position for ExoPlayer
DR-247. The desktop suite cannot reach ExoPlayer: it needs an Android Context
and a Looper, so it exists only inside an app process. These are the same
behaviours, asserted against the engine itself.

Writing them forced the same gap open that mpv had. JellyTauPlayer.load(url,
mediaId) had no way to express a start position, so every caller loaded and
then seeked — the test could not even be written against the old signature,
which is a stronger statement than a failing assertion. The position now goes
to ExoPlayer with the media item via setMediaItem(item, startPositionMs), and
the two-argument form delegates to it, so nothing else had to change.

Running one suite against both engines settled something guesswork could not:

  seekWhileOpeningIsHonoured  passes on ExoPlayer with no fix

ExoPlayer already queues a seek issued before prepare() completes. So the
lost-seek half of DR-241 was mpv-specific, and only the missing vocabulary for
a start position was shared. That is the difference between "both engines have
this bug" and knowing which one does.

All seven cases pass on device (ROD2-W09, arm64).

The fixture is a silent WAV synthesised in the cache directory at setup rather
than committed or pushed: no binary in the repo, no adb step, and an exact
duration, which the seek assertions depend on.

Also adds the instrumentation runner to defaultConfig and teaches
sync-android-sources.sh to mirror src/androidTest, the way it already mirrors
src/test — so the canonical tree stays the only place tests are edited.

Run: ./gradlew :app:connectedUniversalDebugAndroidTest -x :app:rustBuildUniversalDebug
2026-08-22 21:32:59 +02:00
dtourolle 8904acb5f7 feat(player): run the old backend through the new contract
DR-245, first half. `LegacyPlayer` implements `MediaPlayer` over the existing
`PlayerBackend`, so engines not yet ported — ExoPlayer, the webview element,
the null backend — keep working while `PlayerController` moves across. Without
it the port would have to land all four engines at once.

It also makes the two designs comparable on one engine and one file. `open`
reproduces the old sequence faithfully: load, play, then seek for a start
position, with the seek's failure ignored exactly as callers used to ignore
it. Making it pass would defeat the point.

Running both engines over the same media is more informative than expected:

  MpvPlayer     9/9
  LegacyPlayer  8/9 - transport_settings_round_trip fails

Two things fall out of that. The start-position case now passes on *both*,
because DR-241 was fixed inside MpvBackend rather than only in the new engine
— so the suite confirms that fix independently, on a path it was not written
against. And the one genuine failure is a capability gap rather than a bug:
the old trait has no mute and no playback rate, so `LegacyPlayer` reports them
unsupported instead of folding mute into volume and losing the user's level.

That is the abstraction earning its keep on the first run: a missing
capability that was previously invisible is now a named, failing case.

The runner takes an engine argument:

    player-conformance <media-file> [mpv|legacy]
2026-08-22 21:23:39 +02:00
dtourolle a3190cd52b feat(player): MpvPlayer, and a runner that verifies it without the app
DR-244. The first real engine on the contract, and the tooling to interrogate
it in isolation.

The point of difference from MpvBackend is `open`: the start position is
applied at load time via mpv's own `start` option, instead of being seeked to
afterwards. loadfile is asynchronous, so a seek issued after it targets a
player with nothing loaded, fails, and was discarded. A seek that does arrive
during Opening is held and applied on FileLoaded, so no caller has to know
where that window begins or ends.

`close` clears state before issuing the stop, so an open still in flight
checks it on FileLoaded and cannot proceed to play after the caller has
stopped it. It is idempotent: callers legitimately close twice on teardown.

Every property the event loop matches is observed, per DR-239.

The runner is a separate binary that links libmpv and nothing else, so a
wrapper can be verified without building or launching the app — which is what
made the previous round of playback debugging so slow. Audio and video go to
null, so it is safe on a headless runner and does not claim the speakers. It
lives behind a `conformance` feature and exposes one entry point rather than
making the player module tree public.

    cargo run --features conformance --bin player-conformance -- <media-file>

All nine cases pass against real libmpv. Verified the suite can fail: reverting
`open` to the old load-then-seek behaviour makes opens_at_a_start_position fail
and restoring it makes it pass, so DR-241 is now a test rather than an
anecdote.
2026-08-22 21:18:10 +02:00
dtourolle f4892f4cb2 feat(player): FakePlayer and the conformance suite
DR-243. One set of behaviours every engine must satisfy, written before the
second engine exists so it cannot encode whatever the first happens to do —
which is how three playback implementations drifted apart in the first place.

FakePlayer models the one behaviour that matters most: opening is not
instantaneous. `open` parks in Phase::Opening until complete_open() is called,
so a test can put a seek into that window deliberately. That window is where
DR-241 lived, and it was previously unreachable from any test.

The suite drives readiness through a Harness rather than sleeping — the fake
completes on demand, a real engine waits for its own readiness event. A
timing-dependent suite is worse than none, because it teaches people to
re-run until green.

Nine cases, each naming the defect it prevents:

  opens_at_a_start_position          DR-241 - starts there, never at zero
  seek_while_opening_is_honoured     DR-241 - held, not discarded
  seek_while_opening_overrides_start         later intent wins
  pause_and_play_are_observable      DR-239 - state an engine cannot hide
  close_is_silent_and_idempotent             stopped must mean silent
  close_during_open_never_plays              an open cancelled by close
                                             must not come back to life

`audible()` may return None for engines that cannot answer, which skips the
silence assertions rather than passing them vacuously — an assertion that
cannot fail is worse than an absent one.

Also adds MediaItem::sample: the struct has twenty-odd fields, almost none of
which a given test cares about, and repeating the literal per test is how a
new field ends up added in thirty places.
2026-08-22 21:18:10 +02:00
dtourolle 3b91922cca feat(player): the MediaPlayer contract
DR-242. Intent, not device operations.

`open` carries the start position, so no caller sequences load-then-seek and
none can race an engine's asynchronous load — the engine is the only layer
that knows when its pipeline can accept a position, and it absorbs that
internally by deferring or re-opening.

`seek` states a destination and nothing else. Whether that is an in-place seek
or a re-opened stream is the engine's business: hls.js seeks within a VOD
playlist, mpv's HLS demuxer cannot make a server transcode from a new offset.
Callers stop guessing on behalf of engines they do not own.

`snapshot` is one coherent read rather than a dozen getters, because reading
position and duration separately is how a player reported <position> / 0.0
when a file unloaded between the two calls.

`Phase::Opening` names the state the previous design could not express, and is
the direct cause of DR-241: a seek arriving with nothing loaded had no phase
to be queued against, so it was discarded.

`Capabilities` exists so callers adapt without naming engines. If a caller
ever branches on which engine it holds, this struct is missing something —
engine identity leaking into callers is the coupling DR-238 came from.

Nothing consumes it yet; PlayerController is ported in DR-245. Carries an
explicit allow(dead_code) tied to that step rather than being hidden behind
cfg(test), because it is production code being built in shippable pieces.
2026-08-22 21:17:50 +02:00
dtourolle f388777185 docs(specs): one player contract, three interchangeable engines
A day of debugging Linux native video produced four defects (DR-238 … DR-241)
and one regression from fixing them in the wrong place. None of them were mpv
bugs. All four trace to the same missing seam.

`PlayerBackend` abstracts a *device* — load, then seek — rather than an
*intent*. A start position is therefore not expressible, so every caller
sequences load-then-seek itself and each races the engine's asynchronous load
independently. That is why resume worked through the adapter, which seeks
after "file loaded", and silently failed through the command, which seeks
immediately: two callers, one intent, two behaviours.

The same gap put transport rules above the engines. Whether a stream can be
seeked in place was decided by a truth table in a command handler, on behalf
of engines it does not own, which is how `use_html5` came to mean both "who
renders" and "how do I seek". And nothing in the contract obliged an engine to
report its own state, so a handler for mpv's `pause` property sat unreachable
while the UI waited for an event that never came.

Supporting evidence for the diagnosis: commands/player/mod.rs is 3,561 lines
and is where "stop → rebuild URL → update queue → load → seek" lives;
player_play_item needed a cfg(not(linux)) guard; and the frontend carries
didStartNativePlayback, didStopBackendEarly and hasPerformedInitialSeek —
playback state in the UI, which contradicts the one-directional rule.

The proposal is a MediaPlayer contract whose `open` carries the start
position, whose `seek` states a destination and leaves in-place-versus-re-open
to the engine, whose `snapshot` is one coherent read, and whose `Phase`
includes `Opening` — the state the previous design could not express and the
window a seek was lost in.

Testability is the half that makes it worth doing: one conformance suite run
against every engine, and a FakePlayer that lets the controller, queue,
autoplay and session logic be tested with no engine at all. The suite is
written before the second engine on purpose, so it cannot encode whatever the
first happened to do.

Migration is a strangler in eight steps; the first three are pure addition.
2026-08-22 21:17:50 +02:00
dtourolle 14b6a8609d fix(player): three defects native video exposed, and the logs to see them
Each of these was invisible while Linux video played in the webview, and each
became reachable the moment mpv started rendering.

DR-238 — a transcoded seek re-negotiates the stream on every renderer, not
just the webview. `determine_video_seek_strategy` treated `is_hls` as a proxy
for "seekable in place", which held only because hls.js was always the HLS
renderer: it seeks within the VOD playlist it is handed and lets the server
catch up. mpv's HLS demuxer cannot make Jellyfin transcode from a new offset,
so with native video on, every transcoded seek became a backend seek that
silently did nothing. One cell of the truth table changes; all four webview
cells are byte-identical.

DR-239 — properties the mpv event loop handles are now observed. libmpv
delivers PropertyChange only for properties registered with
observe_property, so the `pause` arm was unreachable code that read as
implemented: StateChanged was never emitted and the play/pause control never
moved. UT-218 asserts the two lists agree, so the class cannot recur.

DR-240 — fullscreen moves whatever owns the pixels. requestFullscreen()
fullscreens the *document*, which sufficed while the <video> element lived
inside it and WebKit scaled it. A native surface is drawn behind the webview
at window size, so a document-only fullscreen expanded the page and left the
picture at its old size — on WebKitGTK, a maximised window with decorations
still holding a strip of the screen. Measured on a 3440x1440 panel: 1361 tall
before, 1440 after.

DR-241 — a seek issued before mpv has a file to seek in is honoured rather
than dropped. loadfile returns as soon as the command is queued, so
`time-pos` does not resolve yet and setting it fails. The two callers that
always hit that window are resume and a transcoded seek, both of which
re-open the stream and then ask for a position; the failed seek was discarded
and playback began at zero.

Also adds the instrumentation that made the diagnosis possible rather than
speculative: an entry log on player_stop, a render-size log that re-fires on
change instead of latching once, and decoded-vs-display video geometry on file
load. The last of those retired a wrong theory — a picture that does not fill
an ultrawide turned out to be a 16:9 source with its letterbox baked in, not a
rendering fault.
2026-08-22 21:17:29 +02:00
dtourolle d3ecd8ee91 feat(video): mpv plays video on Linux, composited under the webview
DR-231 works. Video and audio, drawn by mpv into a framebuffer we own and
blitted into the default vbox's draw handler with `gdk_cairo_draw_from_gl()`.
The widget tree Tauri built is untouched, so nothing here can be invalidated by
a Tauri upgrade that assumes its own layout.

That settles finding 2 of playback-backend-unification.md on Linux by
demonstration rather than argument, and completes the half of G1 the spike could
not test.

Three pieces had to land together, none of which existed before:

  - mpv was configured with `video: no` and no video output, so it had never
    decoded a frame in this app. Now `vo=libmpv` when native video is on.
  - `player_play_item` skipped loading into the native backend on Linux behind a
    `#[cfg(not(target_os = "linux"))]`, because the webview always played video
    there. With the webview no longer loading it, that guard meant *nothing*
    played — no picture and no audio, which reads as a broken stream rather than
    as a file nobody was given.
  - The webview paints its own opaque background. Android clears it through a
    Kotlin bridge from `enableNativeVideoCompositing()`; the CSS half of that
    already ran on Linux, so only `transparent: true` on the window was missing.
    Until it was, the frame was rendered correctly and covered by white.

Frame pacing is polled, not pushed. The tick callback asks mpv `has_frame()` and
draws only when the answer is yes. Both neighbouring designs were tried and both
fail, in ways that point at the wrong culprit:

  - Waiting on mpv's update callback before rendering *deadlocks*: mpv does not
    progress until the client renders, so if the client waits to be told, the
    two hold each other. The file loads, one frame appears, and everything
    stops.
  - Rendering every frame-clock tick and reporting a swap each time claims a
    presentation far more often than one happened. It plays, and judders badly —
    which reads as a GPU or compositing limit, exactly as the spike warned.

The update callback survives as a hint and does the least it safely can from an
mpv thread: set an `AtomicBool`. It must not touch GTK — `idle_add_local*`
requires the caller to own the main context and panics from there — and it must
not hold the `Rc<RefCell<..>>` state, which is not `Send`.

Two memory-safety fixes in this file's own short history, both worth recording
because neither announced itself:

  - The callback context was handed over with `Rc::into_raw` (a pointer to the
    Rc's *contents*) and read back as `*const Rc<..>`, reinterpreting a RefCell
    as an Rc and corrupting its refcount on the first clone. mpv invokes the
    callback immediately, so this happened before anything drew. The symptom was
    the process ending quietly with status 0.
  - The surface was attached before the player backend was constructed, so the
    mpv handle it needs had not been registered yet and it found null every
    time.

Teardown (DR-232) is confirmed working on a real run: callback unregistered,
render context freed, GL objects released with the context still current, boxed
callback state reclaimed only after mpv can no longer reach it — no crash.

Still behind JELLYTAU_NATIVE_VIDEO=1 and off by default. Known open: whether
exiting the player stops mpv (reported, evidence ambiguous, needs re-checking
now the picture works), hardware decode (DR-236), and deleting the webview video
path (DR-235).
2026-08-22 14:22:58 +02:00
dtourolle 2f637d4775 feat(video): let mpv decode video at all, behind one shared flag
mpv has never decoded a video frame in this app: the backend sets `video: no`
unconditionally, because Linux video has always been the webview's job and
decoding it twice would burn a core for a picture nobody sees. The render path
built in the previous commit therefore had nothing to draw.

With native video on, mpv is configured for video *and* `vo=libmpv` — the render
API only works through that output, and the default would try to open a window
of its own. Set at construction, because mpv resolves its video output when it
initialises and flipping the property later does not re-open one.

The flag lives in `player::native_video`, read by all three things that must
agree: the backend (configured before anything plays), the surface (nothing to
draw otherwise), and `get_player_status` (which tells the frontend whether to
use a `<video>` element — two decoders on one stream would fight over the
audio). A function rather than three `env::var` checks, because a capability
answered in several places is a capability whose answers drift: four separate
bugs this cycle came from exactly that shape.

Also fixes an ordering bug the first run exposed. The surface was attached in
`setup` before the player backend was constructed, and the mpv handle is
registered *during* that construction — so it found nothing every time and
logged "no mpv handle". Attaching after the backend exists is the whole fix.

Confirmed on a real run: mpv accepts `vo=libmpv`, the GL context comes up on
Tauri's vbox, and `mpv_render_context_create` succeeds — which also proves the
libepoxy data-symbol handling is right, since a wrong `get_proc_address` would
have taken SIGSEGV on the first GL call rather than returning cleanly.

No frame has reached the screen yet. The webview is still opaque, so it will
paint over anything drawn beneath it until transparency is set up.

Security: quick-xml 0.38.4 carried RUSTSEC-2026-0194 (quadratic parse on
duplicate attribute names) and RUSTSEC-2026-0195 (unbounded namespace
allocation, memory-exhaustion DoS). `cargo deny` gates CI on advisories, so this
would have failed the next release. Fixed by plist 1.8 -> 1.10, which pulls
quick-xml 0.41. Licences, bans and sources still pass.

UT-216 pins the flag's parsing: absent, empty, `0`, `no` and anything
unrecognised all mean off. A half-set variable that half-enabled the renderer
would configure mpv for video with nothing drawing it — audio over a black
rectangle.

Also removes a wall-clock timer from the waitForRepository late-arrival test,
which failed once under load. The assertion is about ordering, so it now
publishes on a microtask and cannot race.
2026-08-22 13:45:04 +02:00
dtourolle 45144cb6b0 feat(video): render mpv behind the webview, and collapse duplicated helpers
DR-231 with the design the failed reparent forced. mpv's render API draws into
an FBO we own; the texture is composited by `gdk_cairo_draw_from_gl()` in the
default vbox's own `draw` handler. GTK draws a container before its children, so
the webview lands on top for free — no reparenting, no GtkOverlay, and nothing a
Tauri upgrade can invalidate by assuming its own widget layout.

Split so Windows inherits the useful half: `mpv_render` is the portable side
(render context, framebuffer, GL resolution) and `video_surface` is the GTK side
that consumes it. Nothing in the former is GTK-aware.

Three things the spike paid for, carried over rather than rediscovered:

  - libepoxy exports GL entry points as *data* symbols. `dlsym("epoxy_glFoo")`
    returns the address *of a function pointer*, not of code — returning it
    makes mpv jump into non-executable data and take SIGSEGV on the first GL
    call. The value is read out of that location instead.
  - Frame pacing goes through mpv's update callback plus `report_swap`. Its
    absence looks like a GPU or compositing limit (fine in a window, judders at
    fullscreen) and is neither.
  - The render context is created on `realize` and destroyed on `unrealize`,
    with the update callback unregistered *before* the free, so a callback
    cannot land on a freed pointer. That is DR-232 built in from the start
    rather than retrofitted: the spike had no teardown at all, which remains the
    likeliest explanation for the one SIGSEGV it could not reproduce.

Writing it also caught a bug that would have looked like severe stutter: the
update callback flagged a new frame but never asked GTK to repaint, so decoded
frames would only have reached the screen when something else happened to
invalidate the widget.

Still off by default behind JELLYTAU_NATIVE_VIDEO=1. It compiles and is wired;
no frame has been put on screen yet.

Redundant code, continued. `formatSecondsDuration` had no caller. Three
components had hand-rolled `formatDuration`: Queue's was byte-equivalent to the
shared "mm:ss", while EpisodeFocusView and the library page shared an identical
"1h 23m" shape the util did not offer — so that format joins the other two and
all three components now call one function.

A survey for exported symbols referenced only by tests returns 23 more. They are
deliberately left: spot-checking found `setLogForwarder` is the injection seam
for a lazily-initialised forwarder, and `getCachedImageUrl` is the read path of
a thumbnail cache whose management UI exists in Settings. Neither is dead — one
is test infrastructure and the other is an unwired feature, and deleting either
would remove capability while looking like tidying. The list is worth working
through deliberately, not in a playback branch.
2026-08-22 13:45:04 +02:00
42 changed files with 8151 additions and 4176 deletions
+1
View File
@@ -48,6 +48,7 @@
- [Build & Release](build/build-release.md)
- [Release Checklist](release-checklist.md)
- [Native Player Verification](native-player-verification.md)
- [Desktop Packaging](build/build-desktop-packages.md)
- [Windows Build](build/build-windows.md)
- [Defect Windows](defect-windows.md)
+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.
+22
View File
@@ -90,6 +90,7 @@ For a narrative overview of the system design, see
| UR-078 | JellyTau keeps a record of what it did, and can hand it over. The app forgot everything the moment it exited: the backend logged to stdout only — which a user launching from a desktop icon never sees, and which on Android is not logcat, so the Rust half was invisible on the platform carrying the hardest bugs. A crash left nothing at all. Logs are now written to a size-capped rotating file, a panic is recorded before the process dies, the frontend's messages land in the same timeline as the backend's, and Settings exports the lot as one file to attach to a bug report. Nothing is transmitted anywhere — the user attaches it themselves, which is also what keeps this from being telemetry. Access tokens and passwords never reach the file | Medium | Done |
| UR-079 | The app decides *what stream to play* and says so. Playing a video used to mean asking the server to re-encode it, always — a decision made nowhere, written down nowhere, and re-derived downstream by whoever needed it: the player worked out whether it had been handed a playlist by looking for `.m3u8` in the URL. So a viewer paid for a transcode of a file their device could have played untouched, and the app could not tell them which it was. Now one negotiation produces one self-describing answer — direct play, remux, or transcode; over a playlist, a plain HTTP file, or a local one — and every renderer consumes that same answer instead of guessing from a string. On Android, where the player decodes almost everything the library holds, this stops around 85% of plays from starting a transcode nobody needed | Medium | Done |
| UR-080 | Video on the desktop plays as itself. The picture was drawn by a webview `<video>` element, which decodes little beyond h264 — so the app told the server it could accept only h264, and the server re-encoded almost everything before sending it. That was never a statement about the machine: the same machine already runs mpv for audio, which decodes essentially the whole library. Measured against a real library, 93% of desktop playback was a transcode nobody needed, against 15% on Android where a real decoder does the work. mpv now draws the picture, the app claims what it can genuinely decode, and video is sent as it was stored wherever that is possible — sparing the server the work, the network the bitrate, and the picture a generation of re-encoding | Medium | Proposed |
| UR-081 | Playback behaves the same whichever engine renders it | High | In Progress |
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
---
@@ -432,6 +433,19 @@ Internal architecture, components, and application logic.
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Proposed |
| DR-236 | Hardware-decode policy is decided from what mpv reports it **selected** (`hwdec-current`), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that `auto` reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that `vaapi` fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, `auto` is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limit | Playback | UR-080 | Proposed |
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | Proposed |
| DR-238 | A transcoded seek re-negotiates the stream on every renderer, not just the webview. Jellyfin produces a transcode *from* `StartTimeTicks`, so where a seek lands is a property of the request rather than of the stream in hand. `determine_video_seek_strategy` treated `is_hls` as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer — it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make the server transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing and presented as "resume does not work". The rule is now written on `needs_transcoding` with hls.js as the stated exception; all four webview cells are unchanged | Player | UR-040 | Done |
| DR-239 | Properties the mpv event loop handles are registered with `observe_property`. libmpv delivers `PropertyChange` only for observed properties, so a `match` arm for an unobserved one is unreachable code that reads as implemented — the handler is right there. `pause` was handled and never observed, so `StateChanged` was never emitted on pause or resume and the play/pause control never moved. It stayed invisible while Linux video played in the webview, because the `<video>` element's own DOM events drove that control; native video made the UI depend on the event that never came | Player | UR-005 | Done |
| DR-240 | Fullscreen moves whatever actually owns the pixels. `requestFullscreen()` fullscreens the *document*, which sufficed while every renderer lived inside it — the HTML5 `<video>` element is part of the document, so WebKit scaled it and the OS window's real size never mattered. A native surface is drawn behind the webview at **window** size, so a document-only fullscreen expands the page and leaves the picture where it was; on WebKitGTK the result is a maximised window with decorations still holding a strip of the screen, which reads as "fullscreen is broken" rather than as a windowing problem. Android needed the same rule for the system bars (DR-157); this is its desktop half | Player | UR-066 | Done |
| DR-241 | A seek issued before MPV has a file to seek in is honoured, not dropped. `loadfile` returns as soon as the command is queued, so `time-pos` — a live property of the *loaded* file — does not resolve yet and setting it fails. The two callers that always hit that window are the ones a viewer notices: resume, and a transcoded seek, both of which re-open the stream and then ask for a position. The failed seek was discarded and the stream played from zero, which reads as "resume is broken" and "I cannot skip". The position is now held and applied by the `FileLoaded` handler; a seek that lands normally clears any deferred one, so the newer intent wins | Player | UR-040, UR-005 | Done |
| DR-242 | The player contract expresses intent, not device operations. `MediaPlayer::open` carries the start position, so no caller sequences load-then-seek and none can race an engine's asynchronous load; `seek` states a destination and leaves in-place-vs-re-open to the engine, which is the only layer that knows its own transport; `snapshot` is one coherent read; and `Phase::Opening` names the window a seek used to be lost in. Replaces `PlayerBackend`, which abstracted a device and required each of the three engines to re-derive the same rules | Player | UR-081 | In Progress |
| DR-243 | Every engine passes one conformance suite, and a `FakePlayer` implements the contract deterministically. The suite is written before the second engine so it cannot encode whatever the first happened to do, and it drives readiness through a harness rather than sleeping. `FakePlayer` models the one behaviour that matters — opening is not instantaneous — so the load/seek race can be expressed on purpose, and lets the controller, queue, autoplay and session logic be tested with no engine at all | Player | UR-081 | In Progress |
| DR-244 | `MpvPlayer` implements `MediaPlayer` over libmpv, applying the start position at load time via mpv's own `start` option rather than seeking after an asynchronous `loadfile`, and holding a seek that arrives during `Opening` until the file loads. A standalone `player-conformance` binary runs the suite against it with audio and video routed to null, so a wrapper is verifiable without building or launching the app | Player | UR-081, UR-040 | Done |
| DR-245 | `PlayerController` holds a `MediaPlayer` rather than a `PlayerBackend`, and every engine reaches it through that one contract — `LegacyPlayer` carries the not-yet-ported ones across unchanged, so the port swaps a seam rather than four implementations. Loading an item is now a single `open` carrying its start position, and the controller maps the engine's `Phase` back onto `PlayerState` using the queue, so nothing outside changes. `LegacyPlayer` drives the old `PlayerBackend` through the `MediaPlayer` contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purpose | Player | UR-081 | Done |
| DR-246 | The seek strategy turns on an ability the engine declares, not on the container the stream arrives in. `Capabilities::seeks_transcoded_in_place` is stated by each engine — true for hls.js, which seeks within the VOD playlist it was handed; false for mpv, which cannot make the server transcode from a new offset — and the command asks the engine currently rendering instead of inferring from `is_hls` and `use_html5`. The item's transport is no longer read at the seek site at all. Re-negotiating a stream needs the repository, which sits above the engine, so the engine states the capability and the caller acts on it rather than the engine owning the whole decision | Player | UR-040, UR-081 | Done |
| DR-247 | ExoPlayer can be told where to start. `JellyTauPlayer.load(url, mediaId)` had no way to express a start position, so every caller loaded and then seeked; the position is now handed to ExoPlayer with the media item via `setMediaItem(item, startPositionMs)`, and the two-argument form delegates to it. Running the conformance cases on a device also settled which half of DR-241 was engine-specific: ExoPlayer already queues a seek issued before `prepare()` completes, so it never had the lost-seek defect mpv did — only the missing vocabulary for a start position | Player | UR-081, UR-005 | Done |
| DR-250 | Stopping means nothing is playing, from any renderer — not "whatever we believe owns playback has been asked to stop". A background-audio handoff swaps which renderer that is, and the swap is bookkeeping that can be mid-flight: `exit_background_audio` marks the webview element the player again the moment it is called, while the element has not reloaded. The teardown's stop was gated on flags describing what the component started, so after a handoff it described a player that was no longer making sound and the stop was skipped — the audio stream kept running and the mini player adopted it, which is why a movie reappeared as an audio track. The stop is now unconditional (it is idempotent) and clears the handoff base and flag, so a later position read cannot be interpreted against a handoff that no longer exists | Player | UR-040, UR-005 | Done |
| DR-251 | A duration of zero is treated as "the engine does not know yet", and falls back to the runtime the item already carries. ExoPlayer reports `C.TIME_UNSET` until it resolves one and `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answered `Some(0.0)` rather than `None` — which satisfied every "unknown duration" fallback and left the seek bar with no scale. It presented as scrubbing being broken rather than as a duration that never arrived, and the catalog had the runtime the whole time | Player | UR-005, UR-040 | Done |
| DR-252 | Seconds reported by an engine are converted to a `Duration` only when finite and positive. `Duration::from_secs_f64` panics on a negative or non-finite value and no engine promises otherwise: ExoPlayer reports `C.TIME_UNSET` (`Long::MIN_VALUE`, about -9.2e15) for a stream whose length it does not know, which is every background-audio handoff — `/Audio/{id}/universal` is a chunked, length-less transcode. Held as a float that junk was harmless; converted to a `Duration` by the `MediaPlayer` adapter it became a panic that killed the backend mid-handoff and left a black screen with no controls. One guard on the contract, used by every engine crossing into it | Player | UR-005 | Done |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
---
@@ -739,6 +753,13 @@ Internal architecture, components, and application logic.
| UT-213 | The direct-play negotiation, one test per branch, against `PlaybackInfo` fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as *not* transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that default | DR-225, DR-227 | Done |
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Done |
| UT-215 | Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promise | DR-013 | Done |
| UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Done |
| UT-217 | A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first time | DR-238 | Done |
| UT-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done |
| UT-219 | A fullscreen toggle moves the document only when an in-document `<video>` renders, and moves the OS window as well when a native surface does | DR-240 | Done |
| UT-220 | The conformance suite: opening at a position starts there and never at zero, a seek issued while opening is honoured and overrides the start it overtook, pause and play are observable, close is silent and idempotent, and an open cancelled by close never begins playing | DR-242, DR-243 | In Progress |
| UT-221 | An engine that cannot report a duration does not erase the one the item carries: with the queue holding a 1800s item and the engine answering nothing usable, the controller still reports 1800s | DR-251 | Done |
| UT-222 | The values that killed the backend are rejected rather than converted: `C.TIME_UNSET` as seconds, negatives, zero, NaN and both infinities all yield no duration, while a real runtime survives | DR-252 | Done |
### Integration Tests
@@ -759,6 +780,7 @@ Internal architecture, components, and application logic.
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
| IT-018 | The conformance cases run against ExoPlayer on a device: opening from the beginning and at a position, a seek issued while still preparing, a seek after open, pause and play observable, stop silent and idempotent, and a load cancelled by stop never playing. The fixture is a silent WAV synthesised at setup, so the repo carries no media and the duration is exact | DR-247 | Done |
---
+324
View File
@@ -0,0 +1,324 @@
# Spec: MediaPlayer — one controller API, three interchangeable engines
**Status:** **Partially implemented.** DR-242 … DR-247 have shipped: the
contract, `FakePlayer` and the conformance suite, `MpvPlayer`, the standalone
runner, `LegacyPlayer`, the controller port, the capability-driven seek
strategy, and ExoPlayer conformance on a device. What is left is DR-248 (the
webview as an engine) and DR-249 (deleting `PlayerBackend` and the frontend
playback-state flags).
**Requirements:** UR-081 (new) → DR-242 … DR-249 (new); IR-034. Re-check
`requirements.md` before allocating — ids moved several times while this was
written.
**UX spec:** n/a — no user-visible change is intended. That is the point.
**Supersedes / revises:** absorbs `determine_video_seek_strategy`
(`player/seek.rs`, DR-238) into the engines. Revises the backend half of
[playback-backend-unification.md](playback-backend-unification.md).
**Destination on completion:**
[01-rust-backend.md](../architecture/01-rust-backend.md) — replaces the player
state-machine section; and
[05-platform-backends.md](../architecture/05-platform-backends.md) — the engines
become implementations of a stated contract rather than three separate designs.
## Summary
Replace the `PlayerBackend` trait with a `MediaPlayer` contract that expresses
**intent** ("present this item, starting here") rather than **device operations**
("load", then "seek"). MPV, ExoPlayer and the webview element implement it; a
`FakePlayer` implements it for tests; and one conformance suite runs against
every implementation so a backend is either correct or visibly failing.
No user-visible behaviour changes. What changes is that playback logic stops
being written three times in the command layer.
## Motivation
A day of debugging Linux native video produced four defects (DR-238 … DR-241).
Every one of them traces to the same missing seam, not to mpv:
| Defect | What it looked like | What it was |
|---|---|---|
| DR-241 | "Resume is broken", "I cannot skip" | `loadfile` is async, so a seek issued straight after a load fails and was discarded. The trait has no way to say *open at a position*, so every caller does load-then-seek and each races independently. |
| DR-238 | Transcoded seeks silently did nothing | `use_html5` was doing double duty as "who renders" **and** "how do I seek", decided in the command layer by a truth table. |
| DR-239 | Play/pause control never moved | `PropertyChange { name: "pause" }` was handled but never observed. Nothing in the contract required an engine to report its own state. |
| DR-240 | Fullscreen left the picture at window size | `requestFullscreen()` moves the document; whoever owns the pixels has to be told separately. |
The shape is consistent: **the same intent implemented in several places, each
with its own timing and its own idea of the rules.** Resume worked through the
adapter (which seeks after `File loaded`) and failed through the command (which
seeks immediately). Two callers, one intent, two behaviours.
Supporting evidence for the diagnosis:
- `commands/player/mod.rs` is **3,561 lines** and is where "stop → rebuild URL →
update queue → load → seek" lives. That is playback orchestration in the IPC
layer.
- `player_play_item` needed a `#[cfg(not(target_os = "linux"))]` guard, i.e. a
platform decision in a command handler.
- The frontend carries `didStartNativePlayback`, `didStopBackendEarly`,
`hasPerformedInitialSeek`, `lastAppliedInitialPosition` — playback state in the
UI, which contradicts the one-directional rule in CLAUDE.md.
### Why an abstraction, and not more fixes
Each defect above was individually cheap to patch, and patching them is what
produced a regression: routing transcoded seeks to a reload path turned "seek
does nothing" into "seek jumps to zero", because the reload path's own seek was
broken in the same way. **Symptom fixes in this area compound.**
## The background-audio handoff is an unconfirmed state swap
Diagnosed on a device, 2026-08-23, and the likeliest explanation for "audio
keeps playing after I leave the player" — the report this whole line of work
started from.
`enter_background_audio` and `exit_background_audio` in `PlayerController` are
pure bookkeeping: they flip a boolean and set or clear a base offset. Neither
confirms that the audio stream actually opened, nor that the webview `<video>`
actually came back. `exit_background_audio`'s own doc comment says the element
"becomes the player again once it reloads" — a future event nothing waits for,
while the flag declares the swap complete the moment it is called.
The sequence that exposes it:
1. Background audio is enabled.
2. The app is backgrounded — `enter_background_audio(pos)`, audio stream opens.
3. The app is foregrounded — `exit_background_audio()` sets the flag back, so
the controller believes the video element owns playback again.
4. The player is exited *before the element has reloaded*. The stop is aimed at
an element that does not exist yet; the audio stream is still running.
5. The mini player sees a live audio session and adopts it — which is why the
symptom is a **movie appearing as an audio track**, and why it is
intermittent rather than reliable.
Duration reporting `0.0` on Android widens the window: the reload is slower and
less certain to land at the right position.
**This is the same defect class as DR-238 … DR-241: state asserted rather than
confirmed.** It is what `Phase::Opening` and `MpvPlayer`'s open generation
exist for — a handoff *is* an open in flight, and a `close` during one has to
cancel it rather than race it. The handoff is not modelled as an open at all
today; it is two booleans and an offset.
The fix therefore belongs with this contract rather than beside it: route the
handoff through `open`/`close` so the swap has a phase, and so leaving the
player during one cancels the thing that is actually playing instead of the
thing the controller believes is playing. `close_during_open_never_plays`
already states the required behaviour and passes on all four engines — the gap
is that the handoff never reaches an engine as an open.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| Presenting an item at a position, in one operation | **Engine** (`MediaPlayer`) | Only the engine knows when its pipeline can accept a position. Expressing it as caller-sequenced load-then-seek exports a race the engine is the only one able to close. |
| Whether *this* stream can be seeked in place, or must be re-opened | **Engine** | A property of the engine × transport pair: hls.js seeks a VOD playlist, mpv's HLS demuxer cannot make Jellyfin transcode from a new offset. Today this is a truth table in a command handler that has to guess for engines it does not own. |
| Reporting position, phase, duration, active tracks | **Engine** | The player is the authoritative source of playback state (CLAUDE.md). An engine that does not report is not implementing the contract — DR-239 was exactly this. |
| Choosing *which* stream to open (direct play vs transcode, ceiling, transport) | **Rust, above the engine** | Domain: depends on Jellyfin's `PlaybackInfo`, codec support, quality ceiling. See [backend-owned-stream-selection.md](backend-owned-stream-selection.md). The engine is handed a `StreamSelection`; it never negotiates one. |
| Queue, autoplay, session, playback reporting | **`PlayerController`** | Policy across items. Unchanged — but it talks to one contract instead of branching per platform. |
| Which engine this platform uses | **Rust, at construction** | Already correct today; stays a single `cfg` at the composition root rather than `cfg`s scattered through command handlers. |
| Rendering surfaces, controls, fullscreen chrome | **Frontend / platform** | Presentation. The engine reports *what* is playing; it does not own the window. |
Borderline row and its tie-breaker: "should a transcoded seek re-open the
stream?" reads like domain policy. It is **engine** capability — the *decision*
is "seek to T", and how to achieve it is the engine's business. If it were
policy, every new engine would require editing a shared truth table, which is
precisely the coupling DR-238 came from.
## Design
### The contract
```rust
/// Anything that can present media: MpvPlayer, ExoPlayer, WebviewPlayer, FakePlayer.
pub trait MediaPlayer: Send {
/// Present `req.selection`, beginning at `req.start`.
///
/// One operation, deliberately. `open` is where a start position is
/// *expressible*, so no caller has to sequence load-then-seek and no caller
/// can race the engine's own load. An engine that cannot start at an offset
/// natively must absorb that internally (defer until loaded, or re-open) —
/// it is the only layer that knows when it is able to.
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>;
fn play(&mut self) -> Result<(), PlayerError>;
fn pause(&mut self) -> Result<(), PlayerError>;
/// Stop and release the current item. Must be idempotent, and must leave the
/// engine producing no audio — DR-2xx exists because "stopped" and "silent"
/// were not the same thing.
fn close(&mut self) -> Result<(), PlayerError>;
/// Seek to an absolute position on the item's timeline.
///
/// The engine decides in-place vs re-open. Callers never choose.
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
fn set_volume(&mut self, volume: Volume) -> Result<(), PlayerError>;
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>;
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
/// One coherent read of everything the UI consumes.
fn snapshot(&self) -> PlaybackSnapshot;
/// Engine capabilities, so callers can adapt without naming engines.
fn capabilities(&self) -> Capabilities;
}
```
```rust
pub struct OpenRequest {
pub media: MediaItem,
pub selection: StreamSelection, // url + transport + playback kind
pub start: Duration, // Duration::ZERO for "from the beginning"
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
pub autoplay: bool,
}
pub struct PlaybackSnapshot {
pub phase: Phase,
pub position: Duration,
pub duration: Option<Duration>,
pub seekable: bool,
pub volume: Volume,
pub rate: f64,
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
}
/// `Opening` is the state today's code cannot express, and the direct cause of
/// DR-241: a seek arriving with nothing loaded had no phase to be rejected or
/// queued against, so it was simply lost.
pub enum Phase { Idle, Opening, Ready, Playing, Paused, Ended, Failed(String) }
```
Engines emit `PlayerEvent` for phase, position, track and error changes. Emitting
is part of the contract, and the conformance suite asserts it — an engine that
stays silent fails, which is what would have caught DR-239 the day it landed.
### What this deletes
- `determine_video_seek_strategy` and `VideoSeekStrategy` — replaced by
`seek()` + `capabilities()`. The command layer stops deciding how engines seek.
- The reload orchestration in `player_seek_video` — moves inside the engines that
need it.
- `#[cfg(target_os = "linux")]` branches in command handlers.
- Frontend playback-state flags, which become reads of `snapshot()`.
### IPC
No new commands. Existing ones keep their names and shapes; they become thin
delegations. `PlayerStatus` gains nothing the frontend does not already receive.
Regenerate `bindings.ts` only if `PlaybackSnapshot` is exposed directly — prefer
mapping it onto the existing `PlayerStatus` so this stays invisible at the wire.
## Testing
This is the half that makes the abstraction worth having, and it is the reason to
do it rather than keep patching.
### 1. A conformance suite, run against every engine
One set of tests, parameterised over implementations. Any `MediaPlayer` must pass
it; a new engine is "done" when it does.
```
conformance::run(&mut engine, fixture) covering:
open(start = ZERO) -> phase Ready|Playing, position ~0
open(start = 10min) -> position within tolerance of 10min, NEVER 0 [DR-241]
seek while Opening -> honoured once Ready, not discarded [DR-241]
seek on a transcoded stream -> position lands, by whatever means [DR-238]
pause / play -> phase changes AND an event is emitted [DR-239]
close -> phase Idle, silent, idempotent
close during Opening -> no playback ever starts [audio-on-exit]
volume / rate / track select -> reflected in snapshot()
```
The `open(start = 10min)` and `seek while Opening` cases are the ones that fail
on today's code. They are written first, and they are the acceptance criterion.
### 2. `FakePlayer`
A deterministic in-memory implementation with a controllable clock. Lets
`PlayerController`, autoplay, queue, sleep-timer and session logic be tested with
no mpv, no device, no network — most of which is currently only reachable through
a real engine.
### 3. Per-engine runs
| Engine | Where | Note |
|---|---|---|
| `FakePlayer` | `cargo test` | Always. |
| `MpvPlayer` | `cargo test`, Linux | libmpv is already in the builder image (the Linux build links it), so **no CI toolchain install** — see CLAUDE.md. Needs a tiny local fixture file; generate it in-test rather than committing media. |
| `ExoPlayer` | instrumented, on device | Not in the standard CI job. Run via `scripts/` on a connected device; record results in the PR. |
| `WebviewPlayer` | vitest | Against a stubbed element, as `html5Adapter` is tested today. |
An engine that cannot run in CI still has the same suite; it is just run by hand.
That is the point of writing it once.
## Migration
Strangler, not a rewrite. Each step ships independently and leaves the app working.
1. **DR-242** Define `MediaPlayer`, `OpenRequest`, `PlaybackSnapshot`, `Phase`,
`Capabilities`. No implementations. Compiles alongside `PlayerBackend`.
2. **DR-243** `FakePlayer` + the conformance suite. The suite fails against
nothing yet — it is the specification.
3. **DR-244** `MpvPlayer` implementing `MediaPlayer`, wrapping today's
`MpvBackend` internals. Make conformance pass, including `open(start)`.
4. **DR-245** `PlayerController` talks to `MediaPlayer`. `PlayerBackend` retained
behind an adapter so the other engines keep working.
5. **DR-246** Move seek strategy and reload orchestration out of
`commands/player/mod.rs` into the engines; delete `seek.rs`'s truth table.
**Shipped with a deviation.** The engine cannot own this outright:
re-negotiating a stream needs the repository, which sits *above* the engine.
So the engine *declares* `seeks_transcoded_in_place` and the caller acts on
it. That removes the defect — nobody guesses on another component's behalf,
and adding an engine no longer means editing a shared table — without
pretending an engine can reach upward. `determine_video_seek_strategy`
survives as a correctly-typed decision over declared abilities rather than
being deleted; the defect was its *input*, not its existence.
6. **DR-247** `ExoPlayerPlayer`; conformance on device.
7. **DR-248** `WebviewPlayer`; retire the adapter shim.
8. **DR-249** Delete `PlayerBackend` and the frontend playback-state flags.
Steps 13 are pure addition and risk nothing. Step 5 is where today's defect
classes actually die.
## Out of scope
- Stream selection (which URL, which quality) — that is
[backend-owned-stream-selection.md](backend-owned-stream-selection.md), and
this spec consumes its `StreamSelection` rather than duplicating it.
- Rendering surfaces and compositing.
- Any user-visible behaviour change. If one appears, it is a bug in the migration.
- Replacing hls.js or changing the transcode path.
## Acceptance criteria
- [ ] The conformance suite exists and `open(start = 10min)` fails against the
pre-migration mpv path — proving it reproduces DR-241 — then passes.
- [ ] `FakePlayer` lets at least one controller-level test run with no engine.
- [ ] `determine_video_seek_strategy` is deleted, not merely bypassed.
- [ ] No `cfg(target_os = ...)` remains in `commands/player/`.
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
- [ ] `cargo fmt`, `cargo clippy -D warnings`, `bun run test:rust` pass.
- [ ] `bun run check:boundary` passes.
- [ ] `// TRACES:` on new code; `bun run traces:validate` passes; coverage stays
at or above the CI ratchet.
- [ ] Manual: resume, skip on a transcoded item, pause/play, and exit-while-playing
verified on Linux **and** Android before `PlayerBackend` is deleted.
## Notes for the implementer
- **Write the conformance suite before the second engine**, or it will encode
whatever the first engine happens to do.
- `close()` must mean *silent*. The bug that motivated this spec had `stop` being
called, reported, and audible afterwards.
- Do not let `Capabilities` grow into engine sniffing. If a caller branches on
the engine's identity, the contract is missing something — add it there.
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes.
+3874 -3739
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -53,7 +53,9 @@
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
"traces:validate": "bun run scripts/extract-traces.ts --format validate",
"release:notes": "bun run scripts/release-notes.ts"
"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": {
"@tauri-apps/api": "^2.11.1",
+13
View File
@@ -36,6 +36,19 @@ if [ -d "$TEST_SOURCE_DIR" ]; then
echo " Copied unit tests: src/test"
fi
# Instrumented tests (src/androidTest). These need a device: they drive
# ExoPlayer, which requires an Android Context and a Looper and therefore
# cannot run from the desktop conformance suite. Run with
# `./gradlew :app:connectedDebugAndroidTest` from gen/android.
ANDROID_TEST_SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/androidTest/java/com/dtourolle/jellytau"
ANDROID_TEST_TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/androidTest/java/com/dtourolle/jellytau"
if [ -d "$ANDROID_TEST_SOURCE_DIR" ]; then
rm -rf "$ANDROID_TEST_TARGET_DIR"
mkdir -p "$ANDROID_TEST_TARGET_DIR"
cp -r "$ANDROID_TEST_SOURCE_DIR"/. "$ANDROID_TEST_TARGET_DIR/"
echo " Copied instrumented tests: src/androidTest"
fi
# Copy individual Kotlin files (like VideoOverlayManager.kt)
for kt_file in "$SOURCE_DIR"/*.kt; do
if [ -f "$kt_file" ]; then
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Run the MediaPlayer conformance suite.
#
# See docs/specs/media-player-controller.md. One set of behaviours, run against
# every engine — so a wrapper is verified without building or launching the app.
#
# ./scripts/test-player-conformance.sh desktop engines (mpv, legacy)
# ./scripts/test-player-conformance.sh android ExoPlayer, on a connected device
#
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET="${1:-desktop}"
run_desktop() {
local fixture="${TMPDIR:-/tmp}/jellytau-conformance-1200s.mp4"
if [ ! -f "$fixture" ]; then
# Generated, not committed: the repo carries no media, and the duration
# is exact — the seek assertions depend on it.
echo "Generating a 20-minute fixture at $fixture"
ffmpeg -y -loglevel error \
-f lavfi -i "testsrc2=size=640x360:rate=25" \
-f lavfi -i "sine=frequency=440" \
-t 1200 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -g 50 \
-c:a aac -shortest "$fixture"
fi
cd "$PROJECT_ROOT/src-tauri"
local status=0
for engine in mpv legacy; do
echo
cargo run --quiet --features conformance --bin player-conformance -- \
"$fixture" "$engine" || status=1
done
return $status
}
run_android() {
if ! adb get-state >/dev/null 2>&1; then
echo "No device. Connect one and enable USB debugging." >&2
exit 1
fi
"$PROJECT_ROOT/scripts/sync-android-sources.sh" >/dev/null
cd "$PROJECT_ROOT/src-tauri/gen/android"
# `-x rustBuild...` because raw gradle drives the Rust build through Tauri's
# android-studio-script, which expects a dev-server address file that only
# exists under `tauri android dev`. The native library already in
# app/src/main/jniLibs is what the test process loads.
ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" \
./gradlew :app:connectedUniversalDebugAndroidTest \
-x :app:rustBuildUniversalDebug --console=plain
}
case "$TARGET" in
desktop) run_desktop ;;
android) run_android ;;
*) echo "usage: $0 [desktop|android]" >&2; exit 2 ;;
esac
+19
View File
@@ -1,5 +1,9 @@
[package]
name = "jellytau"
# The app. Named explicitly because the crate also builds
# `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous.
default-run = "jellytau"
version = "0.10.1"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
@@ -141,3 +145,18 @@ ndk-context = "0.1"
[dev-dependencies]
tempfile = "3.24.0"
[features]
# Exposes the MediaPlayer conformance suite and the `player-conformance` binary
# to non-test builds, so an engine that cannot run in-process — ExoPlayer on a
# device — is driven by the same cases as the ones that can, rather than by a
# second checklist that drifts.
conformance = []
# A standalone runner for the conformance suite. Deliberately a separate binary:
# it links libmpv and nothing else, so a wrapper can be verified without building
# or launching the app.
[[bin]]
name = "player-conformance"
path = "src/bin/player_conformance.rs"
required-features = ["conformance"]
+4
View File
@@ -46,6 +46,9 @@ android {
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
// Required to run the on-device conformance suite
// (src/androidTest). See docs/specs/media-player-controller.md.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
@@ -147,6 +150,7 @@ dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
androidTestImplementation("androidx.test:runner:1.5.2")
}
apply(from = "tauri.build.gradle.kts")
@@ -0,0 +1,252 @@
package com.dtourolle.jellytau.player
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.math.abs
/**
* The MediaPlayer conformance cases, run against ExoPlayer on a real device.
*
* The desktop suite (src-tauri/src/player/conformance.rs) cannot reach here:
* ExoPlayer needs an Android Context and a Looper, so it only exists inside an
* app process. These are the same behaviours, asserted against the engine
* itself rather than the Rust wrapper the layer below the contract.
*
* The fixture is generated rather than committed: a long silent WAV written to
* the cache directory at setup. No binary in the repo, no `adb push` step, and
* the duration is exact, which matters for the seek assertions.
*
* Run: ./gradlew :app:connectedDebugAndroidTest (from src-tauri/gen/android)
*
* TRACES: UR-081 | DR-247
*/
@RunWith(AndroidJUnit4::class)
class PlayerConformanceTest {
private lateinit var player: JellyTauPlayer
private lateinit var mediaUrl: String
/** Long enough to seek well past any buffer. */
private val fixtureSeconds = 1200
/**
* ExoPlayer lands on the nearest sync sample, and a `prepare` is not
* instantaneous. Generous on purpose: a tight bound here produces a test
* that fails on a slow device and teaches people to re-run until green.
*/
private val toleranceSeconds = 10.0
@Before
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
JellyTauPlayer.initialize(context)
player = JellyTauPlayer.getInstance()
val fixture = File(context.cacheDir, "conformance-$fixtureSeconds.wav")
if (!fixture.exists() || fixture.length() < 1024) {
writeSilentWav(fixture, fixtureSeconds)
}
mediaUrl = fixture.toURI().toString()
}
@After
fun tearDown() {
onMain { player.stop() }
// Leave nothing playing for the next case.
Thread.sleep(200)
}
// ---------------------------------------------------------------- cases
@Test
fun opensFromTheBeginning() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
assertNear(0.0, position(), "playback should start at the beginning")
assertTrue("duration should be known once loaded", duration() > 0)
}
/**
* DR-241. Opening at a position starts *there*, not at zero.
*
* `load(url, mediaId)` has no way to express a start position, so every
* caller loads and then seeks and a seek issued against a player that is
* still preparing is the window resume was lost in on the desktop side.
* This is the same defect on ExoPlayer.
*/
@Test
fun opensAtAStartPosition() {
val start = 600.0
onMain { player.load(mediaUrl, "conformance", start) }
awaitLoaded()
assertTrue(
"opened at ${start}s but playback began at ${position()}s - " +
"the start position was dropped",
position() > 1.0
)
assertNear(start, position(), "start position")
}
/** DR-241. A seek issued while still preparing is honoured, not lost. */
@Test
fun seekWhileOpeningIsHonoured() {
val target = 300.0
onMain {
player.load(mediaUrl, "conformance")
// Deliberately before the player is ready: this is the race,
// expressed on purpose rather than stumbled into.
player.seek(target)
}
awaitLoaded()
assertNear(target, position(), "seek issued while opening")
}
@Test
fun seeksAfterOpen() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
val target = 420.0
onMain { player.seek(target) }
awaitPosition(target)
assertNear(target, position(), "seek after open")
}
/** DR-239. Pause and play are observable, not merely accepted. */
@Test
fun pauseAndPlayAreObservable() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
onMain { player.pause() }
awaitPlaying(false)
assertFalse("a paused player must not report playing", isPlaying())
onMain { player.play() }
awaitPlaying(true)
assertTrue("a resumed player must report playing", isPlaying())
}
/** `stop()` releases the item, is silent, and can be called twice. */
@Test
fun closeIsSilentAndIdempotent() {
onMain { player.load(mediaUrl, "conformance") }
awaitLoaded()
onMain { player.stop() }
awaitPlaying(false)
assertFalse("a stopped player must not report playing", isPlaying())
onMain { player.stop() }
assertFalse("stop must be idempotent", isPlaying())
}
/**
* An open cancelled by stop must not come back to life.
*
* The shape of "audio kept playing after leaving the player": a prepare
* still in flight completed after the stop, with nothing left to tell it
* not to.
*/
@Test
fun closeDuringOpenNeverPlays() {
onMain {
player.load(mediaUrl, "conformance")
player.stop()
}
Thread.sleep(2000)
assertFalse(
"a load cancelled by stop must not start playing",
isPlaying()
)
}
// -------------------------------------------------------------- helpers
private fun onMain(block: () -> Unit) {
InstrumentationRegistry.getInstrumentation().runOnMainSync(block)
}
private fun position(): Double = readOnMain { player.getPosition() }
private fun duration(): Double = readOnMain { player.getDuration() }
private fun isPlaying(): Boolean = readOnMain { player.getExoPlayer().isPlaying }
private fun <T> readOnMain(block: () -> T): T {
var out: T? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync { out = block() }
@Suppress("UNCHECKED_CAST")
return out as T
}
/** Poll a state the player publishes rather than sleeping a fixed time. */
private fun await(what: String, timeoutMs: Long = 15_000, predicate: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (predicate()) return
Thread.sleep(50)
}
throw AssertionError("timed out waiting for $what")
}
private fun awaitLoaded() {
await("the player to report a duration") { duration() > 0 }
// One more beat so a start position or a deferred seek has landed.
Thread.sleep(500)
}
private fun awaitPosition(target: Double) =
await("position to reach ${target}s") { abs(position() - target) <= toleranceSeconds }
private fun awaitPlaying(expected: Boolean) =
await("isPlaying == $expected", 5_000) { isPlaying() == expected }
private fun assertNear(expected: Double, actual: Double, what: String) {
assertTrue(
"$what: expected ~${expected}s, got ${actual}s (tolerance ${toleranceSeconds}s)",
abs(actual - expected) <= toleranceSeconds
)
}
/**
* Write a silent 8 kHz mono 16-bit WAV of `seconds` length.
*
* Synthesised rather than committed so the repo carries no media, and so
* the duration is exact the seek assertions depend on it.
*/
private fun writeSilentWav(file: File, seconds: Int) {
val sampleRate = 8000
val dataBytes = sampleRate * 2 * seconds
file.outputStream().buffered().use { out ->
fun le32(v: Int) = out.write(
byteArrayOf(
(v and 0xff).toByte(),
((v shr 8) and 0xff).toByte(),
((v shr 16) and 0xff).toByte(),
((v shr 24) and 0xff).toByte()
)
)
fun le16(v: Int) =
out.write(byteArrayOf((v and 0xff).toByte(), ((v shr 8) and 0xff).toByte()))
out.write("RIFF".toByteArray()); le32(36 + dataBytes); out.write("WAVE".toByteArray())
out.write("fmt ".toByteArray()); le32(16); le16(1); le16(1)
le32(sampleRate); le32(sampleRate * 2); le16(2); le16(16)
out.write("data".toByteArray()); le32(dataBytes)
val chunk = ByteArray(sampleRate * 2) // one second of silence
repeat(seconds) { out.write(chunk) }
}
}
}
@@ -556,11 +556,31 @@ class JellyTauPlayer(private val appContext: Context) {
* @param mediaId The unique ID for this media item
*/
fun load(url: String, mediaId: String) {
load(url, mediaId, 0.0)
}
/**
* Load [url] and begin at [startPositionSeconds].
*
* The start position is handed to ExoPlayer with the media item, not seeked
* to afterwards. `prepare()` is asynchronous, so a seek issued straight
* after a load targets a player that is still preparing: ExoPlayer clamps it
* back to zero and the item plays from the beginning. That is what made
* resume and transcoded skip start over, and it is why callers must never
* express a start position as load-then-seek.
*
* TRACES: UR-081, UR-005 | DR-241, DR-247
*/
fun load(url: String, mediaId: String, startPositionSeconds: Double) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
val mediaItem = MediaItem.fromUri(url)
if (startPositionSeconds > 0.0) {
exoPlayer.setMediaItem(mediaItem, (startPositionSeconds * 1000).toLong())
} else {
exoPlayer.setMediaItem(mediaItem)
}
exoPlayer.prepare()
exoPlayer.playWhenReady = true
}
+37
View File
@@ -0,0 +1,37 @@
//! Thin entry point. The suite lives in the library so the binary needs no
//! access to the player internals — one exported function rather than a public
//! module tree.
//!
//! player-conformance <media-file> [mpv|legacy]
//!
//! `legacy` drives the old `PlayerBackend` through the same cases, so the
//! difference between the two designs is demonstrated on one engine and one
//! file rather than argued.
//!
//! TRACES: UR-081 | DR-244, DR-245
use std::process::ExitCode;
use jellytau_lib::conformance_runner::{run_engine, Engine};
fn main() -> ExitCode {
let mut args = std::env::args().skip(1);
let Some(url) = args.next() else {
eprintln!("usage: player-conformance <media-file-or-url> [mpv|legacy]");
return ExitCode::from(2);
};
let engine = match args.next().as_deref() {
None | Some("mpv") => Engine::Mpv,
Some("legacy") => Engine::Legacy,
Some(other) => {
eprintln!("unknown engine {other:?} - expected mpv or legacy");
return ExitCode::from(2);
}
};
if run_engine(&url, engine) == 0 {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
+59 -30
View File
@@ -725,18 +725,30 @@ pub async fn player_play_item(
}
let controller = player.0.lock().await;
// On Linux, video plays in the WebKitGTK HTML5 <video> element (see
// get_player_status -> use_html5_element). The MPV backend has no embedded
// window, so loading the stream into it would only start a redundant decode
// (and the frontend would immediately stop it). Only load into the native
// backend on platforms that actually render video through it (e.g. Android).
#[cfg(not(target_os = "linux"))]
// Who gets the stream depends on who is going to *render* it, which is a
// runtime question, not a platform constant.
//
// Historically Linux video was always the webview's (`use_html5_element`),
// so handing the file to MPV as well would only have started a redundant
// decode with no window to show it in — hence a `#[cfg(not(linux))]` guard
// and a queue-only path here. With mpv drawing the picture that inverts:
// the webview is no longer loading anything, so if this does not load the
// file, *nothing does*. The symptom is total silence — no picture and no
// audio — which reads like a broken stream rather than a stream nobody was
// given.
//
// This is the fifth place in this cycle where a renderer's capability was
// written as a compile-time platform fact. Same fix as the others: ask.
//
// TRACES: UR-080 | DR-231, DR-235
let renders_natively = cfg!(not(target_os = "linux")) || crate::player::native_video::enabled();
if renders_natively {
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
{
// Keep the queue in sync for UI/remote-transfer without starting MPV.
} else {
// The webview will play it; keep the queue in sync for the UI and for a
// remote transfer without starting a second decode.
controller
.set_current_item(media_item)
.map_err(|e| e.to_string())?;
@@ -1169,6 +1181,13 @@ pub async fn player_stop(
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
// Stopping is a state transition worth seeing in a log. Native video is
// what made its absence matter: the webview <video> stopped implicitly when
// the component unmounted, so nothing ever had to call this — and "never
// called" and "called but the backend kept playing" look identical from
// outside without it.
info!("[player_stop] called (mode: {:?})", mode);
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send stop command to remote session - clone client before await
let client = {
@@ -1431,7 +1450,7 @@ pub async fn player_seek_video(
// Get current playing item to analyze stream characteristics
// Clone what we need to avoid holding locks across await points
let (needs_transcoding, jellyfin_item_id, is_local, transport) = {
let (needs_transcoding, jellyfin_item_id, is_local) = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
@@ -1447,31 +1466,34 @@ pub async fn player_seek_video(
.ok_or("Current video has no Jellyfin ID")?
.to_string();
// The URL itself is no longer read here: the seek strategy now comes
// from the item's own `transport`, not from inspecting the string.
// Neither the URL nor the item's transport is read here any more. The
// strategy turns on whether the *engine* can seek a transcode in place,
// which it declares for itself — so the container the stream happens to
// arrive in stopped being a proxy for anything (DR-246).
let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
let needs_trans = current_item.needs_transcoding;
let transport = current_item.transport;
(needs_trans, jellyfin_id, is_local_file, transport)
(current_item.needs_transcoding, jellyfin_id, is_local_file)
}; // Locks are dropped here
// The transport comes from the backend's own decision, not from searching
// the URL for `.m3u8` — Rust built that URL and knows what it is. Items
// queued without one fall back to `needs_transcoding`, which is exact:
// every transcode this app requests is HLS (DR-140).
//
// TRACES: UR-004, UR-079 | DR-225, DR-230
let is_hls = match transport {
Some(crate::repository::Transport::Hls) => true,
Some(crate::repository::Transport::Progressive)
| Some(crate::repository::Transport::LocalFile) => false,
None => needs_transcoding,
// Whether a transcode can be seeked in place is asked of the engine that is
// rendering, not guessed from the URL's shape or from who is rendering.
// TRACES: UR-040, UR-079 | DR-238, DR-246
let seeks_transcoded_in_place = {
let controller = player.0.lock().await;
controller.capabilities().seeks_transcoded_in_place
};
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={:?}",
is_local, is_hls, needs_transcoding, use_html5, strategy);
info!(
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
);
match strategy {
VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
@@ -2086,7 +2108,9 @@ pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio,
supports_native_video: cfg!(target_os = "android"),
// TRACES: UR-080 | DR-235
supports_native_video: cfg!(target_os = "android")
|| crate::player::native_video::enabled(),
})
}
@@ -2095,6 +2119,11 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
let (backend, use_html5_element) = if cfg!(target_os = "android") {
// Android uses ExoPlayer native backend
(VideoBackend::Native, false)
} else if crate::player::native_video::enabled() {
// mpv draws the picture on this desktop; the frontend must not also
// load it into a <video> element or the stream decodes twice and the
// two fight over the audio. TRACES: UR-080 | DR-235
(VideoBackend::Native, false)
} else {
// Linux and other platforms use HTML5 video element in frontend
(VideoBackend::Html5, true)
+171
View File
@@ -0,0 +1,171 @@
//! Runs the `MediaPlayer` conformance suite against a real engine.
//!
//! A separate binary on purpose: it links libmpv and nothing else, so a wrapper
//! can be verified without building or launching the app — which is what made
//! the previous round of playback debugging so slow. Every failure here is a
//! wrapper bug, with no UI, no webview and no server in the way.
//!
//! cargo run --features conformance --bin player-conformance -- <media-file>
//!
//! Audio and video are routed to null, so it is safe on a headless runner and
//! does not claim the speakers.
//!
//! TRACES: UR-081 | DR-244
use std::time::{Duration, Instant};
use crate::player::conformance::Harness;
use crate::player::legacy_player::LegacyPlayer;
use crate::player::media::MediaItem;
use crate::player::media_player::{MediaPlayer, OpenRequest, Phase};
use crate::player::mpv_backend::MpvBackend;
use crate::player::mpv_player::{MpvPlayer, Output};
use crate::repository::stream_selection::StreamSelection;
struct EngineHarness<P: MediaPlayer> {
player: P,
url: String,
}
impl<P: MediaPlayer> Harness for EngineHarness<P> {
type Player = P;
fn player(&mut self) -> &mut P {
&mut self.player
}
fn request(&self, start: Duration) -> OpenRequest {
let selection = StreamSelection::local_file(self.url.clone());
let media = MediaItem::sample("conformance", &self.url);
OpenRequest::new(media, selection).starting_at(start)
}
/// Wait for mpv to leave `Opening`.
///
/// Polling a phase the engine publishes, not a fixed sleep: a suite whose
/// result depends on how fast the machine is will eventually be ignored.
fn settle(&mut self) {
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
if self.player.snapshot().phase != Phase::Opening {
// Let the deferred seek land and one position tick arrive.
std::thread::sleep(Duration::from_millis(300));
return;
}
std::thread::sleep(Duration::from_millis(25));
}
eprintln!(" ! settle timed out - engine stayed in Opening");
}
/// mpv is on a null audio device here, so silence cannot be observed.
/// Reporting `None` skips those assertions rather than passing them
/// vacuously — an assertion that cannot fail is worse than an absent one.
fn audible(&mut self) -> Option<bool> {
None
}
/// Keyframe granularity: mpv lands on the nearest one, not on the request.
fn seek_tolerance(&self) -> Duration {
Duration::from_secs(10)
}
/// Poll until the decoder reports the new position, rather than assuming a
/// seek is visible the instant it is accepted.
fn await_seek(&mut self, target: Duration) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
let pos = self.player.snapshot().position;
if pos.abs_diff(target) <= self.seek_tolerance() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
macro_rules! run {
($failed:ident, $url:expr, $make:expr, $case:path) => {{
let name = stringify!($case).rsplit("::").next().unwrap();
print!(" {name:.<52}");
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut h = EngineHarness {
player: $make,
url: $url.to_string(),
};
$case(&mut h);
// Leave nothing playing behind for the next case.
let _ = h.player.close();
}));
match result {
Ok(()) => println!(" ok"),
Err(_) => {
println!(" FAILED");
$failed += 1;
}
}
}};
}
/// Which engine to interrogate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Engine {
/// The `MediaPlayer` implementation.
Mpv,
/// The old `PlayerBackend`, driven through `LegacyPlayer`.
///
/// Present so the difference between the two designs can be *demonstrated*
/// on the same engine and the same media, rather than argued.
Legacy,
}
/// Run every conformance case against `engine`. Returns the failure count.
pub fn run_engine(url: &str, engine: Engine) -> u32 {
println!("MediaPlayer conformance - {engine:?}");
println!("media: {url}\n");
let mut failed = 0u32;
use crate::player::conformance as c;
macro_rules! all_cases {
($make:expr) => {
run!(failed, url, $make, c::opens_from_the_beginning);
run!(failed, url, $make, c::opens_at_a_start_position);
run!(failed, url, $make, c::seek_while_opening_is_honoured);
run!(failed, url, $make, c::seek_while_opening_overrides_start);
run!(failed, url, $make, c::seeks_after_open);
run!(failed, url, $make, c::pause_and_play_are_observable);
run!(failed, url, $make, c::close_is_silent_and_idempotent);
run!(failed, url, $make, c::close_during_open_never_plays);
run!(failed, url, $make, c::transport_settings_round_trip);
};
}
match engine {
Engine::Mpv => {
all_cases!(MpvPlayer::new(Output::Null).expect("could not create mpv"));
}
Engine::Legacy => {
all_cases!(LegacyPlayer::new(
MpvBackend::new(
None,
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()),
)
.expect("could not create the legacy backend"),
crate::player::media_player::Capabilities::mpv(),
));
}
}
if failed == 0 {
println!("\nall cases passed");
} else {
println!("\n{failed} case(s) failed");
}
failed
}
/// Default entry point: the new engine.
pub fn run(url: &str) -> u32 {
run_engine(url, Engine::Mpv)
}
+87 -49
View File
@@ -2,6 +2,10 @@
mod android_context;
mod auth;
mod commands;
/// The MediaPlayer conformance suite, exposed for the `player-conformance`
/// binary. One entry point rather than a public player module tree.
#[cfg(feature = "conformance")]
pub mod conformance_runner;
mod connectivity;
mod credentials;
mod domain;
@@ -734,6 +738,27 @@ fn create_player_backend(
/// Construct the tauri-specta command builder. Shared by `run()` and the
/// bindings-export test so the TypeScript bindings always match the handler.
/// What the engine built for this platform can do.
///
/// Declared per engine, not per category. ExoPlayer speaks HLS and can seek a
/// server-side transcode in place; mpv cannot, because its HLS demuxer will not
/// make the server produce segments from a new offset. Grouping them as "native
/// engines" gets that backwards — being native is not the property that
/// matters, speaking HLS is — and treating a category as a proxy for an ability
/// is exactly the inference DR-246 removed.
///
/// TRACES: UR-081 | DR-246
fn engine_capabilities() -> crate::player::media_player::Capabilities {
#[cfg(target_os = "android")]
{
crate::player::media_player::Capabilities::exoplayer()
}
#[cfg(not(target_os = "android"))]
{
crate::player::media_player::Capabilities::mpv()
}
}
fn specta_builder() -> Builder<tauri::Wry> {
Builder::<tauri::Wry>::new()
// Throw on error so generated `commands.*` return Promise<T> and throw,
@@ -1210,55 +1235,6 @@ pub fn run() {
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// Native video surface: put a GL area under Tauri's webview so mpv
// can draw beneath the controls (UR-080 / DR-231).
//
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
// walks a hard-coded two-hop path on every button press in the
// webview:
//
// webview.parent() // "This one should be GtkBox"
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-231
#[cfg(target_os = "linux")]
if std::env::var("JELLYTAU_NATIVE_VIDEO").as_deref() == Ok("1") {
use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface; the app will abort on the first click until \
the widget-tree shape is solved (DR-231)"
);
if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() {
Ok(vbox) => match crate::player::video_surface::attach(&vbox) {
Ok(_surface) => {
info!("[INIT] Native video surface attached");
}
Err(e) => log::warn!("[INIT] Native video surface unavailable: {e}"),
},
Err(e) => {
log::warn!("[INIT] No GTK vbox for the main window: {e}")
}
}
}
}
// In-app update, desktop only.
//
@@ -1399,8 +1375,70 @@ pub fn run() {
playback_reporter.clone(),
position_throttler.clone(),
);
// Attached *after* the backend exists: the mpv handle is registered
// during its construction, and doing this in the order the code
// used to read produced "no mpv handle" every time — the surface was
// built before there was anything to draw from.
// Native video surface: put a GL area under Tauri's webview so mpv
// can draw beneath the controls (UR-080 / DR-231).
//
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
// walks a hard-coded two-hop path on every button press in the
// webview:
//
// webview.parent() // "This one should be GtkBox"
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-231
#[cfg(target_os = "linux")]
if crate::player::native_video::enabled() {
use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface (mpv drawn behind the webview, no reparenting)"
);
if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() {
Ok(vbox) => {
let handle = crate::player::mpv_backend::registered_handle();
if crate::player::video_surface::attach(&vbox, handle) {
info!("[INIT] Native video surface attached");
} else {
log::warn!("[INIT] Native video surface unavailable");
}
}
Err(e) => {
log::warn!("[INIT] No GTK vbox for the main window: {e}")
}
}
}
}
// Every engine reaches the controller through the one contract.
// `LegacyPlayer` carries the not-yet-ported ones across unchanged,
// so this port swaps a seam rather than four implementations.
// TRACES: UR-081 | DR-245
let player_controller = PlayerController::new(
Box::new(crate::player::LegacyPlayer::new(
backend,
engine_capabilities(),
)),
playback_reporter.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
/// Forward the trait through a box.
///
/// `Box<dyn PlayerBackend>` does not implement `PlayerBackend` on its own, so
/// without this the boxed engine built at the composition root cannot be handed
/// to anything generic over the trait — `LegacyPlayer` in particular.
impl PlayerBackend for Box<dyn PlayerBackend> {
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
(**self).load(media)
}
fn play(&mut self) -> Result<(), PlayerError> {
(**self).play()
}
fn pause(&mut self) -> Result<(), PlayerError> {
(**self).pause()
}
fn stop(&mut self) -> Result<(), PlayerError> {
(**self).stop()
}
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
(**self).seek(position)
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
(**self).set_volume(volume)
}
fn position(&self) -> f64 {
(**self).position()
}
fn duration(&self) -> Option<f64> {
(**self).duration()
}
fn state(&self) -> PlayerState {
(**self).state()
}
fn volume(&self) -> f32 {
(**self).volume()
}
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
(**self).set_audio_settings(settings)
}
fn audio_settings(&self) -> AudioSettings {
(**self).audio_settings()
}
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
(**self).set_audio_track(stream_index)
}
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
(**self).set_subtitle_track(stream_index)
}
}
#[cfg(test)]
mod tests {
use super::*;
+289
View File
@@ -0,0 +1,289 @@
//! The conformance suite every [`MediaPlayer`] must pass.
//!
//! One set of behaviours, run against every engine: `FakePlayer` and `MpvPlayer`
//! in `cargo test`, `ExoPlayerPlayer` instrumented on a device, `WebviewPlayer`
//! in vitest. A new engine is finished when it passes this.
//!
//! Written *before* the second engine on purpose. A suite written afterwards
//! encodes whatever the first engine happened to do, which is how three separate
//! playback implementations drifted apart in the first place.
//!
//! Each case names the defect it exists to prevent. Two of them —
//! [`opens_at_a_start_position`] and [`seek_while_opening_is_honoured`] — fail
//! against the pre-migration mpv path, which is what makes them a reproduction
//! of DR-241 rather than a restatement of it.
//!
//! Engines differ in *when* an open completes, so the suite drives that through
//! a [`Harness`] rather than sleeping: the fake completes on demand, mpv waits
//! for its `FileLoaded` event, ExoPlayer for `STATE_READY`.
//!
//! Available to `cargo test` and, behind the `conformance` feature, to the
//! `player-conformance` binary — so an engine that cannot run in-process
//! (ExoPlayer on a device) is driven by exactly the same cases rather than by a
//! second, drifting checklist.
//!
//! TRACES: UR-081 | DR-243 | UT-220
use std::time::Duration;
use super::media_player::{MediaPlayer, OpenRequest, Phase};
/// How the suite drives one engine.
pub trait Harness {
type Player: MediaPlayer;
fn player(&mut self) -> &mut Self::Player;
/// A request this engine can actually open, at `start`.
fn request(&self, start: Duration) -> OpenRequest;
/// Block until an in-flight `open` has finished (or failed).
///
/// The fake completes on demand; a real engine waits for its own readiness
/// event. Never a sleep — a timing-dependent suite is worse than none.
fn settle(&mut self);
/// Whether the engine is producing audio. Engines that cannot answer may
/// return `None`, which skips the silence assertions rather than passing
/// them vacuously.
fn audible(&mut self) -> Option<bool>;
/// How far a landed position may differ from the one asked for. Keyframe
/// granularity makes exactness the wrong bar for a real decoder.
fn seek_tolerance(&self) -> Duration {
Duration::from_secs(5)
}
/// Wait for a completed seek to be visible in `snapshot()`.
///
/// Engines differ in when that happens: one may record the target the
/// moment it accepts the seek, another may not report it until the decoder
/// has actually moved. Asserting immediately therefore passes on the first
/// and races on the second — which is precisely how this suite produced a
/// failure that came and went with machine load rather than with the code.
///
/// Default is a no-op, for engines whose snapshot is synchronous.
fn await_seek(&mut self, _target: Duration) {}
}
fn assert_near(actual: Duration, expected: Duration, tolerance: Duration, what: &str) {
let delta = actual.abs_diff(expected);
assert!(
delta <= tolerance,
"{what}: expected ~{expected:?}, got {actual:?} (tolerance {tolerance:?})"
);
}
/// Opening at zero reaches a usable state and starts near the beginning.
pub fn opens_from_the_beginning<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
let s = h.player().snapshot();
assert!(
matches!(s.phase, Phase::Playing | Phase::Ready),
"after open the engine should hold media, phase was {:?}",
s.phase
);
assert_near(
s.position,
Duration::ZERO,
h.seek_tolerance(),
"start of item",
);
}
/// **DR-241.** Opening at a position starts *there*, not at zero.
///
/// The whole reason `OpenRequest` carries `start`. Under the previous contract a
/// caller had to `load()` then `seek()`, and because `loadfile` is asynchronous
/// the seek was issued against a player with nothing loaded, failed, and was
/// discarded — so resume and transcoded skip both played from the beginning.
pub fn opens_at_a_start_position<H: Harness>(h: &mut H) {
let start = Duration::from_secs(600);
let req = h.request(start);
h.player().open(req).expect("open failed");
h.settle();
let s = h.player().snapshot();
assert_ne!(
s.position,
Duration::ZERO,
"opened at {start:?} but playback began at zero - the start position was dropped"
);
assert_near(s.position, start, h.seek_tolerance(), "start position");
}
/// **DR-241.** A seek issued while opening is honoured, not lost.
///
/// The engine owns this window; no caller can avoid it, because a caller cannot
/// see when the pipeline becomes ready.
pub fn seek_while_opening_is_honoured<H: Harness>(h: &mut H) {
let target = Duration::from_secs(300);
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
// Deliberately before settle(): this is the race, expressed on purpose.
h.player().seek(target).expect("seek during open failed");
h.settle();
let s = h.player().snapshot();
assert_near(
s.position,
target,
h.seek_tolerance(),
"seek issued while opening",
);
}
/// A later intent wins: the seek replaces the start position it overtook.
pub fn seek_while_opening_overrides_start<H: Harness>(h: &mut H) {
let start = Duration::from_secs(600);
let target = Duration::from_secs(120);
let req = h.request(start);
h.player().open(req).expect("open failed");
h.player().seek(target).expect("seek during open failed");
h.settle();
assert_near(
h.player().snapshot().position,
target,
h.seek_tolerance(),
"seek should override the start position it overtook",
);
}
/// Seeking a settled item lands where asked.
pub fn seeks_after_open<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
let target = Duration::from_secs(420);
h.player().seek(target).expect("seek failed");
h.await_seek(target);
assert_near(
h.player().snapshot().position,
target,
h.seek_tolerance(),
"seek after open",
);
}
/// **DR-239.** Pause and play are reflected in the engine's own state.
///
/// An engine that changes nothing observable is indistinguishable from one that
/// ignored the call — which is exactly how a handler for mpv's `pause` property
/// sat unreachable while the UI waited for an event that never came.
pub fn pause_and_play_are_observable<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
h.player().pause().expect("pause failed");
assert_eq!(
h.player().snapshot().phase,
Phase::Paused,
"pause must be visible in the snapshot"
);
if let Some(audible) = h.audible() {
assert!(!audible, "a paused engine must be silent");
}
h.player().play().expect("play failed");
assert_eq!(
h.player().snapshot().phase,
Phase::Playing,
"play must be visible in the snapshot"
);
}
/// `close()` reaches Idle, is silent, and can be called twice.
pub fn close_is_silent_and_idempotent<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
h.player().close().expect("close failed");
assert_eq!(h.player().snapshot().phase, Phase::Idle);
if let Some(audible) = h.audible() {
assert!(!audible, "a closed engine must be silent");
}
h.player().close().expect("close must be idempotent");
assert_eq!(h.player().snapshot().phase, Phase::Idle);
}
/// Closing during an open must not let playback start afterwards.
///
/// The shape of the "audio keeps playing after leaving the player" report: an
/// open still in flight completed after the stop, and nothing was left to tell
/// it not to.
pub fn close_during_open_never_plays<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.player().close().expect("close during open failed");
h.settle();
let s = h.player().snapshot();
assert!(
!s.phase.is_active(),
"an open cancelled by close must not start playing, phase was {:?}",
s.phase
);
if let Some(audible) = h.audible() {
assert!(!audible, "an engine closed during open must be silent");
}
}
/// Volume, mute and rate round-trip through the snapshot.
pub fn transport_settings_round_trip<H: Harness>(h: &mut H) {
let req = h.request(Duration::ZERO);
h.player().open(req).expect("open failed");
h.settle();
h.player().set_volume(0.25).expect("set_volume failed");
h.player().set_muted(true).expect("set_muted failed");
h.player().set_rate(1.5).expect("set_rate failed");
let s = h.player().snapshot();
assert!((s.volume - 0.25).abs() < 0.01, "volume did not round-trip");
assert!(s.muted, "mute did not round-trip");
assert!((s.rate - 1.5).abs() < 0.01, "rate did not round-trip");
}
/// Run every case against one engine.
///
/// Each case gets a fresh harness, because a suite whose cases depend on each
/// other's leftovers is one that hides state bugs instead of finding them.
#[macro_export]
macro_rules! media_player_conformance {
($name:ident, $make:expr) => {
mod $name {
use super::*;
use $crate::player::conformance as c;
macro_rules! case {
($case:ident) => {
#[test]
fn $case() {
let mut h = $make;
c::$case(&mut h);
}
};
}
case!(opens_from_the_beginning);
case!(opens_at_a_start_position);
case!(seek_while_opening_is_honoured);
case!(seek_while_opening_overrides_start);
case!(seeks_after_open);
case!(pause_and_play_are_observable);
case!(close_is_silent_and_idempotent);
case!(close_during_open_never_plays);
case!(transport_settings_round_trip);
}
};
}
+232
View File
@@ -0,0 +1,232 @@
//! A deterministic in-memory [`MediaPlayer`], for tests.
//!
//! Two jobs:
//!
//! 1. Give the conformance suite something that is correct by construction, so a
//! failure there means the *suite* is wrong rather than an engine.
//! 2. Let everything above the engine — controller, queue, autoplay, sleep
//! timer, session — be tested with no mpv, no device and no network. Most of
//! that logic is currently only reachable through a real engine, which is why
//! so little of it is covered.
//!
//! It models the one behaviour that matters most: **opening is not
//! instantaneous**. `open()` lands in [`Phase::Opening`] and stays there until
//! [`FakePlayer::complete_open`] is called, so a test can put a `seek` into that
//! window on purpose. That is the window DR-241 lived in.
//!
//! TRACES: UR-081 | DR-243
// `tick` and `fail_open` are for tests not yet written — the controller-level
// ones DR-245 unlocks. Remove this allow once those exist.
#![allow(dead_code)]
use std::time::Duration;
use super::backend::PlayerError;
use super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot};
#[derive(Debug, Clone, PartialEq)]
pub enum FakeEvent {
Opened { url: String, start: Duration },
Played,
Paused,
Closed,
Sought(Duration),
}
pub struct FakePlayer {
snapshot: PlaybackSnapshot,
/// Set while `Opening`; applied when the open completes.
pending_start: Duration,
/// A seek that arrived while opening. Honoured on completion, never dropped.
deferred_seek: Option<Duration>,
autoplay: bool,
duration: Duration,
/// Every call, in order — so tests can assert what an engine was *asked* to
/// do, not only where it ended up.
pub log: Vec<FakeEvent>,
/// Whether audio is being produced. `close()` must clear it; the bug that
/// motivated all this had a "stopped" player that was still audible.
pub audible: bool,
pub capabilities: Capabilities,
}
impl Default for FakePlayer {
fn default() -> Self {
Self::new()
}
}
impl FakePlayer {
pub fn new() -> Self {
Self {
snapshot: PlaybackSnapshot::default(),
pending_start: Duration::ZERO,
deferred_seek: None,
autoplay: true,
duration: Duration::from_secs(3600),
log: Vec::new(),
audible: false,
capabilities: Capabilities {
video: true,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
// The fake honours a seek in any phase, so it can claim this.
seeks_transcoded_in_place: true,
},
}
}
/// The item this fake will report once opened.
pub fn with_duration(mut self, duration: Duration) -> Self {
self.duration = duration;
self
}
/// Finish an in-flight `open`, as a real engine's "file loaded" would.
///
/// Applies the requested start position, then any seek that arrived while
/// opening — the later intent wins.
pub fn complete_open(&mut self) {
if self.snapshot.phase != Phase::Opening {
return;
}
self.snapshot.duration = Some(self.duration);
self.snapshot.seekable = true;
self.snapshot.position = self.deferred_seek.take().unwrap_or(self.pending_start);
if self.autoplay {
self.snapshot.phase = Phase::Playing;
self.audible = true;
} else {
self.snapshot.phase = Phase::Ready;
}
}
/// Advance playback, for tests that care about time passing.
pub fn tick(&mut self, by: Duration) {
if self.snapshot.phase.is_active() {
self.snapshot.position = (self.snapshot.position + by).min(self.duration);
if self.snapshot.position >= self.duration {
self.snapshot.phase = Phase::Ended;
self.audible = false;
}
}
}
pub fn fail_open(&mut self, why: &str) {
self.snapshot.phase = Phase::Failed(why.to_string());
self.audible = false;
}
}
impl MediaPlayer for FakePlayer {
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Opened {
url: req.selection.url.clone(),
start: req.start,
});
self.snapshot = PlaybackSnapshot {
phase: Phase::Opening,
volume: self.snapshot.volume,
muted: self.snapshot.muted,
rate: self.snapshot.rate,
audio_track: req.audio_track,
subtitle_track: req.subtitle_track,
..PlaybackSnapshot::default()
};
self.pending_start = req.start;
self.deferred_seek = None;
self.autoplay = req.autoplay;
self.audible = false;
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Played);
if self.snapshot.phase.has_media() {
if self.snapshot.phase == Phase::Opening {
self.autoplay = true;
} else {
self.snapshot.phase = Phase::Playing;
self.audible = true;
}
}
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Paused);
if self.snapshot.phase == Phase::Opening {
self.autoplay = false;
} else if self.snapshot.phase.has_media() {
self.snapshot.phase = Phase::Paused;
self.audible = false;
}
Ok(())
}
fn close(&mut self) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Closed);
self.snapshot = PlaybackSnapshot {
volume: self.snapshot.volume,
muted: self.snapshot.muted,
rate: self.snapshot.rate,
..PlaybackSnapshot::default()
};
self.pending_start = Duration::ZERO;
self.deferred_seek = None;
// An open that was still in flight must not come back to life.
self.autoplay = false;
self.audible = false;
Ok(())
}
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
self.log.push(FakeEvent::Sought(to));
match self.snapshot.phase {
// The window DR-241 lived in: hold it, do not discard it.
Phase::Opening => self.deferred_seek = Some(to),
Phase::Idle | Phase::Failed(_) => {
return Err(PlayerError {
message: "seek with nothing open".to_string(),
})
}
_ => self.snapshot.position = to.min(self.duration),
}
Ok(())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
self.snapshot.volume = volume.clamp(0.0, 1.0);
Ok(())
}
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> {
self.snapshot.muted = muted;
Ok(())
}
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> {
self.snapshot.rate = rate;
Ok(())
}
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.snapshot.audio_track = index;
Ok(())
}
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.snapshot.subtitle_track = index;
Ok(())
}
fn snapshot(&self) -> PlaybackSnapshot {
self.snapshot.clone()
}
fn capabilities(&self) -> Capabilities {
self.capabilities
}
}
@@ -0,0 +1,56 @@
//! `FakePlayer` runs the conformance suite.
//!
//! It is correct by construction, so a failure here means the *suite* is wrong,
//! not an engine. That is what makes it safe to trust the same cases when they
//! fail against a real one.
//!
//! TRACES: UR-081 | DR-243 | UT-220
use std::time::Duration;
use super::conformance::Harness;
use super::fake_player::FakePlayer;
use super::media::MediaItem;
use super::media_player::OpenRequest;
use crate::repository::stream_selection::StreamSelection;
struct FakeHarness {
player: FakePlayer,
}
impl FakeHarness {
fn new() -> Self {
Self {
player: FakePlayer::new().with_duration(Duration::from_secs(7200)),
}
}
}
impl Harness for FakeHarness {
type Player = FakePlayer;
fn player(&mut self) -> &mut FakePlayer {
&mut self.player
}
fn request(&self, start: Duration) -> OpenRequest {
let selection = StreamSelection::local_file("http://example.invalid/stream.mp4");
let media = MediaItem::sample("fake-item", &selection.url);
OpenRequest::new(media, selection).starting_at(start)
}
fn settle(&mut self) {
self.player.complete_open();
}
fn audible(&mut self) -> Option<bool> {
Some(self.player.audible)
}
/// Exact: the fake has no keyframes to round to, so any drift is a bug.
fn seek_tolerance(&self) -> Duration {
Duration::ZERO
}
}
crate::media_player_conformance!(fake, FakeHarness::new());
+154
View File
@@ -0,0 +1,154 @@
//! A [`MediaPlayer`] over the old [`PlayerBackend`] trait.
//!
//! Two purposes.
//!
//! **Migration.** Engines not yet ported — ExoPlayer, the webview element, the
//! null backend — keep working while `PlayerController` moves onto the new
//! contract (DR-245). Without this the port would have to land all four engines
//! at once.
//!
//! **Evidence.** It reproduces exactly what every caller used to do: `load`,
//! then `play`, then `seek` for a start position. Running the conformance suite
//! against it therefore shows the old path failing the cases the new one passes,
//! on the same engine and the same media — which is the difference between
//! asserting that a design was wrong and demonstrating it.
//!
//! It is deliberately a faithful reproduction, not a fixed-up one. Making it
//! pass would defeat the point.
//!
//! TRACES: UR-081 | DR-245
use std::time::Duration;
use super::backend::{PlayerBackend, PlayerError};
use super::media_player::{
duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
};
use super::state::PlayerState;
pub struct LegacyPlayer<B: PlayerBackend> {
inner: B,
/// Declared at construction: this wrapper is generic over engines with very
/// different abilities, and only the composition root knows which one it
/// just built. Guessing here would reintroduce exactly the inference DR-238
/// removed.
capabilities: Capabilities,
/// The old trait has no notion of "opening", so this is the best the wrapper
/// can do: it knows an item was handed over, not whether the engine is ready
/// for one. That gap is the whole problem.
has_item: bool,
}
impl<B: PlayerBackend> LegacyPlayer<B> {
pub fn new(inner: B, capabilities: Capabilities) -> Self {
Self {
inner,
capabilities,
has_item: false,
}
}
}
impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
/// Load, play, then seek — the sequence every caller used to write.
///
/// The seek is issued immediately, because a caller has no way to know when
/// the engine becomes ready. On an engine whose load is asynchronous it
/// fails and is discarded, and playback begins at zero: DR-241, reproduced.
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
self.inner.load(&req.media)?;
self.has_item = true;
if req.autoplay {
self.inner.play()?;
}
if !req.start.is_zero() {
// Faithfully ignoring the failure, exactly as the old callers did.
let _ = self.inner.seek(req.start.as_secs_f64());
}
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.inner.play()
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.inner.pause()
}
fn close(&mut self) -> Result<(), PlayerError> {
self.has_item = false;
self.inner.stop()
}
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
self.inner.seek(to.as_secs_f64())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
self.inner.set_volume(volume)
}
/// The old trait has no mute. Folding it into volume would lose the user's
/// level, so this reports unsupported rather than pretending.
fn set_muted(&mut self, _muted: bool) -> Result<(), PlayerError> {
Err(PlayerError {
message: "mute is not supported by this backend".to_string(),
})
}
fn set_rate(&mut self, _rate: f64) -> Result<(), PlayerError> {
Err(PlayerError {
message: "playback rate is not supported by this backend".to_string(),
})
}
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.inner.set_audio_track(index.unwrap_or(-1))
}
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.inner.set_subtitle_track(index)
}
fn snapshot(&self) -> PlaybackSnapshot {
let phase = match self.inner.state() {
_ if !self.has_item => Phase::Idle,
PlayerState::Playing { .. } => Phase::Playing,
PlayerState::Paused { .. } => Phase::Paused,
PlayerState::Idle => Phase::Idle,
PlayerState::Error { error, .. } => Phase::Failed(error),
// `Loading` is the closest the old trait comes to an opening state,
// but it is set once the engine has accepted the item rather than
// while it is still accepting it — which is precisely the window it
// cannot describe.
PlayerState::Loading { .. } | PlayerState::Seeking { .. } => Phase::Ready,
};
PlaybackSnapshot {
phase,
position: duration_from_secs(self.inner.position()).unwrap_or(Duration::ZERO),
duration: self.inner.duration().and_then(duration_from_secs),
seekable: true,
volume: self.inner.volume(),
muted: false,
rate: 1.0,
audio_track: None,
subtitle_track: None,
}
}
fn set_audio_settings(
&mut self,
settings: &crate::settings::AudioSettings,
) -> Result<(), PlayerError> {
self.inner.set_audio_settings(settings)
}
fn audio_settings(&self) -> crate::settings::AudioSettings {
self.inner.audio_settings()
}
fn capabilities(&self) -> Capabilities {
self.capabilities
}
}
+55
View File
@@ -171,6 +171,19 @@ pub enum MediaSource {
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 {
/// Get the Jellyfin item ID if available
pub fn jellyfin_id(&self) -> Option<&str> {
@@ -198,6 +211,48 @@ impl MediaItem {
}
}
impl MediaItem {
/// A minimal item for tests.
///
/// The struct has twenty-odd fields, almost none of which any given test
/// cares about, and repeating the literal per test is how a new field ends
/// up added in thirty places. Set what matters on the result.
///
/// TRACES: UR-081 | DR-243
#[cfg(any(test, feature = "conformance"))]
pub fn sample(id: &str, url: &str) -> Self {
Self {
transport: None,
id: id.to_string(),
title: id.to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Video,
source: MediaSource::DirectUrl {
url: url.to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
+328
View File
@@ -0,0 +1,328 @@
//! The `MediaPlayer` contract: one API, interchangeable engines.
//!
//! See docs/specs/media-player-controller.md.
//!
//! This replaces [`PlayerBackend`](super::backend::PlayerBackend), which
//! abstracts a *device* — `load`, then `seek` — rather than an *intent*. That
//! distinction is not academic; it produced four shipped defects in one day:
//!
//! * A start position was not expressible, so every caller sequenced
//! `load()` + `seek()` itself and each raced the engine's asynchronous load
//! independently. Resume worked through one caller and silently failed through
//! another (DR-241).
//! * Whether a stream could be seeked in place was decided *above* the engines,
//! by a truth table in a command handler, for engines it does not own (DR-238).
//! * Nothing in the contract obliged an engine to report its own state, so a
//! handler for mpv's `pause` property sat unreachable and the play/pause
//! control never moved (DR-239).
//!
//! The contract below is written so each of those is a compile-time or
//! conformance-time failure rather than a runtime surprise.
//!
//! TRACES: UR-081 | DR-242
// Scaffolding: nothing consumes this contract until `PlayerController` is
// ported to it (DR-245). Kept out of `cfg(test)` deliberately — it is production
// code being built in shippable steps, not a test fixture. Remove this allow
// when the controller talks to `MediaPlayer`.
#![allow(dead_code)]
use std::time::Duration;
use super::backend::PlayerError;
use super::media::MediaItem;
use crate::repository::stream_selection::StreamSelection;
use crate::settings::AudioSettings;
/// Seconds reported by an engine, as a `Duration`, without trusting the number.
///
/// `Duration::from_secs_f64` **panics** on a negative or non-finite value, and
/// no engine promises otherwise. ExoPlayer reports `C.TIME_UNSET` —
/// `Long::MIN_VALUE`, about -9.2e15 — for a stream whose length it does not
/// know, which is every background-audio handoff: `/Audio/{id}/universal` is a
/// chunked, length-less transcode.
///
/// Held as a float that junk was harmless. Converted to a `Duration` it became
/// a panic that killed the backend mid-handoff and left a black screen with no
/// controls. Every engine crossing into this contract goes through here.
///
/// TRACES: UR-005 | DR-252
pub fn duration_from_secs(seconds: f64) -> Option<Duration> {
(seconds.is_finite() && seconds > 0.0).then(|| Duration::from_secs_f64(seconds))
}
/// What an engine is doing right now.
///
/// `Opening` is the state the previous design could not express, and is the
/// direct cause of DR-241: a seek that arrived while the engine had nothing
/// loaded had no phase to be queued against, so it was simply discarded and
/// playback began at zero.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Phase {
/// Nothing loaded. `close()` must reach this, and must be silent here.
Idle,
/// An `open` is in flight. Position is not yet meaningful; a `seek` arriving
/// now must be honoured once the engine reaches `Ready`, never dropped.
Opening,
/// Loaded and able to play, but not advancing.
Ready,
Playing,
Paused,
/// Reached the end of the item by itself. Distinct from `Idle`, because
/// autoplay cares which one happened.
Ended,
Failed(String),
}
impl Phase {
/// Whether the engine currently holds an item.
pub fn has_media(&self) -> bool {
!matches!(self, Phase::Idle | Phase::Failed(_))
}
/// Whether playback is advancing.
pub fn is_active(&self) -> bool {
matches!(self, Phase::Playing)
}
}
/// Everything the UI consumes, read as one coherent value.
///
/// Deliberately a single snapshot rather than a dozen getters: reading position
/// and duration through separate calls is how a paused player reported
/// `<position> / 0.0` when a file unloaded between them.
#[derive(Debug, Clone)]
pub struct PlaybackSnapshot {
pub phase: Phase,
pub position: Duration,
/// `None` while unknown — a live stream, or an item still opening.
pub duration: Option<Duration>,
/// Whether `seek` can be expected to land. False for live edges.
pub seekable: bool,
/// 0.0 1.0.
pub volume: f32,
pub muted: bool,
pub rate: f64,
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
}
impl Default for PlaybackSnapshot {
fn default() -> Self {
Self {
phase: Phase::Idle,
position: Duration::ZERO,
duration: None,
seekable: false,
volume: 1.0,
muted: false,
rate: 1.0,
audio_track: None,
subtitle_track: None,
}
}
}
/// What an engine can do, so callers adapt without naming engines.
///
/// If a caller ever branches on *which* engine it holds, this struct is missing
/// something — add it here rather than sniffing. Engine identity leaking into
/// callers is the coupling DR-238 came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capabilities {
/// The engine renders pictures, not only sound.
pub video: bool,
/// Audio settings (EQ, normalisation, gapless) are honoured.
pub audio_settings: bool,
/// Subtitle tracks can be selected without re-opening.
pub subtitle_switching: bool,
/// Audio tracks can be selected without re-opening.
pub audio_track_switching: bool,
/// A *server-side transcode* can be seeked without re-opening the stream.
///
/// True for hls.js, which seeks within the VOD playlist it is handed and
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make
/// the server transcode from a new offset.
///
/// Declared by the engine rather than inferred by the caller. The previous
/// design decided this from `is_hls` and `use_html5` in a command handler —
/// on behalf of engines it did not own — which is how "who renders" came to
/// mean "how do I seek" and why a transcoded seek silently did nothing the
/// moment native video changed the renderer (DR-238).
///
/// Re-negotiating a stream needs the repository, which sits above the
/// engine, so the engine states the capability and the caller acts on it.
pub seeks_transcoded_in_place: bool,
}
impl Capabilities {
/// mpv.
///
/// Cannot seek a server-side transcode in place: its HLS demuxer will not
/// make the server produce segments from a new offset, so the stream has to
/// be re-opened.
pub fn mpv() -> Self {
Self {
video: true,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
seeks_transcoded_in_place: false,
}
}
/// ExoPlayer.
///
/// **Can** seek a transcode in place. It is a full HLS client, so like
/// hls.js it seeks within the VOD playlist it was handed and lets the
/// server catch up. Grouping it with mpv as "a native engine" gets this
/// exactly backwards — being native is not the property that matters here,
/// speaking HLS is, and that is the whole reason this is declared per
/// engine rather than inferred from a category.
pub fn exoplayer() -> Self {
Self {
video: true,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
seeks_transcoded_in_place: true,
}
}
/// An engine that renders through the webview element, where hls.js seeks
/// within the playlist it was handed.
pub fn webview() -> Self {
Self {
video: true,
audio_settings: false,
subtitle_switching: true,
audio_track_switching: false,
seeks_transcoded_in_place: true,
}
}
}
/// A request to present an item.
///
/// `start` is the reason this type exists. Carrying it here — rather than
/// leaving callers to `seek` after `open` — is what closes the load/seek race,
/// because the engine is the only layer that knows when its pipeline can accept
/// a position.
#[derive(Debug, Clone)]
pub struct OpenRequest {
pub media: MediaItem,
pub selection: StreamSelection,
/// Where to begin. `Duration::ZERO` means the start of the item.
pub start: Duration,
pub audio_track: Option<i32>,
pub subtitle_track: Option<i32>,
/// Begin playing as soon as the engine is able.
pub autoplay: bool,
}
impl OpenRequest {
/// Open at the beginning, playing.
pub fn new(media: MediaItem, selection: StreamSelection) -> Self {
Self {
media,
selection,
start: Duration::ZERO,
audio_track: None,
subtitle_track: None,
autoplay: true,
}
}
pub fn starting_at(mut self, start: Duration) -> Self {
self.start = start;
self
}
}
/// Anything that can present media.
///
/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of
/// them must pass [`super::conformance`].
pub trait MediaPlayer: Send {
/// Present `req.selection`, beginning at `req.start`.
///
/// One operation, deliberately. An engine that cannot start at an offset
/// natively absorbs that internally — by deferring until loaded, or by
/// re-opening — because it is the only layer that knows when it can.
/// Callers must never follow `open` with a `seek` to achieve a start
/// position; that is the bug this signature exists to prevent.
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError>;
fn play(&mut self) -> Result<(), PlayerError>;
fn pause(&mut self) -> Result<(), PlayerError>;
/// Stop and release the current item.
///
/// Must be **idempotent** and must leave the engine **silent**. "Stopped"
/// and "producing no audio" were not the same thing in the previous design,
/// and the gap between them is audible.
fn close(&mut self) -> Result<(), PlayerError>;
/// Seek to an absolute position on the item's own timeline.
///
/// Whether that is an in-place seek or a re-open of the stream is the
/// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer
/// cannot make a server transcode from a new offset. Callers state the
/// destination and nothing else.
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError>;
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError>;
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError>;
/// One coherent read of the engine's state.
fn snapshot(&self) -> PlaybackSnapshot;
fn capabilities(&self) -> Capabilities;
/// Apply EQ, normalisation and gapless settings.
///
/// Provided rather than required: engines that cannot honour them say so
/// through [`Capabilities::audio_settings`] and inherit this no-op, instead
/// of every implementation carrying an `Ok(())` it does not mean.
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
Ok(())
}
fn audio_settings(&self) -> AudioSettings {
AudioSettings::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The value that killed the backend: `C.TIME_UNSET` as seconds.
///
/// ExoPlayer reports it for any stream whose length it does not know, and
/// `Duration::from_secs_f64` panics on it. A player must not be the place
/// anyone discovers a float was strange.
///
/// TRACES: UR-005 | DR-252 | UT-222
#[test]
fn test_junk_durations_do_not_panic() {
// Long::MIN_VALUE milliseconds, as ExoPlayer hands it over.
assert_eq!(duration_from_secs(-9_223_372_036_854_776.0), None);
assert_eq!(duration_from_secs(-1.0), None);
assert_eq!(duration_from_secs(0.0), None, "zero is not a duration");
assert_eq!(duration_from_secs(f64::NAN), None);
assert_eq!(duration_from_secs(f64::INFINITY), None);
assert_eq!(duration_from_secs(f64::NEG_INFINITY), None);
// A real one still survives.
assert_eq!(
duration_from_secs(6997.024),
Some(Duration::from_secs_f64(6997.024))
);
}
}
+164 -22
View File
@@ -5,8 +5,18 @@
pub mod autoplay;
pub mod backend;
pub mod background_policy;
#[cfg(any(test, feature = "conformance"))]
pub mod conformance;
pub mod events;
#[cfg(any(test, feature = "conformance"))]
pub mod fake_player;
#[cfg(test)]
mod fake_player_conformance;
pub mod legacy_player;
pub mod media;
pub mod media_player;
#[cfg(target_os = "linux")]
pub mod mpv_player;
pub mod queue;
pub mod seek;
pub mod session;
@@ -24,11 +34,22 @@ pub mod android;
#[cfg(target_os = "linux")]
pub mod mpv_backend;
/// Whether this process renders video natively — one answer, three consumers
/// (UR-080 / DR-231, DR-235).
pub mod native_video;
/// mpv's render API into a framebuffer we own (UR-080 / DR-231, IR-033).
///
/// Deliberately *not* GTK-gated beyond the platform that currently builds it:
/// everything here is the portable half, and Windows reuses it unchanged behind
/// its own surface.
#[cfg(target_os = "linux")]
pub mod mpv_render;
/// The native video surface mpv renders into (UR-080 / DR-231).
///
/// Linux-gated for now because the surface is GTK. Everything *around* it — the
/// render context, its lifetime, frame pacing, the device profile — is
/// deliberately not, so Windows reuses it behind its own surface.
/// Linux-gated because the *surface* is GTK. Everything around it — the render
/// context, its lifetime, frame pacing, the device profile — is not.
#[cfg(target_os = "linux")]
pub mod video_surface;
@@ -38,10 +59,13 @@ pub mod video_surface;
pub mod webview_audio_backend;
// Re-export commonly used types
use crate::repository::stream_selection::StreamSelection;
pub use autoplay::{AutoplayDecision, AutoplaySettings};
pub use backend::{NullBackend, PlayerBackend, PlayerError};
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
pub use legacy_player::LegacyPlayer;
pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
pub use media_player::{MediaPlayer, OpenRequest, Phase};
pub use queue::{QueueManager, RepeatMode};
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
pub use session::{MediaSessionManager, MediaSessionType};
@@ -216,7 +240,9 @@ use crate::utils::conversions::seconds_to_ticks;
/// Central player controller that coordinates playback
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>>,
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
muted: bool,
@@ -315,7 +341,7 @@ pub struct PlayerController {
impl PlayerController {
pub fn new(
backend: Box<dyn PlayerBackend>,
backend: Box<dyn MediaPlayer>,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
) -> Self {
@@ -493,7 +519,12 @@ impl PlayerController {
/// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it.
#[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> {
debug!(
"[PlayerController] set_current_item (no backend load): {}",
@@ -546,8 +577,16 @@ impl PlayerController {
*self.html5_playing.lock_safe() = None;
let mut backend = self.backend.lock_safe();
backend.load(item)?;
backend.play()?;
// One operation: the engine is handed the item and where to begin, so
// there is no window between them for a position to be lost in.
backend.open(OpenRequest::new(
item.clone(),
StreamSelection::for_queued_item(
item.playable_url(),
item.transport,
item.needs_transcoding,
),
))?;
drop(backend);
// A different item is loading; the last one's reported position must not
@@ -721,7 +760,7 @@ impl PlayerController {
return Ok(());
}
let mut backend = self.backend.lock_safe();
if backend.state().is_playing() {
if backend.snapshot().phase.is_active() {
backend.pause()
} else {
backend.play()
@@ -746,10 +785,32 @@ impl PlayerController {
let position = self.absolute_position();
let mut backend = self.backend.lock_safe();
backend.stop()?;
backend.close()?;
drop(backend);
self.clear_reported_time();
// Stopping means *nothing is playing*, from any renderer — not "the
// thing we currently believe owns playback has been asked to stop".
//
// A background-audio handoff swaps which renderer that is, and the swap
// is bookkeeping that can be mid-flight: `exit_background_audio` marks
// the webview element the player again the moment it is called, while
// the element has not reloaded yet. A stop aimed at what the flags say
// is playing therefore misses the audio stream that actually is, and it
// resurfaces in the mini player as an audio track.
//
// Clearing the handoff here is the other half of that: a stop that
// leaves the base offset and the active flag behind lets the next
// position read be interpreted against a handoff that no longer exists.
//
// TRACES: UR-040, UR-005 | DR-250
if self.is_background_audio_active() {
debug!("[PlayerController] stop: clearing an active background-audio handoff");
}
*self.background_audio_active.lock_safe() = false;
self.set_background_audio_base(0.0);
*self.html5_playing.lock_safe() = None;
if let Some(jellyfin_id) = jellyfin_id {
self.report_stopped_at(jellyfin_id, position);
}
@@ -827,7 +888,7 @@ impl PlayerController {
// If we're more than 3 seconds in, restart current track
{
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)");
drop(backend);
return self.seek(0.0);
@@ -859,7 +920,7 @@ impl PlayerController {
/// timeline and is what every caller outside the player itself means.
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe();
backend.seek(position)
backend.seek(Duration::from_secs_f64(position.max(0.0)))
}
/// Seek to an **absolute** position on the item's own timeline.
@@ -910,23 +971,48 @@ impl PlayerController {
/// Set the active audio track by stream index
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe();
backend.set_audio_track(stream_index)
backend.select_audio_track(Some(stream_index))
}
/// Set the active subtitle track by stream index (None to disable subtitles)
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe();
backend.set_subtitle_track(stream_index)
backend.select_subtitle_track(stream_index)
}
/// Get current state
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
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.
@@ -952,7 +1038,7 @@ impl PlayerController {
///
/// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
pub fn absolute_position(&self) -> f64 {
let native = self.backend.lock_safe().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 base = if self.is_background_audio_active() {
*self.background_audio_base.lock_safe()
@@ -1016,10 +1102,34 @@ impl PlayerController {
///
/// TRACES: UR-005 | DR-178
pub fn duration(&self) -> Option<f64> {
// Zero is not a duration, it is an engine saying it does not know yet.
//
// ExoPlayer reports `C.TIME_UNSET` until it has resolved one, and
// `JellyTauPlayer.getDuration()` maps that to `0.0` — so the engine
// answers `Some(0.0)`, every "unknown duration" fallback below is
// skipped, and the seek bar is left with no scale. That presents as
// scrubbing being broken rather than as a duration that never arrived.
//
// The item usually knows: the catalog carried a runtime long before
// anything started decoding.
//
// TRACES: UR-005, UR-040 | DR-251
let usable = |d: f64| (d > 0.0).then_some(d);
self.backend
.lock_safe()
.duration()
.or_else(|| self.observed_duration())
.snapshot()
.duration
.map(|d| d.as_secs_f64())
.and_then(usable)
.or_else(|| self.observed_duration().and_then(usable))
.or_else(|| {
self.queue
.lock_safe()
.current()
.and_then(|item| item.duration)
.and_then(usable)
})
}
/// Get queue reference
@@ -1074,7 +1184,7 @@ impl PlayerController {
/// Get current volume (0.0 - 1.0)
pub fn volume(&self) -> f32 {
self.backend.lock_safe().volume()
self.backend.lock_safe().snapshot().volume
}
/// Check if muted
@@ -1175,7 +1285,7 @@ impl PlayerController {
drop(timer);
// 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);
}
continue;
@@ -2190,7 +2300,10 @@ impl Default for PlayerController {
let playback_reporter = Arc::new(TokioMutex::new(None));
let position_throttler = Arc::new(EventThrottler::new());
Self::new(
Box::new(NullBackend::new()),
Box::new(LegacyPlayer::new(
NullBackend::new(),
crate::player::media_player::Capabilities::mpv(),
)),
playback_reporter,
position_throttler,
)
@@ -2199,6 +2312,35 @@ impl Default for PlayerController {
#[cfg(test)]
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::*;
/// Test emitter that captures events for asserting the HTML5 report methods
+147 -6
View File
@@ -34,6 +34,19 @@ pub struct MpvBackend {
/// through reported 0.0 / unknown exactly when end-of-file handling needed to
/// know where playback reached. See [`ObservedTime`].
observed: Arc<Mutex<ObservedTime>>,
/// A seek that arrived before MPV had a file to seek in.
///
/// `loadfile` is asynchronous: it returns as soon as the command is queued,
/// so `time-pos` is not yet a resolvable property and setting it fails. A
/// seek issued in that window used to be dropped on the floor, and the two
/// callers that do exactly this are the ones a viewer notices — resume, and
/// a transcoded seek, both of which re-open the stream and then ask for a
/// position. The stream reloaded and played from zero.
///
/// Held here and applied by the `FileLoaded` arm.
///
/// TRACES: UR-040, UR-005 | DR-241
pending_seek: Arc<Mutex<Option<f64>>>,
}
struct InternalState {
@@ -89,6 +102,32 @@ fn get_stream_url(media: &MediaItem) -> String {
}
}
/// The mpv handle of the backend this process created, for the video surface.
///
/// A `OnceLock` rather than a field reached through `PlayerBackend`, because the
/// trait is cross-platform and a raw mpv pointer is not something every backend
/// should have to pretend to have. Stored as `usize` because a raw pointer is
/// neither `Send` nor `Sync`; the only consumer is the GTK main thread, which is
/// also where mpv was created.
///
/// Written once at construction and never cleared: the backend outlives the
/// window, so there is no window in which this could dangle while a surface is
/// still using it.
///
/// TRACES: UR-080 | DR-231
static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
/// The registered handle, or null if no MPV backend was created (initialisation
/// can fail, and the app falls back to a no-op backend rather than dying).
///
/// TRACES: UR-080 | DR-231
pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
MPV_HANDLE
.get()
.map(|p| *p as *mut libmpv_sys::mpv_handle)
.unwrap_or(std::ptr::null_mut())
}
impl MpvBackend {
/// Create a new MPV backend
pub fn new(
@@ -137,9 +176,28 @@ impl MpvBackend {
message: format!("Failed to configure MPV audio-display: {:?}", e),
})?;
// Video is disabled unless this process is drawing it.
//
// `video: no` is why mpv has never decoded a frame here: Linux video has
// always gone through the webview, and decoding it twice would burn a
// core for a picture nobody sees. With native video on, mpv needs both
// the decoder *and* `vo=libmpv` — the render API only works through that
// output, and the default would try to open a window of its own.
//
// Set at construction because mpv resolves the video output when it
// initialises; flipping it later does not re-open one.
//
// TRACES: UR-080 | DR-231, DR-235
if super::native_video::enabled() {
mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
message: format!("Failed to select the libmpv video output: {:?}", e),
})?;
info!("[MpvBackend] native video enabled (vo=libmpv)");
} else {
mpv.set_property("video", "no").map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
}
// Set volume to 100% (we'll control via MPV's volume property)
mpv.set_property("volume", 100i64)
@@ -178,13 +236,21 @@ impl MpvBackend {
}));
let backend = MpvBackend {
mpv: Arc::new(mpv),
mpv: {
let mpv = Arc::new(mpv);
// Publish the handle for the video surface (DR-231). Ignores a
// second call: only one MPV backend is ever constructed, and a
// failed re-init must not replace a live handle.
let _ = MPV_HANDLE.set(mpv.ctx.as_ptr() as usize);
mpv
},
state,
event_emitter,
audio_settings: AudioSettings::default(),
playback_reporter,
position_throttler,
last_seek_time: Arc::new(AtomicU64::new(0)),
pending_seek: Arc::new(Mutex::new(None)),
observed: Arc::new(Mutex::new(ObservedTime::default())),
};
@@ -202,6 +268,7 @@ impl MpvBackend {
let state = self.state.clone();
let reporter = self.playback_reporter.clone();
let throttler = self.position_throttler.clone();
let pending_seek_for_events = self.pending_seek.clone();
std::thread::spawn(move || {
info!("[MpvBackend] Event loop started");
@@ -211,6 +278,30 @@ impl MpvBackend {
error!("[MpvBackend] Failed to disable deprecated events: {:?}", e);
});
// libmpv delivers PropertyChange only for properties registered
// here. Every name matched in the loop below needs a line in this
// block or its handler is unreachable — an omission that reads as
// working code, because the handler is sitting right there.
// UT-218 holds the two lists together.
//
// `pause` drives the play/pause control: the UI consumes
// StateChanged rather than tracking playback itself, per the
// one-directional state rule. Unobserved, the event never came and
// the button never moved. Invisible until native video shipped,
// because the webview <video> element's own DOM events drove that
// control on Linux.
//
// TRACES: UR-005 | DR-239
ev_ctx
.observe_property("pause", libmpv::Format::Flag, 0)
.unwrap_or_else(|e| {
error!(
"[MpvBackend] Failed to observe 'pause': {:?} — the play/pause \
control will not follow the player",
e
);
});
loop {
match ev_ctx.wait_event(1.0) {
Some(Ok(event)) => match event {
@@ -220,6 +311,43 @@ impl MpvBackend {
libmpv::events::Event::FileLoaded => {
info!("[MpvBackend] File loaded");
// Apply a seek that arrived while there was nothing
// to seek in. TRACES: UR-040, UR-005 | DR-241
{
let target = pending_seek_for_events.lock_safe().take();
if let Some(position) = target {
match mpv.set_property("time-pos", position) {
Ok(()) => info!(
"[MpvBackend] applied deferred seek to {position}"
),
Err(e) => warn!(
"[MpvBackend] deferred seek to {position} failed: {:?}",
e
),
}
}
}
// Geometry, so "the picture does not fill the screen"
// can be attributed rather than guessed at. `width`/
// `height` are the decoded frame; `dwidth`/`dheight`
// are what mpv will *display* after aspect
// correction. A file that carries its letterbox
// baked into the picture reports a 16:9 dwidth and
// is then pillarboxed on a wider panel — which looks
// identical to a rendering bug from outside.
{
let n = |k: &str| mpv.get_property::<i64>(k).unwrap_or(-1);
info!(
"[MpvBackend] video geometry: {}x{} decoded, {}x{} display, aspect {:?}",
n("width"),
n("height"),
n("dwidth"),
n("dheight"),
mpv.get_property::<f64>("video-params/aspect").ok(),
);
}
// Get duration
if let Ok(duration) = mpv.get_property::<f64>("duration") {
if let Some(emitter) = &event_emitter {
@@ -522,11 +650,24 @@ impl PlayerBackend for MpvBackend {
.as_millis() as u64;
self.last_seek_time.store(now, Ordering::Relaxed);
self.mpv
.set_property("time-pos", position)
.map_err(|e| PlayerError {
message: format!("Failed to seek: {:?}", e),
})?;
// `time-pos` only resolves while a file is loaded. `loadfile` is
// asynchronous, so a seek issued straight after a reload — resume, or a
// transcoded seek — lands in a window where this fails, and dropping it
// there is what makes the stream play from zero instead of the position
// that was asked for. Hold it and let `FileLoaded` apply it.
// TRACES: UR-040, UR-005 | DR-241
if let Err(e) = self.mpv.set_property("time-pos", position) {
debug!(
"[MpvBackend] seek to {position} deferred until the file loads ({:?})",
e
);
*self.pending_seek.lock_safe() = Some(position);
self.observed.lock_safe().record_position(position);
return Ok(());
}
// A seek that lands clears any earlier deferred one: the newer intent wins.
*self.pending_seek.lock_safe() = None;
// The poll thread suppresses updates for 150ms after a seek, so without
// this a file ending inside that window would report the pre-seek time.
+46
View File
@@ -13,6 +13,52 @@ mod tests {
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
/// Every property the event loop *handles* must also be *observed*.
///
/// libmpv only delivers `PropertyChange` for properties registered with
/// `mpv_observe_property`. A `match` arm for an unobserved property is
/// unreachable code that looks exactly like working code: the handler is
/// right there, so the behaviour reads as implemented.
///
/// This cost a real bug. `pause` was handled and never observed, so
/// `StateChanged` was never emitted on pause or resume. It stayed invisible
/// while Linux video played in the webview, because the `<video>` element's
/// own DOM events drove the play/pause control; turning native video on made
/// the UI depend on the event that never came, and the button stopped
/// responding.
///
/// Asserted against the source because there is no way to observe the
/// registration at runtime without a live mpv instance.
///
/// TRACES: UR-005 | DR-239 | UT-218
#[test]
fn test_every_handled_property_is_observed() {
let src = include_str!("mpv_backend.rs");
let handled: Vec<&str> = src
.match_indices("PropertyChange { name: \"")
.filter_map(|(i, m)| {
let rest = &src[i + m.len()..];
rest.find('"').map(|end| &rest[..end])
})
.collect();
assert!(
!handled.is_empty(),
"no PropertyChange arms found - has the event loop been restructured?"
);
for name in handled {
let observed = format!("observe_property(\"{name}\"");
assert!(
src.contains(&observed),
"mpv_backend.rs handles PropertyChange for {name:?} but never calls \
observe_property({name:?}, ..). libmpv will never deliver that event, \
so the handler is dead code."
);
}
}
/// Test that simulates the position update thread spawning async tasks
/// without a Tokio runtime (the bug we just fixed)
#[test]
+383
View File
@@ -0,0 +1,383 @@
//! [`MediaPlayer`] over libmpv.
//!
//! The point of difference from `MpvBackend` is [`MpvPlayer::open`]: the start
//! position is applied **at load time**, via mpv's own `start` option, instead
//! of being seeked to afterwards. `loadfile` is asynchronous, so a seek issued
//! after it targets a player that has nothing loaded, fails, and — under the old
//! contract — was discarded. That is DR-241, and it is why resume and transcoded
//! skip both played from zero.
//!
//! A seek arriving during [`Phase::Opening`] is held and applied when the file
//! loads, so no caller has to know where that window begins or ends.
//!
//! TRACES: UR-081, UR-040, UR-005 | DR-244
#![allow(dead_code)] // Wired to PlayerController in DR-245.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use libmpv::Mpv;
use log::{debug, info, warn};
use super::backend::PlayerError;
use super::media_player::{
duration_from_secs, Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot,
};
use crate::utils::lock::MutexSafe;
/// State the event thread writes and the caller reads.
#[derive(Debug)]
struct Shared {
phase: Phase,
position: Duration,
duration: Option<Duration>,
seekable: bool,
/// A seek that arrived while opening. Applied on `FileLoaded`.
deferred_seek: Option<Duration>,
/// Cleared by `close()`, so an open still in flight cannot come back to life
/// and start playing after the caller has stopped it.
open_generation: u64,
}
impl Default for Shared {
fn default() -> Self {
Self {
phase: Phase::Idle,
position: Duration::ZERO,
duration: None,
seekable: false,
deferred_seek: None,
open_generation: 0,
}
}
}
pub struct MpvPlayer {
mpv: Arc<Mpv>,
shared: Arc<Mutex<Shared>>,
volume: f32,
muted: bool,
rate: f64,
audio_track: Option<i32>,
subtitle_track: Option<i32>,
}
/// How the engine should talk to the machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Output {
/// Real audio and video. What the app uses.
Real,
/// No audio device, no window. What conformance uses, so the suite can run
/// on a headless runner without claiming the user's speakers.
Null,
}
impl MpvPlayer {
pub fn new(output: Output) -> Result<Self, PlayerError> {
// mpv refuses to start under a non-C LC_NUMERIC, and anything that has
// initialised GTK before us will have set one.
unsafe {
let c = std::ffi::CString::new("C").unwrap();
libc::setlocale(libc::LC_NUMERIC, c.as_ptr());
}
let mpv = Mpv::new().map_err(|e| PlayerError {
message: format!("mpv_create failed: {e:?}"),
})?;
let set = |k: &str, v: &str| {
if let Err(e) = mpv.set_property(k, v) {
warn!("[MpvPlayer] could not set {k}={v}: {e:?}");
}
};
match output {
Output::Real => {
set("vo", "libmpv");
}
Output::Null => {
set("ao", "null");
set("vo", "null");
}
}
set("msg-level", "all=warn");
// Survive a blip rather than ending the item on it.
set(
"stream-lavf-o",
"reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=5",
);
let player = Self {
mpv: Arc::new(mpv),
shared: Arc::new(Mutex::new(Shared::default())),
volume: 1.0,
muted: false,
rate: 1.0,
audio_track: None,
subtitle_track: None,
};
player.spawn_events();
Ok(player)
}
fn spawn_events(&self) {
let mpv = self.mpv.clone();
let shared = self.shared.clone();
std::thread::spawn(move || {
let mut ev = mpv.create_event_context();
let _ = ev.disable_deprecated_events();
// Every property matched below must be observed, or libmpv never
// delivers it and the handler is unreachable (DR-239).
for prop in ["pause", "eof-reached"] {
if let Err(e) = ev.observe_property(prop, libmpv::Format::Flag, 0) {
warn!("[MpvPlayer] could not observe {prop}: {e:?}");
}
}
loop {
match ev.wait_event(0.25) {
Some(Ok(libmpv::events::Event::FileLoaded)) => {
let deferred = {
let mut s = shared.lock_safe();
// Closed while opening: do not start.
if s.phase == Phase::Idle {
continue;
}
s.duration = mpv
.get_property::<f64>("duration")
.ok()
.and_then(duration_from_secs);
s.seekable = mpv.get_property::<bool>("seekable").unwrap_or(true);
s.phase = Phase::Playing;
s.deferred_seek.take()
};
if let Some(to) = deferred {
debug!("[MpvPlayer] applying deferred seek to {to:?}");
if let Err(e) = mpv.set_property("time-pos", to.as_secs_f64()) {
warn!("[MpvPlayer] deferred seek failed: {e:?}");
}
}
}
Some(Ok(libmpv::events::Event::PropertyChange { name: "pause", .. })) => {
if let Ok(paused) = mpv.get_property::<bool>("pause") {
let mut s = shared.lock_safe();
if s.phase.has_media() {
s.phase = if paused {
Phase::Paused
} else {
Phase::Playing
};
}
}
}
Some(Ok(libmpv::events::Event::EndFile(reason))) => {
let mut s = shared.lock_safe();
// 0 = EOF. Anything else is a stop, a quit or an error,
// and must not read as "the item finished".
s.phase = if reason == 0 {
Phase::Ended
} else {
Phase::Idle
};
}
Some(Ok(libmpv::events::Event::Shutdown)) => break,
_ => {}
}
if let Ok(pos) = mpv.get_property::<f64>("time-pos") {
let mut s = shared.lock_safe();
if s.phase.has_media() && s.deferred_seek.is_none() {
s.position = Duration::from_secs_f64(pos.max(0.0));
}
}
}
});
}
}
impl MediaPlayer for MpvPlayer {
fn open(&mut self, req: OpenRequest) -> Result<(), PlayerError> {
{
let mut s = self.shared.lock_safe();
*s = Shared {
phase: Phase::Opening,
open_generation: s.open_generation + 1,
..Shared::default()
};
// Report the requested position immediately, so a caller reading
// back during the open sees where it asked to be rather than zero.
s.position = req.start;
}
// The whole point. `start` is applied by mpv as it opens the file, so
// there is no window in which the position can be asked for and lost.
let start = if req.start.is_zero() {
"none".to_string()
} else {
format!("{:.3}", req.start.as_secs_f64())
};
self.mpv
.set_property("start", start.as_str())
.map_err(|e| PlayerError {
message: format!("could not set start position: {e:?}"),
})?;
self.mpv
.set_property("pause", !req.autoplay)
.map_err(|e| PlayerError {
message: format!("could not set pause: {e:?}"),
})?;
info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
self.mpv
.command("loadfile", &[&req.selection.url, "replace"])
.map_err(|e| PlayerError {
message: format!("loadfile failed: {e:?}"),
})?;
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
self.mpv
.set_property("pause", false)
.map_err(|e| PlayerError {
message: format!("play failed: {e:?}"),
})?;
let mut s = self.shared.lock_safe();
if s.phase.has_media() && s.phase != Phase::Opening {
s.phase = Phase::Playing;
}
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
self.mpv
.set_property("pause", true)
.map_err(|e| PlayerError {
message: format!("pause failed: {e:?}"),
})?;
let mut s = self.shared.lock_safe();
if s.phase.has_media() && s.phase != Phase::Opening {
s.phase = Phase::Paused;
}
Ok(())
}
fn close(&mut self) -> Result<(), PlayerError> {
// State first: an open still in flight checks this on FileLoaded and
// must not proceed to play after the caller has stopped it.
{
let mut s = self.shared.lock_safe();
*s = Shared {
open_generation: s.open_generation,
..Shared::default()
};
}
// Idempotent: stopping an already-stopped mpv is not an error worth
// propagating, and callers legitimately close twice on teardown.
if let Err(e) = self.mpv.command("stop", &[]) {
debug!("[MpvPlayer] stop on an idle player: {e:?}");
}
Ok(())
}
fn seek(&mut self, to: Duration) -> Result<(), PlayerError> {
{
let mut s = self.shared.lock_safe();
match s.phase {
// Held, not dropped. The caller cannot see this window.
Phase::Opening => {
s.deferred_seek = Some(to);
s.position = to;
return Ok(());
}
Phase::Idle | Phase::Failed(_) => {
return Err(PlayerError {
message: "seek with nothing open".to_string(),
})
}
_ => s.position = to,
}
}
self.mpv
.set_property("time-pos", to.as_secs_f64())
.map_err(|e| PlayerError {
message: format!("seek failed: {e:?}"),
})
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
self.volume = clamped;
self.mpv
.set_property("volume", (clamped as f64) * 100.0)
.map_err(|e| PlayerError {
message: format!("set_volume failed: {e:?}"),
})
}
fn set_muted(&mut self, muted: bool) -> Result<(), PlayerError> {
self.muted = muted;
self.mpv
.set_property("mute", muted)
.map_err(|e| PlayerError {
message: format!("set_muted failed: {e:?}"),
})
}
fn set_rate(&mut self, rate: f64) -> Result<(), PlayerError> {
self.rate = rate;
self.mpv
.set_property("speed", rate)
.map_err(|e| PlayerError {
message: format!("set_rate failed: {e:?}"),
})
}
fn select_audio_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.audio_track = index;
let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
self.mpv
.set_property("aid", value.as_str())
.map_err(|e| PlayerError {
message: format!("select_audio_track failed: {e:?}"),
})
}
fn select_subtitle_track(&mut self, index: Option<i32>) -> Result<(), PlayerError> {
self.subtitle_track = index;
let value = index.map(|i| i.to_string()).unwrap_or_else(|| "no".into());
self.mpv
.set_property("sid", value.as_str())
.map_err(|e| PlayerError {
message: format!("select_subtitle_track failed: {e:?}"),
})
}
fn snapshot(&self) -> PlaybackSnapshot {
let s = self.shared.lock_safe();
PlaybackSnapshot {
phase: s.phase.clone(),
position: s.position,
duration: s.duration,
seekable: s.seekable,
volume: self.volume,
muted: self.muted,
rate: self.rate,
audio_track: self.audio_track,
subtitle_track: self.subtitle_track,
}
}
fn capabilities(&self) -> Capabilities {
Capabilities {
video: true,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
// mpv's HLS demuxer cannot make the server transcode from a new
// offset, so a transcoded seek must re-open the stream.
seeks_transcoded_in_place: false,
}
}
}
+405
View File
@@ -0,0 +1,405 @@
//! mpv's render API, driven into an OpenGL framebuffer we own.
//!
//! This is the half of native video that is not GTK: create a render context
//! over the mpv handle the audio backend already drives, render a frame into a
//! texture, and hand that texture id back for the toolkit to composite.
//!
//! Kept apart from `video_surface` deliberately — everything here is portable
//! across the platforms this app targets, while the surface that consumes it is
//! not. Windows reuses this file unchanged (DR-237).
//!
//! TRACES: UR-080 | DR-231, DR-232, IR-033
use std::ffi::{c_void, CStr, CString};
use std::os::raw::{c_char, c_int};
use std::ptr;
use log::{error, info, warn};
/// GL entry points, resolved once.
///
/// Only the handful needed to own a framebuffer; mpv resolves everything else
/// it needs through [`get_proc_address`].
struct Gl {
gen_framebuffers: unsafe extern "C" fn(c_int, *mut u32),
delete_framebuffers: unsafe extern "C" fn(c_int, *const u32),
bind_framebuffer: unsafe extern "C" fn(u32, u32),
framebuffer_texture_2d: unsafe extern "C" fn(u32, u32, u32, u32, c_int),
gen_textures: unsafe extern "C" fn(c_int, *mut u32),
delete_textures: unsafe extern "C" fn(c_int, *const u32),
bind_texture: unsafe extern "C" fn(u32, u32),
tex_image_2d:
unsafe extern "C" fn(u32, c_int, c_int, c_int, c_int, c_int, u32, u32, *const c_void),
tex_parameteri: unsafe extern "C" fn(u32, u32, c_int),
check_framebuffer_status: unsafe extern "C" fn(u32) -> u32,
}
const GL_TEXTURE_2D: u32 = 0x0DE1;
const GL_FRAMEBUFFER: u32 = 0x8D40;
const GL_COLOR_ATTACHMENT0: u32 = 0x8CE0;
const GL_RGBA: u32 = 0x1908;
const GL_RGBA8: c_int = 0x8058;
const GL_UNSIGNED_BYTE: u32 = 0x1401;
const GL_LINEAR: c_int = 0x2601;
const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
const GL_TEXTURE_MAG_FILTER: u32 = 0x2800;
const GL_FRAMEBUFFER_COMPLETE: u32 = 0x8CD5;
/// Resolve a GL symbol the way libepoxy actually exports it.
///
/// **This is the trap that cost the spike a debugging cycle.** libepoxy does not
/// export `glFoo` as a function. It exports `epoxy_glFoo` as a *data* symbol
/// holding a lazily-resolving function pointer. So the address `dlsym` returns
/// is the address *of the pointer*, not of any code: returning it makes mpv jump
/// into non-executable data and take SIGSEGV/SEGV_ACCERR on the very first GL
/// call. The value must be read *out of* that location.
///
/// The `epoxy` crate does this correctly and is unusable here — its
/// `gl_generator` dependency pulls a yanked `xml-rs`.
///
/// TRACES: UR-080 | IR-033
unsafe fn resolve(name: &str) -> *mut c_void {
let epoxy_name = match CString::new(format!("epoxy_{name}")) {
Ok(n) => n,
Err(_) => return ptr::null_mut(),
};
let slot = libc::dlsym(libc::RTLD_DEFAULT, epoxy_name.as_ptr());
if !slot.is_null() {
// The symbol holds the function pointer; return what is stored there.
return *(slot as *mut *mut c_void);
}
// Fall back to a plain symbol, for a GL stack that is not behind epoxy.
match CString::new(name) {
Ok(n) => libc::dlsym(libc::RTLD_DEFAULT, n.as_ptr()),
Err(_) => ptr::null_mut(),
}
}
/// What mpv calls to find GL entry points. Same rule as [`resolve`].
unsafe extern "C" fn get_proc_address(_ctx: *mut c_void, name: *const c_char) -> *mut c_void {
if name.is_null() {
return ptr::null_mut();
}
match CStr::from_ptr(name).to_str() {
Ok(n) => resolve(n),
Err(_) => ptr::null_mut(),
}
}
macro_rules! load {
($name:literal) => {{
let p = resolve($name);
if p.is_null() {
error!("[MpvRender] GL symbol not found: {}", $name);
return None;
}
std::mem::transmute(p)
}};
}
impl Gl {
/// Resolve every entry point, or none — a partially-loaded table would fail
/// later at a call site with no context.
///
/// The transmutes are unannotated on purpose: each target type is declared
/// once on the struct field above, and repeating it at the call site would
/// be two places to get the same signature wrong.
#[allow(clippy::missing_transmute_annotations)]
unsafe fn load() -> Option<Self> {
Some(Gl {
gen_framebuffers: load!("glGenFramebuffers"),
delete_framebuffers: load!("glDeleteFramebuffers"),
bind_framebuffer: load!("glBindFramebuffer"),
framebuffer_texture_2d: load!("glFramebufferTexture2D"),
gen_textures: load!("glGenTextures"),
delete_textures: load!("glDeleteTextures"),
bind_texture: load!("glBindTexture"),
tex_image_2d: load!("glTexImage2D"),
tex_parameteri: load!("glTexParameteri"),
check_framebuffer_status: load!("glCheckFramebufferStatus"),
})
}
}
/// A colour-renderable framebuffer mpv draws into, sized to the widget.
struct Target {
fbo: u32,
texture: u32,
width: i32,
height: i32,
}
/// mpv's render context plus the framebuffer it draws into.
///
/// # Lifetime (DR-232)
///
/// The render context must not outlive the GL context it was created against.
/// `Drop` unregisters mpv's update callback *before* freeing the context, so a
/// callback cannot land on a freed pointer, and frees the GL objects while the
/// caller still has the context current. The caller is responsible for making
/// the GL context current around both creation and drop — see `video_surface`.
///
/// This is DR-184 on Android restated: a surface outliving its player. The spike
/// had no defence at all and saw one unexplained SIGSEGV in a decoder thread.
pub struct MpvRenderContext {
ctx: *mut libmpv_sys::mpv_render_context,
gl: Gl,
target: Option<Target>,
}
// The render context is driven only from the GTK main thread; the update
// callback merely schedules a redraw and touches nothing here.
unsafe impl Send for MpvRenderContext {}
impl MpvRenderContext {
/// Create a render context over an existing mpv handle.
///
/// The GL context must already be current on this thread.
///
/// TRACES: UR-080 | DR-231, IR-033
pub unsafe fn new(mpv: *mut libmpv_sys::mpv_handle) -> Option<Self> {
let gl = Gl::load()?;
let mut init = libmpv_sys::mpv_opengl_init_params {
get_proc_address: Some(get_proc_address),
get_proc_address_ctx: ptr::null_mut(),
};
let mut api_type = CString::new("opengl").ok()?;
// Advanced control is deliberately OFF.
//
// With it on, mpv expects the client to drive rendering to a stricter
// contract than a GTK draw handler can promise — it will wait on us, and
// if we in turn wait on its update callback, neither side proceeds. That
// deadlock presents as a file that loads, renders one frame, and then
// sits there with no audio and a spinner.
//
// Off, mpv is tolerant of being rendered on the toolkit's schedule,
// which is what the frame clock gives us.
let mut advanced: c_int = 0;
let mut params = [
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
data: api_type.as_ptr() as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
data: &mut init as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_ADVANCED_CONTROL,
data: &mut advanced as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: 0,
data: ptr::null_mut(),
},
];
let mut ctx: *mut libmpv_sys::mpv_render_context = ptr::null_mut();
let rc = libmpv_sys::mpv_render_context_create(&mut ctx, mpv, params.as_mut_ptr());
// Keep the CString alive until after the call.
let _ = &mut api_type;
if rc < 0 || ctx.is_null() {
error!("[MpvRender] mpv_render_context_create failed: {rc}");
return None;
}
info!("[MpvRender] render context created");
Some(MpvRenderContext {
ctx,
gl,
target: None,
})
}
/// Ask to be told when a new frame is ready.
///
/// Paired with [`report_swap`](Self::report_swap): without both, mpv has
/// nothing to time against. The symptom is misleading — playback looks fine
/// in a window and judders at fullscreen, which reads as a compositing or
/// GPU limit and is neither (DR-233).
///
/// TRACES: UR-080 | DR-233
pub unsafe fn set_update_callback(
&mut self,
callback: libmpv_sys::mpv_render_update_fn,
ctx: *mut c_void,
) {
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, callback, ctx);
}
/// Whether mpv has a new frame waiting.
///
/// Asked of mpv directly rather than inferred from its update callback, and
/// that distinction is the whole of frame pacing here:
///
/// - Waiting only on the callback deadlocks — mpv will not progress until
/// the client renders, so if the client will not render until mpv says
/// so, neither moves. That presents as a file that loads, shows one
/// frame, and then sits silent.
/// - Rendering on *every* frame-clock tick regardless is the opposite
/// error: `report_swap` then claims a presentation far more often than
/// real frames exist, mpv has nothing coherent to time against, and
/// playback judders badly.
///
/// Polling is neither. It runs on the main thread, costs a single atomic
/// read inside mpv, and answers the only question that matters.
///
/// TRACES: UR-080 | DR-233
pub unsafe fn has_frame(&self) -> bool {
let flags = libmpv_sys::mpv_render_context_update(self.ctx);
(flags & libmpv_sys::mpv_render_update_flag_MPV_RENDER_UPDATE_FRAME as u64) != 0
}
/// Render the current frame at `width` x `height`, returning the texture id
/// holding it. The GL context must be current.
///
/// TRACES: UR-080 | DR-231
pub unsafe fn render(&mut self, width: i32, height: i32) -> Option<u32> {
if width <= 0 || height <= 0 {
return None;
}
self.ensure_target(width, height)?;
let target = self.target.as_ref()?;
let mut fbo = libmpv_sys::mpv_opengl_fbo {
fbo: target.fbo as c_int,
w: width as c_int,
h: height as c_int,
internal_format: 0,
};
// GTK's cairo surface has its origin at the top left; mpv defaults to
// OpenGL's bottom-left. Without this the picture is drawn upside down —
// which looks like a broken decode rather than a coordinate convention.
let mut flip: c_int = 1;
let mut params = [
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_FBO,
data: &mut fbo as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_FLIP_Y,
data: &mut flip as *mut _ as *mut c_void,
},
libmpv_sys::mpv_render_param {
type_: 0,
data: ptr::null_mut(),
},
];
let rc = libmpv_sys::mpv_render_context_render(self.ctx, params.as_mut_ptr());
if rc < 0 {
warn!("[MpvRender] render failed: {rc}");
return None;
}
Some(target.texture)
}
/// Tell mpv the frame reached the screen. See [`set_update_callback`].
///
/// TRACES: UR-080 | DR-233
pub unsafe fn report_swap(&self) {
libmpv_sys::mpv_render_context_report_swap(self.ctx);
}
/// Create or resize the framebuffer. Reused across frames — reallocating per
/// frame would churn GPU memory at the display rate.
unsafe fn ensure_target(&mut self, width: i32, height: i32) -> Option<()> {
if let Some(t) = &self.target {
if t.width == width && t.height == height {
return Some(());
}
}
self.drop_target();
let gl = &self.gl;
let mut texture: u32 = 0;
(gl.gen_textures)(1, &mut texture);
(gl.bind_texture)(GL_TEXTURE_2D, texture);
(gl.tex_image_2d)(
GL_TEXTURE_2D,
0,
GL_RGBA8,
width,
height,
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
ptr::null(),
);
(gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
(gl.tex_parameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
(gl.bind_texture)(GL_TEXTURE_2D, 0);
let mut fbo: u32 = 0;
(gl.gen_framebuffers)(1, &mut fbo);
(gl.bind_framebuffer)(GL_FRAMEBUFFER, fbo);
(gl.framebuffer_texture_2d)(
GL_FRAMEBUFFER,
GL_COLOR_ATTACHMENT0,
GL_TEXTURE_2D,
texture,
0,
);
let status = (gl.check_framebuffer_status)(GL_FRAMEBUFFER);
(gl.bind_framebuffer)(GL_FRAMEBUFFER, 0);
if status != GL_FRAMEBUFFER_COMPLETE {
error!("[MpvRender] framebuffer incomplete: 0x{status:x}");
(gl.delete_framebuffers)(1, &fbo);
(gl.delete_textures)(1, &texture);
return None;
}
self.target = Some(Target {
fbo,
texture,
width,
height,
});
Some(())
}
unsafe fn drop_target(&mut self) {
if let Some(t) = self.target.take() {
(self.gl.delete_framebuffers)(1, &t.fbo);
(self.gl.delete_textures)(1, &t.texture);
}
}
/// Free everything, with the GL context current.
///
/// Explicit rather than left to `Drop` because the ordering matters and the
/// caller is the only one that can guarantee the GL context is current. See
/// DR-232.
pub unsafe fn destroy(mut self) {
// Unregister first: a callback arriving after the free would be a use
// after free, and it is scheduled from mpv's own threads.
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
self.drop_target();
libmpv_sys::mpv_render_context_free(self.ctx);
self.ctx = ptr::null_mut();
info!("[MpvRender] render context freed");
std::mem::forget(self);
}
}
impl Drop for MpvRenderContext {
fn drop(&mut self) {
if !self.ctx.is_null() {
// Reached only if `destroy` was not called — the GL context may not
// be current, so the GL objects are deliberately leaked rather than
// deleted against whatever context happens to be bound. Freeing the
// render context is still safe and is the part that matters.
warn!("[MpvRender] dropped without destroy(); GL objects leaked deliberately");
unsafe {
libmpv_sys::mpv_render_context_set_update_callback(self.ctx, None, ptr::null_mut());
libmpv_sys::mpv_render_context_free(self.ctx);
}
}
}
}
+78
View File
@@ -0,0 +1,78 @@
//! Whether this process renders video natively, answered once.
//!
//! Three things need this and must agree: the mpv backend (which has to be
//! configured for video *at construction*, before anything plays), the video
//! surface (which has nothing to draw otherwise), and `get_player_status`
//! (which tells the frontend whether to use a webview `<video>` element).
//!
//! It is a function rather than three `env::var` checks for the reason this
//! codebase keeps rediscovering: a capability answered in several places is a
//! capability whose answers drift. Four separate bugs this cycle came from
//! exactly that shape — a webview's decode limits applied to ExoPlayer, a
//! transcode target contradicting a direct-play claim, a codec list hardcoded in
//! a URL builder. One source, read by everyone.
//!
//! TRACES: UR-080 | DR-231, DR-235
/// The opt-in for native desktop video.
///
/// Off by default while the render path is unproven — the webview path still
/// works and is what ships. This becomes the *default* (and then the only path)
/// when DR-235 lands; the variable is how it is exercised until then.
const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
/// Whether mpv should decode and draw video in this process.
///
/// Read fresh rather than cached: it is consulted a handful of times at startup,
/// and a `OnceLock` here would only make it harder to test.
///
/// TRACES: UR-080 | DR-231, DR-235
pub fn enabled() -> bool {
// Only where a native renderer exists. On Android ExoPlayer already does
// this and `use_html5_element` is false for entirely separate reasons.
if !cfg!(all(target_os = "linux", not(target_os = "android"))) {
return false;
}
matches!(
std::env::var(ENV_FLAG).as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
}
#[cfg(test)]
mod tests {
use super::*;
/// Absent, empty, or anything unrecognised means off. A half-set variable
/// must not half-enable a renderer — the failure mode would be mpv
/// configured for video with nothing drawing it, i.e. audio playing over a
/// black rectangle.
///
/// TRACES: UR-080 | DR-231 | UT-216
#[test]
fn test_only_explicit_truthy_values_enable_it() {
let restore = std::env::var(ENV_FLAG).ok();
for value in ["", "0", "no", "false", "maybe", "2"] {
std::env::set_var(ENV_FLAG, value);
assert!(!enabled(), "{value:?} must not enable native video");
}
for value in ["1", "true", "yes"] {
std::env::set_var(ENV_FLAG, value);
assert_eq!(
enabled(),
cfg!(all(target_os = "linux", not(target_os = "android"))),
"{value:?} enables it exactly where a native renderer exists"
);
}
std::env::remove_var(ENV_FLAG);
assert!(!enabled(), "absent means off");
match restore {
Some(v) => std::env::set_var(ENV_FLAG, v),
None => std::env::remove_var(ENV_FLAG),
}
}
}
+75 -20
View File
@@ -25,12 +25,14 @@ pub enum VideoSeekStrategy {
///
/// # Arguments
/// * `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
/// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy(
is_local: bool,
is_hls: bool,
seeks_transcoded_in_place: bool,
needs_transcoding: bool,
use_html5: bool,
) -> VideoSeekStrategy {
@@ -39,24 +41,40 @@ pub fn determine_video_seek_strategy(
return VideoSeekStrategy::LocalNativeSeek;
}
// HLS streams and direct play (non-transcoded) support native seeking
if is_hls || !needs_transcoding {
// A server-side transcode is produced *from* `StartTimeTicks`, so where the
// seek lands is a property of the request, not of the stream in hand.
//
// hls.js is the exception: handed a VOD playlist it seeks within it and lets
// the server catch up segment by segment. mpv's HLS demuxer cannot make
// Jellyfin transcode from a new offset, so for the native backend a
// transcoded seek must re-negotiate the stream regardless of container.
//
// Before native video shipped, `use_html5` was always true for HLS and the
// native+HLS+transcode cell was unreachable, which is why `is_hls` alone
// used to be a safe proxy for "seekable in place". It no longer is: turning
// native video on routed every transcoded seek into a backend seek that
// silently does nothing, and presents as "resume does not work".
if needs_transcoding {
// Whether a transcode can be seeked in place is a property of the
// engine, and the engine states it. This used to be inferred from
// `is_hls`, which held only while hls.js was the sole HLS renderer —
// and stopped holding the moment mpv became one (DR-238).
return match (seeks_transcoded_in_place, use_html5) {
(true, true) => VideoSeekStrategy::Html5NativeSeek,
(true, false) => VideoSeekStrategy::BackendNativeSeek,
(false, true) => VideoSeekStrategy::Html5ReloadStream,
(false, false) => VideoSeekStrategy::BackendReloadStream,
};
}
// Direct play and direct stream are seekable where they sit.
if use_html5 {
// HTML5 backend - frontend handles seeking via videoElement.currentTime
// We don't call backend.seek() because video is in HTML5 element, not in MPV
// The frontend seeks via videoElement.currentTime; calling backend.seek()
// would move a player that is not the one rendering.
VideoSeekStrategy::Html5NativeSeek
} else {
// Native backend (MPV) - backend handles seeking
VideoSeekStrategy::BackendNativeSeek
}
} else {
// Transcoded non-HLS streams need server-side seek (reload from new position)
if use_html5 {
VideoSeekStrategy::Html5ReloadStream
} else {
VideoSeekStrategy::BackendReloadStream
}
}
}
// The four items below are consumed by the Android MediaSessionHandler; on other
@@ -220,26 +238,63 @@ mod tests {
);
}
/// Test video seek strategy for HLS streams
/// Non-transcoded streams seek in place regardless of the engine's
/// transcode ability, which only applies to transcodes.
#[test]
fn test_seek_strategy_hls_stream() {
// HLS with HTML5 - frontend handles seek, don't call backend
fn test_seek_strategy_direct_stream() {
// HTML5 renders, so the frontend seeks the element
assert_eq!(
determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// HLS with native backend - backend handles seek
// The native engine renders, so it seeks
assert_eq!(
determine_video_seek_strategy(false, true, false, false),
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!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
}
/// A server-side transcode cannot be seeked by the native backend.
///
/// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek
/// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
/// the server transcode from a new offset, so the stream has to be
/// re-negotiated. Before native video existed, `use_html5` was always true
/// for HLS and this case was unreachable — turning native video on routed
/// every transcoded seek into a native seek that silently does nothing,
/// which presents as "resume does not work".
///
/// TRACES: UR-040 | DR-238, DR-246 | UT-217
#[test]
fn test_transcoded_seek_follows_the_engines_declared_ability() {
// An engine that cannot move a server-side transcode re-opens it,
// whichever side is rendering.
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream
);
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// hls.js can, and says so, so it seeks in place.
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
// The container the stream arrives in no longer decides anything: the
// same declared ability gives the same answer on the native side.
assert_eq!(
determine_video_seek_strategy(false, true, true, false),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for direct play (non-transcoded) streams
#[test]
fn test_seek_strategy_direct_play() {
+373 -193
View File
@@ -1,232 +1,412 @@
//! The native video surface: a GL area beneath Tauri's own webview.
//! The native video surface: mpv drawn *behind* Tauri's webview, without
//! touching the widget tree.
//!
//! This is the desktop counterpart of the Android arrangement — a native
//! renderer at the bottom of the stack with a transparent webview drawn over it,
//! so the Svelte controls composite on top of moving video.
//! # Why there is no overlay here
//!
//! The spike that authorised this built its *own* `GtkOverlay` and proved mpv
//! renders into it on X11 and Wayland. What it could not prove is the step this
//! module exists for: taking the overlay Tauri already built and reparenting the
//! real webview into it. Same widgets, one extra move, and the only place
//! Tauri-specific behaviour can still bite — which is why it is gate one.
//! The obvious arrangement — wrap the webview in a `GtkOverlay` with a
//! `GtkGLArea` beneath — attaches cleanly and then aborts the process on the
//! first click. `tauri-runtime-wry` connects a button-press handler to the
//! webview that walks a hard-coded path:
//!
//! TRACES: UR-080 | DR-231, IR-033
//! ```text
//! webview.parent() // "This one should be GtkBox"
//! .parent() // ...and this one the GtkWindow
//! .downcast::<gtk::Window>().unwrap()
//! ```
//!
//! An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast
//! fails, and because the panic is non-unwinding it takes the app with it.
//! Nothing in configuration avoids it: on Linux the handler is attached
//! *unconditionally* (the Windows path guards it behind `is_decorated()`), and
//! the decoration check that would make it inert runs *after* the unwrap.
//!
//! So the widget tree is left exactly as Tauri built it. GTK draws a container
//! before its children, so rendering into the vbox's own `draw` handler puts the
//! picture underneath the webview for free — the same z-order, no reparenting,
//! one less widget, and nothing a Tauri upgrade can invalidate by assuming its
//! own layout.
//!
//! TRACES: UR-080 | DR-231, DR-232, DR-233, IR-033
use std::cell::RefCell;
use std::ffi::c_void;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use gtk::prelude::*;
use log::{info, warn};
use gtk::{gdk, glib};
use log::{error, info, warn};
/// The widgets that make up the video surface, kept together because their
/// lifetimes are bound: the render context (added next) is created when the GL
/// area realizes and must be freed before it unrealizes — DR-232.
use super::mpv_render::MpvRenderContext;
/// GL enum for `gdk_cairo_draw_from_gl`'s `source_type`. GDK takes the GL
/// constant itself rather than an enum of its own.
const GL_TEXTURE: i32 = 0x1702;
/// Everything the draw handler needs, shared with the GTK callbacks.
struct SurfaceState {
gl: Option<gdk::GLContext>,
render: Option<MpvRenderContext>,
mpv: *mut libmpv_sys::mpv_handle,
/// Set by mpv's update callback (on an mpv thread), cleared by the frame
/// clock (on the main thread). The whole cross-thread contract.
frame_ready: Arc<AtomicBool>,
/// The boxed clone of `frame_ready` handed to mpv, reclaimed on teardown.
/// Null when no callback is registered.
callback_ctx: *mut Arc<AtomicBool>,
// One-shot diagnostic latches; see `draw`.
logged_first_draw: bool,
logged_first_frame: bool,
/// Last size we logged, so a size change re-reports rather than staying silent.
logged_size: (i32, i32),
logged_no_gl: bool,
logged_no_window: bool,
logged_no_size: bool,
logged_render_fail: bool,
}
impl SurfaceState {
/// Tear down in the order DR-232 requires, with the GL context current.
///
/// The update callback is unregistered before the context is freed (inside
/// `destroy`), and the GL objects go while their context is still bound.
/// Getting this wrong is DR-184 on Android restated — a surface outliving
/// its player — and is the likeliest cause of the one unexplained SIGSEGV
/// the spike recorded.
fn teardown(&mut self) {
if let Some(render) = self.render.take() {
if let Some(gl) = &self.gl {
gl.make_current();
}
// Unregisters the callback before freeing the context.
unsafe { render.destroy() };
}
// Only now is it safe to reclaim what the callback was holding: mpv can
// no longer reach it. Freeing it first would be the use-after-free this
// ordering exists to prevent.
if !self.callback_ctx.is_null() {
unsafe { drop(Box::from_raw(self.callback_ctx)) };
self.callback_ctx = std::ptr::null_mut();
}
self.gl = None;
}
}
/// A live video surface. Dropping it tears the render context down.
pub struct VideoSurface {
/// The GL area mpv renders into. Main child of the overlay, so it sits
/// *under* everything else.
#[allow(dead_code)]
gl_area: gtk::GLArea,
/// The overlay holding the GL area and the webview.
#[allow(dead_code)]
overlay: gtk::Overlay,
state: Rc<RefCell<SurfaceState>>,
widget: gtk::Box,
handlers: Vec<glib::SignalHandlerId>,
}
impl VideoSurface {
// Consumed by the render context, which binds to the GL area on `realize`
// and is freed on `unrealize` (DR-232). Held here from the moment the
// surface exists so that binding has something to attach to.
#[allow(dead_code)]
/// The GL area, for the render context to bind to.
pub fn gl_area(&self) -> &gtk::GLArea {
&self.gl_area
impl Drop for VideoSurface {
fn drop(&mut self) {
for id in self.handlers.drain(..) {
self.widget.disconnect(id);
}
#[allow(dead_code)]
/// The overlay, for teardown.
pub fn overlay(&self) -> &gtk::Overlay {
&self.overlay
self.state.borrow_mut().teardown();
self.widget.queue_draw();
info!("[VideoSurface] detached");
}
}
/// Why a surface could not be attached.
/// mpv's update callback. Runs on an mpv thread, so it does the least possible:
/// flags the state and asks GTK to redraw on the main loop.
///
/// One variant, because there is exactly one way this fails that is not already
/// reported by Tauri itself: the window exists and has a vbox, but the vbox is
/// not shaped the way Tauri has always shaped it.
#[derive(Debug)]
pub enum SurfaceError {
/// The vbox held no webview to reparent — Tauri's layout has changed.
NoWebviewChild,
/// **Nothing here may block or re-enter the player.** The project's deadlock
/// gotcha applies with full force — this is called from mpv's own threads.
///
/// TRACES: UR-080 | DR-233
unsafe extern "C" fn on_mpv_update(ctx: *mut c_void) {
if ctx.is_null() {
return;
}
// Runs on an *mpv* thread. It therefore does exactly one thing that is safe
// to do from there: set an atomic flag.
//
// It must not touch GTK, and specifically must not schedule work with
// `idle_add_local*`, which requires the calling thread to own the default
// main context — from here that panics with "default main context already
// acquired by another thread". Nor can it hold the `Rc<RefCell<..>>` state:
// an `Rc` is not `Send`, and cloning one from two threads races its
// refcount.
//
// The frame clock on the widget picks the flag up on the main thread. See
// `install_frame_clock`.
let flag = &*(ctx as *const Arc<AtomicBool>);
flag.store(true, Ordering::Release);
}
impl std::fmt::Display for SurfaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SurfaceError::NoWebviewChild => write!(
f,
"Tauri's default vbox had no child to reparent — its window layout has changed"
),
/// Start drawing mpv's video underneath the webview.
///
/// `vbox` is Tauri's `default_vbox()` — the container the webview already lives
/// in. It is not modified; only a `draw` handler is added.
///
/// Must run on the GTK main thread.
///
/// TRACES: UR-080 | DR-231, DR-232, DR-233
pub fn attach(vbox: &gtk::Box, mpv: *mut libmpv_sys::mpv_handle) -> bool {
if mpv.is_null() {
warn!("[VideoSurface] no mpv handle; native video unavailable");
return false;
}
let state = Rc::new(RefCell::new(SurfaceState {
gl: None,
render: None,
mpv,
frame_ready: Arc::new(AtomicBool::new(false)),
callback_ctx: std::ptr::null_mut(),
logged_first_draw: false,
logged_first_frame: false,
logged_size: (0, 0),
logged_no_gl: false,
logged_no_window: false,
logged_no_size: false,
logged_render_fail: false,
}));
let mut handlers = Vec::new();
// The GL context can only be created once the widget has a GdkWindow, which
// is what `realize` announces. Creating it earlier leaves nothing to attach
// to — the same ordering constraint the render context has.
let realize_state = state.clone();
handlers.push(vbox.connect_realize(move |widget| {
if let Err(e) = init_gl(widget, &realize_state) {
error!("[VideoSurface] GL init failed: {e}");
}
}));
// A render context outliving its GL context is the defect DR-232 exists to
// prevent, so teardown is bound to `unrealize` rather than left to Drop.
let unrealize_state = state.clone();
handlers.push(vbox.connect_unrealize(move |_| {
unrealize_state.borrow_mut().teardown();
}));
// Drive the render loop from the widget's frame clock, on the main thread,
// rendering only when mpv actually has a frame.
//
// Both nearby mistakes were made and are worth naming, because each has a
// symptom that points somewhere else:
//
// - Waiting on mpv's update callback before rendering deadlocks. mpv does
// not progress until the client renders. The file loads, one frame
// appears, and everything stops — no picture, no audio, a spinner that
// never clears. It reads as a broken stream.
// - Rendering unconditionally every tick and reporting a swap each time
// tells mpv a frame reached the screen far more often than one did. It
// plays, and judders badly. It reads as a GPU or compositing limit.
//
// Polling `has_frame` each tick is neither.
//
// The frame clock only ticks while the widget is mapped, so this costs
// nothing when the window is hidden.
//
// TRACES: UR-080 | DR-233
let tick_state = state.clone();
vbox.add_tick_callback(move |widget, _clock| {
// Ask mpv, on the main thread, whether there is anything new. The
// update callback's flag is only a hint that something *may* have
// happened; `has_frame` is the authority, and asking it here is what
// keeps this from either deadlocking or over-presenting.
let ready = match tick_state.try_borrow() {
Ok(s) => {
s.frame_ready.swap(false, Ordering::AcqRel);
match s.render.as_ref() {
Some(render) => unsafe { render.has_frame() },
None => false,
}
}
Err(_) => false,
};
if ready {
widget.queue_draw();
}
glib::ControlFlow::Continue
});
let draw_state = state.clone();
handlers.push(vbox.connect_draw(move |widget, cr| {
draw(widget, cr, &draw_state);
// Propagate: the webview is a child and must still draw over us.
glib::Propagation::Proceed
}));
// The window is already up by the time we are called, so run the init the
// `realize` signal would have.
if vbox.is_realized() {
if let Err(e) = init_gl(vbox, &state) {
error!("[VideoSurface] GL init failed: {e}");
}
}
info!("[VideoSurface] attached to Tauri's vbox without reparenting");
// The surface lives as long as the window. Held in a thread-local rather
// than returned, because it owns `Rc` and GTK types and so is neither `Send`
// nor `Sync` — it cannot go into Tauri's managed state, and leaking it would
// give up the ability to tear it down at all.
//
// Teardown does not depend on this being dropped: it is driven by the
// widget's `unrealize`, which is the signal that actually means "your GL
// context is going away" (DR-232).
LIVE_SURFACE.with(|cell| {
*cell.borrow_mut() = Some(VideoSurface {
state,
widget: vbox.clone(),
handlers,
});
});
true
}
impl std::error::Error for SurfaceError {}
/// Build the overlay and move Tauri's webview on top of it.
///
/// Tauri's Linux window is an `ApplicationWindow` holding a single vertical
/// `gtk::Box` (`default_vbox`), with the webview packed into it. This takes that
/// webview out, puts a `GtkGLArea` in its place inside a `GtkOverlay`, and adds
/// the webview back as the *overlay* child so it draws above.
///
/// **Must run on the GTK main thread.** Every GTK call here is main-thread-only,
/// and the caller reaches it via `run_on_main_thread`.
///
/// Ordering matters: the GL area is added as the overlay's main child *before*
/// the webview goes back, because `GtkOverlay` treats its first `add` as the
/// bottom of the stack. Adding them the other way round yields a webview with
/// video painted over it — an easy mistake with an obvious symptom.
///
/// TRACES: UR-080 | DR-231
pub fn attach(vbox: &gtk::Box) -> Result<VideoSurface, SurfaceError> {
// Tauri packs exactly one child (the webview) into the default vbox. Take it
// rather than assume its type: wry's widget is an implementation detail, and
// all this needs is "whatever Tauri put here".
let children = vbox.children();
let webview = children
.into_iter()
.next()
.ok_or(SurfaceError::NoWebviewChild)?;
let gl_area = gtk::GLArea::new();
// No depth buffer: mpv draws a flat picture into an FBO and nothing here is
// 3D. Asking for one costs memory on every resize for nothing.
gl_area.set_has_depth_buffer(false);
gl_area.set_has_stencil_buffer(false);
// Fill the overlay rather than centring at intrinsic size — the same defect
// `videoFitClass` had to fix on the webview side, where `max-w-full` only
// ever shrank and a 480p source rendered as a small box on a black screen.
gl_area.set_hexpand(true);
gl_area.set_vexpand(true);
let overlay = gtk::Overlay::new();
// Reparent. `remove` drops the container's reference, so hold one across the
// move or the widget is destroyed between the two calls.
let webview_ref = webview.clone();
vbox.remove(&webview);
overlay.add(&gl_area); // main child — the bottom of the stack
overlay.add_overlay(&webview_ref); // drawn above the video
// The webview must keep receiving input: it *is* the UI. `GtkOverlay` passes
// events to overlay children by default, so pass-through stays off — setting
// it would send clicks to the GL area, which has no controls on it.
overlay.set_overlay_pass_through(&webview_ref, false);
vbox.pack_start(&overlay, true, true, 0);
overlay.show_all();
info!("[VideoSurface] GL area attached beneath Tauri's webview");
Ok(VideoSurface { gl_area, overlay })
thread_local! {
/// The one live surface, on the GTK main thread.
static LIVE_SURFACE: RefCell<Option<VideoSurface>> = const { RefCell::new(None) };
}
/// Put Tauri's window back the way it was found.
/// Drop the live surface, if there is one. Idempotent.
///
/// Not merely tidiness: the webview outlives the video surface, so if the
/// surface is torn down without returning the webview to the vbox the UI
/// disappears while the app keeps running. Mirrors [`attach`] exactly.
///
/// TRACES: UR-080 | DR-231, DR-232
// Called by the render-context teardown, which lands with DR-232. Written now,
// beside `attach`, because a reparent whose inverse is written later is a
// reparent whose inverse is written wrong.
/// TRACES: UR-080 | DR-232
#[allow(dead_code)]
pub fn detach(vbox: &gtk::Box, surface: &VideoSurface) {
let children = surface.overlay.children();
for child in children {
// Everything except the GL area came from the vbox and goes back to it.
if child.downcast_ref::<gtk::GLArea>().is_some() {
continue;
}
surface.overlay.remove(&child);
vbox.pack_start(&child, true, true, 0);
}
vbox.remove(&surface.overlay);
vbox.show_all();
warn!("[VideoSurface] detached; webview returned to Tauri's vbox");
pub fn detach() {
LIVE_SURFACE.with(|cell| {
cell.borrow_mut().take();
});
}
#[cfg(test)]
mod tests {
//! These exercise GTK widget wiring, so they need a display and are ignored
//! by default — CI has no X11 or Wayland session. Run locally with
//! `cargo test -- --ignored video_surface`.
/// Create the GL context and the mpv render context over it.
fn init_gl(widget: &gtk::Box, state: &Rc<RefCell<SurfaceState>>) -> Result<(), String> {
if state.borrow().render.is_some() {
return Ok(());
}
let window = widget.window().ok_or("widget has no GdkWindow")?;
use super::*;
let gl = window
.create_gl_context()
.map_err(|e| format!("create_gl_context: {e}"))?;
gl.realize().map_err(|e| format!("realize: {e}"))?;
gl.make_current();
/// The stacking order is the whole point, and getting it backwards produces
/// video painted over the controls rather than under them.
///
/// TRACES: UR-080 | DR-231
#[test]
#[ignore = "requires a display"]
fn test_gl_area_is_below_the_reparented_webview() {
if gtk::init().is_err() {
let mpv = state.borrow().mpv;
let mut render =
unsafe { MpvRenderContext::new(mpv) }.ok_or("mpv render context creation failed")?;
// The callback needs an owned handle that outlives this function, so a
// clone of the flag is boxed and leaked. `Arc<AtomicBool>` rather than the
// state itself: it is the only thing that may cross to an mpv thread. The
// pointer is kept so teardown can reclaim it — after the callback is
// unregistered, never before.
let flag = state.borrow().frame_ready.clone();
let ctx_box: *mut Arc<AtomicBool> = Box::into_raw(Box::new(flag));
unsafe { render.set_update_callback(Some(on_mpv_update), ctx_box as *mut c_void) };
let mut s = state.borrow_mut();
s.gl = Some(gl);
s.render = Some(render);
s.callback_ctx = ctx_box;
info!("[VideoSurface] GL and render context ready");
Ok(())
}
/// Draw the current frame, if there is one.
///
/// Runs *before* the children, which is what puts the picture behind the
/// webview. Deliberately forgiving: no frame, no GL, or a borrowed state all
/// mean "draw nothing this pass" rather than an error — the webview then paints
/// over an untouched background, which is exactly the pre-native appearance.
fn draw(widget: &gtk::Box, cr: &gtk::cairo::Context, state: &Rc<RefCell<SurfaceState>>) {
// Report each way of doing nothing exactly once. Without this the whole
// path is invisible: a draw handler that never runs, one that bails on a
// zero allocation, and one that renders perfectly all look identical from
// outside — and mpv stalls if frames are never consumed, so "no audio and
// it hangs" is a plausible symptom of *any* of them.
fn once(flag: &mut bool, msg: &str) {
if !*flag {
*flag = true;
warn!("[VideoSurface] not drawing: {msg}");
}
}
let Ok(mut s) = state.try_borrow_mut() else {
return;
};
if !s.logged_first_draw {
s.logged_first_draw = true;
info!("[VideoSurface] draw handler running");
}
let Some(gl) = s.gl.clone() else {
let f = &mut s.logged_no_gl;
once(f, "no GL context");
return;
};
let Some(window) = widget.window() else {
let f = &mut s.logged_no_window;
once(f, "widget has no GdkWindow");
return;
};
let scale = widget.scale_factor();
let width = widget.allocated_width() * scale;
let height = widget.allocated_height() * scale;
if width <= 0 || height <= 0 {
let f = &mut s.logged_no_size;
once(f, "zero allocation");
return;
}
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
// Stand in for the webview; `attach` deliberately does not care what it is.
let stand_in = gtk::DrawingArea::new();
vbox.pack_start(&stand_in, true, true, 0);
let surface = attach(&vbox).expect("attaches");
let children = surface.overlay().children();
gl.make_current();
// GtkOverlay lists its main child first.
assert!(
children[0].downcast_ref::<gtk::GLArea>().is_some(),
"the GL area must be the overlay's main child, i.e. underneath"
);
assert!(
children.len() > 1,
"the reparented widget must still be present"
// Render and end the mutable borrow before touching the latches again.
let rendered = match s.render.as_mut() {
Some(render) => unsafe { render.render(width, height) },
None => return,
};
let Some(texture) = rendered else {
let f = &mut s.logged_render_fail;
once(f, "mpv render produced no texture");
return;
};
// Log the first frame, and again whenever the target size changes. Latching
// this once per session hid the case that matters: a second file, rendered
// at a different size, in a window that never moved. "The picture is a small
// box in the middle" and "the picture fills the widget" are indistinguishable
// from outside without it.
if !s.logged_first_frame || s.logged_size != (width, height) {
s.logged_first_frame = true;
s.logged_size = (width, height);
// The allocation *origin* matters as much as its size. A GtkBox is a
// no-window widget, so `widget.window()` is the parent's GdkWindow and
// the box sits at an offset inside it. `draw_from_gl` composites into
// that window; if it does not honour the cairo translation GTK applied
// for this widget, the picture lands at the window origin instead of
// the widget's — misaligned by exactly this offset, which is the shape
// of a letterbox that does not line up.
let alloc = widget.allocation();
info!(
"[VideoSurface] rendering {width}x{height} at widget origin ({}, {}) scale {scale} (texture {texture})",
alloc.x(),
alloc.y()
);
}
/// A surface that tears down without returning the webview leaves a running
/// app with no UI.
///
/// TRACES: UR-080 | DR-231, DR-232
#[test]
#[ignore = "requires a display"]
fn test_detach_returns_the_webview_to_the_vbox() {
if gtk::init().is_err() {
return;
}
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
let stand_in = gtk::DrawingArea::new();
vbox.pack_start(&stand_in, true, true, 0);
let surface = attach(&vbox).expect("attaches");
detach(&vbox, &surface);
let children = vbox.children();
assert_eq!(children.len(), 1, "exactly the original child comes back");
assert!(
children[0].downcast_ref::<gtk::DrawingArea>().is_some(),
"and it is the webview stand-in, not the overlay"
unsafe {
cr.draw_from_gl(
&window,
texture as i32,
GL_TEXTURE,
scale,
0,
0,
width,
height,
);
// Tell mpv the frame reached the screen. Without this it has nothing to
// pace against — see DR-233.
if let Some(render) = s.render.as_ref() {
render.report_swap();
}
/// A vbox Tauri has not populated is a changed assumption, not a panic.
///
/// TRACES: UR-080 | DR-231
#[test]
#[ignore = "requires a display"]
fn test_an_empty_vbox_is_an_error_not_a_panic() {
if gtk::init().is_err() {
return;
}
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 0);
assert!(matches!(attach(&vbox), Err(SurfaceError::NoWebviewChild)));
}
}
@@ -184,6 +184,45 @@ impl StreamSelection {
needs_transcoding: false,
}
}
/// A selection for an item already sitting in the queue.
///
/// The queue predates `StreamSelection`: its items carry a URL, an optional
/// transport and the older `needs_transcoding` flag. This rebuilds a
/// selection from those without re-negotiating with the server, so the
/// controller can hand an engine an `OpenRequest` for an item it already
/// holds.
///
/// The transport falls back rather than being sniffed from the URL — the
/// substring check is exactly what DR-230 removed. `needs_transcoding` is an
/// exact stand-in because every transcode this app requests is HLS (DR-140).
///
/// TRACES: UR-079, UR-081 | DR-225, DR-245
pub fn for_queued_item(
url: impl Into<String>,
transport: Option<Transport>,
needs_transcoding: bool,
) -> Self {
let transport = transport.unwrap_or(if needs_transcoding {
Transport::Hls
} else {
Transport::Progressive
});
Self {
url: url.into(),
transport,
playback_kind: if needs_transcoding {
PlaybackKind::Transcode
} else {
PlaybackKind::DirectPlay
},
rendition: None,
available: Vec::new(),
media_source_id: None,
play_session_id: None,
needs_transcoding,
}
}
}
/// Build the quality ladder as it applies to a source of a known bitrate.
+2 -1
View File
@@ -17,7 +17,8 @@
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true
"resizable": true,
"transparent": true
}
],
"security": {
@@ -8,6 +8,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { formatDuration } from "$lib/utils/duration";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -88,18 +89,6 @@
: null,
);
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function getProgress(ep: MediaItem): number {
if (!ep.userData || !ep.durationMs) {
return 0;
@@ -117,7 +106,7 @@
}
const episodeLabel = $derived(`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`);
const duration = $derived(formatDuration(episode.durationMs));
const duration = $derived(formatDuration(episode.durationMs, "h m"));
const progress = $derived(getProgress(episode));
</script>
+1 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { playerController } from "$lib/player";
import { formatDuration } from "$lib/utils/duration";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
import type { MediaItem } from "$lib/api/types";
@@ -34,14 +35,6 @@
let dragDisabled = $state(true);
const flipDurationMs = 200;
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleConsider(
e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>,
) {
+40 -9
View File
@@ -1,6 +1,7 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
<script lang="ts">
import { onMount, onDestroy, tick, untrack } from "svelte";
import { planFullscreen } from "./fullscreenTarget";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings";
@@ -250,7 +251,6 @@
function nativeSeekSettling(): boolean {
return Date.now() - lastNativeSeekAt < NATIVE_SEEK_SETTLE_MS;
}
let didStartNativePlayback = $state(false); // Track if we started playback (to know if we should stop on unmount)
let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
let swipeType = $state<"brightness" | null>(null);
let hls: Hls | null = null; // HLS.js instance for streaming HLS content
@@ -1031,7 +1031,6 @@
"Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions",
);
// Backend is kept running but should not play audio since HTML5 element handles playback
didStartNativePlayback = true; // Track that we need to stop backend on unmount
}
// Register the adapter with the facade so control intents (UI, or a
@@ -1097,7 +1096,6 @@
if (!useHtml5Element) {
// Using native backend, subscribe to player events
didStartNativePlayback = true; // Track that we started native playback
isPlaying = (response.state?.kind ?? response.state) === "playing";
// Cleanup happens in the component's top-level onDestroy. Calling
// onDestroy() here — after an await — throws lifecycle_outside_component,
@@ -1138,7 +1136,6 @@
}
} else {
// For transcoded content, keep backend for seeking
didStartNativePlayback = true;
}
}
}
@@ -1272,15 +1269,26 @@
}
// Stop the player when component is destroyed
// Skip if we already stopped the backend early (non-transcoded + HTML5)
if (didStartNativePlayback && !didStopBackendEarly) {
// Unconditional. Leaving the player means nothing should still be playing,
// whichever renderer happened to own it.
//
// This used to be gated on `didStartNativePlayback && !didStopBackendEarly`
// — flags describing what *this component* started. A background-audio
// handoff swaps the renderer underneath them, so after one they describe a
// player that is no longer the one making sound, and the stop was skipped
// while the audio stream kept going. It then reappeared in the mini player
// as an audio track.
//
// `playerStop` is idempotent, so calling it when nothing is playing costs a
// no-op IPC round trip. That is a far cheaper failure than the alternative.
//
// TRACES: UR-040, UR-005 | DR-250
try {
log.debug("Stopping backend player on component unmount");
await commands.playerStop();
} catch (err) {
log.error("Failed to stop backend player:", err);
}
}
// Report stop when component is destroyed (skip for live - no resume tracking)
if (!isLive && onReportStop && currentTime > 0) {
@@ -2038,7 +2046,6 @@
transport: targetSelection.transport,
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
});
didStartNativePlayback = true;
await playerAdapter?.load(targetSelection.url, {
mediaId: media.id,
selection: targetSelection,
@@ -2085,23 +2092,47 @@
// Activity, so on its own it left the status and navigation bars painted over
// the video. The native bridge is what actually makes fullscreen full screen;
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
function toggleFullscreen() {
async function toggleFullscreen() {
// A native surface draws the picture *behind* the webview at window size, so
// fullscreening the document alone leaves the video at its old size while
// the page around it expands. See fullscreenTarget.ts. (DR-240)
const plan = planFullscreen(!useHtml5Element);
if (!document.fullscreenElement) {
if (plan.document) {
document.documentElement.requestFullscreen().catch((err) => {
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
// the immersive call below is what matters on Android, so don't let a
// rejection here abort it.
log.warn("requestFullscreen rejected:", err);
});
}
if (plan.osWindow) {
await setOsWindowFullscreen(true);
}
enterImmersive();
isFullscreen = true;
} else {
document.exitFullscreen();
if (plan.osWindow) {
await setOsWindowFullscreen(false);
}
exitImmersive();
isFullscreen = false;
}
}
/// Resize the OS window itself. Best-effort: a platform without a window to
/// resize (Android) must not break the rest of the toggle.
async function setOsWindowFullscreen(on: boolean) {
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
await getCurrentWindow().setFullscreen(on);
} catch (err) {
log.warn("setFullscreen on the OS window failed:", err);
}
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
@@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { planFullscreen } from "./fullscreenTarget";
describe("planFullscreen", () => {
it("fullscreens only the document when an in-document <video> renders", () => {
// Unchanged behaviour: WebKit scales the element, the window need not move.
expect(planFullscreen(false)).toEqual({ document: true, osWindow: false });
});
it("also fullscreens the OS window when a native surface renders", () => {
// The picture is drawn behind the webview at window size, so a
// document-only fullscreen leaves it at the old size.
expect(planFullscreen(true)).toEqual({ document: true, osWindow: true });
});
});
@@ -0,0 +1,35 @@
/**
* Which surfaces a fullscreen toggle has to move.
*
* `requestFullscreen()` only ever fullscreens the *document*. That was
* sufficient while every renderer lived inside it: the HTML5 `<video>` element
* is part of the document, so WebKit scaled it to the screen and the OS
* window's real size never mattered.
*
* A native video surface is drawn *behind* the webview at **window** size, so a
* document-only fullscreen leaves the picture exactly where it was while the
* page around it goes fullscreen. On WebKitGTK the observed result is a
* maximised window with decorations still taking a strip of the screen the
* video renders correctly, at the wrong size, which reads as "fullscreen is
* broken" rather than as a windowing problem.
*
* Android already needed its own answer here for the system bars (DR-157); this
* is the desktop equivalent of the same rule: whoever actually owns the pixels
* has to be the thing that goes fullscreen.
*
* TRACES: UR-066 | DR-240 | UT-219
*/
export interface FullscreenPlan {
/** Ask the document to go fullscreen (harmless everywhere, needed for CSS). */
document: boolean;
/** Resize the OS window itself. Required when a native surface owns the picture. */
osWindow: boolean;
}
/**
* @param rendersNatively true when a native surface (mpv/ExoPlayer) draws the
* picture rather than an in-document `<video>` element.
*/
export function planFullscreen(rendersNatively: boolean): FullscreenPlan {
return { document: true, osWindow: rendersNatively };
}
@@ -72,8 +72,11 @@ describe("waitForRepository", () => {
const w = makeWaiter();
const repo = {};
const pending = w.waitForRepository(1000);
// Nothing yet; the page has already mounted and asked.
setTimeout(() => w.publish(repo), 10);
// Nothing yet; the page has already mounted and asked. Published on a
// microtask rather than a timer: the point is *ordering* (asked before it
// arrived), and a wall-clock delay would make this a race under load.
await Promise.resolve();
w.publish(repo);
await expect(pending).resolves.toBe(repo);
});
+1 -21
View File
@@ -5,7 +5,7 @@
*/
import { describe, it, expect } from "vitest";
import { formatDuration, formatSecondsDuration } from "./duration";
import { formatDuration } from "./duration";
describe("formatDuration", () => {
it("should format duration from milliseconds (mm:ss format)", () => {
@@ -39,23 +39,3 @@ describe("formatDuration", () => {
expect(formatDuration(9045000, "hh:mm:ss")).toBe("2:30:45");
});
});
describe("formatSecondsDuration", () => {
it("should format duration from seconds (mm:ss format)", () => {
expect(formatSecondsDuration(1)).toBe("0:01");
expect(formatSecondsDuration(60)).toBe("1:00");
expect(formatSecondsDuration(61)).toBe("1:01");
expect(formatSecondsDuration(3661)).toBe("61:01");
});
it("should format duration with hh:mm:ss format", () => {
expect(formatSecondsDuration(3600, "hh:mm:ss")).toBe("1:00:00");
expect(formatSecondsDuration(3661, "hh:mm:ss")).toBe("1:01:01");
expect(formatSecondsDuration(7325, "hh:mm:ss")).toBe("2:02:05");
});
it("should pad minutes and seconds with leading zeros", () => {
expect(formatSecondsDuration(5, "hh:mm:ss")).toBe("0:00:05");
expect(formatSecondsDuration(65, "hh:mm:ss")).toBe("0:01:05");
});
});
+13 -25
View File
@@ -12,11 +12,23 @@
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string or empty string if no duration
*/
export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
export function formatDuration(
ms?: number | null,
format: "mm:ss" | "hh:mm:ss" | "h m" = "mm:ss",
): string {
if (!ms) return "";
const totalSeconds = Math.floor(ms / 1000);
// "1h 23m" / "45m" — the shape a runtime is read at a glance, as opposed to
// the clock shape a *position* is read at. Three components had hand-rolled
// this identically; it belongs here with the other two.
if (format === "h m") {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
if (format === "hh:mm:ss") {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
@@ -30,27 +42,3 @@ export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss"
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Convert seconds to formatted duration string
* @param seconds Duration in seconds
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
* @returns Formatted duration string
*/
export function formatSecondsDuration(
seconds: number,
format: "mm:ss" | "hh:mm:ss" = "mm:ss",
): string {
if (format === "hh:mm:ss") {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
// Default "mm:ss" format
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}
+2 -13
View File
@@ -1,6 +1,7 @@
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
<script lang="ts">
import { onMount, untrack } from "svelte";
import { formatDuration } from "$lib/utils/duration";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
@@ -250,18 +251,6 @@
// Images now handled by CachedImage component
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function handleItemClick(clickedItem: MediaItem | Library) {
if (!("kind" in clickedItem)) {
// Library item - navigate to library
@@ -534,7 +523,7 @@
>
{/if}
{#if item.durationMs}
<span>{formatDuration(item.durationMs)}</span>
<span>{formatDuration(item.durationMs, "h m")}</span>
{/if}
{#if item.communityRating}
<span class="flex items-center gap-1">