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}