Rust becomes the single owner of which stream to play — direct play or
transcode, at what ceiling, over what transport — and hands every player
backend a self-describing StreamSelection instead of a bare URL. mpv,
ExoPlayer and the HTML5/hls.js path all consume one decision rather than
three places re-deriving it.
The motivating leak is concrete. VideoPlayer.svelte determines transport with
`currentStreamUrl.includes(".m3u8")`, in two places, for a URL Rust
constructed and therefore already knows the shape of. That is the boundary
rule in miniature: not item-type taxonomy, but the same error of
reconstructing a domain fact in the presentation layer because the wire shape
did not carry it. A tagged Transport enum deletes it.
The design line, which ExoPlayer forces: Rust decides *what stream*, the
player decides *how to deliver it*. ExoPlayer has genuine adaptive track
selection; this spec must not reimplement or fight it. Rust only adapts where
the player cannot (mpv) and the server actually offers a ladder.
Six phases, and phase 1 stands alone as pure ownership movement with no
behaviour change. Phase 4 (direct-play negotiation) is what removes the
transcode and unblocks the Linux native-video work. Phase 5 (adaptation) is
gated on counting EXT-X-STREAM-INF entries in a real playlist — the
acceptance criteria require that count be recorded before it is either
started or dropped.
Takes DR-121 from read-through-media-cache.md, which specced Rust-owned
quality reporting but never built it; that spec keeps its capture half.
Three corrections to the Linux native-video spike, each of which reverses
something recorded earlier in the same session:
- The crash is unexplained. It was first blamed on hwdec=auto-safe's Vulkan
failures, on a misreading of the logs — those are two per run at start-up,
not per-frame, and every clean run has the same two. A 300s soak on
auto-safe survived. So did 240s of automated fullscreen toggling (~120
transitions) and 240s of continuous resizing (~2000 reallocations). Three
hypotheses, none reproduced. Recorded rather than dismissed: an
intermittent fault nobody can reproduce is worse to inherit than a
deterministic one.
- G5 drops to amber. It looked and felt smooth, but the only SIGSEGV observed
came from the only session in which fullscreen was exercised, and the spike
has no lifecycle handling at all — it never frees the render context. An
implementation must bind that to the GL context's lifetime regardless of
what caused this crash, because Android already paid for that lesson as
DR-184.
- Finding 3's premise is in doubt. "The webview path already has real ABR via
hls.js" was never checked against the URLs this app builds:
get_video_stream_url requests a single rendition, the frontend has no
level-handling code at all, and a quality switch is implemented by
re-opening the stream. If the playlist is single-variant there is no
adaptation to lose. The decisive test needs a live server and is recorded
as unrun.
Also records hardware decode working through the render API (nvdec-copy
engaged), and that hwdec=vaapi silently fell back to software on this box.
Adds the spike write-up and re-opens finding 2 of
playback-backend-unification.md, which concluded that video cannot unify
on a native engine because the webview owns the surface.
That finding's general form has since been falsified on Android, where
native video composites behind a transparent WebView and ships on by
default. The evidence behind it was also entirely about foreign-window
embedding -- mpv's render API, drawing into a GL context we own inside
Tauri's own GTK tree, was never tested. The spike tests that one claim
and comes back green on Linux for both X11 and Wayland, bar the Tauri
default_vbox() half of G1.
Findings 3-6 are deliberately left standing. Finding 3 in particular --
mpv has no adaptive bitrate -- is an independent disqualifier that a
green compositing result does not clear, and the spike says so rather
than reading as a green light.
The next-free-id line moves to UR-079 / IR-033 / DR-219: this branch
allocated UR-077 and UR-078 for the updater and diagnostics work, and
DR-215 through DR-218 with them, after that line was last written.
Authored in a parallel session working in the same checkout; committed
here so it travels with the rest of the branch.
2026-08-21 19:06:13 +02:00
5 changed files with 739 additions and 2 deletions
@@ -27,8 +27,8 @@ know how something *works*, read
| Design authority | No code of its own — it records a decision later specs act on. |
**Next free requirement ids** (always re-check
[requirements.md](../requirements.md) before allocating): **UR-077**,
**IR-033**, **DR-215**. Three specs below suggested ids that have since been
[requirements.md](../requirements.md) before allocating): **UR-079**,
**IR-033**, **DR-219**. Three specs below suggested ids that have since been
taken by other work; each carries a ⚠️ note at the top.
## Partially implemented
@@ -44,9 +44,11 @@ taken by other work; each carries a ⚠️ note at the top.
| Spec | Blocked on / note |
|---|---|
| [backend-owned-stream-selection.md](backend-owned-stream-selection.md) | Rust owns direct-play-vs-transcode, transport and quality; players consume one `StreamSelection`. Phase 1 (delete the `.m3u8` sniff) stands alone. Unblocks Linux native video. |
| [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. |
| [linux-native-video-spike.md](linux-native-video-spike.md) | **Spike run 2026-08-21: compositing works on Linux, X11 and Wayland.** G1-G6 green bar the Tauri `default_vbox()` half of G1. Needs an implementation spec that answers adaptive bitrate. |
`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. |
| Which backend renders video on this platform (`use_html5_element`, `supports_native_video`) | Rust | Already there — `get_player_status` in `commands/player/mod.rs` computes it from a `cfg!`. The spike would widen that `cfg!`, not relocate the decision. The frontend already consumes it via `createAdapter`. |
| Whether *this stream* is direct-play or transcoded, and therefore whether mpv or hls.js renders it | Rust | Domain. It depends on Jellyfin's `PlaybackInfo` response, container/codec support, and the bitrate cap — all of which change when Jellyfin's API or our quality ladder changes. The frontend must never re-derive it from a URL shape. |
| Creating, sizing, and destroying the GL surface; the mpv render context | Rust | Owns the backend and the GTK window handle. There is no presentation decision in it. |
| Where controls, subtitles, and the mini-player sit above the video, and the letterbox/poster treatment | Frontend | Pure presentation; changes only if the UI is redesigned. Precisely the split the Android path already uses. |
| Reserving the video rectangle in layout and marking the shell transparent | Frontend | Presentation. `nativeVideo.ts` + the `[data-native-video="active"]` rule in `app.css` already do this for Android and are platform-agnostic. |
Borderline row, stated with its tie-breaker: *"is the surface currently
attached?"* reads like view state, but the Android work found that a surface left
in the hierarchy outlives its player (DR-184). Attachment is backend lifecycle →
**Rust**, with the frontend told about it, not asked.
## Design
A throwaway branch. No merge to `master` except the decision note.
### What gets built
One `#[cfg(target_os = "linux")]` experiment behind a feature flag, in a scratch
binary or an ignored test — **not** in `MpvBackend`'s constructor path:
1. From `app.get_webview_window(...)`, take `gtk_window()` and `default_vbox()`.
2. Reparent the webview into a `gtk::Overlay`: `GLArea` as the main child, the
webview as the overlay child.
3. Set the webview background to fully transparent (wry does this when
`"transparent": true`; verify it reaches `webkit_web_view_set_background_color`).
4. In the `GLArea`'s `render` signal, drive
`mpv_render_context_render` with `MPV_RENDER_PARAM_OPENGL_FBO` pointing at the
FBO GTK bound for us.
5. Play one local file. Draw an opaque HTML element over the video area.
None. The spike crosses no boundary. If it goes green, the follow-up changes only
the *value* of the existing `useHtml5Element` / `supportsNativeVideo` fields — no
new wire shapes, no `bindings.ts` regeneration.
## Gates
Each is pass/fail with a named failure. Stop at the first red and write it up —
a red result is a successful spike.
| # | Question | Fails if |
|---|---|---|
| G1 | Can a custom GTK widget join Tauri's widget tree and survive the window's lifetime? | `default_vbox()` is absent/unusable, or reparenting the webview breaks input or crashes. |
| G2 | Does the webview still paint, with a transparent backdrop, over that widget? | The backdrop renders opaque black ([wry#1540](https://github.com/tauri-apps/wry/issues/1540)) or the webview stops repainting ([tauri#12800](https://github.com/tauri-apps/tauri/issues/12800)). **This is the highest-risk gate.** |
| G3 | Does mpv render a frame into our FBO? | The render context refuses GTK's context, or frames land in the wrong buffer. |
| G4 | Does HTML drawn over the video area actually appear over it? | Video covers the controls — the exact failure wry#284 and tauri#6343 report. Without this, the whole thing is worthless: our controls, subtitles and mini-player all sit over the video. |
| G5 | Does it survive resize, fullscreen, and SPA navigation away and back? | Flicker on resize, or a surface that outlives its route. |
| G6 | Does it hold on **both** X11 and Wayland? | Either session backend fails. Wayland is the one the 2024 maintainer quote says is impossible — test it first, not last. |
G6 is not a nice-to-have. A result that only holds on X11 is red for a project
shipping to current desktops.
### Time box
If G1–G4 are not all green, stop and write the result up. The value of this spike
is a dated, method-specific answer — including "still no, and here is the
mechanism" — not a working player.
## Result (2026-08-21)
Run on GNOME, kernel 7.1.8, libmpv 2.5.0 (mpv 0.41.0), GTK 3.24.52, WebKitGTK
2.52.6, wry 0.53.5 — the versions `src-tauri/Cargo.lock` resolves. Spike source:
a ~250-line standalone crate using wry + gtk + `libmpv2-sys` raw FFI, driving
mpv's render API with an update callback, frame-gated repaints and
`report_swap`.
| Gate | Result | Observed mechanism |
|---|---|---|
| G1 widget in GTK tree | 🟡 **partial** | `GtkOverlay` with `GtkGLArea` as main child and the wry webview as overlay child works, built directly. **Tauri's own `default_vbox()` was not exercised** — see below. |
| G2 webview paints transparently over it | ✅ green | `with_transparent(true)` alone. No window-level transparency was used or needed. |
| G3 mpv renders into our FBO | ✅ green | `vo=libmpv` + `mpv_render_context_create` with `MPV_RENDER_PARAM_OPENGL_FBO` into the FBO GTK binds. |
| G4 HTML over video | ✅ green | Opaque panel and a translucent control bar both drew over moving video. |
| G5 resize / drag / fullscreen | 🟡 **green on appearance, suspect underneath** | No flicker, gap or misalignment, and smooth once frame pacing was correct (trap 3). But the only crash observed came from the only session where fullscreen was exercised — see "What is still open". |
| G6 X11 **and** Wayland | ✅ green | Identical on both; `GDK_BACKEND` flipped between runs. |
**Finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
is false on Linux** when tested by the render API rather than by foreign-window
embedding. Wayland — the half the 2024 maintainer quote called impossible — is
green.
Better than the gate asked for: the translucent bar composited *alpha* against
the video, not merely opaque-over. Scrims, gradient fades and subtitle backdrops
therefore work, which is most of how a player UI actually looks. mpv also painted
the letterbox bars black on its own — the Android equivalent was a shipped defect
(DR-194).
### Three traps, each of which cost a debugging cycle
Carry these into the implementation; each produced a failure that looked like a
platform limitation and was not.
1.**`LC_NUMERIC` must be reset *after*`gtk::init()`, not before.** mpv refuses
to start under a non-C numeric locale. `mpv_backend.rs` already handles this,
but it has no GTK init in front of it; on this path `gtk::init()` applies the
user's locale afterwards and `mpv_create` returns null.
2.**libepoxy exports GL entry points as *data* symbols.** There is no `glFoo`
function to resolve — there is `epoxy_glFoo`, a variable holding a lazily
resolving function pointer. `get_proc_address` must return the pointer *stored
at* that symbol; returning the symbol's own address makes mpv jump into
non-executable data and take SIGSEGV/SEGV_ACCERR on the first GL call. The
`epoxy` crate does this correctly but is unusable — its `gl_generator`
dependency pulls a yanked `xml-rs`.
3.**Frame pacing is not optional, and its symptom is misleading.** Driving
`queue_render()` off the widget's frame clock on every tick, without calling
`mpv_render_context_report_swap` after each render, leaves mpv with nothing to
time against. Playback looks fine in a window and **judders at fullscreen** —
which reads as a compositing or GPU limit and is neither. The fix is to
register `mpv_render_context_set_update_callback`, redraw only when it says a
frame is ready, and report the swap afterwards. Fullscreen was smooth
immediately once both were in place.
### Hardware decode through the render API
Tested by asking mpv what it actually selected (`hwdec-current`), not what it was
asked for. All three ran 20s clean at a steady 30 fps.
| `hwdec` | `hwdec-current` | Note |
|---|---|---|
| `vaapi` | `no` | **Did not engage** on this box — silently fell back to software. `vainfo` is not installed, so the libva driver for the Iris Xe iGPU is likely absent. No render-API error; this looks like a missing driver package, not a compositing limit. |
| `auto` | `nvdec-copy` | Hardware decode **does** work through the render API, on the discrete RTX 3050. Copy-back rather than zero-copy interop. |
| `no` | `no` | Software. Clean baseline. |
The load-bearing result is the middle row: **hardware decode is compatible with
mpv's render API**, so the direct-play prize is real and not traded away for
software decoding. Which decoder to prefer is an implementation question — on a
hybrid Intel+NVIDIA laptop `auto` reached for the discrete GPU in copy-back mode,
which is the least efficient hardware path. An implementation should evaluate
zero-copy VA-API on the iGPU (after confirming the driver is installed) before
accepting `auto`.
`hwdec=auto-safe` probes Vulkan video decode, which this GPU does not support.
It logs two `Failed setup for format vulkan` / `no frame!` pairs at start-up and
then settles on `nvdec-copy` — the same place `auto` lands. A first reading of
these logs mistook the start-up pair for a per-frame flood; **it is not**. Every
run, clean or crashed, contains exactly two. `auto-safe` is not implicated in
anything.
### What is still open
- **The Tauri half of G1.** The spike built its own `GtkOverlay`. The app must
instead reach `WebviewWindow::gtk_window()` / `default_vbox()` and reparent
Tauri's existing webview into an overlay. Low risk — the same widgets, one
extra reparent — but unproven, and it is the only place Tauri-specific
behaviour could still bite.
- 🔴 **ABR — finding 3's premise is in doubt.** Finding 3 says mpv would regress
streaming quality because "the webview path already has real ABR via hls.js".
Three pieces of evidence in this repo suggest that is **not true of the URLs we
actually build**:
1.`get_video_stream_url` (`repository/online.rs`) requests a *single*
rendition — one `VideoBitrate`, one `MaxStreamingBitrate`, one `MaxHeight`.
Jellyfin transcodes to what it is asked for; it does not build a ladder.
2. The frontend contains **no level-handling code at all** — no `hls.levels`,
no `LEVEL_SWITCH`, no `currentLevel`. The `abrEwma*` options in
`VideoPlayer.svelte` are default tuning with nothing to act on. hls.js is
serving as an HLS *demuxer* (WebKitGTK cannot play HLS natively), not as an
adaptation engine.
3. That function's own comment describes a quality switch as **rebuilding the
URL** — "every path that re-opens a stream (quality switch, transcoded seek,
audio-track switch)". Manual selection by stream re-open is what you build
when there is no adaptation, and mpv can do the same thing.
**The decisive test has not been run** and needs a live server plus an API key:
count `#EXT-X-STREAM-INF` lines in a real `master.m3u8`. One line means there
is no ABR to lose and this blocker disappears. More than one means finding 3
stands and the work below applies.
If ABR does turn out to be real, it belongs in **Rust**, not in mpv, and there
are three designs in increasing cost: pick the variant at open; re-open at a
new bitrate on sustained throughput drops (this is the quality-switch path the
app already has, so it is nearly free); or run a local proxy serving mpv a
synthesized single-variant playlist while swapping renditions underneath. The
middle option is almost certainly sufficient.
Either way the **direct-play path still does not exist** — every video play
currently goes through the HLS transcode endpoint. Building it is the real
project; the compositing work proven above is the smaller half.
- 🔴 **One unexplained SIGSEGV.** A ~180s
run died in a *decoder* thread (libavcodec -> `av_log` -> libmpv's log handler
-> libc). No Tauri, wry, WebKitGTK, GTK or GL frame appears anywhere in the
stack, so the fault is on the mpv/ffmpeg side of the process rather than in the
compositing seam.
Three hypotheses were tested and **none reproduced it**:
| Hypothesis | Test | Result |
|---|---|---|
| `hwdec=auto-safe`'s Vulkan failures | 300s soak on `auto-safe` | Survived. Also based on a misreading — the failures are 2 per run at start-up, not per-frame. Dead. |
| Fullscreen transitions recreating the GL context under mpv's render context | 240s soak, ~120 automated transitions | Survived, no core dumped. |
| Continuous resize thrashing the GL framebuffer | 240s soak, ~2000 resizes | Survived, no core dumped. |
**The crash is therefore unexplained.** It was observed exactly once, in the
only session a human interacted with, and did not recur in ~13 minutes of
targeted stress across the three most plausible causes. It is recorded here
rather than dismissed precisely because nothing explains it: an intermittent
fault that nobody can reproduce is worse to inherit than a deterministic one,
not better.
The underlying concern stands regardless of which test eventually reproduces
it. A SIGSEGV in an unrelated thread is characteristic of memory corruption,
and this spike never calls `mpv_render_context_free` and never tears down on
`unrealize` — it has no defence against the GL context being recreated beneath
the render context. That is DR-184 on Android restated: a surface outliving its
player. An implementation must bind the two lifetimes together whether or not
this particular crash is ever explained.
**Therefore G5 is recorded green on appearance only**, and this crash is the
single largest piece of unfinished business in the spike. Do not read the green
gates above as "safe to build on" until it is explained or a long soak clears
it.
- Long-run stability, seeking, track switching, HDR, and multi-window were not
exercised at all.
## Out of scope
- Any change to the shipping Linux video path. `experimentalNativeVideo` in
`adapters/index.ts` is a **suppressor, never a promoter**; the spike must not
change that.
- Adaptive bitrate. See "The blocker a green spike does not clear".
- Windows and macOS — different mechanisms, and **Windows is the easier case, not
the endangered one**. See below.
- Android. Already shipped; it is the precedent, not the target.
- Crossfade, the libmpv2 migration, and the audio-parity work.
### Why Windows is unaffected, and cheaper
Nothing here can regress Windows. `use_html5_element` is already a per-platform
`cfg!` in `get_player_status` — Android native, everything else HTML5 — so
divergent video paths are the existing design rather than something this
introduces. Windows keeps `<video>` + hls.js whatever this spike returns.
The mechanism does not port: `default_vbox()`, `GtkOverlay` and `GtkGLArea` are
GTK3/WebKitGTK concepts. But the *question* is already answered more favourably
there. Both mpv plugins list Windows as **fully tested** and Linux as broken,
because WebView2 honours a transparent background — the "native surface beneath a
transparent webview" approach that fails on WebKitGTK is the one that works on
Windows. That asymmetry is why
[windows-native-audio-backend.md](windows-native-audio-backend.md) can call
Windows "the cleanest available win".
Windows' cost is packaging, not compositing: the build cross-compiles with MSVC +
`cargo-xwin`, so libmpv arrives as a bundled prebuilt DLL (the ⚠️ in finding 5's
comparison table). That cost is already committed for *audio*. Once the DLL ships
to replace `WebviewAudioBackend`, Windows video is largely a follow-on.
Sequencing, if native video is ever pursued on both:
1. [libmpv2-migration.md](libmpv2-migration.md) — prerequisite for either.
### 4. Crossfade is architecturally blocked on mpv
mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter
Reference in New Issue
Block a user
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.