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.
401 lines
15 KiB
Rust
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: >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<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: >k::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: >k::Box, cr: >k::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();
|
|
}
|
|
}
|
|
}
|