Skip to main content

egui_sharkplayer/
backend.rs

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
14/// Free function used as `OpenGLInitParams::get_proc_address`.
15/// Receives a reference to the per-instance GPA stored in `PlayerState`.
16fn 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            /// # Errors
81            ///
82            /// This function returns `Ok(None)` if the property is
83            /// unavailable, and returns an `Err(_)` if a libmpv error occured.
84            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
122/// The rendered video frame, dispatched from [`PlayerState::render_frame`].
123pub(crate) enum RenderedFrame {
124    /// Fast path (glow renderer): a framebuffer ready to be blitted via a
125    /// `egui_glow` paint callback.
126    GlFramebuffer(GlResources),
127    /// Slow path (wgpu renderer): the frame has been read back to the CPU and
128    /// uploaded to egui's texture manager. The [`egui::TextureId`] can be
129    /// drawn directly with [`egui::Painter::image`].
130    #[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    // SAFETY: `render_ctx` MUST be dropped prior to `mpv`. Which means that `render_ctx` MUST be prior to
142    // `mpv` in the struct ordering.
143    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    // --- File Specific Properties ---
195    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
225/// The persistant state for the video player. This must be created on the UI
226/// thread and persist across frames.
227pub 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        /// Get the file format of the currently loaded media.
244        file_format, "file-format", OptionalStr;
245        /// Get the filename of the currently loaded media.
246        filename, "filename", OptionalStr;
247        /// Get which hardware decoder is being used, if any.
248        hwdec_current, "hwdec-current", OptionalStr;
249        /// Get the title of the current media.
250        media_title, "media_title", OptionalStr;
251        /// Get whether playback is muted.
252        muted, "mute", bool;
253        /// Get the codec of the current video.
254        video_codec, "video-codec", OptionalStr;
255        /// Get the format of the current video.
256        video_format, "video-format", OptionalStr;
257        /// Get the current playback volume
258        volume, "volume", f64;
259        /// Get whether playback is paused.
260        paused, "pause", bool;
261        /// Get the total duration of the current video in seconds.
262        duration, "duration", OptionF64, |dur| dur.is_some_and(|dur| dur > 0.);
263    );
264
265    /// Get the dimensions of the loaded video.
266    ///
267    /// Upon success, this function returns `Ok(Some(width, height))`.
268    ///
269    /// # Errors
270    ///
271    /// If the dimensions are unavailable, it returns `Ok(None)`. An `Err(_)` is
272    /// returned in the event of a [`BackendError`].
273    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            // Only cache valid dimensions. If we request prior to the first frame being
282            // decoded, we won't get anything.
283            dimensions.is_some_and(|(w, h)| w > 0 && h > 0),
284        )
285    }
286
287    /// Get the aspect ratio of the loaded video.
288    /// # Errors
289    ///
290    /// If the dimensions are unavailable, it returns `Ok(None)`. An `Err(_)` is
291    /// returned in the event of a [`BackendError`].
292    #[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    /// Create a new [`PlayerState`] using an [`eframe::CreationContext`].
300    ///
301    /// # Panics
302    ///
303    /// This function should **never** panic. If it does, this is a bug.
304    ///
305    /// # Errors
306    ///
307    /// An `Err(_)` is returned if there was an issue initializing the libmpv
308    /// backend.
309    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        // Support HDR video
343        mpv.set_property("tone-mapping", "auto")?;
344        mpv.set_property("video-timing-offset", 0.0_f64)?;
345        // Ensure that completed videos are rewatchable.
346        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        // SAFETY: This transmutation is done to erase the lifetime, but it's safe so
365        // long as the render context is dropped before `mpv`. This is the case because
366        // it's placed in `MpvContainer`, which is a private struct with the correct
367        // drop order.
368        let mut render_ctx: RenderContext<'static> = unsafe { std::mem::transmute(render_ctx) };
369        let egui_ctx = cc.egui_ctx.clone();
370        // This update callback is what ensures we show frames when we need to.
371        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    /// Get the framebuffer associated with the current frame, resizing if
407    /// necessary.
408    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            // Reallocate framebuffer
411            if res.framebuffer_size != size && size.width > 0 && size.height > 0 {
412                // SAFETY:
413                //
414                // 1. This is on the UI thread because `Self::new` guarantees as much.
415                unsafe {
416                    self.gl.delete_framebuffer(res.framebuffer);
417                    self.gl.delete_texture(res.texture);
418                }
419
420                // SAFETY: If allocation fails, then gl_resources is `None` so there are no
421                // use-after-free errors.
422                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    /// Load the file at `path`.
450    ///
451    /// # Errors
452    ///
453    /// An `Err(_)` is returned in the event of a [`BackendError`].
454    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    /// Toggle playback status.
464    ///
465    /// # Errors
466    ///
467    /// An `Err(_)` is returned in the event of a [`BackendError`].
468    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    /// # Errors
477    ///
478    /// An `Err(_)` is returned in the event of a [`BackendError`].
479    pub fn play(&self) -> Result<(), BackendError> {
480        self.mpv.mpv.set_property("pause", false)?;
481        self.properties.paused.clear();
482        Ok(())
483    }
484
485    /// # Errors
486    ///
487    /// An `Err(_)` is returned in the event of a [`BackendError`].
488    pub fn pause(&self) -> Result<(), BackendError> {
489        self.mpv.mpv.set_property("pause", true)?;
490        self.properties.paused.clear();
491        Ok(())
492    }
493
494    /// # Errors
495    ///
496    /// An `Err(_)` is returned in the event of a [`BackendError`].
497    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    /// Set the playback volume as a percentage in the range `0.0..=100.0`.
505    ///
506    /// # Errors
507    ///
508    /// An `Err(_)` is returned in the event of a [`BackendError`].
509    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    /// Get the current playback position in seconds.
517    ///
518    /// # Errors
519    ///
520    /// This function returns `Ok(None)` if the `time-pos` property is
521    /// unavailable. An `Err(_)` is returned in the event of a [`BackendError`].
522    pub fn time_pos(&self) -> Result<Option<f64>, BackendError> { self.get_optional_property("time-pos") }
523
524    /// Seek to an exact timestamp in the video.
525    ///  
526    /// # Errors
527    ///
528    /// An `Err(_)` is returned in the event of a [`BackendError`].
529    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    /// Seek to an offset relative to the current position.
536    ///
537    /// # Errors
538    ///
539    /// An `Err(_)` is returned in the event of a [`BackendError`].
540    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    /// Get the frame drop count.
548    ///
549    /// # Errors
550    ///
551    /// An `Err(_)` is returned in the event of a [`BackendError`].
552    pub fn frame_drop_count(&self) -> Result<Option<i64>, BackendError> {
553        self.get_optional_property("frame-drop-count")
554    }
555
556    /// Call this in your app's [`App::on_exit`][eframe::App::on_exit]
557    /// implementation to clean up OpenGL resources.
558    ///
559    /// ```rust
560    /// # use egui_sharkplayer::PlayerState;
561    /// struct App {
562    ///     player: PlayerState,
563    /// }
564    ///
565    /// impl eframe::App for App {
566    ///     fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
567    ///         self.player.destroy_gl_resources();
568    ///     }
569    ///     // ...
570    /// #   fn ui(&mut self, _ui: &mut eframe::egui::Ui, _frame: &mut eframe::Frame) { unimplemented!(); }
571    /// }
572    /// ```
573    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    /// Allocate an RGBA8 texture and framebuffer.
596    ///
597    /// # Safety
598    ///
599    /// 1. GL context MUST be located on the current thread.
600    #[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            // NOTE: dellocate to prevent memory leaks.
646            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}