//! 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::().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, render: Option, 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, /// The boxed clone of `frame_ready` handed to mpv, reclaimed on teardown. /// Null when no callback is registered. callback_ctx: *mut Arc, // 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>, widget: gtk::Box, handlers: Vec, } 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>` 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); 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: >k::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> = 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: >k::Box, state: &Rc>) -> 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` 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 = 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: >k::Box, cr: >k::cairo::Context, state: &Rc>) { // 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(); } } }