egui-sharkplayer 0.1.0

A hardware accelerated video player for egui using mpv
Documentation
use eframe::glow::{self, HasContext as _};
use libmpv2::Mpv;
use libmpv2::render::{OpenGLInitParams, RenderContext, RenderParam, RenderParamApiType, mpv_render_update};
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)]
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),
}

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(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct FramebufferSize {
    pub width:  i32,
    pub height: i32,
}

impl FramebufferSize {
    pub const fn new(width: i32, height: i32) -> Self { Self { width, height } }
}

pub struct PlayerState {
    mpv: MpvContainer,

    gl:               Arc<glow::Context>,
    framebuffer:      glow::NativeFramebuffer,
    texture:          glow::NativeTexture,
    framebuffer_size: FramebufferSize,
}

impl 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());

        // TODO: why this framebuffer size?
        // Allocate framebuffer
        let fb_size = FramebufferSize::new(1280, 720);

        // SAFETY:
        // 1. The GL context is on the UI thread. This is almost certainly the case
        //    because `eframe::CreationContext` is `!Send + !Sync`, and it can't be
        //    properly constructed outside of `eframe` on account of the private fields.
        let (framebuffer, texture) =
            unsafe { Self::allocate_framebuffer(&gl, fb_size.width, fb_size.height)? };

        let mpv = Mpv::new()?;
        if cfg!(debug_assertions) {
            mpv.set_property("terminal", "yes")?;
        }
        mpv.set_property("vo", "libmpv")?;
        // FIXME: Should this be `auto-safe`, or just `auto`?
        mpv.set_property("hwdec", "auto-safe")?;
        // TODO: Why?
        mpv.set_property("video-timing-offset", 0.0_f64)?;

        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,
            framebuffer,
            texture,
            framebuffer_size: fb_size,
        })
    }

    pub fn load_file<'p>(&self, paths: impl AsRef<[&'p str]>) -> Result<(), BackendError> {
        let paths = paths.as_ref();
        self.mpv.mpv.command("loadfile", paths)?;
        Ok(())
    }

    /// Toggle playback status.
    pub fn toggle_pause(&self) -> Result<(), BackendError> {
        let paused: bool = self.mpv.mpv.get_property("pause")?;
        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(())
    }

    /// Get the framebuffer associated with the current frame, resizing if
    /// neccesary.
    pub(crate) fn get_current_framebuffer(
        &mut self,
        size: FramebufferSize,
    ) -> Result<(glow::NativeFramebuffer, FramebufferSize), BackendError> {
        if self.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(self.framebuffer);
                self.gl.delete_texture(self.texture);
                let (fbo, tex) = Self::allocate_framebuffer(&self.gl, size.width, size.height)?;
                self.framebuffer = fbo;
                self.texture = tex;
            }
            self.framebuffer_size = size;
        }

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

        Ok((self.framebuffer, self.framebuffer_size))
    }

    /// It's neccesary to call this in your app's
    /// [`on_exit`][eframe::App::on_exit] implementation:
    ///
    /// ```rust
    /// # struct App {
    /// #     player: egui_sharkplayer::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(&self) {
        unsafe {
            self.gl.delete_framebuffer(self.framebuffer);
            self.gl.delete_texture(self.texture);
        }
    }

    /// 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,
        width: i32,
        height: i32,
    ) -> Result<(glow::NativeFramebuffer, glow::NativeTexture), BackendError> {
        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((framebuffer, texture))
    }
}