1use eframe::glow::{self, HasContext as _};
2use libmpv2::render::{OpenGLInitParams, RenderContext, RenderParam, RenderParamApiType, mpv_render_update};
3use libmpv2::{Mpv, mpv_error};
4use std::cell::RefCell;
5use std::ffi::{CString, c_void};
6use std::rc::Rc;
7use std::sync::Arc;
8use tracing::{debug, error, trace};
9
10#[cfg(feature = "wgpu")] mod wgpu_backend;
11
12type GpaFn = dyn Fn(&str) -> *mut c_void + Send + Sync;
13
14fn resolve_gl_proc(gpa: &Arc<GpaFn>, name: &str) -> *mut c_void { gpa(name) }
17
18macro_rules! cached_prop {
19 ($state:expr, $field:ident, $prop:expr, OptionalStr, $validation:expr) => {{
20 cached_prop!(
21 $state,
22 $field,
23 $prop,
24 || {
25 let $field: Option<Rc<str>> = $state.get_optional_property::<String>($prop)?.map(Rc::from);
26 Ok($field)
27 },
28 $validation
29 )
30 }};
31 ($state:expr, $field:ident, $prop:expr, RcStr, $validation:expr) => {{
32 cached_prop!(
33 $state,
34 $field,
35 $prop,
36 || { Ok(Rc::from($state.mpv.mpv.get_property::<String>($prop)?)) },
37 $validation
38 )
39 }};
40 ($state:expr, $field:ident, $prop:expr, OptionF64, $validation:expr) => {{
41 cached_prop!(
42 $state,
43 $field,
44 $prop,
45 || { $state.get_optional_property::<f64>($prop) },
46 $validation
47 )
48 }};
49 ($state:expr, $field:ident, $prop:expr, f64, $validation:expr) => {{
50 cached_prop!(
51 $state,
52 $field,
53 $prop,
54 || { Ok($state.mpv.mpv.get_property::<f64>($prop)?) },
55 $validation
56 )
57 }};
58 ($state:expr, $field:ident, $prop:expr, bool, $validation:expr) => {{
59 cached_prop!(
60 $state,
61 $field,
62 $prop,
63 || { Ok($state.mpv.mpv.get_property::<bool>($prop)?) },
64 $validation
65 )
66 }};
67
68 ($state:expr, $field:ident, $prop:expr, $init:expr, $validation:expr) => {{ $state.properties.$field.get_or_try_init($init, $validation) }};
69}
70
71macro_rules! def_cached_getters {
72 (
73 $(
74 $(#[$attr:meta])*
75 $field:ident, $prop:expr, $type:tt $(, $validation:expr)?
76 );+ $(;)?
77 ) => {
78 $(
79 $(#[$attr])*
80 pub fn $field(&self) -> Result<$type, BackendError> {
85 def_cached_getters!(@internal self, $field, $prop, $type $(, $validation)?)
86 }
87 )+
88 };
89
90 (@internal $self:ident, $field:ident, $prop:expr, $type:tt, $validation:expr) => {
91 cached_prop!($self, $field, $prop, $type, $validation)
92 };
93 (@internal $self:ident, $field:ident, $prop:expr, $type:tt) => {
94 cached_prop!($self, $field, $prop, $type, |_| true)
95 };
96}
97
98#[derive(Debug, thiserror::Error)]
99pub enum BackendError {
100 #[error("This library only functions when egui is using glow, but the GL context was unavailable.")]
101 ExpectedGlow,
102 #[error("libmpv2: {0}")]
103 Mpv(#[from] libmpv2::Error),
104 #[error("glow: {0}")]
105 Glow(String),
106 #[error("Incomplete framebuffer (0x{0:x}); Ensure RGBA8 support.")]
107 IncompleteFramebuffer(u32),
108 #[error("Tried to use GL resoures after they were destroyed by PlayerState::destroy_gl_resources.")]
109 UseAfterDestroy,
110
111 #[cfg(feature = "wgpu")]
112 #[error("Failed to load libEGL.so.1: {0}")]
113 EglLoad(#[from] khronos_egl::LoadError<libloading::Error>),
114 #[cfg(feature = "wgpu")]
115 #[error("EGL initialization error: {0}")]
116 EglInit(#[from] khronos_egl::Error),
117 #[cfg(feature = "wgpu")]
118 #[error("No suitable EGL config found.")]
119 NoEglConfig,
120}
121
122pub(crate) enum RenderedFrame {
124 GlFramebuffer(GlResources),
127 #[cfg(feature = "wgpu")]
131 EguiTexture(eframe::egui::TextureId),
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub(crate) struct FramebufferSize {
136 pub width: i32,
137 pub height: i32,
138}
139
140struct MpvContainer {
141 ctx: RenderContext<'static>,
144 mpv: Mpv,
145}
146
147#[derive(Clone, Copy)]
148pub(crate) struct GlResources {
149 texture: glow::NativeTexture,
150 framebuffer: glow::NativeFramebuffer,
151 framebuffer_size: FramebufferSize,
152}
153
154impl GlResources {
155 pub(crate) fn framebuffer(&self) -> glow::NativeFramebuffer { self.framebuffer }
156
157 pub(crate) fn framebuffer_size(&self) -> FramebufferSize { self.framebuffer_size }
158}
159
160#[derive(Debug, Default)]
161struct CachedProperty<T: Clone + std::fmt::Debug>(RefCell<Option<T>>);
162impl<T: Clone + std::fmt::Debug> CachedProperty<T> {
163 fn clear(&self) { self.0.replace(None); }
164
165 fn get_or_try_init<E, I, S>(&self, init: I, store: S) -> Result<T, E>
166 where
167 I: FnOnce() -> Result<T, E>,
168 S: FnOnce(&T) -> bool, {
169 if let Some(v) = self.0.borrow().clone() {
170 return Ok(v);
171 }
172
173 match init() {
174 Ok(val) if store(&val) => {
175 debug!("Caching property: {val:?}");
176 self.0.replace(Some(val.clone()));
177 Ok(val)
178 }
179 Ok(val) => Ok(val),
180 Err(e) => Err(e),
181 }
182 }
183}
184
185type OptionalStr = Option<Rc<str>>;
186type OptionF64 = Option<f64>;
187
188#[derive(Debug, Default)]
189struct Properties {
190 paused: CachedProperty<bool>,
191 volume: CachedProperty<f64>,
192 muted: CachedProperty<bool>,
193
194 colormatrix: CachedProperty<OptionalStr>,
196 container_fps: CachedProperty<Option<f64>>,
197 current_demuxer: CachedProperty<OptionalStr>,
198 dimensions: CachedProperty<Option<(i64, i64)>>,
199 duration: CachedProperty<Option<f64>>,
200 file_format: CachedProperty<OptionalStr>,
201 filename: CachedProperty<OptionalStr>,
202 hwdec_current: CachedProperty<OptionalStr>,
203 media_title: CachedProperty<OptionalStr>,
204 video_codec: CachedProperty<OptionalStr>,
205 video_format: CachedProperty<OptionalStr>,
206}
207
208impl Properties {
209 fn clear(&self) {
210 self.paused.clear();
211 self.colormatrix.clear();
212 self.container_fps.clear();
213 self.current_demuxer.clear();
214 self.dimensions.clear();
215 self.duration.clear();
216 self.file_format.clear();
217 self.filename.clear();
218 self.hwdec_current.clear();
219 self.media_title.clear();
220 self.video_codec.clear();
221 self.video_format.clear();
222 }
223}
224
225pub struct PlayerState {
228 mpv: MpvContainer,
229 gl: Arc<glow::Context>,
230 _gpa: Arc<GpaFn>,
231 gl_resources: Option<GlResources>,
232 properties: Properties,
233
234 #[cfg(feature = "wgpu")]
235 wgpu: wgpu_backend::WgpuState,
236}
237
238impl PlayerState {
239 def_cached_getters!(
240 colormatrix, "colormatrix", OptionalStr;
241 container_fps, "container-fps", OptionF64;
242 current_demuxer, "current-demuxer", OptionalStr;
243 file_format, "file-format", OptionalStr;
245 filename, "filename", OptionalStr;
247 hwdec_current, "hwdec-current", OptionalStr;
249 media_title, "media_title", OptionalStr;
251 muted, "mute", bool;
253 video_codec, "video-codec", OptionalStr;
255 video_format, "video-format", OptionalStr;
257 volume, "volume", f64;
259 paused, "pause", bool;
261 duration, "duration", OptionF64, |dur| dur.is_some_and(|dur| dur > 0.);
263 );
264
265 pub fn dimensions(&self) -> Result<Option<(i64, i64)>, BackendError> {
274 self.properties.dimensions.get_or_try_init(
275 || {
276 let w = self.get_optional_property::<i64>("video-out-params/dw")?;
277 let h = self.get_optional_property::<i64>("video-out-params/dh")?;
278 Ok(w.zip(h))
279 },
280 |dimensions|
281 dimensions.is_some_and(|(w, h)| w > 0 && h > 0),
284 )
285 }
286
287 #[expect(clippy::cast_precision_loss)]
293 pub fn aspect_ratio(&self) -> Result<Option<f64>, BackendError> {
294 Ok(self
295 .dimensions()?
296 .and_then(|(w, h)| if h != 0 { Some(w as f64 / h as f64) } else { None }))
297 }
298
299 pub fn new(cc: &eframe::CreationContext<'_>) -> Result<Self, BackendError> {
310 #[allow(unused_variables, clippy::allow_attributes)]
311 let (gl, gpa, is_offscreen) = if let Some(gl) = cc.gl.clone() {
312 let proc_addr = cc
313 .get_proc_address
314 .clone()
315 .expect("get_proc_address unavailable despite glow being active");
316 let gpa: Arc<GpaFn> = Arc::new(move |name: &str| -> *mut c_void {
317 CString::new(name)
318 .ok()
319 .map_or(std::ptr::null_mut(), |s| proc_addr(&s).cast_mut())
320 });
321
322 (gl, gpa, false)
323 } else {
324 cfg_select! {
325 feature = "wgpu" => {
326 let (gl, gpa) = Self::init_offscreen_egl()?;
327 let is_offscreen = true;
328 (gl, gpa, is_offscreen)
329 }
330 _ => {
331 return Err(BackendError::ExpectedGlow);
332 }
333 }
334 };
335
336 let mpv = Mpv::new()?;
337 if cfg!(debug_assertions) {
338 mpv.set_property("terminal", "yes")?;
339 }
340 mpv.set_property("vo", "libmpv")?;
341 mpv.set_property("hwdec", "auto-safe")?;
342 mpv.set_property("tone-mapping", "auto")?;
344 mpv.set_property("video-timing-offset", 0.0_f64)?;
345 mpv.set_property("keep-open", "yes")?;
347
348 #[cfg(feature = "wgpu")]
349 if is_offscreen {
350 wgpu_backend::OFFSCREEN_EGL.get().unwrap().make_current()?;
351 }
352 let render_ctx = mpv.create_render_context([
353 RenderParam::ApiType(RenderParamApiType::OpenGl),
354 RenderParam::InitParams(OpenGLInitParams {
355 get_proc_address: resolve_gl_proc,
356 ctx: Arc::clone(&gpa),
357 }),
358 ])?;
359 #[cfg(feature = "wgpu")]
360 if is_offscreen {
361 wgpu_backend::OFFSCREEN_EGL.get().unwrap().unbind()?;
362 }
363
364 let mut render_ctx: RenderContext<'static> = unsafe { std::mem::transmute(render_ctx) };
369 let egui_ctx = cc.egui_ctx.clone();
370 render_ctx.set_update_callback(move || {
372 egui_ctx.request_repaint();
373 });
374
375 Ok(Self {
376 mpv: MpvContainer { ctx: render_ctx, mpv },
377 gl,
378 _gpa: gpa,
379 gl_resources: None,
380 properties: Properties::default(),
381
382 #[cfg(feature = "wgpu")]
383 wgpu: wgpu_backend::WgpuState {
384 is_offscreen,
385 frame_texture: None,
386 pixel_buffer: Vec::new(),
387 },
388 })
389 }
390
391 pub(crate) fn render_frame(
392 &mut self,
393 ctx: &eframe::egui::Context,
394 size: FramebufferSize,
395 ) -> Result<RenderedFrame, BackendError> {
396 #[cfg(feature = "wgpu")]
397 if self.wgpu.is_offscreen {
398 return self.render_frame_offscreen(ctx, size);
399 }
400
401 let _ = ctx;
402 self.render_glow(size)
403 .map(|(res, _)| RenderedFrame::GlFramebuffer(res))
404 }
405
406 pub(crate) fn render_glow(&mut self, size: FramebufferSize) -> Result<(GlResources, bool), BackendError> {
409 let (res, reallocated) = if let Some(res) = self.gl_resources.take() {
410 if res.framebuffer_size != size && size.width > 0 && size.height > 0 {
412 unsafe {
416 self.gl.delete_framebuffer(res.framebuffer);
417 self.gl.delete_texture(res.texture);
418 }
419
420 let new_res = unsafe { Self::allocate_framebuffer(&self.gl, size)? };
423 self.gl_resources = Some(new_res);
424 (new_res, true)
425 } else {
426 self.gl_resources = Some(res);
427 (res, false)
428 }
429 } else {
430 let res = unsafe { Self::allocate_framebuffer(&self.gl, size)? };
431 self.gl_resources = Some(res);
432 (res, true)
433 };
434
435 let flags = self.mpv.ctx.update().unwrap_or(0);
436 let frame_updated = reallocated || flags & mpv_render_update::Frame != 0;
437 if frame_updated {
438 match res.framebuffer.0.get().try_into() {
439 Ok(fb) => self.mpv.ctx.render::<()>(fb, size.width, size.height, true)?,
440 Err(e) => {
441 error!("GL framebuffer ID is too large for libmpv2: {e}");
442 }
443 }
444 }
445
446 Ok((res, frame_updated))
447 }
448
449 pub fn load_file(&self, path: impl AsRef<str>) -> Result<(), BackendError> {
455 let path = path.as_ref();
456
457 self.properties.clear();
458
459 self.mpv.mpv.command("loadfile", &[path, "replace"])?;
460 Ok(())
461 }
462
463 pub fn toggle_pause(&self) -> Result<(), BackendError> {
469 let paused = self.paused()?;
470 self.mpv.mpv.set_property("pause", !paused)?;
471 self.properties.paused.clear();
472 trace!("Toggled playback: {}", if paused { "playing" } else { "paused" });
473 Ok(())
474 }
475
476 pub fn play(&self) -> Result<(), BackendError> {
480 self.mpv.mpv.set_property("pause", false)?;
481 self.properties.paused.clear();
482 Ok(())
483 }
484
485 pub fn pause(&self) -> Result<(), BackendError> {
489 self.mpv.mpv.set_property("pause", true)?;
490 self.properties.paused.clear();
491 Ok(())
492 }
493
494 pub fn toggle_mute(&self) -> Result<(), BackendError> {
498 let is_muted = self.muted()?;
499 self.mpv.mpv.set_property("mute", !is_muted)?;
500 self.properties.muted.clear();
501 Ok(())
502 }
503
504 pub fn set_volume(&self, volume: f64) -> Result<(), BackendError> {
510 self.mpv.mpv.set_property("volume", volume.clamp(0., 100.))?;
511 self.properties.volume.clear();
512 trace!("Set volume to {volume}%.");
513 Ok(())
514 }
515
516 pub fn time_pos(&self) -> Result<Option<f64>, BackendError> { self.get_optional_property("time-pos") }
523
524 pub fn seek_to(&self, seconds: f64) -> Result<(), BackendError> {
530 self.mpv.mpv.set_property("time-pos", seconds)?;
531 trace!("Seeked to {seconds:.3}.");
532 Ok(())
533 }
534
535 pub fn seek_relative(&self, seconds: f64) -> Result<(), BackendError> {
541 let seconds = seconds.to_string();
542 self.mpv.mpv.command("seek", &[&seconds, "relative+exact"])?;
543 trace!("Seeked {seconds:.3}.");
544 Ok(())
545 }
546
547 pub fn frame_drop_count(&self) -> Result<Option<i64>, BackendError> {
553 self.get_optional_property("frame-drop-count")
554 }
555
556 pub fn destroy_gl_resources(&mut self) {
574 if let Some(GlResources {
575 framebuffer,
576 texture,
577 framebuffer_size: _,
578 }) = self.gl_resources.take()
579 {
580 unsafe {
581 self.gl.delete_framebuffer(framebuffer);
582 self.gl.delete_texture(texture);
583 }
584 }
585 }
586
587 fn get_optional_property<T: libmpv2::GetData>(&self, name: &str) -> Result<Option<T>, BackendError> {
588 match self.mpv.mpv.get_property(name) {
589 Ok(value) => Ok(Some(value)),
590 Err(libmpv2::Error::Raw(e)) if e == mpv_error::PropertyUnavailable => Ok(None),
591 Err(e) => Err(BackendError::Mpv(e)),
592 }
593 }
594
595 #[expect(unsafe_op_in_unsafe_fn)]
601 unsafe fn allocate_framebuffer(
602 gl: &glow::Context,
603 size: FramebufferSize,
604 ) -> Result<GlResources, BackendError> {
605 let FramebufferSize { width, height } = size;
606
607 let texture = gl.create_texture().map_err(BackendError::Glow)?;
608 gl.bind_texture(glow::TEXTURE_2D, Some(texture));
609 gl.tex_image_2d(
610 glow::TEXTURE_2D,
611 0,
612 glow::RGBA8.cast_signed(),
613 width,
614 height,
615 0,
616 glow::RGBA,
617 glow::UNSIGNED_BYTE,
618 glow::PixelUnpackData::Slice(None),
619 );
620
621 for (name, val) in [
622 (glow::TEXTURE_MIN_FILTER, glow::LINEAR.cast_signed()),
623 (glow::TEXTURE_MAG_FILTER, glow::LINEAR.cast_signed()),
624 (glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE.cast_signed()),
625 (glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE.cast_signed()),
626 ] {
627 gl.tex_parameter_i32(glow::TEXTURE_2D, name, val);
628 }
629 gl.bind_texture(glow::TEXTURE_2D, None);
630
631 let framebuffer = gl.create_framebuffer().map_err(BackendError::Glow)?;
632 gl.bind_framebuffer(glow::FRAMEBUFFER, Some(framebuffer));
633
634 gl.framebuffer_texture_2d(
635 glow::FRAMEBUFFER,
636 glow::COLOR_ATTACHMENT0,
637 glow::TEXTURE_2D,
638 Some(texture),
639 0,
640 );
641
642 let status = gl.check_framebuffer_status(glow::FRAMEBUFFER);
643 if status != glow::FRAMEBUFFER_COMPLETE {
644 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
645 gl.delete_framebuffer(framebuffer);
647 gl.delete_texture(texture);
648 return Err(BackendError::IncompleteFramebuffer(status));
649 }
650
651 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
652
653 Ok(GlResources {
654 texture,
655 framebuffer,
656 framebuffer_size: size,
657 })
658 }
659}