Compare commits

..
19 Commits
Author SHA1 Message Date
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
dtourolle 7545de6cc7 refactor: delete two orphans, and record why the reparent design changed
`resolveVideoSource` chose between a local file and a remote URL for video
playback. Backend-owned stream selection took that decision into Rust —
`media_local_selection` for a downloaded file, `get_stream_selection` for a
streamed one — and its last caller went with it. What remained was the function
plus sixty lines of tests exercising nothing that ships.

`fittedVideoSize` computed the rendered size of a video letterboxed into its
container. Nothing has ever called it: it arrived with the fix that made the
video fill its viewport and was superseded by `object-fit: contain` in the same
change. There is some irony in a helper that models letterboxing sitting unused
beside a container that was not letterboxing at all — the bug fixed in the
previous commit was CSS, and this function would not have helped.

A survey for exported symbols referenced only by their own tests finds 22 more.
Most are legitimate — test mocks, deliberate reset hooks, public utility APIs —
and the rest are unrelated to this work, so they are left for a cleanup that can
be reviewed on its own terms rather than smuggled into a playback branch.

Also records in the spec why DR-231's design changed. Reparenting Tauri's
webview into a GtkOverlay aborts the process on the first click: Linux calls
`attach_resize_handler` unconditionally (the Windows path guards it with
`is_decorated()`), and its handler walks webview -> GtkBox -> GtkWindow with an
unwrap that an overlay breaks. So the webview is not moved at all — mpv draws
into the default vbox's own `draw` handler via `gdk_cairo_draw_from_gl()`, and
GTK's container-before-children order puts the webview on top for free. No
reparent, one less widget, and nothing a Tauri upgrade can invalidate by
assuming its own layout.
2026-08-22 13:45:04 +02:00
dtourolle 0445a6d0aa docs(requirements): DR-234 is in progress, not proposed
The renderer-derived codec source was built while chasing four Android bugs
that all turned out to be the same defect, so it landed ahead of the spec that
allocates it. Five call sites now read one source instead of re-deriving or
hardcoding the webview's answer.

Not Done: on Linux it still resolves per platform, because there is still only
one renderer there. It becomes the runtime question the spec describes when mpv
draws the picture.
2026-08-22 13:45:04 +02:00
dtourolle a8c44145ff fix(player): letterbox the picture, and stop racing the session
Two bugs found by resizing the window during playback. Neither was introduced
by this branch; both are the kind that only surface when somebody actually
drags a window edge.

The picture cropped and sat at the top instead of letterboxing. The video's
flex wrapper had no `min-h-0`, and a flex item defaults to `min-height: auto` —
it refuses to shrink below its content's intrinsic size, and a <video> reports
the *media's* natural dimensions. So whenever the picture was larger than the
window the wrapper grew past the viewport, the overflow went off the bottom,
and what was visible was the top-left of an uncentred, uncropped image.
`object-contain` was doing its job the whole time, inside a box that was the
wrong size. This is also what put the picture at the bottom in fullscreen,
reported earlier and unexplained until now.

"Not connected to a server", shown as a *playback* error. The player page asks
for the repository on mount, but the session is restored asynchronously at
startup, so losing that race turned a perfectly good stream into a fatal error
screen. `getRepository()` throwing instantly is right for a click handler,
where the user is present; it is wrong for anything that runs on mount.
`waitForRepository()` resolves as soon as the session lands and still rejects
when there genuinely is not one, so a real logged-out state surfaces — just not
as a race.

Worth recording how this was found, because it was nearly misdiagnosed: the
symptom correlated with window resizes, but the log showed 230 Vite HMR updates
against a single app start — the frontend was being remounted under the test by
edits made while it ran, and a remount empties the in-memory auth store. The
race is real and worth fixing on its own merits, but "resize causes it" was an
artifact of how it was being observed, not a property of the bug.

UT-215 covers the waiting contract: resolves when already restored, resolves
when the session arrives late, still rejects when there is none, unsubscribes
once settled, and leaves no armed timer to reject an already-resolved promise.
2026-08-22 13:45:04 +02:00
dtourolle fecd6022fe chore(traceability): shift this branch's ids clear of master's
Master allocated DR-224 and UT-211 while this branch was in flight — the third
collision on this work. Everything here moves up by one: DR-224..236 become
DR-225..237, UT-211..213 become UT-212..214. UR-079, UR-080 and IR-033 were
still free and are unchanged.

Mechanical, and matched on each row's own text rather than on its number, so a
row cannot be shifted twice or the wrong one caught. Master's DR-224 (the
background-audio toggle) and UT-211 are untouched.
2026-08-22 13:45:04 +02:00
dtourolle 156b9e3684 fix(playback): ask the renderer what it can decode, in one place
Four bugs, one cause. "What can this device decode" was answered in five
places, four of which assumed the webview was decoding:

  - the device profile's direct-play codecs      (cfg per platform, inline)
  - the transcoding targets                       (hardcoded "h264,hevc")
  - the direct-play audio narrowing               (webview list, all platforms)
  - the client-side audio override                (webview list, all platforms)
  - get_video_stream_url's VideoCodec             (hardcoded "h264")

On Android the decoder is ExoPlayer, so four of those were simply wrong there,
and the costs were invisible without a device:

  - dts is in the tablet's own codec list, gets stripped from the profile, and
    is then forced to transcode by a rule about a renderer that is not playing
    it.
  - An hevc source whose *audio* is eac3 had its **picture fully re-encoded**.
    The server's own transcoding URL got this right — VideoCodec=h264,hevc,
    TranscodeReasons=AudioCodecNotSupported, video copied — but the moment a
    quality change or track switch re-opened the stream through our builder,
    the hardcoded h264 turned a cheap audio remux into a full transcode. That
    is a quality change silently making playback more expensive, on the exact
    path a viewer uses when playback is already struggling.

`renderer_codecs()` and `renderer_can_decode_audio()` are now the single
source, and all five sites read them. On the webview path every value resolves
exactly as before, so desktop behaviour is unchanged by construction; on
Android the profile becomes the device's own.

The list is also what lets the server *copy* rather than re-encode: naming
every codec the renderer can decode is what turns a transcode into a
passthrough when the source is already playable. That is the whole of "use the
best format available".

Also corrects this branch's headline number where it is asserted — the
architecture doc, the desktop-native-video spec and the spike. The measured 85%
Android direct-play rate used a profile containing ac3/eac3; the device it was
later verified on reports neither, so eac3 content correctly transcodes there.
It is a ceiling for an ExoPlayer-appropriate profile, not what the app achieves,
and realising any of it depends on this change. Left in place with the caveat
rather than deleted, because the measurement is real — it just measures
something narrower than it was quoted as measuring.

Unverified: this changes what Android negotiates and has not been exercised on
the tablet yet. Desktop is unchanged by construction but also unre-tested.
2026-08-22 13:45:03 +02:00
dtourolle 4f6cf22419 fix(player): tell the native backend's caller what it actually did
Two defects found by running on an Android tablet, both invisible on the
desktop, and both the same mistake: a rule written for the webview applied to a
backend that is not one.

The quality picker froze on the first stream. `StreamQualityResponse::Native`
carried only a position, so nothing replaced the selection the UI holds after a
native quality change. The picker derives the rung in force from that
selection's rendition, and a transcode always has a rendition — so the fallback
that would have used the requested value was never reached. The stream changed
and the menu did not. The native variant now carries the `StreamSelection` the
backend opened, like the HTML5 variant already did.

