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: >k::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: >k::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: >k::Box, cr: >k::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}