Not a generic smoke test. Every case exists because something specific went
wrong, and most were found on hardware after the suites were already green.
The sequences are load-bearing. Two defects this cycle only appeared in a
particular order of actions — play, enable background audio, background,
foreground, exit — and testing the same features in any other order found
neither. So the plan asks for that order explicitly rather than listing
"background audio" as a feature to try.
It also states plainly that a green conformance run is not sufficient evidence
to ship, because both regressions introduced during this work passed
conformance and were caught by a person using the app.
Includes a symptom-to-cause table, because none of these presented as their
cause: a dead play/pause button was an unobserved property, a black screen was
a float that could not become a Duration, and a scrub bar with no scale was a
duration of zero being believed.
"Known open" lists what is deliberately unfixed so each gets a decision rather
than a surprise — device-local resume, the unconfirmed handoff swap, and the
broken side-by-side debug install whose own error message advises an uninstall
that would destroy the real app's data.
DR-252, and a regression I introduced in DR-245.
`Duration::from_secs_f64` panics on a negative or non-finite value. The old
PlayerBackend contract passed durations around as a bare Option<f64> and never
promised otherwise, so junk flowed through harmlessly. LegacyPlayer converts
that value to a Duration on the way into the MediaPlayer contract, which turned
it into a hard panic.
ExoPlayer reports C.TIME_UNSET — Long::MIN_VALUE, about -9.2e15 seconds — for
any stream whose length it does not know. That is every background-audio
handoff: /Audio/{id}/universal is a chunked, length-less transcode. So the
panic fired exactly when the handoff started, killed the Rust backend
mid-swap, and left a black screen with no controls.
Caught on the device, in the user's own repro sequence: enable background
audio, background the app, come back. Not by any suite — the conformance cases
run against engines that report sane numbers, and nothing was asking what
happens when one does not.
One guard on the contract now, used by every engine crossing into it, rather
than each adapter deciding for itself. mpv had the same unguarded conversion
for its duration property and would have hit it the moment libmpv reported
something odd.
UT-222 pins the values: TIME_UNSET as seconds, negatives, zero, NaN and both
infinities yield no duration; a real runtime survives.
791 Rust tests, mpv conformance still 9/9, clippy clean both ways.
DR-251. Scrubbing was dead on Android because the seek bar had no scale:
every position tick read `<position> / 0.0`.
ExoPlayer reports C.TIME_UNSET until it has resolved a duration, and
JellyTauPlayer.getDuration() maps that to 0.0. So the engine answered
Some(0.0) rather than None, which satisfied every "unknown duration" fallback
in the controller — `observed_duration()` was never consulted, and neither was
the runtime the catalog had carried since long before anything started
decoding.
Zero is now read as "does not know yet" at each step, with a final fallback to
the item's own duration. That fixes it for any engine that cannot answer,
rather than for ExoPlayer specifically.
Red first: the test asserts a controller whose engine reports nothing usable
still reports the queued item's 1800s, and failed with None before the change.
790 Rust tests, clippy clean both ways.
DR-250. The user's diagnosis, and a better fix than modelling the handoff more
carefully: a close that stops only what we believe is playing is fragile by
construction. A close that stops everything is correct whatever the bookkeeping
thinks.
Two places it did not.
The teardown's stop was gated on `didStartNativePlayback &&
!didStopBackendEarly` — flags describing what *this component* started. A
background-audio handoff swaps the renderer underneath them, so after one they
describe a player that is no longer making sound and the stop was skipped
entirely. The audio stream kept running and the mini player adopted it, which
is exactly why a movie reappeared as an audio track. It is unconditional now;
`playerStop` is idempotent, so the cost of calling it when nothing plays is a
no-op round trip, against the alternative of silently leaving audio running.
And `PlayerController::stop` never cleared the handoff. Leaving the base offset
and the active flag behind lets a later position read be interpreted against a
handoff that no longer exists. Stopping now clears both.
`didStartNativePlayback` had no remaining reader and is deleted rather than
silenced — dead bookkeeping about which renderer was in charge is precisely the
frontend playback state this contract is meant to remove, and the eslint
ratchet caught it going one over.
The underlying unconfirmed state swap is still there and still worth fixing —
it is written up in media-player-controller.md. This makes the symptom
impossible while that lands.
789 Rust tests, 1088 frontend, lint back at 158, clippy clean both ways.
Diagnosed on a device. The likeliest explanation for "audio keeps playing
after I leave the player", which is the report this line of work started from.
enter_background_audio and exit_background_audio are pure bookkeeping: a
boolean and a base offset. Neither confirms the audio stream opened, nor that
the webview <video> came back. exit_background_audio's own comment says the
element "becomes the player again once it reloads" — a future event nothing
waits for, while the flag calls the swap done the moment it is invoked.
Foreground the app, then leave the player before the element has reloaded, and
the stop is aimed at something that does not exist yet while the audio stream
keeps running. The mini player then adopts a live audio session, which is why a
movie reappears as an audio track and why it is intermittent.
Same defect class as DR-238 … DR-241: state asserted rather than confirmed. It
is what Phase::Opening and the open generation exist for — a handoff is an open
in flight, and a close during one must cancel it. Today the handoff never
reaches an engine as an open at all, which is why
close_during_open_never_plays passes on all four engines while the bug
survives.
Credit where due: the sequence came from the user reproducing it deliberately,
not from the logs.
`set_current_item` was `#[cfg(target_os = "linux")]` from when its caller was a
`#[cfg]` branch too. d3ecd8ee correctly replaced that branch with a runtime
question — "does this renderer draw the picture?" — which means the `else` arm
is now compiled on every platform, including ones where it never runs. The gate
stayed, so the Android build stopped compiling at that commit.
It went unnoticed because nothing built for Android afterwards. CI's Android
`cargo check` would have caught it; this branch has never been pushed.
Also adds the widget's allocation origin to the video-surface log. A GtkBox is
a no-window widget, so `widget.window()` is the parent's GdkWindow and the box
sits at an offset inside it; if `draw_from_gl` does not honour the cairo
translation GTK applied, the picture lands at the window origin instead of the
widget's — misaligned by exactly that offset, which is the shape of a letterbox
that does not line up. Logging the origin says whether that is what is
happening before anyone changes the geometry.
A regression I introduced in DR-246 and did not catch, because the capability
was declared once for "native engines" as though being native were the
property that mattered.
It is not. Speaking HLS is. ExoPlayer is a full HLS client: like hls.js it
seeks within the VOD playlist it was handed and lets the server catch up. mpv's
HLS demuxer will not make the server produce segments from a new offset, so it
has to re-open the stream. Grouping them together declared false for both, so
on Android a transcoded seek began re-opening the stream where it previously
seeked in place — the same class of defect DR-238 was about, reintroduced on
the platform I had not exercised.
Capabilities::native() is gone, replaced by mpv() and exoplayer(), and the
composition root chooses per platform through engine_capabilities(). Treating a
category as a proxy for an ability is precisely the inference this design
removes; a helper named after the category invited it straight back in.
Not yet verified on a device. The conformance cases run against JellyTauPlayer
in isolation and do not cover a transcoded seek, PiP, background audio or the
media session — none of which have been exercised since the controller port.
DR-242 … DR-247 are in. The spec now says so rather than reading as a proposal
for work that already exists.
One deviation is recorded rather than quietly absorbed: DR-246 called for the
engines to own seek strategy outright, and they cannot — re-negotiating a
stream needs the repository, which sits above them. The engine declares the
ability and the caller acts on it. `determine_video_seek_strategy` therefore
survives, correctly typed over a declared capability instead of over a guess,
because the defect was its input rather than its existence.
The desktop runner needed a hand-generated fixture and the Android one needed
`-x :app:rustBuildUniversalDebug`, which nobody was going to remember. Both are
now `bun run test:player` and `bun run test:player:android`.
The fixture is generated on first use rather than committed: no media in the
repo, and an exact duration, which the seek assertions depend on.
The gradle exclusion carries its reason inline — raw gradle drives the Rust
build through Tauri's android-studio-script, which expects a dev-server address
file that only exists under `tauri android dev`, and the library already in
jniLibs is what the test process loads.
DR-246. The strategy used to turn on `is_hls` and `use_html5`, decided in a
command handler on behalf of engines it does not own. That is how "who
renders" came to mean "how do I seek", and why a transcoded seek silently did
nothing the moment native video changed the renderer (DR-238).
Engines now declare `Capabilities::seeks_transcoded_in_place` — true for
hls.js, which seeks within the VOD playlist it was handed and lets the server
catch up; false for mpv, whose HLS demuxer cannot make the server transcode
from a new offset. The command asks whichever engine is rendering. Adding an
engine no longer means editing a shared truth table.
The item's transport is not read at the seek site any more; the compiler
flagged it unused, which is the URL-shape input finally disappearing.
A deviation from the spec, recorded deliberately: it called for the engine to
own the decision outright. It cannot. Re-negotiating a stream needs the
repository, which sits above the engine, so the engine states the ability and
the caller acts on it. That still removes the defect — nobody guesses on
another component's behalf — without pretending an engine can reach upward.
Also fixes a latent race in the conformance suite, found by running it: the
seek case asserted immediately, which passes on an engine that records the
target when it accepts a seek and races on one that waits for the decoder to
move. `Harness::await_seek` polls instead, the way the Android suite already
did. It failed with machine load rather than with the code, which is the kind
of test that teaches people to re-run until green.
MpvPlayer 9/9
LegacyPlayer 8/9 - still only the mute/rate gap in the old trait
789 tests, clippy -D warnings clean with and without the feature.
DR-245. PlayerController now holds a MediaPlayer instead of a PlayerBackend,
and every engine reaches it through that contract.
Deliberately a seam swap, not four rewrites: the existing backends are carried
across by LegacyPlayer, so MPV keeps its EQ and normalisation, ExoPlayer keeps
its media session, and nothing loses a feature to the migration. MpvPlayer
stays available for conformance until it grows the audio-settings half.
The substantive change is at the load site. Where the controller used to call
load() and then play(), it now issues one open() carrying the item and where
to begin — so the window a start position could be lost in is gone from the
controller as well as from the engines.
`state()` maps the engine's Phase back onto PlayerState using the queue, which
is what knows the item. External behaviour is unchanged.
Supporting pieces:
- The contract gains set_audio_settings/audio_settings as *provided*
methods. Engines that cannot honour them say so through Capabilities and
inherit a no-op, rather than every implementation carrying an Ok(()) it
does not mean.
- PlayerBackend is implemented for Box<dyn PlayerBackend>, without which the
boxed engine built at the composition root cannot be handed to anything
generic over the trait.
- StreamSelection::for_queued_item rebuilds a selection for an item already
in the queue, without re-negotiating. The transport falls back rather than
being sniffed out of the URL — that substring check is what DR-230 removed
— and needs_transcoding is an exact stand-in because every transcode this
app requests is HLS (DR-140).
- default-run = "jellytau". The conformance binary made a bare `cargo run`
ambiguous, which broke `tauri dev` outright. Caught by running the app
rather than by any suite, which is the argument for doing both.
789 tests, clippy -D warnings clean with and without the feature.
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
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]
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.
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.
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.
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.
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.
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).
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.
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.
`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.
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.
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.
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.
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.
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.
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.
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.
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
15 changed files with 23 additions and 614 deletions
@@ -446,9 +446,6 @@ Internal architecture, components, and application logic.
| 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 |
---
@@ -763,10 +760,6 @@ Internal architecture, components, and application logic.
| UT-220 | The conformance suite: opening at a position starts there and never at zero, a seek issued while opening is honoured and overrides the start it overtook, pause and play are observable, close is silent and idempotent, and an open cancelled by close never begins playing | DR-242, DR-243 | In Progress |
| UT-221 | An engine that cannot report a duration does not erase the one the item carries: with the queue holding a 1800s item and the engine answering nothing usable, the controller still reports 1800s | DR-251 | Done |
| UT-222 | The values that killed the backend are rejected rather than converted: `C.TIME_UNSET` as seconds, negatives, zero, NaN and both infinities all yield no duration, while a real runtime survives | DR-252 | Done |
| UT-223 | The adapter survives an engine that answers badly. A `HostileBackend` reports `C.TIME_UNSET` as seconds, NaN, both infinities, a negative and a zero; reading a snapshot yields no duration and a zero position rather than panicking, and a well-behaved engine still round-trips. The conformance suite could not have caught this — it only ever drives engines that report sane numbers, which is why it stayed green while a real one took the backend down | DR-252 | Done |
| UT-224 | Stopping clears an active background-audio handoff, both the flag and the base offset, so a later position read cannot be interpreted against a handoff that no longer exists. Previously verified only by listening to a device | DR-250 | Done |
| 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 |
@@ -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` → 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. |
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.