This was invisible on the desktop because the webview path replaces the
selection as a side effect of reloading its element. It looked correct there for
a reason that does not generalise.

A quality change restarted playback from zero. The resume position came from
`videoElement.currentTime`, which the frontend cannot supply on a native backend
— there is no `<video>` element, so it correctly sends null and the backend
substituted 0. Reading it from the DOM at all inverts the rule that the player
is the authority on playback state; the fallback now asks the controller where
it is. Captured before the negotiation round-trip, so it resumes a few hundred
milliseconds behind rather than ahead, which is the right direction to err.

Also from the tablet, and NOT fixed here because it changes playback behaviour
and deserves its own change: `audio_forces_transcode` judges against
`WEBVIEW_AUDIO_CODECS` on every platform, and `video_audio_codecs` narrows the
advertised direct-play audio set to that same webview list. On Android the
decoder is ExoPlayer. The tablet reports dts among its platform codecs, has it
stripped from the profile, and then has the webview rule force a transcode for
it. That is the third instance of a decode capability tied to the wrong
renderer, and it is what DR-233 exists to collapse — evidence now, not a design
preference.

It also corrects the record on this branch's headline number. The measured 85%
direct-play rate used a hypothetical Android profile including ac3/eac3; this
tablet's MediaCodecList reports neither, so eac3 content — about a third of the
sampled library — correctly transcodes here. 85% was the ceiling of a profile
the app does not send, on hardware that could not use it. The negotiation and
the contract are sound; the figure was not a measurement of what ships.
2026-08-22 13:45:03 +02:00
dtourolle 84cf31b929 feat(video): build the native video surface, and fix what running it exposed
Three things, all found by actually running the app rather than by reading it.

The surface (DR-230). A GtkGLArea as the main child of a GtkOverlay with
Tauri's own webview reparented on top — the desktop shape of what Android
already does with ExoPlayer. It attaches cleanly and is then **off by
default**, because the reparent fails the gate the spike said it would.

`tauri-runtime-wry`'s undecorated-resizing handler walks a hard-coded 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 makes that chain webview -> GtkOverlay -> GtkBox, the
downcast fails, and the panic is non-unwinding so it aborts the process. The
decoration check that would make the handler inert runs *after* the unwrap, so
no window configuration avoids it. The surface attaching successfully is
therefore not the gate — a click is. It lives behind JELLYTAU_NATIVE_VIDEO=1
with the mechanism written down, because the next attempt needs to keep Tauri's
two-hop shape intact and that is the whole design constraint.

Also settles a dependency question the spike left implied: the render API is
reachable from the pinned libmpv revision. Its safe `render` module is an empty
stub, but libmpv-sys carries every render symbol and `Mpv::ctx` is public, so
the context can be built over the handle the audio backend already drives. This
does not need the libmpv2 migration first.

The HLS effect re-ran on object identity. `currentSelection` is a struct, and
every reload replaces it even when the URL and transport are unchanged — so the
effect tore down hls.js and reattached for an unchanged stream, leaving the
element blank until a seek forced another cycle. The pre-DR-224 code read a
plain URL *string*, where re-assigning the same value was a no-op; the codebase
documents relying on that and swapping in a struct broke it silently. The
loader decision now takes a primitive transport tag, so the component cannot
depend on object identity — the bug is unrepresentable rather than merely
fixed.

The device profile contradicted itself. The direct-play profile claimed h264
alone on the webview path while the transcoding profile said "you may transcode
to h264 or hevc" — telling the server "I cannot play hevc, so re-encode it" and
then "re-encoding it to hevc is fine". Streams came back carrying
VideoCodec=h264,hevc with hevc-level/profile/bitdepth set. When the server took
that option the webview got something it could not decode, which presents as
video stuck on its first frame rather than as an error. Transcode targets are
now derived from the same codec list as direct play, capped to the two codecs a
Jellyfin server actually encodes so a wider decode list never asks for an av1
encode.

That is the third defect in one family: a decode capability stated in more than
one place, with the copies disagreeing. DR-233 exists to collapse them into one
renderer-derived source, and this is evidence for it rather than a preference.

Not fixed here, and worth knowing:

- The requested VideoBitrate is sized to the ceiling, not to the source — a
  2.2 Mbps source was being re-encoded at 19.8 Mbps, roughly 9x. Pre-existing,
  but this branch is the first thing that knows the source bitrate and so the
  first that can cap it.
- The `debug` build type produces an APK with the *release* applicationId:
  `applicationIdSuffix = ".debug"` is present in the canonical gradle and absent
  from the generated copy, though the identical line in the `release` block
  survives. Not caused by our sync, which is a plain cp. Independent of this
  work; it is why the side-by-side release build is the one that installs.
2026-08-22 13:45:03 +02:00
dtourolle 7cc392d78f docs(specs): mpv draws desktop video, and the webview path goes
The spike proved compositing works on Linux, including Wayland, and left two
blockers. One is now closed: DR-228 measured a single EXT-X-STREAM-INF in the
server's master playlist, so there is no adaptive bitrate for mpv to lose and
finding 3 of playback-backend-unification.md is false. The spike is updated to
record that. The other — an unexplained SIGSEGV in a decoder thread — is carried
into the spec as DR-231 rather than chased: the spike had no render-context
teardown at all, which is DR-184 on Android restated, and removing the likeliest
cause is worth doing whether or not it was the cause.

The spec targets every desktop platform rather than Linux alone, because the
maintenance argument runs the other way. Video has three renderers today. A
Linux-only version makes it four, permanently — mpv on Linux, HTML5 on Windows,
ExoPlayer on Android, hls.js underneath — and the webview path then survives
indefinitely because something still needs it. Finishing the job leaves mpv on
desktop and ExoPlayer on Android, and hls.js, html5Adapter.ts, videoLoaderFor
and the <video> element are deleted in a phase that has its own acceptance
criterion so it cannot quietly become "later".

The load-bearing change is DR-233: the device profile stops being a
compile-time platform constant and becomes a property of the renderer that will
decode the stream. The measured 7% desktop direct-play rate and Android's 85%
differ by nothing except which component decodes, so that one change is what
converts the former toward the latter. It looks like configuration and is not —
it decides whether the server re-encodes, and it fails silently when wrong.

Windows is costed rather than waved at: the surface is genuinely different code
(WebView2 in an HWND, not GTK), but everything else is shared, so nothing may be
guarded on cfg!(target_os = "linux"). The real cost is build — libmpv is a
Linux-only dependency while Windows cross-compiles via cargo-xwin, so a Windows
libmpv must reach that build and ship in the NSIS bundle under the LGPL terms
DR-216 already records.

Allocates UR-080, DR-230..236, IR-033. No product code yet.
2026-08-22 13:45:03 +02:00
dtourolle 109700b949 feat(playback): let Rust decide what stream to play, and say so
Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. 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.

One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.

Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:

  Linux / WebKitGTK (h264 only, 2ch)          3/40 —  7% direct play
  Android / ExoPlayer (hevc, ac3/eac3, 6ch)  34/40 — 85% direct play

The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.

DR-219  StreamSelection: url + tagged Transport (hls/progressive/localFile)
        + PlaybackKind (directPlay/directStream/transcode) + the negotiated
        rendition + this source's ladder + a needs_transcoding flag derived
        in Rust so the rule is answered once. Both enums are serde-tagged
        so the frontend matches a discriminant, not a substring. The paths
        that never negotiate get the same shape from Rust rather than
        assembling one — media_local_selection for a downloaded file,
        LiveStreamInfo.transport for a live channel — so there is no second
        place where a transport is decided.

