Files
jellytau/src-tauri/src/player/video_surface.rs
T
dtourolle 14b6a8609d fix(player): three defects native video exposed, and the logs to see them
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.
2026-08-22 21:17:29 +02:00

401 lines
15 KiB
Rust

//! The native video surface: mpv drawn *behind* Tauri's webview, without
//! touching the widget tree.
//!
//! # Why there is no overlay here
//!
//! The obvious arrangement — wrap the webview in a `GtkOverlay` with a
//! `GtkGLArea` beneath — attaches cleanly and then aborts the process on the
//! first click. `tauri-runtime-wry` connects a button-press handler to the
//! webview that walks a hard-coded path:
//!
//! ```text
//! webview.parent() // "This one should be GtkBox"
//! .parent() // ...and this one the GtkWindow
//! .downcast::<gtk::Window>().unwrap()
//! ```
//!
//! An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast
//! fails, and because the panic is non-unwinding it takes the app with it.
//! Nothing in configuration avoids it: on Linux the handler is attached
//! *unconditionally* (the Windows path guards it behind `is_decorated()`), and
//! the decoration check that would make it inert runs *after* the unwrap.
//!
//! So the widget tree is left exactly as Tauri built it. GTK draws a container
//! before its children, so rendering into the vbox's own `draw` handler puts the
//! picture underneath the webview for free — the same z-order, no reparenting,
//! one less widget, and nothing a Tauri upgrade can invalidate by assuming its
//! own layout.
//!
//! TRACES: UR-080 | DR-231, DR-232, DR-233, IR-033
use std::cell::RefCell;
use std::ffi::c_void;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use gtk::prelude::*;
use gtk::{gdk, glib};
use log::{error, info, warn};
use super::mpv_render::MpvRenderContext;
/// GL enum for `gdk_cairo_draw_from_gl`'s `source_type`. GDK takes the GL
/// constant itself rather than an enum of its own.
const GL_TEXTURE: i32 = 0x1702;
/// Everything the draw handler needs, shared with the GTK callbacks.
struct SurfaceState {
gl: Option<gdk::GLContext>,
render: Option<MpvRenderContext>,
mpv: *mut libmpv_sys::mpv_handle,
/// Set by mpv's update callback (on an mpv thread), cleared by the frame
/// clock (on the main thread). The whole cross-thread contract.
frame_ready: Arc<AtomicBool>,
/// The boxed clone of `frame_ready` handed to mpv, reclaimed on teardown.
/// Null when no callback is registered.
callback_ctx: *mut Arc<AtomicBool>,
// One-shot diagnostic latches; see `draw`.
logged_first_draw: bool,
logged_first_frame: bool,
/// Last size we logged, so a size change re-reports rather than staying silent.
logged_size: (i32, i32),
logged_no_gl: bool,
logged_no_window: bool,
logged_no_size: bool,
logged_render_fail: bool,
}
impl SurfaceState {
/// Tear down in the order DR-232 requires, with the GL context current.
///
/// The update callback is unregistered before the context is freed (inside
/// `destroy`), and the GL objects go while their context is still bound.
/// Getting this wrong is DR-184 on Android restated — a surface outliving
/// its player — and is the likeliest cause of the one unexplained SIGSEGV
/// the spike recorded.
fn teardown(&mut self) {
if let Some(render) = self.render.take() {
if let Some(gl) = &self.gl {
gl.make_current();
}
// Unregisters the callback before freeing the context.
unsafe { render.destroy() };
}
// Only now is it safe to reclaim what the callback was holding: mpv can
// no longer reach it. Freeing it first would be the use-after-free this
// ordering exists to prevent.
if !self.callback_ctx.is_null() {
unsafe { drop(Box::from_raw(self.callback_ctx)) };
self.callback_ctx = std::ptr::null_mut();
}
self.gl = None;
}
}
/// A live video surface. Dropping it tears the render context down.
pub struct VideoSurface {
state: Rc<RefCell<SurfaceState>>,
widget: gtk::Box,
handlers: Vec<glib::SignalHandlerId>,
}
impl Drop for VideoSurface {
fn drop(&mut self) {
for id in self.handlers.drain(..) {
self.widget.disconnect(id);
}
self.state.borrow_mut().teardown();
self.widget.queue_draw();
info!("[VideoSurface] detached");
}
}
/// mpv's update callback. Runs on an mpv thread, so it does the least possible:
/// flags the state and asks GTK to redraw on the main loop.
///
/// **Nothing here may block or re-enter the player.** The project's deadlock
/// gotcha applies with full force — this is called from mpv's own threads.
///
/// TRACES: UR-080 | DR-233
unsafe extern "C" fn on_mpv_update(ctx: *mut c_void) {
if ctx.is_null() {
return;
}
// Runs on an *mpv* thread. It therefore does exactly one thing that is safe
// to do from there: set an atomic flag.
//
// It must not touch GTK, and specifically must not schedule work with
// `idle_add_local*`, which requires the calling thread to own the default
// main context — from here that panics with "default main context already
// acquired by another thread". Nor can it hold the `Rc<RefCell<..>>` state:
// an `Rc` is not `Send`, and cloning one from two threads races its
// refcount.
//
// The frame clock on the widget picks the flag up on the main thread. See
// `install_frame_clock`.
let flag = &*(ctx as *const Arc<AtomicBool>);
flag.store(true, Ordering::Release);
}
/// Start drawing mpv's video underneath the webview.
///
/// `vbox` is Tauri's `default_vbox()` — the container the webview already lives
/// in. It is not modified; only a `draw` handler is added.
///
/// Must run on the GTK main thread.
///
/// TRACES: UR-080 | DR-231, DR-232, DR-233
pub fn attach(vbox: &gtk::Box, mpv: *mut libmpv_sys::mpv_handle) -> bool {
if mpv.is_null() {
warn!("[VideoSurface] no mpv handle; native video unavailable");
return false;
}
let state = Rc::new(RefCell::new(SurfaceState {
gl: None,
render: None,
mpv,
frame_ready: Arc::new(AtomicBool::new(false)),
callback_ctx: std::ptr::null_mut(),
logged_first_draw: false,
logged_first_frame: false,
logged_size: (0, 0),
logged_no_gl: false,
logged_no_window: false,
logged_no_size: false,
logged_render_fail: false,
}));
let mut handlers = Vec::new();
// The GL context can only be created once the widget has a GdkWindow, which
// is what `realize` announces. Creating it earlier leaves nothing to attach
// to — the same ordering constraint the render context has.
let realize_state = state.clone();
handlers.push(vbox.connect_realize(move |widget| {
if let Err(e) = init_gl(widget, &realize_state) {
error!("[VideoSurface] GL init failed: {e}");
}
}));
// A render context outliving its GL context is the defect DR-232 exists to
// prevent, so teardown is bound to `unrealize` rather than left to Drop.
let unrealize_state = state.clone();
handlers.push(vbox.connect_unrealize(move |_| {
unrealize_state.borrow_mut().teardown();
}));
// Drive the render loop from the widget's frame clock, on the main thread,
// rendering only when mpv actually has a frame.
//
// Both nearby mistakes were made and are worth naming, because each has a
// symptom that points somewhere else:
//
// - Waiting on mpv's update callback before rendering deadlocks. mpv does
// not progress until the client renders. The file loads, one frame
// appears, and everything stops — no picture, no audio, a spinner that
// never clears. It reads as a broken stream.
// - Rendering unconditionally every tick and reporting a swap each time
// tells mpv a frame reached the screen far more often than one did. It
// plays, and judders badly. It reads as a GPU or compositing limit.
//
// Polling `has_frame` each tick is neither.
//
// The frame clock only ticks while the widget is mapped, so this costs
// nothing when the window is hidden.
//
// TRACES: UR-080 | DR-233
let tick_state = state.clone();
vbox.add_tick_callback(move |widget, _clock| {
// Ask mpv, on the main thread, whether there is anything new. The
// update callback's flag is only a hint that something *may* have
// happened; `has_frame` is the authority, and asking it here is what
// keeps this from either deadlocking or over-presenting.
let ready = match tick_state.try_borrow() {
Ok(s) => {
s.frame_ready.swap(false, Ordering::AcqRel);
match s.render.as_ref() {
Some(render) => unsafe { render.has_frame() },
None => false,
}
}
Err(_) => false,
};
if ready {
widget.queue_draw();
}
glib::ControlFlow::Continue
});
let draw_state = state.clone();
handlers.push(vbox.connect_draw(move |widget, cr| {
draw(widget, cr, &draw_state);
// Propagate: the webview is a child and must still draw over us.
glib::Propagation::Proceed
}));
// The window is already up by the time we are called, so run the init the
// `realize` signal would have.
if vbox.is_realized() {
if let Err(e) = init_gl(vbox, &state) {
error!("[VideoSurface] GL init failed: {e}");
}
}
info!("[VideoSurface] attached to Tauri's vbox without reparenting");
// The surface lives as long as the window. Held in a thread-local rather
// than returned, because it owns `Rc` and GTK types and so is neither `Send`
// nor `Sync` — it cannot go into Tauri's managed state, and leaking it would
// give up the ability to tear it down at all.
//
// Teardown does not depend on this being dropped: it is driven by the
// widget's `unrealize`, which is the signal that actually means "your GL
// context is going away" (DR-232).
LIVE_SURFACE.with(|cell| {
*cell.borrow_mut() = Some(VideoSurface {
state,
widget: vbox.clone(),
handlers,
});
});
true
}
thread_local! {
/// The one live surface, on the GTK main thread.
static LIVE_SURFACE: RefCell<Option<VideoSurface>> = const { RefCell::new(None) };
}
/// Drop the live surface, if there is one. Idempotent.
///
/// TRACES: UR-080 | DR-232
#[allow(dead_code)]
pub fn detach() {
LIVE_SURFACE.with(|cell| {
cell.borrow_mut().take();
});
}
/// Create the GL context and the mpv render context over it.
fn init_gl(widget: &gtk::Box, state: &Rc<RefCell<SurfaceState>>) -> Result<(), String> {
if state.borrow().render.is_some() {
return Ok(());
}
let window = widget.window().ok_or("widget has no GdkWindow")?;
let gl = window
.create_gl_context()
.map_err(|e| format!("create_gl_context: {e}"))?;
gl.realize().map_err(|e| format!("realize: {e}"))?;
gl.make_current();
let mpv = state.borrow().mpv;
let mut render =
unsafe { MpvRenderContext::new(mpv) }.ok_or("mpv render context creation failed")?;
// The callback needs an owned handle that outlives this function, so a
// clone of the flag is boxed and leaked. `Arc<AtomicBool>` rather than the
// state itself: it is the only thing that may cross to an mpv thread. The
// pointer is kept so teardown can reclaim it — after the callback is
// unregistered, never before.
let flag = state.borrow().frame_ready.clone();
let ctx_box: *mut Arc<AtomicBool> = Box::into_raw(Box::new(flag));
unsafe { render.set_update_callback(Some(on_mpv_update), ctx_box as *mut c_void) };
let mut s = state.borrow_mut();
s.gl = Some(gl);
s.render = Some(render);
s.callback_ctx = ctx_box;
info!("[VideoSurface] GL and render context ready");
Ok(())
}
/// Draw the current frame, if there is one.
///
/// Runs *before* the children, which is what puts the picture behind the
/// webview. Deliberately forgiving: no frame, no GL, or a borrowed state all
/// mean "draw nothing this pass" rather than an error — the webview then paints
/// over an untouched background, which is exactly the pre-native appearance.
fn draw(widget: &gtk::Box, cr: &gtk::cairo::Context, state: &Rc<RefCell<SurfaceState>>) {
// Report each way of doing nothing exactly once. Without this the whole
// path is invisible: a draw handler that never runs, one that bails on a
// zero allocation, and one that renders perfectly all look identical from
// outside — and mpv stalls if frames are never consumed, so "no audio and
// it hangs" is a plausible symptom of *any* of them.
fn once(flag: &mut bool, msg: &str) {
if !*flag {
*flag = true;
warn!("[VideoSurface] not drawing: {msg}");
}
}
let Ok(mut s) = state.try_borrow_mut() else {
return;
};
if !s.logged_first_draw {
s.logged_first_draw = true;
info!("[VideoSurface] draw handler running");
}
let Some(gl) = s.gl.clone() else {
let f = &mut s.logged_no_gl;
once(f, "no GL context");
return;
};
let Some(window) = widget.window() else {
let f = &mut s.logged_no_window;
once(f, "widget has no GdkWindow");
return;
};
let scale = widget.scale_factor();
let width = widget.allocated_width() * scale;
let height = widget.allocated_height() * scale;
if width <= 0 || height <= 0 {
let f = &mut s.logged_no_size;
once(f, "zero allocation");
return;
}
gl.make_current();
// Render and end the mutable borrow before touching the latches again.
let rendered = match s.render.as_mut() {
Some(render) => unsafe { render.render(width, height) },
None => return,
};
let Some(texture) = rendered else {
let f = &mut s.logged_render_fail;
once(f, "mpv render produced no texture");
return;
};
// Log the first frame, and again whenever the target size changes. Latching
// this once per session hid the case that matters: a second file, rendered
// at a different size, in a window that never moved. "The picture is a small
// box in the middle" and "the picture fills the widget" are indistinguishable
// from outside without it.
if !s.logged_first_frame || s.logged_size != (width, height) {
s.logged_first_frame = true;
s.logged_size = (width, height);
info!("[VideoSurface] rendering {width}x{height} (texture {texture})");
}
unsafe {
cr.draw_from_gl(
&window,
texture as i32,
GL_TEXTURE,
scale,
0,
0,
width,
height,
);
// Tell mpv the frame reached the screen. Without this it has nothing to
// pace against — see DR-233.
if let Some(render) = s.render.as_ref() {
render.report_swap();
}
}
}