feat(video): build the native video surface, and fix what running it exposed

Three things, all found by actually running the app rather than by reading it.

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

`tauri-runtime-wry`'s undecorated-resizing handler walks a hard-coded path on
every button press in the webview:

    webview.parent()   // "This one should be GtkBox"
           .parent()   // ...and this one the GtkWindow
           .downcast::<gtk::Window>().unwrap()

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

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

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

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

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

Not fixed here, and worth knowing:

- The requested VideoBitrate is sized to the ceiling, not to the source — a
  2.2 Mbps source was being re-encoded at 19.8 Mbps, roughly 9x. Pre-existing,
  but this branch is the first thing that knows the source bitrate and so the
  first that can cap it.
- The `debug` build type produces an APK with the *release* applicationId:
  `applicationIdSuffix = ".debug"` is present in the canonical gradle and absent
  from the generated copy, though the identical line in the `release` block
  survives. Not caused by our sync, which is a plain cp. Independent of this
  work; it is why the side-by-side release build is the one that installs.
This commit is contained in:
2026-08-22 13:45:03 +02:00
parent 7cc392d78f
commit 84cf31b929
8 changed files with 370 additions and 4 deletions
+50
View File
@@ -1210,6 +1210,56 @@ pub fn run() {
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// Native video surface: put a GL area under Tauri's webview so mpv
// can draw beneath the controls (UR-080 / DR-230).
//
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
// walks a hard-coded two-hop path on every button press in the
// webview:
//
// webview.parent() // "This one should be GtkBox"
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-230
#[cfg(target_os = "linux")]
if std::env::var("JELLYTAU_NATIVE_VIDEO").as_deref() == Ok("1") {
use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface; the app will abort on the first click until \
the widget-tree shape is solved (DR-230)"
);
if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() {
Ok(vbox) => match crate::player::video_surface::attach(&vbox) {
Ok(_surface) => {
info!("[INIT] Native video surface attached");
}
Err(e) => log::warn!("[INIT] Native video surface unavailable: {e}"),
},
Err(e) => {
log::warn!("[INIT] No GTK vbox for the main window: {e}")
}
}
}
}
// In-app update, desktop only.
//
// Registered here rather than in the builder chain above because a