DR-220  The ceiling becomes two levels: a durable device default (Settings,
        persisted) and a per-playback override the in-player picker sets.
        The picker had called itself a "this film, this connection" control
        since it was written but wrote the process-wide default, so dropping
        one awkward film to 2 Mbps silently capped every video played
        afterwards for the rest of the process, with Settings still showing
        the old value. The override is cleared whenever playback moves to a
        new item, which stops it surviving into an autoplayed next episode.
        effective_streaming_quality() is the single resolution point.

DR-221  The quality picker is filled from what this media source can offer.
        Rust marks a rung exceeds_source when its ceiling is at or above the
        source's own bitrate — such a rung is another way to spell Original
        — and the frontend does not draw those. Original is never marked; a
        source whose bitrate the server does not report marks nothing, which
        keeps every rung offered.

DR-222  Direct play and direct stream are negotiated, with two client-side
        overrides on top because the server's answer is right about the file
        and wrong about what this app will do with it: undecodable audio
        (Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
        codec but ignores its audio codec, so it offers direct play for an
        E-AC-3 track the webview renders in silence) and a viewer-pinned
        audio track the file does not default to. A direct stream is a remux
        and is deliberately not counted as transcoding.

DR-223  Dropped on measurement, not deferred. A master playlist from this
        server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
        the single rendition the request asked for rather than publishing a
        ladder. So there is no adaptation for hls.js to be preserving and
        none mpv would lose — the claim that there was, in
        playback-backend-unification.md, does not hold. Recorded rather than
        deleted because it is a measurement: a server that does publish a
        ladder would change the answer.

DR-224  Every backend consumes the same selection. The queue item carries
        the transport, so player_seek_video picks its seek strategy from the
        backend's decision instead of the last stream_url.contains(".m3u8")
        in the codebase. Items queued by a path that never negotiated carry
        None and fall back to needs_transcoding, which is exact rather than
        a guess because every transcode this app requests is HLS (DR-140).

The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.

Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.

