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.
20 KiB
Spec: Desktop native video — mpv renders the picture, everywhere
Status: Proposed
Requirements: UR-080 (new) → DR-230 … DR-236 (new); IR-033 (new)
UX spec: n/a — nothing about the player's appearance changes. What changes is
what is behind the controls.
Supersedes / revises: consumes and closes
linux-native-video-spike.md, whose gates
authorised exactly this spec and nothing more. Settles finding 2 of
playback-backend-unification.md on the
desktop; finding 3 was already settled by DR-228. Absorbs the video half of what
windows-native-audio-backend.md leaves open.
Depends on: backend-owned stream selection (DR-224 … DR-229), the branch
below this one. mpv is a consumer of StreamSelection, never a second place to
decide what to play.
Destination on completion: 05-platform-backends.md — a "Native Video Compositing (Desktop)" section beside the existing Android one, which this mirrors; and 01-rust-backend.md — the device profile becomes renderer-dependent, beside the stream-selection section. The spike is deleted in the same commit, its three traps and its hardware-decode table folded in; they are the durable half.
Summary
mpv decodes and draws video on every desktop platform, composited beneath the
transparent webview, exactly as Android already does with ExoPlayer. The HTML5
<video> path and hls.js are then deleted, not merely bypassed.
The user-visible change is that most video stops being re-encoded by the server before it can be watched. The change for whoever maintains this is that video goes from three renderers to two.
Motivation
The transcode is a decoder constraint, not a rendering one
Desktop video goes through an h264 HLS transcode because the picture is drawn by
a WebKitGTK <video> element, and that element decodes little else. The device
profile therefore claims h264 alone. That is not a statement about the machine
— the same machine runs mpv, which decodes essentially everything in the library
— it is a statement about which widget is holding the frame.
DR-227 made the cost measurable. Over 40 items negotiated against the development server:
| Profile | Direct play |
|---|---|
Desktop / WebKitGTK — h264 only, 2ch |
7% |
Android / ExoPlayer — h264,hevc,vp8,vp9,av1,mpeg4 + ac3,eac3, 6ch |
85% |
The sampled library is ~80% hevc. Those rows differ only by which component decodes.
Moving the picture to mpv is what lets the desktop row claim what the machine can actually do, and that — not the compositing — is the product.
The 85% is a ceiling, not a shipped result. It was measured with a profile containing
ac3,eac3. The Android device later used for verification reports neither in itsMediaCodecList— no Dolby licence, normal for a tablet — so eac3 content, about a third of the sampled library, correctly transcodes there. Realising any of this depends on DR-233, deriving the profile from the renderer rather than from the platform, which is why that requirement is load-bearing and not tidy-up.
One desktop video path, not two
This is why the spec covers Windows rather than stopping at Linux.
Today video has three renderers: ExoPlayer, the WebKitGTK <video> element,
and (on Android, via the opt-out) that same element again. A Linux-only version
of this work would make it four, permanently: mpv on Linux, HTML5 on Windows,
ExoPlayer on Android, plus hls.js underneath the HTML5 one. Every seek strategy,
every track switch, every quality change, every lifecycle bug would then have one
more place to be got right — and the HTML5 path would survive indefinitely
because something would still need it.
Finishing the job removes that: mpv on desktop, ExoPlayer on Android, and
hls.js, html5Adapter.ts, videoLoaderFor and the webview video element all
go. The maintenance win is the reason Windows is in this spec and not in a
follow-up that never gets written.
Three blockers are gone
- Compositing works, including Wayland. The spike ran all six gates; the 2024 "not possible on Wayland at all" claim is out of date when the render API is used instead of foreign-window embedding.
- There is no ABR to lose. DR-228: the server's master playlist carries one
EXT-X-STREAM-INF. hls.js was demuxing, not adapting. - A direct-play path exists. It did not when the spike was written. DR-227 built it; DR-229 proved the contract is player-agnostic.
And on Windows specifically, tauri-plugin-libmpv lists Windows as its fully
tested platform — the inverse of the Linux situation the spike had to
disprove. The embedding difficulty was always WebKitGTK-specific.
Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|---|---|---|
| Which codecs this device can decode | Rust | Domain: it is the input to Jellyfin's PlaybackInfo negotiation. It stops being a property of the platform and becomes a property of the renderer in use — see "The structural change". |
| Which backend renders video | Rust | Rust already owns this (use_html5_element / VideoBackend). It stops being a cfg! constant and becomes a runtime fact. |
| What stream to play (direct / remux / transcode, transport, ceiling) | Rust — already decided | DR-224. mpv consumes StreamSelection. Re-deriving any of it in a new backend would be the defect DR-224 exists to remove, restated. |
| Creating the GL surface, reparenting the webview, owning the render context | Rust (platform layer) | Native window and GL-context lifetime. Not presentation, and not expressible above the IPC boundary at all. |
| Render-context ↔ GL-context lifetime binding | Rust | A correctness invariant over native resources. DR-231. |
Frame pacing (update callback, report_swap) |
Rust | Timing against the compositor; mpv's own contract. |
| Hardware-decode selection | Rust | A capability question about the machine, answered from what mpv reports it actually selected. |
| Z-order of controls over video, overlay chrome, letterbox colour | Frontend / mpv | Presentation. Controls already draw over a transparent webview on Android; mpv paints its own letterbox bars (better than the Android equivalent, which shipped DR-194 as a defect). |
| Whether the surface is visible right now | Frontend | nativeVideoActive already exists and toggles data-native-video. Unchanged. |
The structural change
Everything above is routine except one row, and it carries the whole benefit.
video_codecs in build_device_profile is a compile-time constant per
platform:
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
let (video_codecs, audio_codecs) = ("h264".to_string(), "aac,mp3,opus,…");
That is correct only while a build has exactly one video renderer. It must be derived from which renderer will decode this stream, which is runtime state.
It looks like configuration and is not: it is the input that decides whether the server re-encodes, it changes when Jellyfin's API or our renderer changes, and getting it wrong fails silently — a claimed codec the renderer cannot decode is a black picture or silence, which is DR-148 and DR-227's audio override already.
Write this against "the active video renderer", never cfg!(target_os). It
is the single piece that must not be Linux-shaped, because phase 2 reuses it
unchanged.
Design
Backend and compositing (DR-230, IR-033)
An MpvVideoBackend beside the existing MpvBackend (audio). The mpv side —
render context, FBO, update callback, hwdec — is shared; only the surface
differs per platform:
| Platform | Surface | Status |
|---|---|---|
| Linux (X11 + Wayland) | GtkOverlay: GtkGLArea main child, Tauri's webview reparented as overlay child, via WebviewWindow::gtk_window() / default_vbox() |
Proven by the spike, bar the reparent |
| Windows | Native HWND child beneath a transparent WebView2 | Phase 2 |
vo=libmpv plus mpv_render_context_create with MPV_RENDER_PARAM_OPENGL_FBO.
Webview transparency via with_transparent(true) — no window-level transparency;
the spike showed it is neither used nor needed.
The untested half of G1 is the reparent. The spike built its own overlay; the app must adopt Tauri's. Same widgets, one extra step, but it is the only place Tauri-specific behaviour can still bite — so it is the first gate, proven before anything is built on it.
Three traps from the spike, each of which cost a debugging cycle and each of which looks like a platform limitation and is not:
LC_NUMERICmust be reset aftergtk::init(). mpv refuses to start under a non-C numeric locale.mpv_backend.rsalready handles this but has no GTK init in front of it; heregtk::init()applies the user's locale afterwards andmpv_createreturns null.- libepoxy exports GL entry points as data symbols. There is no
glFoofunction — there isepoxy_glFoo, a variable holding a lazily-resolving pointer.get_proc_addressmust return the pointer stored at that symbol; returning the symbol's own address makes mpv jump into non-executable data and take SIGSEGV on the first GL call. Theepoxycrate does this correctly but is unusable — itsgl_generatordependency pulls a yankedxml-rs. - Frame pacing is not optional and its symptom misleads. See DR-232.
Render-context lifetime (DR-231) — the crash defence
The spike's one unexplained SIGSEGV landed in a decoder thread with no Tauri, GTK or GL frame in the stack, and three plausible causes failed to reproduce it across ~13 minutes of targeted stress.
What is not unexplained is that the spike had no defence: it never calls
mpv_render_context_free and never tears down on unrealize, so nothing stopped
the GL context being recreated beneath the render context. That is DR-184 on
Android restated — a surface outliving its player.
Built as a requirement in its own right, not as a fix for a crash we cannot yet reproduce:
- Render context created on
realize, freed onunrealize, same thread, before the GL context goes away. - The update callback is unregistered before the context is freed, so a callback cannot land on a freed context.
- Playback teardown and surface teardown are ordered, not racing.
If the crash recurs after this, it is a different bug and the likeliest cause is out of the search space. If it does not, we needed this anyway.
Frame pacing (DR-232)
Register mpv_render_context_set_update_callback; redraw only when it reports a
frame ready; call mpv_render_context_report_swap after each render.
Recorded because the failure mode is a trap: driving queue_render() off the
frame clock every tick without reporting the swap leaves mpv nothing to time
against. It looks fine in a window and judders at fullscreen, which reads as
a compositing or GPU limit and is neither.
Renderer-dependent device profile (DR-233)
build_device_profile takes the active video renderer and derives the codec
lists from it:
| Renderer | Video codecs | Audio (video direct play) | Channels |
|---|---|---|---|
| mpv (desktop native) | h264,hevc,vp8,vp9,av1,mpeg4 |
platform list incl. ac3,eac3 where the sink can voice it |
from the audio route |
WebKitGTK <video> |
h264 |
webview-decodable set only | 2 |
| ExoPlayer (Android) | unchanged | unchanged | unchanged |
The existing video_audio_codecs() narrowing exists because the webview decodes
a narrower audio set than the platform. With mpv decoding, that no longer
applies to the video path — but the multichannel bound still does, since a 5.1
track direct-played into a 2-channel sink is silence or inaudible dialogue. Both
constraints stay, sourced from the renderer rather than assumed.
This is what converts the 7% figure upward (toward, not necessarily to, the 85% ceiling — see the caveat above), and it is also the change most able to break playback silently — so it lands after compositing is proven, covered by the DR-227 override tests.
Deleting the webview video path (DR-234)
get_player_status stops reporting use_html5_element: true on desktop;
supports_native_video becomes true there.
Deletion is staged, because a path cannot be removed while a shipped platform still needs it:
| Phase | Linux | Windows | HTML5 video path |
|---|---|---|---|
| 1 | mpv | HTML5 | alive — Windows needs it |
| 2 | mpv | mpv | alive but unreached |
| 3 | mpv | mpv | deleted, with hls.js |
Phase 3 is a real phase with its own acceptance criterion, not a "later". The whole maintenance argument for including Windows collapses if the fork survives.
Android keeps ExoPlayer and keeps the webview as its documented opt-out; the
<audio> element and the background-audio handoff are untouched throughout.
What happens when mpv fails to initialise. With no HTML5 path there is no
silent fallback, and inventing one resurrects what we deleted. The
graceful-backend-init principle applies as written: fall back to the no-op
backend, emit backend-init-failed, and surface a real error rather than a black
rectangle. An honest failure beats a hidden downgrade to the transcode we are
trying to stop paying for.
Hardware decode (DR-235)
The spike established the load-bearing fact: hardware decode works through the
render API (hwdec-current reported nvdec-copy on the discrete GPU), so the
direct-play prize is not traded for software decoding.
Policy is decided from what mpv reports it selected, never from what it was asked for:
- Prefer zero-copy VA-API on the integrated GPU where the driver is present.
autoreached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop — the least efficient hardware path — soautois a fallback, not the default.vaapisilently fell back to software on the spike box becausevainfowas absent. A missing driver must be detected and logged, not mistaken for a compositing limit.- Log
hwdec-currentat start-up; knowing what was actually chosen is the whole diagnostic value.
Windows: what phase 2 actually costs (DR-236)
Not hidden, because it is the part most likely to be underestimated:
- The surface is different code. WebView2 in an HWND, not GTK. A transparent WebView2 over a native child window is a solved arrangement, but DR-230's Linux surface does not transfer. Everything else does.
- libmpv is currently a Linux-only dependency, and Windows is
cross-compiled from Linux via
x86_64-pc-windows-msvc+cargo-xwin. Phase 2 must source a Windows libmpv (DLL + import library) into that cross-build and ship the DLL in the NSIS bundle. - LGPL obligations follow the DLL. DR-216 already records them for Linux: keep the linkage dynamic, ship libmpv's licence text with any bundle carrying it. The Windows bundle inherits both.
bun run test:rustand CI must still build. Per the CI rule, any tool this needs goes into the builder image and is pushed — never installed at job time.
Windows also gains a native audio decoder as a side effect, which is what windows-native-audio-backend.md wants and cannot currently have. If that spec lands first, phase 2 inherits its build work and shrinks to the surface.
Out of scope
- Android. Unchanged in every respect.
- macOS. Not a shipped target. If it becomes one it joins phase 2's shape.
- Audio backends. mpv already plays audio on Linux; this adds a video renderer beside it. Windows audio is its own spec.
- HDR, tone mapping, multi-window. Not exercised by the spike at all.
- Re-deciding what stream to play. DR-224 owns that. If this spec finds itself choosing a URL, something has gone wrong.
Acceptance criteria
Phase 1 — Linux
- Tauri's own webview reparents into the overlay (the untested half of G1), on X11 and Wayland.
- Video plays, seeks and switches audio track in mpv, with the Svelte controls composited over it and alpha blending intact.
- The render context is freed on
unrealizeand the update callback unregistered before the free; a test demonstrates the ordering. - A direct-play negotiation returns
DirectPlayfor an hevc source that today returnsTranscode, and it plays. - Direct-play rate over the same 40-item sample rises from 7% toward the Android figure. Record the number.
- mpv init failure emits
backend-init-failedand surfaces an error rather than falling back to a transcode. hwdec-currentis logged and is not copy-back where zero-copy is available.- A soak covering seek, track switch and fullscreen runs clean for an agreed duration. The spike's SIGSEGV is why this is a criterion.
Phase 2 — Windows
- libmpv links in the
cargo-xwincross-build; the DLL and its licence ship in the NSIS bundle; any new tool lives in the builder image, not in a CI step. - Video plays composited under a transparent WebView2.
- The device profile, lifetime and hwdec code are reused, not
reimplemented — a reviewer confirms no
cfg!(target_os = "linux")guards them.
Phase 3 — deletion
use_html5_elementis false on every desktop platform.hls.jsis gone frompackage.json;html5Adapter.ts,videoLoaderForand the<video>element are deleted; Android's opt-out and the background-audio<audio>path still work.
Throughout
bun run check,bun run test,bun run format:check,bun run lintpass.cargo fmtclean,cargo clippy -D warningsclean,bun run test:rustpasses.bun run check:boundarypasses, and a reviewer confirms no stream decision was reconstructed in the new backend.bindings.tsregenerated from Rust.bun run traces:validatepasses; coverage stays ≥ the CI ratchet.- The spike and this spec are folded into 05-platform-backends.md and both deleted in the same commit.
Testing
- Rust, pure: the device profile per renderer — mpv claims hevc, the webview does not, the multichannel bound survives both. The DR-233 table as a table-driven test.
- Rust, pure:
PlaybackInfofixtures that transcode under the webview profile and direct-play under the mpv profile — the direct-play conversion as a unit test, not only as a measurement. - Rust: teardown ordering — callback unregistered before context freed, freed before GL context destroyed. Structure it so the ordering is assertable without a live GL context.
- Frontend: no desktop path selects an HTML5 video adapter. After phase 3, the adapter does not exist and the test goes with it.
- Manual / soak: the criterion above. The spike's automated fullscreen and resize soaks are reusable and already written.
TRACES
| Piece | Tag |
|---|---|
| mpv video backend + compositing | UR-080 | DR-230, IR-033 |
| Render-context lifetime binding | UR-080 | DR-231 |
| Frame pacing | UR-080 | DR-232 |
| Renderer-dependent device profile | UR-080, UR-070 | DR-233 |
| Webview video path removed | UR-080 | DR-234 |
| Hardware-decode policy | UR-080 | DR-235 |
| Windows surface + cross-build | UR-080 | DR-236 |
Notes for the implementer
- Read the spike before writing a line. Its three traps and its hardware-decode table are the most valuable things in this directory, and each cost a debugging cycle to find.
- mpv consumes
StreamSelection; it does not decide. The transport is on the queue item (DR-229). If you are parsing a URL, stop. - Guard nothing on
cfg!(target_os = "linux")that phase 2 will need. That is the one avoidable mistake here. - The Android backend is the reference for the shape of this — transparent
webview over a native surface at index 0. Read
05-platform-backends.md's Android section for what shipped and what its defects were (DR-184 surface lifetime, DR-194 letterbox). - Do not call sync/blocking APIs from mpv event callbacks that can re-enter the player or hold a lock. The existing deadlock gotchas apply.
- A parallel Claude session may be active in this repo —
git diffbefore "repairing" unexpected changes. - This branch is stacked on backend-owned stream selection. Rebase when that merges rather than merging master into it.