mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
413 lines
16 KiB
Rust
413 lines
16 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);
|
|
// The allocation *origin* matters as much as its size. A GtkBox is a
|
|
// no-window widget, so `widget.window()` is the parent's GdkWindow and
|
|
// the box sits at an offset inside it. `draw_from_gl` composites into
|
|
// that window; if it does not honour the cairo translation GTK applied
|
|
// for this widget, the picture lands at the window origin instead of
|
|
// the widget's — misaligned by exactly this offset, which is the shape
|
|
// of a letterbox that does not line up.
|
|
let alloc = widget.allocation();
|
|
info!(
|
|
"[VideoSurface] rendering {width}x{height} at widget origin ({}, {}) scale {scale} (texture {texture})",
|
|
alloc.x(),
|
|
alloc.y()
|
|
);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
}
|