Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 89 additions & 53 deletions src/app/video/imp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
}
Expand All @@ -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(())
Expand All @@ -53,13 +66,17 @@ impl Default for Video {
}

impl Video {
fn on_event<T: Fn(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<T: Fn(Event)>(&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]) {
Expand Down Expand Up @@ -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
}
),
);
}
}

Expand Down Expand Up @@ -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
}
));

Expand Down
37 changes: 37 additions & 0 deletions src/app/video/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<imp::Video>)
@extends gtk::GLArea, gtk::Widget,
Expand Down Expand Up @@ -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);
}
}
Expand Down
18 changes: 18 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down