From 84cf31b92980a527930cb6ee333c2b2508977757 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 09:05:06 +0200 Subject: [PATCH] feat(video): build the native video surface, and fix what running it exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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::().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. --- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 19 ++ src-tauri/src/lib.rs | 50 ++++ src-tauri/src/player/mod.rs | 8 + src-tauri/src/player/video_surface.rs | 232 +++++++++++++++++++ src-tauri/src/repository/online.rs | 24 +- src/lib/components/player/VideoPlayer.svelte | 17 +- src/lib/player/streamTransport.ts | 22 +- 8 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/player/video_surface.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 32751700..608acafd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2191,11 +2191,13 @@ dependencies = [ "env_logger", "futures-util", "getrandom 0.2.16", + "gtk", "hostname", "jni 0.21.1", "keyring", "libc", "libmpv", + "libmpv-sys", "log", "ndk-context", "rand 0.8.7", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 553eeca7..7d7d1e5d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -114,6 +114,25 @@ libc = "0.2" # than changing it. To take upstream fixes, bump this deliberately. libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" } +# The raw FFI bindings behind `libmpv`, pinned to the *same* revision so the two +# can never describe different ABIs. +# +# Needed because the safe crate's `render` module is an empty stub at this +# revision — the render API (`mpv_render_context_create` and friends) exists only +# in the sys bindings, which do carry all of it. `Mpv::ctx` is public, so the +# render context can be built over the same handle the safe wrapper drives. This +# is what makes native video reachable *without* first completing the libmpv2 +# migration, which the spike's use of `libmpv2-sys` had implied was a +# prerequisite. +# +# TRACES: UR-080 | DR-230, IR-033 +libmpv-sys = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" } + +# Same major as the one Tauri/wry already resolve, so `gtk_window()` and +# `default_vbox()` hand back types this crate can name rather than a second, +# incompatible GTK. +gtk = "0.18" + # JNI for Android ExoPlayer integration [target.'cfg(target_os = "android")'.dependencies] jni = "0.21" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a51fa8e0..5b8e76ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::().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 diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 1ae2e871..bd11bf57 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -24,6 +24,14 @@ pub mod android; #[cfg(target_os = "linux")] pub mod mpv_backend; +/// The native video surface mpv renders into (UR-080 / DR-230). +/// +/// Linux-gated for now because the surface is GTK. Everything *around* it — the +/// render context, its lifetime, frame pacing, the device profile — is +/// deliberately not, so Windows reuses it behind its own surface. +#[cfg(target_os = "linux")] +pub mod video_surface; + // Platforms with no native audio backend (e.g. Windows) render audio-only // playback through a webview