The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.

Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
2026-08-22 13:45:03 +02:00
29 changed files with 103 additions and 1463 deletions
-102
View File
@@ -9,108 +9,6 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.11.0
Video can play through the native renderer on Linux, and the machinery every
platform's playback goes through was rebuilt around one contract. Nine defects
fell out of doing it — each one a capability the code had written down as a
fact about the platform rather than asking the thing that would know.
### ✨ Changes
- **Video can decode natively on Linux, without the server re-encoding it.**
Until now every video played on the desktop was transcoded by Jellyfin to
h264 and handed to the browser engine, whatever the file actually was — so the
server burned CPU on every play, and quality was capped by that conversion.
mpv can now draw the picture directly, composited beneath the interface so the
controls, subtitles and overlays still sit on top of it. Direct play means the
original file, hardware decoding, and no server work at all. This is off by
default while it settles: set `JELLYTAU_NATIVE_VIDEO=1` to try it. The browser
path is untouched and remains what you get otherwise. (UR-080 → DR-231 …
DR-237)
- **Playback speaks one language across every player.** Linux, Android and
Windows each drove their engine through a different set of calls, and a rule
learned on one did not reach the others — which is why several of the fixes
below existed on one platform and not another. All three now go through a
single contract, and one suite of behaviours runs against every engine,
including ExoPlayer on a real device. An engine is either correct or visibly
failing. Nothing about this is visible while it works, which is the point.
(UR-081 → DR-242 … DR-247)
### 🐛 Fixes
- **Resuming a film starts where you left it, instead of at the beginning.**
Asking a player to open a file and asking it to start at a position were two
separate steps, and the second was issued before the first had finished — so
it failed, was discarded, and playback began at zero. It affected resume and
any skip on a stream the server was converting. The position is now part of
opening the file, so there is no gap for it to fall into. (DR-241)
- **Skipping works on films the server is converting.** A skip was routed by the
*shape* of the stream rather than by what the player could do with it. That
happened to be right while one particular player handled those streams and
became wrong the moment another did — after which skipping simply did nothing,
silently. Players now say what they can do and are asked. (DR-238, DR-246)
- **The play and pause button follows the player again.** The code that reacted
to pausing was never subscribed to the event it was waiting for, so the button
stayed where it was while playback did something else. (DR-239)
- **Fullscreen fills the screen.** It expanded the page rather than the window,
which was invisible while the picture was drawn inside the page and obvious as
soon as it was not. (DR-240)
- **The seek bar knows how long the film is.** A player that had not yet worked
out the duration reported zero, and zero was believed — leaving the bar with
no scale and nothing to drag against, even though the length had been known
since the library listed it. (DR-251)
- **Leaving the player stops the sound.** The stop was aimed at whichever
renderer the app believed was in charge. Enabling background audio hands over
to a different one, so afterwards the app stopped something that was no longer
playing and the film carried on as an audio track in the mini player. Closing
now stops everything, regardless of who was in charge. (DR-250)
- **Coming back from background audio no longer leaves a black screen.** The
stream that plays while the app is hidden has no fixed length, and the value a
player uses to say so is a very large negative number. Converting it crashed
the playback engine outright, which looked like a dead player with no
controls. (DR-252)
- **Android builds again.** A rule that only applied to Linux stayed attached to
code that had stopped being Linux-only, and the Android build had not compiled
since. (DR-247)
- **A quality you chose for one episode no longer caps every episode after it.**
Dropping the quality mid-episode is meant to describe that episode. When the
next one started in the background, nothing reset it — so the ceiling stayed
in force indefinitely, with nothing in the interface saying why later episodes
looked worse. (DR-254)
- **Skipping to the next item no longer starts it part-way through.** Scrubbing
near the end of a converted stream re-opens it, and the position being waited
for was not discarded if you skipped onward first — so the next item began
wherever you had dragged to in the previous one. (DR-253)
### 🧹 Under the hood
- The conformance suite can be run on its own: `bun run test:player` for the
desktop engines, `bun run test:player:android` for ExoPlayer on a connected
device. Both build a test fixture rather than carrying media in the
repository.
- [docs/native-player-verification.md](docs/native-player-verification.md)
records what to check before a release, including the exact sequences that
found two of the defects above — both of which passed every automated test.
### Known limitations
- Resume reads progress saved on the device, not from the server, so a fresh
install or a second device will not offer to resume something watched
elsewhere.
- Native video on Linux is opt-in and is not yet the default.
## v0.10.1
A single fix, for something that had been quietly overriding a choice you made.
-2
View File
@@ -33,7 +33,6 @@
- [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md)
- [Playback Backend Unification](specs/playback-backend-unification.md)
- [Linux Native Video Spike](specs/linux-native-video-spike.md)
- [Backend-Owned Stream Selection](specs/backend-owned-stream-selection.md)
- [Player Facade Enforcement](specs/player-facade-enforcement.md)
- [Windows Native Audio Backend](specs/windows-native-audio-backend.md)
- [libmpv2 Migration](specs/libmpv2-migration.md)
@@ -49,7 +48,6 @@
- [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)
-202
View File
@@ -1,202 +0,0 @@
# 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 originally verified only engines that *behave*, which is why both
regressions passed them. That gap is now partly closed in code rather than in
this document: `UT-223` drives a deliberately hostile engine — `C.TIME_UNSET`,
NaN, infinities, negatives — through the adapter, and fails with the exact
panic that produced a black screen on a tablet. `UT-224` pins the handoff
clearing that was previously verified by listening to a device.
**Prefer moving cases out of this file and into tests.** Anything here that
could fail automatically should; a checklist depends on someone remembering to
follow it, and the two defects it was written for cost hardware time that would
have been better spent making the suites realistic. What is left below is what
genuinely needs eyes, ears, or a display — not what merely has not been
automated yet.
## 1. Automated gates
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.
+1 -14
View File
@@ -440,15 +440,8 @@ Internal architecture, components, and application logic.
| DR-242 | The player contract expresses intent, not device operations. `MediaPlayer::open` carries the start position, so no caller sequences load-then-seek and none can race an engine's asynchronous load; `seek` states a destination and leaves in-place-vs-re-open to the engine, which is the only layer that knows its own transport; `snapshot` is one coherent read; and `Phase::Opening` names the window a seek used to be lost in. Replaces `PlayerBackend`, which abstracted a device and required each of the three engines to re-derive the same rules | Player | UR-081 | In Progress |
| DR-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-245 | `LegacyPlayer` drives the old `PlayerBackend` through the `MediaPlayer` contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purpose | Player | UR-081 | In Progress |
| DR-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-253 | A deferred seek is discarded when the file it was issued against stops being the one loading. `seek` holds a position while MPV has nothing loaded and the `FileLoaded` handler applies it (DR-241), but neither `load` nor `stop` cleared it — so scrubbing near the end of a transcoded item, which re-opens the stream, and then skipping to the next item before the reload completed applied the old position to the new item. It started wherever the previous one had been scrubbed to, silently | Player | UR-040, UR-005 | Done |
| DR-254 | Advancing to the next episode drops a per-playback quality override. The override is process-wide and describes one playback: a viewer who drops to 720p for a struggling episode has said nothing about the next. Every advance the frontend drives clears it via `player_play_item`; the background audio-only advance loads the next episode in Rust and skipped all three clearing sites, so every later episode stayed capped with nothing in the UI saying why | Repository | UR-074 | Done |
| DR-255 | One helper answers "what URL should an engine open". `playback_url` was gated to Android because only ExoPlayer needed it, and that gate is why a byte-identical copy was later added for the cross-platform open path — the original is invisible in a Linux build, so nothing warned. Two matches over `MediaSource` meant a new variant could be handled in one and forgotten in the other | Player | UR-081 | Done |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
---
@@ -761,12 +754,6 @@ Internal architecture, components, and application logic.
| UT-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done |
| UT-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 |
| UT-223 | The adapter survives an engine that answers badly. A `HostileBackend` reports `C.TIME_UNSET` as seconds, NaN, both infinities, a negative and a zero; reading a snapshot yields no duration and a zero position rather than panicking, and a well-behaved engine still round-trips. The conformance suite could not have caught this — it only ever drives engines that report sane numbers, which is why it stayed green while a real one took the backend down | DR-252 | Done |
| UT-224 | Stopping clears an active background-audio handoff, both the flag and the base offset, so a later position read cannot be interpreted against a handoff that no longer exists. Previously verified only by listening to a device | DR-250 | Done |
| UT-225 | Both `load` and `stop` discard a deferred seek, so a position held for a file that is no longer loading cannot be applied to whatever loads next | DR-253 | Done |
| UT-226 | The background episode advance clears the per-playback quality override, so a ceiling chosen for one episode does not cap every episode after it | DR-254 | Done |
### Integration Tests
-1
View File
@@ -45,7 +45,6 @@ taken by other work; each carries a ⚠️ note at the top.
| Spec | Blocked on / note |
|---|---|
| [desktop-native-video.md](desktop-native-video.md) | mpv draws video on every desktop platform, then the webview `<video>` path and hls.js are deleted. Converts a measured 7% direct-play rate toward Android's 85%. Stacked on backend-owned stream selection. |
| [backend-owned-stream-selection.md](backend-owned-stream-selection.md) | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Partly built — `StreamSelection`, `Transport` and the `.m3u8` sniff removal have landed. |
| [build-provenance.md](build-provenance.md) | `build.rs` is still bare. ⚠️ suggested id DR-093 is taken. |
| [player-facade-enforcement.md](player-facade-enforcement.md) | ~60 `commands.player*` sites still outside the facade; no lint rule. ⚠️ suggested id DR-095 is taken. |
| [windows-native-audio-backend.md](windows-native-audio-backend.md) | Blocked on the libmpv2 swap. ⚠️ suggested id IR-030 is taken. |
@@ -1,242 +0,0 @@
# Spec: Backend-owned stream selection
**Status:** Proposed
**Requirements:** UR-079 (new) → DR-219 … DR-224 (new); **implements and extends
DR-121**, currently allocated to
[read-through-media-cache.md](read-through-media-cache.md) and not started.
Re-check `requirements.md` before allocating — the ids moved twice while this was
being written (`DR` max was 215, then 218).
**UX spec:** the quality selector in `VideoPlayer.svelte` already exists; this
changes what fills it, not how it looks.
**Supersedes / revises:** takes DR-121 out of
[read-through-media-cache.md](read-through-media-cache.md), which should keep
only its capture/eviction half. Unblocks
[linux-native-video-spike.md](linux-native-video-spike.md).
**Destination on completion:**
[01-rust-backend.md](../architecture/01-rust-backend.md) — extends the
"Streaming quality ladder" section; and
[03-data-flow.md](../architecture/03-data-flow.md) — playback initiation. The
durable half is the layer line and the `StreamSelection` contract; phases and
acceptance criteria are disposable.
## Summary
Make Rust the single owner of *which stream to play* — direct play or transcode,
at what ceiling, over what transport — and hand every player backend a
self-describing selection instead of a bare URL. mpv, ExoPlayer and the HTML5
`<video>`/hls.js path all become consumers of the same decision rather than three
places that re-derive it.
Nothing about how playback *looks* changes. What changes is that the frontend
stops inferring transport from a URL string, and that direct play becomes
possible at all.
## Motivation
Four concrete problems, all the same shape.
**1. The frontend sniffs transport out of the URL.**
[VideoPlayer.svelte:569](../../src/lib/components/player/VideoPlayer.svelte#L569):
```ts
const isHlsStream = currentStreamUrl.includes(".m3u8");
```
and again inline at line 2364. Rust *built* that URL and knows exactly what it
is; the frontend re-derives it by substring match. Change the endpoint, add a DASH
path, serve a progressive file, and this silently picks wrong. This is the
boundary rule in miniature — not item-type taxonomy, but the same error: a
domain fact reconstructed in the presentation layer because the wire shape did
not carry it.
**2. There is no direct-play path.** `get_video_stream_url` always builds an HLS
transcode URL (`TranscodingProtocol=hls`, `VideoCodec=h264` first). Every video
play burns server CPU, even when the file would play untouched. This is the cost
the Linux native-video work exists to remove, and it cannot be removed without a
decision that does not currently exist anywhere in the codebase.
**3. Quality is a process-wide global.** `streaming_quality()` /
`set_streaming_quality()` in `repository/online.rs` read and write a static.
It is not per-session or per-item, so it cannot express "this 4K remux needs a
ceiling, that podcast does not", and two concurrent playbacks would share one
setting.
**4. Rust cannot say what qualities *this* media source supports.** The selector
is populated from a fixed enum rather than from what the source actually offers.
DR-121 already names this; it has not been built.
### The prior question
Finding 3 of [playback-backend-unification.md](playback-backend-unification.md)
holds that hls.js gives us real adaptive bitrate and mpv would lose it. Evidence
in this repo suggests **there is no ABR today**: a single rendition is requested,
no level-handling code exists anywhere in the frontend, and a quality switch is
implemented by re-opening the stream.
**Run this before sizing the adaptation work.** It needs a live server:
```
curl -s "https://<server>/Videos/<itemId>/master.m3u8?api_key=<key>&…" \
| grep -c EXT-X-STREAM-INF
```
`1` → there is no adaptation to preserve, and the adaptation half of this spec
collapses to "pick well at open". `>1` → finding 3 stands and DR-223 applies.
**Everything else in this spec is worth doing either way** — the ownership
problems above are independent of the answer.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| Direct play vs direct stream vs transcode | Rust | Depends on Jellyfin's `PlaybackInfo`, container/codec support and the device profile. Changes when Jellyfin's API or our profile changes → domain, by the litmus test. |
| Transport of the chosen stream (HLS / progressive / local file) | Rust | Rust constructs the URL; it is the only place that *knows* rather than infers. Today the frontend guesses from `.m3u8`. |
| Which qualities this media source can offer | Rust | Derived from the source's own streams and the quality→transcode-parameter mapping that `get_video_download_url` already holds. DR-121. |
| The quality ceiling in force, per playback session | Rust | Domain state that outlives any one view and must survive a backend swap or a mode transfer. Currently a process-wide static. |
| Deciding to re-negotiate mid-playback (if adaptation is needed) | Rust | It performs the HTTP and already derives reachability from real traffic via `ConnectivityMonitor`. Throughput estimation is the same pattern on the same data — a side-channel probe would repeat the mistake that principle exists to prevent. |
| Frame-level delivery *within* the selected stream, including a player's own ABR | **Player** | ExoPlayer has genuine adaptive selection; if Rust hands it a multi-variant playlist it should use it. Rust chooses *what to request*, never how a player paces bytes. See "The line". |
| Rendering the selector, showing the current quality, ordering the list | Frontend | Pure presentation over a backend-supplied list. |
| Poster, letterbox, controls, overlay z-order | Frontend | Unchanged. |
### The line
**Rust decides *what stream*. The player decides *how to deliver it*.**
This matters most for ExoPlayer, which already does real adaptive track selection
over HLS. This spec must not reimplement that or fight it — if a multi-variant
playlist reaches ExoPlayer, ExoPlayer adapts and Rust stays out of the way. The
same restraint applies to any future backend that gains the capability. Rust only
steps in where the player has no such ability (mpv) *and* the server actually
offers a ladder.
Borderline row, with its tie-breaker: "which media source of a multi-source item"
looks like a user choice, and its *presentation* is. The default and the
constraint set are domain → **Rust**, per the borderline-defaults-to-Rust rule.
## Design
### The contract
One self-describing selection replaces the bare URL. Nested fields are
camelCase over the wire (`#[serde(rename_all = "camelCase")]`); the enums are
tagged so the frontend matches a tag instead of parsing a string.
```rust
#[derive(Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct StreamSelection {
pub url: String,
pub transport: Transport,
pub playback_kind: PlaybackKind,
/// The negotiated rendition; None when direct-playing the source as-is.
pub rendition: Option<Rendition>,
/// What this media source can offer — fills the selector (DR-121).
pub available: Vec<QualityOption>,
}
#[derive(Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Transport { Hls, Progressive, LocalFile }
#[derive(Serialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PlaybackKind { DirectPlay, DirectStream, Transcode }
```
`Transport` is the field that deletes the `.m3u8` sniff. The frontend picks
hls.js on `Hls` and the element's own loader otherwise — a tag match, not a
substring search.
### Re-negotiation
Rust emits `stream-selection-changed` (kebab-case, per convention) carrying a new
`StreamSelection` plus the position to resume at. The existing
`playerSetStreamQuality` response already has exactly the right shape — a tagged
`strategy` that tells the caller who reloads, with the backend handling native
itself and handing HTML5 a URL for `reloadSource`
([index.ts:198](../../src/lib/player/index.ts#L198)). **Extend that; do not
invent a second mechanism.** It is the one piece of this that is already right.
Note the existing wart to preserve or fix deliberately, not accidentally:
tauri-specta keeps those response fields snake_case (`new_url`), and the facade
comments say so.
### Phases
1. **DR-219** `StreamSelection` + `Transport`; delete the `.m3u8` sniff. No
behaviour change — pure ownership move, and independently shippable.
2. **DR-220** Per-session quality ceiling replacing the `online.rs` static.
3. **DR-221** `available` populated from the media source (DR-121's substance).
4. **DR-222** Direct-play/direct-stream negotiation via `PlaybackInfo`. This is
the phase that unlocks native video and removes the transcode.
5. **DR-223** Adaptation, **only if the playlist check says a ladder exists**.
Cheapest sufficient design: re-negotiate on sustained throughput drop, reusing
the phase-1 re-negotiation path. A local proxy synthesizing a single-variant
playlist is a last resort, not a starting point.
6. **DR-224** ExoPlayer and mpv consume `StreamSelection` unchanged, proving the
contract is player-agnostic rather than HTML5-shaped.
Phases 14 stand on their own merits with no dependency on the ladder question.
## Out of scope
- Rendering, compositing, and the Linux native-video work itself. This spec
unblocks [linux-native-video-spike.md](linux-native-video-spike.md); it does
not contain it.
- Replacing hls.js. It stays as the HLS loader for the webview path.
- Reimplementing or overriding ExoPlayer's own adaptive selection. See "The line".
- The download/capture half of [read-through-media-cache.md](read-through-media-cache.md)
(DR-122, DR-124, DR-125), which keeps its own spec.
- Audio. The same argument applies, but video is where the transcode cost is.
## Acceptance criteria
- [ ] The `.m3u8` substring check is gone from `VideoPlayer.svelte` (both sites)
and transport comes from the tagged enum.
- [ ] `bun run check`, `bun run test`, `bun run format:check`, `bun run lint` pass.
- [ ] `cargo fmt` clean, `cargo clippy -D warnings` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes — and the reviewer confirms by reading that
no transport/kind decision was reconstructed in `src/`, since the tripwire
only catches item-type array literals.
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
- [ ] New code carries `// TRACES:` comments; `bun run traces:validate` passes and
coverage stays ≥ the CI ratchet.
- [ ] The `EXT-X-STREAM-INF` count is recorded in this spec before DR-223 is
started or dropped.
- [ ] DR-121 is removed from `read-through-media-cache.md` with a pointer here.
## Testing
- Rust: `PlaybackInfo` fixtures → expected `PlaybackKind`, one per branch
(supported container direct-plays; unsupported codec transcodes; a ceiling
below the source bitrate transcodes even when the codec is fine).
- Rust: `Transport` round-trips through serde with the tag the frontend matches.
- Frontend: adapter selection driven by `transport`, including the case a URL
ending `.m3u8` is served as `Progressive` — that test fails on today's code,
which is the point.
- Extend `tauriIntegration.test.ts` for the new command params (camelCase rule).
- No test asserts a URL substring.
## TRACES
| Piece | Tag |
|---|---|
| `StreamSelection` / `Transport` | `UR-079 \| DR-219` |
| Per-session ceiling | `UR-074 \| DR-220` |
| `available` from media source | `UR-079 \| DR-221, DR-121` |
| Direct-play negotiation | `UR-079 \| DR-222` |
| Adaptation, if built | `UR-079 \| DR-223` |
| ExoPlayer/mpv consumers | `UR-003, UR-004 \| DR-224` |
## Notes for the implementer
- **Phase 1 is worth doing on its own**, even if everything after it is dropped.
It removes a real leak and costs almost nothing.
- Do not frame any phase as "no Rust changes required" — that framing is what
produced the leak `scoped-search-boundary.md` records.
- `ConnectivityMonitor` is the precedent for DR-223: derive network facts from
real traffic, never from a side-channel poller.
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes. Requirement ids in particular moved twice
during the writing of this spec.
+1 -56
View File
@@ -1,11 +1,6 @@
# 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).
**Status:** Proposed
**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.
@@ -66,47 +61,6 @@ 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 |
@@ -272,15 +226,6 @@ Strangler, not a rewrite. Each step ships independently and leaves the app worki
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.
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.11.0",
"version": "0.10.1",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
@@ -53,9 +53,7 @@
"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",
"test:player": "./scripts/test-player-conformance.sh",
"test:player:android": "./scripts/test-player-conformance.sh android"
"release:notes": "bun run scripts/release-notes.ts"
},
"dependencies": {
"@tauri-apps/api": "^2.11.1",
-60
View File
@@ -1,60 +0,0 @@
#!/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
+1 -1
View File
@@ -2181,7 +2181,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.11.0"
version = "0.10.1"
dependencies = [
"aes-gcm",
"async-trait",
+1 -5
View File
@@ -1,10 +1,6 @@
[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.11.0"
version = "0.10.1"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
+20 -23
View File
@@ -1450,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) = {
let (needs_transcoding, jellyfin_item_id, is_local, transport) = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
@@ -1466,34 +1466,31 @@ pub async fn player_seek_video(
.ok_or("Current video has no Jellyfin ID")?
.to_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).
// The URL itself is no longer read here: the seek strategy now comes
// from the item's own `transport`, not from inspecting the string.
let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
(current_item.needs_transcoding, jellyfin_id, is_local_file)
let needs_trans = current_item.needs_transcoding;
let transport = current_item.transport;
(needs_trans, jellyfin_id, is_local_file, transport)
}; // Locks are dropped here
// 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
// 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,
};
let strategy = determine_video_seek_strategy(
is_local,
seeks_transcoded_in_place,
needs_transcoding,
use_html5,
);
let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);
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
);
info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, is_hls, needs_transcoding, use_html5, strategy);
match strategy {
VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
+1 -15
View File
@@ -68,19 +68,6 @@ impl<P: MediaPlayer> Harness for EngineHarness<P> {
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 {
@@ -151,8 +138,7 @@ pub fn run_engine(url: &str, engine: Engine) -> u32 {
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(),
.expect("could not create the legacy backend")
));
}
}
+1 -29
View File
@@ -738,27 +738,6 @@ 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,
@@ -1430,15 +1409,8 @@ pub fn run() {
}
}
// Every engine reaches the controller through the one contract.
// `LegacyPlayer` carries the not-yet-ported ones across unchanged,
// so this port swaps a seam rather than four implementations.
// TRACES: UR-081 | DR-245
let player_controller = PlayerController::new(
Box::new(crate::player::LegacyPlayer::new(
backend,
engine_capabilities(),
)),
backend,
playback_reporter.clone(),
position_throttler.clone(),
);
-50
View File
@@ -249,56 +249,6 @@ 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::*;
-12
View File
@@ -53,17 +53,6 @@ pub trait Harness {
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) {
@@ -162,7 +151,6 @@ pub fn seeks_after_open<H: Harness>(h: &mut H) {
let target = Duration::from_secs(420);
h.player().seek(target).expect("seek failed");
h.await_seek(target);
assert_near(
h.player().snapshot().position,
-2
View File
@@ -72,8 +72,6 @@ impl FakePlayer {
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,
},
}
}
+16 -128
View File
@@ -18,21 +18,16 @@
//!
//! TRACES: UR-081 | DR-245
#![allow(dead_code)] // Consumed when PlayerController is ported (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::media_player::{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.
@@ -40,13 +35,16 @@ pub struct LegacyPlayer<B: PlayerBackend> {
}
impl<B: PlayerBackend> LegacyPlayer<B> {
pub fn new(inner: B, capabilities: Capabilities) -> Self {
pub fn new(inner: B) -> Self {
Self {
inner,
capabilities,
has_item: false,
}
}
pub fn inner_mut(&mut self) -> &mut B {
&mut self.inner
}
}
impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
@@ -126,8 +124,8 @@ impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
};
PlaybackSnapshot {
phase,
position: duration_from_secs(self.inner.position()).unwrap_or(Duration::ZERO),
duration: self.inner.duration().and_then(duration_from_secs),
position: Duration::from_secs_f64(self.inner.position().max(0.0)),
duration: self.inner.duration().map(Duration::from_secs_f64),
seekable: true,
volume: self.inner.volume(),
muted: false,
@@ -137,122 +135,12 @@ impl<B: PlayerBackend + Send> MediaPlayer for LegacyPlayer<B> {
}
}
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
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::player::media::MediaItem;
use crate::settings::AudioSettings;
/// A backend that answers badly, on purpose.
///
/// Every engine the conformance suite drives reports sane numbers, which is
/// why it passed while a real one did not: ExoPlayer returns
/// `C.TIME_UNSET` — `Long::MIN_VALUE`, about -9.2e15 seconds — for any
/// stream whose length it does not know, and the adapter converted that
/// straight into a `Duration` and panicked the whole backend.
///
/// The old `PlayerBackend` contract is a plain `f64`. It never promised
/// finite, never promised positive, and nothing enforced it. So this is the
/// engine the suites were missing.
struct HostileBackend {
duration: f64,
position: f64,
}
impl PlayerBackend for HostileBackend {
fn load(&mut self, _media: &MediaItem) -> Result<(), PlayerError> {
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
Ok(())
}
fn stop(&mut self) -> Result<(), PlayerError> {
Ok(())
}
fn seek(&mut self, _position: f64) -> Result<(), PlayerError> {
Ok(())
}
fn set_volume(&mut self, _volume: f32) -> Result<(), PlayerError> {
Ok(())
}
fn position(&self) -> f64 {
self.position
}
fn duration(&self) -> Option<f64> {
Some(self.duration)
}
fn state(&self) -> PlayerState {
PlayerState::Idle
}
fn volume(&self) -> f32 {
1.0
}
fn set_audio_settings(&mut self, _s: &AudioSettings) -> Result<(), PlayerError> {
Ok(())
}
fn audio_settings(&self) -> AudioSettings {
AudioSettings::default()
}
fn set_audio_track(&mut self, _i: i32) -> Result<(), PlayerError> {
Ok(())
}
fn set_subtitle_track(&mut self, _i: Option<i32>) -> Result<(), PlayerError> {
Ok(())
}
}
fn hostile(duration: f64, position: f64) -> LegacyPlayer<HostileBackend> {
LegacyPlayer::new(
HostileBackend { duration, position },
crate::player::media_player::Capabilities::mpv(),
)
}
/// Reading an engine that answers badly must not take the process down.
///
/// This is DR-252 as a test. It fails — by panicking — against the adapter
/// as originally written, which is the property the conformance suite could
/// not have: it only ever drove engines that behave.
///
/// TRACES: UR-005 | DR-252 | UT-223
#[test]
fn test_snapshot_survives_an_engine_that_answers_badly() {
// The exact value ExoPlayer reports for an unknown length.
let s = hostile(-9_223_372_036_854_776.0, 0.0).snapshot();
assert_eq!(s.duration, None, "a negative duration is not a duration");
for bad in [f64::NAN, f64::NEG_INFINITY, f64::INFINITY, -1.0, 0.0] {
let s = hostile(bad, bad).snapshot();
assert_eq!(s.duration, None, "{bad} should not become a duration");
assert_eq!(
s.position,
Duration::ZERO,
"{bad} should not become a position"
);
}
// And a well-behaved engine still works.
let s = hostile(6997.024, 540.0).snapshot();
assert_eq!(s.duration, Some(Duration::from_secs_f64(6997.024)));
assert_eq!(s.position, Duration::from_secs_f64(540.0));
Capabilities {
video: false,
audio_settings: true,
subtitle_switching: true,
audio_track_switching: true,
}
}
}
+3 -9
View File
@@ -185,16 +185,10 @@ impl MediaItem {
}
}
/// The URL or path an engine should open.
/// Get the playback URL or file path
///
/// Not gated to Android any more. It was, back when only ExoPlayer needed
/// direct URL access — and that gate is why a byte-identical copy was later
/// added for the cross-platform `MediaPlayer::open` path without anyone
/// noticing this existed: it is invisible in a Linux build, so nothing
/// warned. Two matches over `MediaSource` meant a new variant could be
/// handled in one and forgotten in the other, silently.
///
/// TRACES: UR-081 | DR-245, DR-255
/// Only available on Android where ExoPlayer needs direct URL access
#[cfg(target_os = "android")]
pub fn playback_url(&self) -> String {
match &self.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
-122
View File
@@ -32,24 +32,6 @@ 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.
///
@@ -138,68 +120,6 @@ pub struct Capabilities {
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.
@@ -283,46 +203,4 @@ pub trait MediaPlayer: Send {
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))
);
}
}
+20 -221
View File
@@ -12,6 +12,7 @@ pub mod events;
pub mod fake_player;
#[cfg(test)]
mod fake_player_conformance;
#[cfg(any(test, feature = "conformance"))]
pub mod legacy_player;
pub mod media;
pub mod media_player;
@@ -59,13 +60,10 @@ 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};
@@ -240,9 +238,7 @@ use crate::utils::conversions::seconds_to_ticks;
/// Central player controller that coordinates playback
pub struct PlayerController {
/// 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>>>,
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
queue: Arc<Mutex<QueueManager>>,
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
muted: bool,
@@ -341,7 +337,7 @@ pub struct PlayerController {
impl PlayerController {
pub fn new(
backend: Box<dyn MediaPlayer>,
backend: Box<dyn PlayerBackend>,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
) -> Self {
@@ -519,12 +515,7 @@ 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.
///
/// 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.
#[cfg(target_os = "linux")]
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_current_item (no backend load): {}",
@@ -577,16 +568,8 @@ impl PlayerController {
*self.html5_playing.lock_safe() = None;
let mut backend = self.backend.lock_safe();
// 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.playback_url(),
item.transport,
item.needs_transcoding,
),
))?;
backend.load(item)?;
backend.play()?;
drop(backend);
// A different item is loading; the last one's reported position must not
@@ -760,7 +743,7 @@ impl PlayerController {
return Ok(());
}
let mut backend = self.backend.lock_safe();
if backend.snapshot().phase.is_active() {
if backend.state().is_playing() {
backend.pause()
} else {
backend.play()
@@ -785,32 +768,10 @@ impl PlayerController {
let position = self.absolute_position();
let mut backend = self.backend.lock_safe();
backend.close()?;
backend.stop()?;
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);
}
@@ -888,7 +849,7 @@ impl PlayerController {
// If we're more than 3 seconds in, restart current track
{
let backend = self.backend.lock_safe();
if backend.snapshot().position.as_secs_f64() > 3.0 {
if backend.position() > 3.0 {
debug!("[PlayerController] previous: restarting current track (position > 3s)");
drop(backend);
return self.seek(0.0);
@@ -920,7 +881,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(Duration::from_secs_f64(position.max(0.0)))
backend.seek(position)
}
/// Seek to an **absolute** position on the item's own timeline.
@@ -971,48 +932,23 @@ 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.select_audio_track(Some(stream_index))
backend.set_audio_track(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.select_subtitle_track(stream_index)
backend.set_subtitle_track(stream_index)
}
/// Get current state
pub fn state(&self) -> PlayerState {
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()
self.backend.lock_safe().state()
}
/// Get current position
pub fn position(&self) -> f64 {
self.backend.lock_safe().snapshot().position.as_secs_f64()
self.backend.lock_safe().position()
}
/// The position on the **item's own timeline**, whatever is rendering it.
@@ -1038,7 +974,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().snapshot().position.as_secs_f64();
let native = self.backend.lock_safe().position().max(0.0);
let reported = self.reported_time.lock_safe().last_position();
let base = if self.is_background_audio_active() {
*self.background_audio_base.lock_safe()
@@ -1102,34 +1038,10 @@ 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()
.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)
})
.duration()
.or_else(|| self.observed_duration())
}
/// Get queue reference
@@ -1184,7 +1096,7 @@ impl PlayerController {
/// Get current volume (0.0 - 1.0)
pub fn volume(&self) -> f32 {
self.backend.lock_safe().snapshot().volume
self.backend.lock_safe().volume()
}
/// Check if muted
@@ -1285,7 +1197,7 @@ impl PlayerController {
drop(timer);
// Stop the backend
if let Err(e) = backend.lock_safe().close() {
if let Err(e) = backend.lock_safe().stop() {
error!("[SleepTimer] Failed to stop playback: {}", e);
}
continue;
@@ -1995,15 +1907,6 @@ impl PlayerController {
&self,
next_episode_id: &str,
) -> Result<(), String> {
// A new episode is a new playback, so a ceiling chosen for the previous
// one does not carry into it. Every advance the frontend drives goes
// through `player_play_item` and is cleared there; this one loads the
// next episode in Rust and would otherwise keep the old cap forever,
// with nothing in the UI saying why. Cleared before the URL is built,
// since that is what reads it.
// TRACES: UR-074 | DR-254
crate::repository::online::clear_playback_quality_override();
let repo = self
.repository
.lock_safe()
@@ -2309,10 +2212,7 @@ impl Default for PlayerController {
let playback_reporter = Arc::new(TokioMutex::new(None));
let position_throttler = Arc::new(EventThrottler::new());
Self::new(
Box::new(LegacyPlayer::new(
NullBackend::new(),
crate::player::media_player::Capabilities::mpv(),
)),
Box::new(NullBackend::new()),
playback_reporter,
position_throttler,
)
@@ -2321,107 +2221,6 @@ impl Default for PlayerController {
#[cfg(test)]
mod tests {
/// Advancing to the next episode drops a per-playback quality override.
///
/// The override is process-wide and describes *one* playback: a viewer who
/// drops to 720p for a struggling episode has said nothing about the next
/// one. `player_play_item`, `player_play_queue` and `player_play_tracks`
/// all clear it, so every advance the frontend drives is covered — but the
/// background audio-only advance loads the next episode in Rust and skips
/// all three, so every later episode stayed capped at the old quality with
/// nothing in the UI saying so.
///
/// A wiring assertion, like UT-218 and UT-225: the call site is what
/// matters, and reaching it at runtime needs a repository, a server and a
/// live player.
///
/// TRACES: UR-074 | DR-254 | UT-226
#[test]
fn test_background_episode_advance_clears_the_quality_override() {
let src = include_str!("mod.rs");
let start = src
.find("fn advance_to_next_episode_audio_only")
.expect("advance_to_next_episode_audio_only not found");
let rest = &src[start..];
let end = rest.find("\n pub ").unwrap_or(rest.len());
let body = &rest[..end];
assert!(
body.contains("clear_playback_quality_override"),
"the background episode advance does not clear the per-playback \
quality override, so a ceiling chosen for one episode silently \
caps every episode after it"
);
}
/// Stopping clears a background-audio handoff.
///
/// This was verified by listening to a tablet, which is not a test. The
/// handoff swaps which renderer owns playback, and the swap is bookkeeping:
/// leaving the base offset and the active flag behind after a stop lets a
/// later position read be interpreted against a handoff that no longer
/// exists, and left the film playing on as an audio track in the mini
/// player.
///
/// TRACES: UR-040, UR-005 | DR-250 | UT-224
#[test]
fn test_stop_clears_an_active_background_audio_handoff() {
let controller = PlayerController::default();
let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
{
let queue_arc = controller.queue();
let mut queue = queue_arc.lock_safe();
queue.set_queue(vec![item], 0);
}
controller.enter_background_audio(557.5);
assert!(
controller.is_background_audio_active(),
"precondition: the handoff is active"
);
controller.stop().expect("stop failed");
assert!(
!controller.is_background_audio_active(),
"a stop must not leave a handoff behind for the next position read"
);
assert_eq!(
*controller.background_audio_base.lock_safe(),
0.0,
"the handoff base must be cleared with it"
);
}
/// A duration the engine does not know must fall back to the one the item
/// 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
-12
View File
@@ -592,14 +592,6 @@ impl PlayerBackend for MpvBackend {
// one's "last observed" position.
self.observed.lock_safe().reset();
// Nor its deferred seek. A seek held for a file that is no longer the
// one loading would be applied to this one by the `FileLoaded` handler
// — so scrubbing near the end of a transcoded item, which re-opens the
// stream, and then skipping to the next item before the reload finished
// started the new item wherever the old one had been scrubbed to.
// TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None;
// Load the media file
self.mpv
.command("loadfile", &[&stream_url])
@@ -642,10 +634,6 @@ impl PlayerBackend for MpvBackend {
message: format!("Failed to stop: {:?}", e),
})?;
// Stopping ends the seek's subject along with the playback.
// TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None;
let mut state = self.state.lock_safe();
state.current_media = None;
-37
View File
@@ -59,43 +59,6 @@ mod tests {
}
}
/// A deferred seek belongs to the file it was issued against.
///
/// `seek` holds a position when MPV has nothing loaded yet, and the
/// `FileLoaded` handler applies it (DR-241). Nothing discarded it when a
/// *different* file was loaded or playback stopped — so scrubbing near the
/// end of a transcoded item (which re-opens the stream) and then skipping to
/// the next item before the reload completed applied the old position to the
/// new item. It silently started wherever you had scrubbed to in the
/// previous one.
///
/// Asserted against the source: the state lives behind a live MPV handle,
/// and constructing one needs libmpv and an audio device that CI cannot be
/// assumed to have. Crude, but it pins the one thing that matters — that
/// both lifecycle points discard it.
///
/// TRACES: UR-040, UR-005 | DR-253 | UT-225
#[test]
fn test_load_and_stop_discard_a_deferred_seek() {
let src = include_str!("mpv_backend.rs");
for func in ["fn load(", "fn stop("] {
let start = src
.find(func)
.unwrap_or_else(|| panic!("{func} not found - has the backend been restructured?"));
// The body runs to the next top-level ` fn ` at the same depth.
let rest = &src[start + func.len()..];
let end = rest.find("\n fn ").unwrap_or(rest.len());
let body = &rest[..end];
assert!(
body.contains("pending_seek"),
"{func} does not discard `pending_seek`. A seek held for a file \
that is no longer loading will be applied to whatever loads next."
);
}
}
/// Test that simulates the position update thread spawning async tasks
/// without a Tokio runtime (the bug we just fixed)
#[test]
+2 -7
View File
@@ -21,9 +21,7 @@ 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 super::media_player::{Capabilities, MediaPlayer, OpenRequest, Phase, PlaybackSnapshot};
use crate::utils::lock::MutexSafe;
/// State the event thread writes and the caller reads.
@@ -147,7 +145,7 @@ impl MpvPlayer {
s.duration = mpv
.get_property::<f64>("duration")
.ok()
.and_then(duration_from_secs);
.map(Duration::from_secs_f64);
s.seekable = mpv.get_property::<bool>("seekable").unwrap_or(true);
s.phase = Phase::Playing;
s.deferred_seek.take()
@@ -375,9 +373,6 @@ impl MediaPlayer for MpvPlayer {
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,
}
}
}
+19 -35
View File
@@ -25,14 +25,12 @@ pub enum VideoSeekStrategy {
///
/// # Arguments
/// * `is_local` - Whether the file is a local download
/// * `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.
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
/// * `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,
seeks_transcoded_in_place: bool,
is_hls: bool,
needs_transcoding: bool,
use_html5: bool,
) -> VideoSeekStrategy {
@@ -55,15 +53,14 @@ pub fn determine_video_seek_strategy(
// 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,
return if use_html5 {
if is_hls {
VideoSeekStrategy::Html5NativeSeek
} else {
VideoSeekStrategy::Html5ReloadStream
}
} else {
VideoSeekStrategy::BackendReloadStream
};
}
@@ -238,21 +235,20 @@ mod tests {
);
}
/// Non-transcoded streams seek in place regardless of the engine's
/// transcode ability, which only applies to transcodes.
/// Test video seek strategy for HLS streams
#[test]
fn test_seek_strategy_direct_stream() {
// HTML5 renders, so the frontend seeks the element
fn test_seek_strategy_hls_stream() {
// HLS with HTML5 - frontend handles seek, don't call backend
assert_eq!(
determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// The native engine renders, so it seeks
// HLS with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, true, false, false),
VideoSeekStrategy::BackendNativeSeek
);
// A transcode an engine says it can move: seek in place
// HLS even with needs_transcoding flag - still native seek (HLS supports it)
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
@@ -269,30 +265,18 @@ mod tests {
/// 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
/// TRACES: UR-040 | DR-238 | 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.
fn test_seek_strategy_transcoded_hls_native_backend() {
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
determine_video_seek_strategy(false, true, 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.
// The HTML5 side of the same case is unchanged: hls.js seeks in-playlist.
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
+1 -13
View File
@@ -377,19 +377,7 @@ fn draw(widget: &gtk::Box, cr: &gtk::cairo::Context, state: &Rc<RefCell<SurfaceS
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()
);
info!("[VideoSurface] rendering {width}x{height} (texture {texture})");
}
unsafe {
@@ -184,45 +184,6 @@ 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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.11.0",
"version": "0.10.1",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+13 -19
View File
@@ -251,6 +251,7 @@
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,6 +1032,7 @@
"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
@@ -1096,6 +1098,7 @@
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,
@@ -1136,6 +1139,7 @@
}
} else {
// For transcoded content, keep backend for seeking
didStartNativePlayback = true;
}
}
}
@@ -1269,25 +1273,14 @@
}
// Stop the player when component is destroyed
// 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);
// Skip if we already stopped the backend early (non-transcoded + HTML5)
if (didStartNativePlayback && !didStopBackendEarly) {
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)
@@ -2046,6 +2039,7 @@
transport: targetSelection.transport,
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
});
didStartNativePlayback = true;
await playerAdapter?.load(targetSelection.url, {
mediaId: media.id,
selection: targetSelection,