egui-sharkplayer 0.2.2

A hardware accelerated video player for egui using libmpv
Documentation
use eframe::glow::{self, HasContext as _};
use libmpv2::render::{OpenGLInitParams, RenderContext, RenderParam, RenderParamApiType, mpv_render_update};
use libmpv2::{Mpv, mpv_error};
use std::ffi::{CStr, CString, c_void};
use std::sync::{Arc, OnceLock};

/// Get Proc Address -- Store eframe's get_proc_address wrapper so the FFI
/// function can access it.
#[expect(clippy::type_complexity)]
static GPA: OnceLock<Arc<dyn Fn(&CStr) -> *const c_void + Send + Sync>> = OnceLock::new();

fn gl_get_proc_address(_ctx: &(), name: &str) -> *mut c_void {
    let gpa = GPA.get().expect("GPA not initialized");
    let cname = CString::new(name).expect("null byte in GL function name");
    gpa(&cname) as *mut c_void
}

#[derive(Debug, thiserror::Error, PartialEq, Eq, Hash)]
pub enum BackendError {
    #[error("This library only functions when egui is using glow, but the GL context was unavailable.")]
    ExpectedGlow,
    #[error("libmpv2: {0}")]
    Mpv(#[from] libmpv2::Error),
    #[error("glow: {0}")]
    Glow(String),
    #[error("Incomplete framebuffer (0x{0:x}); Ensure RGBA8 support.")]
    IncompleteFramebuffer(u32),
    #[error("Tried to use GL resoures after they were destroyed by PlayerState::destroy_gl_resources.")]
    UseAfterDestroy,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct FramebufferSize {
    pub width:  i32,
    pub height: i32,
}

struct MpvContainer {
    // SAFETY: `render_ctx` MUST be dropped prior to `mpv`. Which means that `render_ctx` MUST be prior to
    // `mpv` in the struct ordering.
    ctx: RenderContext<'static>,
    mpv: Mpv,
}

#[derive(Clone, Copy)]
pub(crate) struct GlResources {
    texture:          glow::NativeTexture,
    framebuffer:      glow::NativeFramebuffer,
    framebuffer_size: FramebufferSize,
}

impl GlResources {
    pub(crate) fn framebuffer(&self) -> glow::NativeFramebuffer { self.framebuffer }

    pub(crate) fn framebuffer_size(&self) -> FramebufferSize { self.framebuffer_size }
}

/// The persistant state for the video player. This must be created on the UI
/// thread and persist across frames.
pub struct PlayerState {
    mpv:          MpvContainer,
    gl:           Arc<glow::Context>,
    gl_resources: Option<GlResources>,
}

impl PlayerState {
    /// Create a new [`PlayerState`].
    pub fn new(cc: &eframe::CreationContext<'_>) -> Result<Self, BackendError> {
        let gl = cc.gl.clone().ok_or(BackendError::ExpectedGlow)?;

        let gpa = cc
            .get_proc_address
            .clone()
            .expect("egui's get_proc_address wrapper is unavailable despite using glow");
        let _ = GPA.get_or_init(|| gpa.clone());

        let mpv = Mpv::new()?;
        if cfg!(debug_assertions) {
            mpv.set_property("terminal", "yes")?;
        }
        mpv.set_property("vo", "libmpv")?;
        mpv.set_property("hwdec", "auto-safe")?;
        mpv.set_property("video-timing-offset", 0.0_f64)?;
        // Ensure that completed videos are rewatchable.
        mpv.set_property("keep-open", "yes")?;

        let render_ctx = mpv.create_render_context([
            RenderParam::ApiType(RenderParamApiType::OpenGl),
            RenderParam::InitParams(OpenGLInitParams {
                get_proc_address: gl_get_proc_address,
                ctx:              (),
            }),
        ])?;

        // SAFETY: This transmutation is done to erase the lifetime, but it's safe so
        // long as the render context is dropped before `mpv`. This is the case because
        // it's placed in `MpvContainer`, which is a private struct with the correct
        // drop order.
        let mut render_ctx: RenderContext<'static> = unsafe { std::mem::transmute(render_ctx) };
        let egui_ctx = cc.egui_ctx.clone();
        // This update callback is what ensures we show frames when we need to.
        render_ctx.set_update_callback(move || {
            egui_ctx.request_repaint();
        });

        Ok(Self {
            mpv: MpvContainer { ctx: render_ctx, mpv },
            gl,
            gl_resources: None,
        })
    }

    /// Load the file at `path`.
    pub fn load_file(&self, path: impl AsRef<str>) -> Result<(), BackendError> {
        let path = path.as_ref();
        self.mpv.mpv.command("loadfile", &[path])?;
        Ok(())
    }

    /// Get the dimensions of the loaded video.
    ///
    /// # Returns
    ///
    /// Upon success, this function returns `Ok(Some(width, height))`. If the
    /// dimensions are unavailable, it returns `Ok(None)`.
    pub fn dimensions(&self) -> Result<Option<(i64, i64)>, BackendError> {
        let w = self.get_optional_property::<i64>("video-out-params/dw")?;
        let h = self.get_optional_property::<i64>("video-out-params/dh")?;
        Ok(w.zip(h))
    }

    /// Get the aspect ratio of the loaded video.
    pub fn aspect_ratio(&self) -> Result<Option<f32>, BackendError> {
        Ok(self.dimensions()?.map(|(w, h)| w as f32 / h as f32))
    }

    /// Get the total duration of the current video in seconds.
    ///
    /// # Returns
    ///
    /// This function returns `Ok(None)` if the `duration` property is
    /// unavailable.
    pub fn duration(&self) -> Result<Option<f64>, BackendError> { self.get_optional_property("duration") }

    /// Get the current playback position in seconds.
    ///
    /// # Returns
    ///
    /// This function returns `Ok(None)` if the `time-pos` property is
    /// unavailable.
    pub fn time_pos(&self) -> Result<Option<f64>, BackendError> { self.get_optional_property("time-pos") }

    /// Seek to an exact timestamp in the video.
    pub fn seek_to(&self, seconds: f64) -> Result<(), BackendError> {
        self.mpv.mpv.set_property("time-pos", seconds)?;
        Ok(())
    }

    /// Returns true if the video is currently paused or stopped.
    pub fn is_paused(&self) -> Result<bool, BackendError> { Ok(self.mpv.mpv.get_property::<bool>("pause")?) }

    /// Toggle playback status.
    pub fn toggle_pause(&self) -> Result<(), BackendError> {
        let paused = self.is_paused().unwrap_or(true);
        self.mpv.mpv.set_property("pause", !paused)?;
        Ok(())
    }

    pub fn play(&self) -> Result<(), BackendError> {
        self.mpv.mpv.set_property("pause", false)?;
        Ok(())
    }

    pub fn pause(&self) -> Result<(), BackendError> {
        self.mpv.mpv.set_property("pause", true)?;
        Ok(())
    }

    /// Call this in your app's [`App::on_exit`][eframe::App::on_exit]
    /// implementation to clean up OpenGL resources.
    ///
    /// ```rust
    /// # use egui_sharkplayer::PlayerState;
    /// struct App {
    ///     player: PlayerState,
    /// }
    ///
    /// impl eframe::App for App {
    ///     fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
    ///         self.player.destroy_gl_resources();
    ///     }
    ///     // ...
    /// #   fn ui(&mut self, _ui: &mut egui::Ui, _frame: &mut eframe::Frame) { unimplemented!(); }
    /// }
    /// ```
    pub fn destroy_gl_resources(&mut self) {
        if let Some(GlResources {
            framebuffer,
            texture,
            framebuffer_size: _,
        }) = self.gl_resources.take()
        {
            unsafe {
                self.gl.delete_framebuffer(framebuffer);
                self.gl.delete_texture(texture);
            }
        }
    }

    /// Get the framebuffer associated with the current frame, resizing if
    /// neccesary.
    pub(crate) fn get_current_framebuffer(
        &mut self,
        size: FramebufferSize,
    ) -> Result<GlResources, BackendError> {
        let res = if let Some(res) = self.gl_resources {
            // Reallocate framebuffer
            if res.framebuffer_size != size && size.width > 0 && size.height > 0 {
                // SAFETY:
                //
                // 1. This is on the UI thread because `Self::new` gaurentees so.
                unsafe {
                    self.gl.delete_framebuffer(res.framebuffer);
                    self.gl.delete_texture(res.texture);
                    self.gl_resources = Some(Self::allocate_framebuffer(&self.gl, size)?);
                }
            }
            self.gl_resources.unwrap()
        } else {
            let res = unsafe { Self::allocate_framebuffer(&self.gl, size)? };
            self.gl_resources = Some(res);
            res
        };

        let flags = self.mpv.ctx.update().unwrap_or(0);
        if flags & mpv_render_update::Frame != 0 {
            self.mpv
                .ctx
                .render::<()>(res.framebuffer.0.get() as i32, size.width, size.height, true)?;
        }

        Ok(res)
    }

    fn get_optional_property<T: libmpv2::GetData>(&self, name: &str) -> Result<Option<T>, BackendError> {
        match self.mpv.mpv.get_property(name) {
            Ok(value) => Ok(Some(value)),
            Err(libmpv2::Error::Raw(e)) if e == mpv_error::PropertyUnavailable => Ok(None),
            Err(e) => Err(BackendError::Mpv(e)),
        }
    }

    /// Allocate an RGBA8 texture and framebuffer.
    ///
    /// # Safety
    ///
    /// 1. GL context MUST be located on the current thread.
    #[expect(unsafe_op_in_unsafe_fn)]
    unsafe fn allocate_framebuffer(
        gl: &glow::Context,
        size: FramebufferSize,
    ) -> Result<GlResources, BackendError> {
        let FramebufferSize { width, height } = size;

        let texture = gl.create_texture().map_err(BackendError::Glow)?;
        gl.bind_texture(glow::TEXTURE_2D, Some(texture));
        gl.tex_image_2d(
            glow::TEXTURE_2D,
            0,
            glow::RGBA8 as i32,
            width,
            height,
            0,
            glow::RGBA,
            glow::UNSIGNED_BYTE,
            glow::PixelUnpackData::Slice(None),
        );

        for (name, val) in [
            (glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32),
            (glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32),
            (glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32),
            (glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32),
        ] {
            gl.tex_parameter_i32(glow::TEXTURE_2D, name, val);
        }
        gl.bind_texture(glow::TEXTURE_2D, None);

        let framebuffer = gl.create_framebuffer().map_err(BackendError::Glow)?;
        gl.bind_framebuffer(glow::FRAMEBUFFER, Some(framebuffer));

        gl.framebuffer_texture_2d(
            glow::FRAMEBUFFER,
            glow::COLOR_ATTACHMENT0,
            glow::TEXTURE_2D,
            Some(texture),
            0,
        );

        let status = gl.check_framebuffer_status(glow::FRAMEBUFFER);
        if status != glow::FRAMEBUFFER_COMPLETE {
            gl.bind_framebuffer(glow::FRAMEBUFFER, None);
            return Err(BackendError::IncompleteFramebuffer(status));
        }

        gl.bind_framebuffer(glow::FRAMEBUFFER, None);

        Ok(GlResources {
            texture,
            framebuffer,
            framebuffer_size: size,
        })
    }
}