diff --git a/src/app/video/imp.rs b/src/app/video/imp.rs index 55d6f5a..8ef58b4 100644 --- a/src/app/video/imp.rs +++ b/src/app/video/imp.rs @@ -14,6 +14,13 @@ use libmpv2::{ use std::{cell::RefCell, env, os::raw::c_void, sync::OnceLock}; use tracing::error; +use crate::spawn_local; + +/// How often mpv's event queue is drained (~60 Hz). Small enough that playback +/// state and observed properties reach the UI promptly, large enough that the +/// GLib main loop still sleeps between ticks so idle CPU stays negligible. +const EVENT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(16); + fn get_proc_address(_context: &GLContext, name: &str) -> *mut c_void { epoxy::get_proc_addr(name) as _ } @@ -33,10 +40,16 @@ impl Default for Video { _ => "all=no", }; + // Enable zero-copy hardware decoding by default (see `super::default_hwdec`). + // mpv otherwise defaults to software decoding, and the web UI only asks for + // copy-back (`hwdec=auto-copy`, remapped in mod.rs). + let hwdec = super::default_hwdec(); + let mpv = Mpv::with_initializer(|init| { init.set_property("vo", "libmpv")?; init.set_property("video-timing-offset", "0")?; init.set_property("video-sync", "audio")?; + init.set_property("hwdec", hwdec.as_str())?; init.set_property("terminal", "yes")?; init.set_property("msg-level", msg_level)?; Ok(()) @@ -53,13 +66,17 @@ impl Default for Video { } impl Video { - fn on_event(&self, callback: T) { - if let Some(result) = self.mpv.borrow_mut().wait_event(0.0) { - match result { - Ok(event) => callback(event), - Err(e) => error!("Failed to wait for event: {e}"), + fn process_events(&self, callback: T) { + loop { + match self.mpv.borrow_mut().wait_event(0.0) { + Some(Ok(event)) => callback(event), + Some(Err(e)) => { + error!("Failed to wait for event: {e}"); + break; + } + None => break, } - }; + } } pub fn send_command(&self, name: &str, args: &[&str]) { @@ -108,48 +125,67 @@ impl ObjectImpl for Video { fn constructed(&self) { self.parent_constructed(); - glib::idle_add_local(clone!( - #[weak(rename_to = video)] - self, - #[weak(rename_to = object)] - self.obj(), - #[upgrade_or] - ControlFlow::Break, - move || { - video.on_event(|event| match event { - Event::PropertyChange { name, change, .. } => { - let value = match change { - PropertyData::Str(v) => Some(v.to_variant()), - PropertyData::Flag(v) => Some(v.to_variant()), - PropertyData::Double(v) => Some(v.to_variant()), - _ => None, - }; - - if let Some(value) = value { - object.emit_by_name::<()>("property-changed", &[&name, &value]); + // Drain mpv's event queue on a timer. + // + // We deliberately do NOT drive this purely from `mpv_set_wakeup_callback`. + // That callback is only a best-effort "there might be new events" hint: + // mpv coalesces property changes (they are only produced once the queue + // drains to MPV_EVENT_NONE) and explicitly "there's only one wakeup + // callback invocation for multiple events", plus there is an inherent + // lost-wakeup race between the callback firing and `wait_event` clearing + // the pending-wakeup flag. Relying on it alone latches after the first + // burst, so StartFile / property changes / EndFile never reach the web UI + // and playback appears stuck (the Stremio player sits on the loading + // screen showing 0 peers). + // + // A `timeout` source drains the whole queue unconditionally every tick, + // so no event is lost. Unlike the previous `idle_add_local`, a timeout + // lets the GLib main loop sleep between ticks, keeping idle CPU low. + glib::timeout_add_local( + EVENT_POLL_INTERVAL, + clone!( + #[weak(rename_to = video)] + self, + #[weak(rename_to = object)] + self.obj(), + #[upgrade_or] + ControlFlow::Break, + move || { + video.process_events(|event| match event { + Event::PropertyChange { name, change, .. } => { + let value = match change { + PropertyData::Str(v) => Some(v.to_variant()), + PropertyData::Flag(v) => Some(v.to_variant()), + PropertyData::Double(v) => Some(v.to_variant()), + _ => None, + }; + + if let Some(value) = value { + object.emit_by_name::<()>("property-changed", &[&name, &value]); + } } - } - Event::StartFile => { - object.emit_by_name::<()>("playback-started", &[]); - } - Event::EndFile(reason) => { - let reason = match reason { - mpv_end_file_reason::Eof => "eof".to_string(), - mpv_end_file_reason::Stop => "stop".to_string(), - mpv_end_file_reason::Redirect => "redirect".to_string(), - mpv_end_file_reason::Error => "error".to_string(), - mpv_end_file_reason::Quit => "quit".to_string(), - _ => "other".to_string(), - }; - - object.emit_by_name::<()>("playback-ended", &[&reason]); - } - _ => {} - }); + Event::StartFile => { + object.emit_by_name::<()>("playback-started", &[]); + } + Event::EndFile(reason) => { + let reason = match reason { + mpv_end_file_reason::Eof => "eof".to_string(), + mpv_end_file_reason::Stop => "stop".to_string(), + mpv_end_file_reason::Redirect => "redirect".to_string(), + mpv_end_file_reason::Error => "error".to_string(), + mpv_end_file_reason::Quit => "quit".to_string(), + _ => "other".to_string(), + }; + + object.emit_by_name::<()>("playback-ended", &[&reason]); + } + _ => {} + }); - ControlFlow::Continue - } - )); + ControlFlow::Continue + } + ), + ); } } @@ -190,17 +226,17 @@ impl WidgetImpl for Video { let (sender, receiver) = flume::unbounded::<()>(); - glib::idle_add_local(clone!( + spawn_local!(clone!( #[weak] object, - #[upgrade_or] - ControlFlow::Break, - move || { - if let Ok(()) = receiver.try_recv() { + async move { + while receiver.recv_async().await.is_ok() { + // Drain any additional pending updates so a burst of + // update callbacks only triggers a single redraw. + while receiver.try_recv().is_ok() {} + object.queue_render(); } - - ControlFlow::Continue } )); diff --git a/src/app/video/mod.rs b/src/app/video/mod.rs index c996efd..83ff3b4 100644 --- a/src/app/video/mod.rs +++ b/src/app/video/mod.rs @@ -10,6 +10,35 @@ use tracing::warn; use crate::app::video::config::{BOOL_PROPERTIES, FLOAT_PROPERTIES, STRING_PROPERTIES}; +/// The `hwdec` mode to request. Zero-copy keeps decoded frames on the GPU — a +/// large CPU/bandwidth win (measured ~100% -> ~23% of a core on 4K HDR); copy-back +/// (`*-copy`) round-trips every frame through system RAM. +/// +/// - **Mesa (AMD/Intel):** `auto-safe` selects VAAPI dmabuf zero-copy. +/// - **Nvidia proprietary:** `auto-safe` only offers copy-back (`vulkan-copy`), +/// so request `nvdec` explicitly for CUDA-interop zero-copy. Verified clean and +/// ~4x cheaper than copy-back on 4K HDR. (Earlier reports of `nvdec` artifacts +/// were a host-only libmpv build issue; see BENCHMARKS.md / DEVLOG.) +/// +/// REQUIRES libmpv built with the CUDA interop, else `nvdec` falls back to +/// copy-back/software: mpv `--enable-cuda-hwaccel --enable-cuda-interop`, ffmpeg +/// with `--enable-ffnvcodec` (nvdec), and `libplacebo`. Verified with libmpv +/// 0.41 / ffmpeg 7.1 (org.gnome.Platform 50 runtime). Distro `libmpv` packages +/// (e.g. for the .deb) must ship these features for the Nvidia win to apply. +/// +/// `STREMIO_HWDEC` overrides everything (e.g. `nvdec`, `auto-safe`, `auto-copy`, +/// `no`) for testing decode modes without a rebuild. +fn default_hwdec() -> String { + std::env::var("STREMIO_HWDEC").unwrap_or_else(|_| { + if std::path::Path::new("/dev/nvidia0").exists() { + "nvdec" + } else { + "auto-safe" + } + .to_string() + }) +} + glib::wrapper! { pub struct Video(ObjectSubclass) @extends gtk::GLArea, gtk::Widget, @@ -117,6 +146,14 @@ impl Video { } name if STRING_PROPERTIES.contains(&name) => { if let Some(value) = value.as_str() { + // The web UI enables hardware decoding by sending + // `hwdec=auto-copy` (copy-back: decode on the GPU, copy every + // frame back to system RAM and re-upload it — CPU/bandwidth + // heavy, ~30x costlier than zero-copy on 4K HDR). Remap it to + // the platform's zero-copy mode (`super::default_hwdec`). + let hwdec = (name == "hwdec" && value == "auto-copy").then(default_hwdec); + let value = hwdec.as_deref().unwrap_or(value); + widget.set_property(name, value); } } diff --git a/src/main.rs b/src/main.rs index 13a2e12..e57b025 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,24 @@ struct Args { } fn main() -> ExitCode { + // GTK 4.22 defaults to the Vulkan GSK renderer. Compositing our OpenGL video + // GLArea through it forces a per-frame GL->Vulkan copy, which dominates + // playback CPU even when hardware decoding is active. Prefer the GL renderer, + // unless a renderer was already chosen — data/stremio.sh sets `opengl` for + // Nvidia, and `opengl` and `gl` are the same renderer here, so the two compose. + // Must be set before GTK initializes. + // + // The name is `gl`: GTK renamed the new GL renderer in 4.18. `ngl` still + // resolves to it as a deprecated alias, but warns. Any name GTK does not + // recognize falls back to the Vulkan renderer this is meant to avoid, so the + // value is worth keeping in step with `GSK_RENDERER=help`. + // + // SAFETY: this runs at the very start of main, before any other threads are + // spawned, so there is no concurrent access to the environment. + if env::var_os("GSK_RENDERER").is_none() { + unsafe { env::set_var("GSK_RENDERER", "gl") }; + } + tracing_subscriber::fmt::init(); let data_dir = dirs::data_dir()