Skip to main content

jellytau_lib/player/
video_surface.rs

1//! The native video surface: mpv drawn *behind* Tauri's webview, without
2//! touching the widget tree.
3//!
4//! # Why there is no overlay here
5//!
6//! The obvious arrangement — wrap the webview in a `GtkOverlay` with a
7//! `GtkGLArea` beneath — attaches cleanly and then aborts the process on the
8//! first click. `tauri-runtime-wry` connects a button-press handler to the
9//! webview that walks a hard-coded path:
10//!
11//! ```text
12//! webview.parent()          // "This one should be GtkBox"
13//!        .parent()          // ...and this one the GtkWindow
14//!        .downcast::<gtk::Window>().unwrap()
15//! ```
16//!
17//! An overlay makes that chain `webview → GtkOverlay → GtkBox`, the downcast
18//! fails, and because the panic is non-unwinding it takes the app with it.
19//! Nothing in configuration avoids it: on Linux the handler is attached
20//! *unconditionally* (the Windows path guards it behind `is_decorated()`), and
21//! the decoration check that would make it inert runs *after* the unwrap.
22//!
23//! So the widget tree is left exactly as Tauri built it. GTK draws a container
24//! before its children, so rendering into the vbox's own `draw` handler puts the
25//! picture underneath the webview for free — the same z-order, no reparenting,
26//! one less widget, and nothing a Tauri upgrade can invalidate by assuming its
27//! own layout.
28//!
29//! TRACES: UR-080 | DR-231, DR-232, DR-233, IR-033
30
31use std::cell::RefCell;
32use std::ffi::c_void;
33use std::rc::Rc;
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::sync::Arc;
36
37use gtk::prelude::*;
38use gtk::{gdk, glib};
39use log::{error, info, warn};
40
41use super::mpv_render::MpvRenderContext;
42
43/// GL enum for `gdk_cairo_draw_from_gl`'s `source_type`. GDK takes the GL
44/// constant itself rather than an enum of its own.
45const GL_TEXTURE: i32 = 0x1702;
46
47/// Everything the draw handler needs, shared with the GTK callbacks.
48struct SurfaceState {
49    gl: Option<gdk::GLContext>,
50    render: Option<MpvRenderContext>,
51    mpv: *mut libmpv_sys::mpv_handle,
52    /// Set by mpv's update callback (on an mpv thread), cleared by the frame
53    /// clock (on the main thread). The whole cross-thread contract.
54    frame_ready: Arc<AtomicBool>,
55    /// The boxed clone of `frame_ready` handed to mpv, reclaimed on teardown.
56    /// Null when no callback is registered.
57    callback_ctx: *mut Arc<AtomicBool>,
58    // One-shot diagnostic latches; see `draw`.
59    logged_first_draw: bool,
60    logged_first_frame: bool,
61    /// Last size we logged, so a size change re-reports rather than staying silent.
62    logged_size: (i32, i32),
63    logged_no_gl: bool,
64    logged_no_window: bool,
65    logged_no_size: bool,
66    logged_render_fail: bool,
67}
68
69impl SurfaceState {
70    /// Tear down in the order DR-232 requires, with the GL context current.
71    ///
72    /// The update callback is unregistered before the context is freed (inside
73    /// `destroy`), and the GL objects go while their context is still bound.
74    /// Getting this wrong is DR-184 on Android restated — a surface outliving
75    /// its player — and is the likeliest cause of the one unexplained SIGSEGV
76    /// the spike recorded.
77    fn teardown(&mut self) {
78        if let Some(render) = self.render.take() {
79            if let Some(gl) = &self.gl {
80                gl.make_current();
81            }
82            // Unregisters the callback before freeing the context.
83            unsafe { render.destroy() };
84        }
85        // Only now is it safe to reclaim what the callback was holding: mpv can
86        // no longer reach it. Freeing it first would be the use-after-free this
87        // ordering exists to prevent.
88        if !self.callback_ctx.is_null() {
89            unsafe { drop(Box::from_raw(self.callback_ctx)) };
90            self.callback_ctx = std::ptr::null_mut();
91        }
92        self.gl = None;
93    }
94}
95
96/// A live video surface. Dropping it tears the render context down.
97pub struct VideoSurface {
98    state: Rc<RefCell<SurfaceState>>,
99    widget: gtk::Box,
100    handlers: Vec<glib::SignalHandlerId>,
101}
102
103impl Drop for VideoSurface {
104    fn drop(&mut self) {
105        for id in self.handlers.drain(..) {
106            self.widget.disconnect(id);
107        }
108        self.state.borrow_mut().teardown();
109        self.widget.queue_draw();
110        info!("[VideoSurface] detached");
111    }
112}
113
114/// mpv's update callback. Runs on an mpv thread, so it does the least possible:
115/// flags the state and asks GTK to redraw on the main loop.
116///
117/// **Nothing here may block or re-enter the player.** The project's deadlock
118/// gotcha applies with full force — this is called from mpv's own threads.
119///
120/// TRACES: UR-080 | DR-233
121unsafe extern "C" fn on_mpv_update(ctx: *mut c_void) {
122    if ctx.is_null() {
123        return;
124    }
125    // Runs on an *mpv* thread. It therefore does exactly one thing that is safe
126    // to do from there: set an atomic flag.
127    //
128    // It must not touch GTK, and specifically must not schedule work with
129    // `idle_add_local*`, which requires the calling thread to own the default
130    // main context — from here that panics with "default main context already
131    // acquired by another thread". Nor can it hold the `Rc<RefCell<..>>` state:
132    // an `Rc` is not `Send`, and cloning one from two threads races its
133    // refcount.
134    //
135    // The frame clock on the widget picks the flag up on the main thread. See
136    // `install_frame_clock`.
137    let flag = &*(ctx as *const Arc<AtomicBool>);
138    flag.store(true, Ordering::Release);
139}
140
141/// Start drawing mpv's video underneath the webview.
142///
143/// `vbox` is Tauri's `default_vbox()` — the container the webview already lives
144/// in. It is not modified; only a `draw` handler is added.
145///
146/// Must run on the GTK main thread.
147///
148/// TRACES: UR-080 | DR-231, DR-232, DR-233
149pub fn attach(vbox: &gtk::Box, mpv: *mut libmpv_sys::mpv_handle) -> bool {
150    if mpv.is_null() {
151        warn!("[VideoSurface] no mpv handle; native video unavailable");
152        return false;
153    }
154
155    let state = Rc::new(RefCell::new(SurfaceState {
156        gl: None,
157        render: None,
158        mpv,
159        frame_ready: Arc::new(AtomicBool::new(false)),
160        callback_ctx: std::ptr::null_mut(),
161        logged_first_draw: false,
162        logged_first_frame: false,
163        logged_size: (0, 0),
164        logged_no_gl: false,
165        logged_no_window: false,
166        logged_no_size: false,
167        logged_render_fail: false,
168    }));
169
170    let mut handlers = Vec::new();
171
172    // The GL context can only be created once the widget has a GdkWindow, which
173    // is what `realize` announces. Creating it earlier leaves nothing to attach
174    // to — the same ordering constraint the render context has.
175    let realize_state = state.clone();
176    handlers.push(vbox.connect_realize(move |widget| {
177        if let Err(e) = init_gl(widget, &realize_state) {
178            error!("[VideoSurface] GL init failed: {e}");
179        }
180    }));
181
182    // A render context outliving its GL context is the defect DR-232 exists to
183    // prevent, so teardown is bound to `unrealize` rather than left to Drop.
184    let unrealize_state = state.clone();
185    handlers.push(vbox.connect_unrealize(move |_| {
186        unrealize_state.borrow_mut().teardown();
187    }));
188
189    // Drive the render loop from the widget's frame clock, on the main thread,
190    // rendering only when mpv actually has a frame.
191    //
192    // Both nearby mistakes were made and are worth naming, because each has a
193    // symptom that points somewhere else:
194    //
195    //   - Waiting on mpv's update callback before rendering deadlocks. mpv does
196    //     not progress until the client renders. The file loads, one frame
197    //     appears, and everything stops — no picture, no audio, a spinner that
198    //     never clears. It reads as a broken stream.
199    //   - Rendering unconditionally every tick and reporting a swap each time
200    //     tells mpv a frame reached the screen far more often than one did. It
201    //     plays, and judders badly. It reads as a GPU or compositing limit.
202    //
203    // Polling `has_frame` each tick is neither.
204    //
205    // The frame clock only ticks while the widget is mapped, so this costs
206    // nothing when the window is hidden.
207    //
208    // TRACES: UR-080 | DR-233
209    let tick_state = state.clone();
210    vbox.add_tick_callback(move |widget, _clock| {
211        // Ask mpv, on the main thread, whether there is anything new. The
212        // update callback's flag is only a hint that something *may* have
213        // happened; `has_frame` is the authority, and asking it here is what
214        // keeps this from either deadlocking or over-presenting.
215        let ready = match tick_state.try_borrow() {
216            Ok(s) => {
217                s.frame_ready.swap(false, Ordering::AcqRel);
218                match s.render.as_ref() {
219                    Some(render) => unsafe { render.has_frame() },
220                    None => false,
221                }
222            }
223            Err(_) => false,
224        };
225        if ready {
226            widget.queue_draw();
227        }
228        glib::ControlFlow::Continue
229    });
230
231    let draw_state = state.clone();
232    handlers.push(vbox.connect_draw(move |widget, cr| {
233        draw(widget, cr, &draw_state);
234        // Propagate: the webview is a child and must still draw over us.
235        glib::Propagation::Proceed
236    }));
237
238    // The window is already up by the time we are called, so run the init the
239    // `realize` signal would have.
240    if vbox.is_realized() {
241        if let Err(e) = init_gl(vbox, &state) {
242            error!("[VideoSurface] GL init failed: {e}");
243        }
244    }
245
246    info!("[VideoSurface] attached to Tauri's vbox without reparenting");
247    // The surface lives as long as the window. Held in a thread-local rather
248    // than returned, because it owns `Rc` and GTK types and so is neither `Send`
249    // nor `Sync` — it cannot go into Tauri's managed state, and leaking it would
250    // give up the ability to tear it down at all.
251    //
252    // Teardown does not depend on this being dropped: it is driven by the
253    // widget's `unrealize`, which is the signal that actually means "your GL
254    // context is going away" (DR-232).
255    LIVE_SURFACE.with(|cell| {
256        *cell.borrow_mut() = Some(VideoSurface {
257            state,
258            widget: vbox.clone(),
259            handlers,
260        });
261    });
262    true
263}
264
265thread_local! {
266    /// The one live surface, on the GTK main thread.
267    static LIVE_SURFACE: RefCell<Option<VideoSurface>> = const { RefCell::new(None) };
268}
269
270/// Drop the live surface, if there is one. Idempotent.
271///
272/// TRACES: UR-080 | DR-232
273#[allow(dead_code)]
274pub fn detach() {
275    LIVE_SURFACE.with(|cell| {
276        cell.borrow_mut().take();
277    });
278}
279
280/// Create the GL context and the mpv render context over it.
281fn init_gl(widget: &gtk::Box, state: &Rc<RefCell<SurfaceState>>) -> Result<(), String> {
282    if state.borrow().render.is_some() {
283        return Ok(());
284    }
285    let window = widget.window().ok_or("widget has no GdkWindow")?;
286
287    let gl = window
288        .create_gl_context()
289        .map_err(|e| format!("create_gl_context: {e}"))?;
290    gl.realize().map_err(|e| format!("realize: {e}"))?;
291    gl.make_current();
292
293    let mpv = state.borrow().mpv;
294    let mut render =
295        unsafe { MpvRenderContext::new(mpv) }.ok_or("mpv render context creation failed")?;
296
297    // The callback needs an owned handle that outlives this function, so a
298    // clone of the flag is boxed and leaked. `Arc<AtomicBool>` rather than the
299    // state itself: it is the only thing that may cross to an mpv thread. The
300    // pointer is kept so teardown can reclaim it — after the callback is
301    // unregistered, never before.
302    let flag = state.borrow().frame_ready.clone();
303    let ctx_box: *mut Arc<AtomicBool> = Box::into_raw(Box::new(flag));
304    unsafe { render.set_update_callback(Some(on_mpv_update), ctx_box as *mut c_void) };
305
306    let mut s = state.borrow_mut();
307    s.gl = Some(gl);
308    s.render = Some(render);
309    s.callback_ctx = ctx_box;
310    info!("[VideoSurface] GL and render context ready");
311    Ok(())
312}
313
314/// Draw the current frame, if there is one.
315///
316/// Runs *before* the children, which is what puts the picture behind the
317/// webview. Deliberately forgiving: no frame, no GL, or a borrowed state all
318/// mean "draw nothing this pass" rather than an error — the webview then paints
319/// over an untouched background, which is exactly the pre-native appearance.
320fn draw(widget: &gtk::Box, cr: &gtk::cairo::Context, state: &Rc<RefCell<SurfaceState>>) {
321    // Report each way of doing nothing exactly once. Without this the whole
322    // path is invisible: a draw handler that never runs, one that bails on a
323    // zero allocation, and one that renders perfectly all look identical from
324    // outside — and mpv stalls if frames are never consumed, so "no audio and
325    // it hangs" is a plausible symptom of *any* of them.
326    fn once(flag: &mut bool, msg: &str) {
327        if !*flag {
328            *flag = true;
329            warn!("[VideoSurface] not drawing: {msg}");
330        }
331    }
332
333    let Ok(mut s) = state.try_borrow_mut() else {
334        return;
335    };
336    if !s.logged_first_draw {
337        s.logged_first_draw = true;
338        info!("[VideoSurface] draw handler running");
339    }
340    let Some(gl) = s.gl.clone() else {
341        let f = &mut s.logged_no_gl;
342        once(f, "no GL context");
343        return;
344    };
345    let Some(window) = widget.window() else {
346        let f = &mut s.logged_no_window;
347        once(f, "widget has no GdkWindow");
348        return;
349    };
350
351    let scale = widget.scale_factor();
352    let width = widget.allocated_width() * scale;
353    let height = widget.allocated_height() * scale;
354    if width <= 0 || height <= 0 {
355        let f = &mut s.logged_no_size;
356        once(f, "zero allocation");
357        return;
358    }
359
360    gl.make_current();
361
362    // Render and end the mutable borrow before touching the latches again.
363    let rendered = match s.render.as_mut() {
364        Some(render) => unsafe { render.render(width, height) },
365        None => return,
366    };
367    let Some(texture) = rendered else {
368        let f = &mut s.logged_render_fail;
369        once(f, "mpv render produced no texture");
370        return;
371    };
372    // Log the first frame, and again whenever the target size changes. Latching
373    // this once per session hid the case that matters: a second file, rendered
374    // at a different size, in a window that never moved. "The picture is a small
375    // box in the middle" and "the picture fills the widget" are indistinguishable
376    // from outside without it.
377    if !s.logged_first_frame || s.logged_size != (width, height) {
378        s.logged_first_frame = true;
379        s.logged_size = (width, height);
380        // The allocation *origin* matters as much as its size. A GtkBox is a
381        // no-window widget, so `widget.window()` is the parent's GdkWindow and
382        // the box sits at an offset inside it. `draw_from_gl` composites into
383        // that window; if it does not honour the cairo translation GTK applied
384        // for this widget, the picture lands at the window origin instead of
385        // the widget's — misaligned by exactly this offset, which is the shape
386        // of a letterbox that does not line up.
387        let alloc = widget.allocation();
388        info!(
389            "[VideoSurface] rendering {width}x{height} at widget origin ({}, {}) scale {scale} (texture {texture})",
390            alloc.x(),
391            alloc.y()
392        );
393    }
394
395    unsafe {
396        cr.draw_from_gl(
397            &window,
398            texture as i32,
399            GL_TEXTURE,
400            scale,
401            0,
402            0,
403            width,
404            height,
405        );
406        // Tell mpv the frame reached the screen. Without this it has nothing to
407        // pace against — see DR-233.
408        if let Some(render) = s.render.as_ref() {
409            render.report_swap();
410        }
411    }
412}