egui-sharkplayer 0.2.2

A hardware accelerated video player for egui using libmpv
Documentation
use std::sync::Arc;

use crate::backend::{FramebufferSize, PlayerState};
use eframe::glow::HasContext as _;
use eframe::{egui, glow};
use tracing::error;

pub trait ControlsIconProvider {
    #[inline]
    fn play(&self) -> egui::WidgetText { "".into() }
    #[inline]
    fn pause(&self) -> egui::WidgetText { "".into() }
}

/// The default [`ControlsIconProvider`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DefaultControlsIconProvider;
impl ControlsIconProvider for DefaultControlsIconProvider {}

/// Constrain [`SharkPlayer`] dimensions. To maintian the aspect ratio, only the
/// width or height can be specified.
///
/// See [`SharkPlayer::sizing`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Sizing {
    /// Set the desired width. To maintain aspect ratio, the height is
    /// calculated automatically.
    Width(f32),
    /// Set the desired height. To maintain aspect ratio, the width is
    /// calculated automatically.
    Height(f32),
}

/// The video player widget. This is what actually get's created in the `ui`
/// function.
pub struct SharkPlayer<'a, P: ControlsIconProvider = DefaultControlsIconProvider> {
    backend:    &'a mut PlayerState,
    icons:      P,
    sizing:     Option<Sizing>,
    bar_height: f32,
}

impl<'a> SharkPlayer<'a> {
    /// Create a new [`SharkPlayer`]. To use a custom icons provider, see
    /// [`SharkPlayer::new_with_icons`].
    #[inline]
    pub fn new(backend: &'a mut PlayerState) -> Self {
        SharkPlayer::new_with_icons(backend, DefaultControlsIconProvider)
    }
}

impl<'a, P: ControlsIconProvider> SharkPlayer<'a, P> {
    /// Create a new [`SharkPlayer`] using the provied [`ControlsIconProvider`].
    #[inline]
    pub fn new_with_icons(backend: &'a mut PlayerState, icons: P) -> Self {
        Self {
            backend,
            icons,
            sizing: None,
            bar_height: 40.0,
        }
    }

    /// Constrain player size.
    #[inline]
    pub fn sizing(mut self, sizing: Sizing) -> Self {
        self.sizing = Some(sizing);
        self
    }

    /// Set the control bar height.
    #[inline]
    pub fn bar_height(mut self, height: f32) -> Self {
        self.bar_height = height;
        self
    }

    fn format_time_mm_ss(time: f64) -> String {
        format!(
            "{m:02.0}:{s:02.02}",
            m = (time / 60.).floor(),
            s = (time % 60.).floor(),
        )
    }

    fn format_time_hh_mm_ss(time: f64) -> String {
        format!(
            "{h:02.0}:{m:02.0}:{s:02.02}",
            h = time / 3600.,
            m = (time % 3600.) / 60.,
            s = time % 60.,
        )
    }

    fn player_size(&self, ui: &mut egui::Ui, aspect_ratio: f32) -> egui::Vec2 {
        match self.sizing {
            Some(Sizing::Width(w)) => egui::vec2(w, w / aspect_ratio),
            Some(Sizing::Height(h)) => egui::vec2(h * aspect_ratio, h),
            None => {
                let avaliable_size = ui.available_size();
                let (max_w, max_h) = (avaliable_size.x, avaliable_size.y);

                let (width, height) = if max_w / aspect_ratio <= max_h {
                    (max_w, max_w / aspect_ratio)
                } else {
                    (max_h * aspect_ratio, max_h)
                };
                egui::vec2(width, height)
            }
        }
    }

    fn player_ui(&mut self, ui: &mut egui::Ui, rect: egui::Rect) {
        let ppp = ui.ctx().pixels_per_point();
        let (fb, fb_size) = match self.backend.get_current_framebuffer(FramebufferSize {
            width:  (rect.width() * ppp) as i32,
            height: (rect.height() * ppp) as i32,
        }) {
            Ok(res) => (res.framebuffer(), res.framebuffer_size()),
            Err(e) if cfg!(debug_assertions) => {
                error!("Failed to get current framebuffer: {e}");
                return;
            }
            Err(_) => return,
        };

        let cb = eframe::egui_glow::CallbackFn::new(move |info, painter| {
            let gl = painter.gl();
            paint(gl, rect, fb, fb_size, info);
        });

        ui.painter().add(egui::PaintCallback {
            rect,
            callback: Arc::new(cb),
        });
    }

    fn controls_ui(self, ui: &mut egui::Ui, rect: egui::Rect) {
        const SECOND: f64 = 1.0;
        const MINUTE: f64 = SECOND * 60.0;
        const HOUR: f64 = MINUTE * 60.0;
        /// The threshold, in seconds, by which to determine whether the video
        /// has finished playing.
        const FINISHED_THRESHOLD: f64 = 0.2;

        let is_hovered = ui.rect_contains_pointer(rect);
        let is_paused = self.backend.is_paused().unwrap_or(true);

        if is_hovered || is_paused {
            let controls_rect =
                egui::Rect::from_min_max(egui::pos2(rect.min.x, rect.max.y - self.bar_height), rect.max);

            ui.scope_builder(egui::UiBuilder::new().max_rect(controls_rect), |ui| {
                // semi-transparent block background
                ui.painter()
                    .rect_filled(ui.max_rect(), 0.0, egui::Color32::from_black_alpha(180));

                let inner_rect = ui.max_rect().shrink(8.0); // padding
                ui.scope_builder(egui::UiBuilder::new().max_rect(inner_rect), |ui| {
                    ui.horizontal(|ui| {
                        let duration = self.backend.duration().ok().flatten().unwrap_or(0.0);
                        let mut current_time = self.backend.time_pos().ok().flatten().unwrap_or(0.0);

                        // play/pause button
                        let icon = if is_paused {
                            self.icons.play()
                        } else {
                            self.icons.pause()
                        };
                        if ui.button(icon).clicked() {
                            // restart playback upon request when completed
                            if (current_time - duration).abs() < FINISHED_THRESHOLD {
                                if let Err(e) = self.backend.seek_to(0.0) {
                                    error!("Failed to seek to start: {e}");
                                }
                                if let Err(e) = self.backend.play() {
                                    error!("Failed to start playback: {e}");
                                }
                            } else {
                                if let Err(e) = self.backend.toggle_pause() {
                                    error!("Failed to pause player: {e}");
                                }
                            }
                        }

                        // Current time label
                        if duration >= HOUR {
                            ui.label(Self::format_time_hh_mm_ss(current_time));
                        } else {
                            ui.label(Self::format_time_mm_ss(current_time));
                        }

                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                            if duration >= HOUR {
                                ui.label(Self::format_time_hh_mm_ss(duration));
                            } else {
                                ui.label(Self::format_time_mm_ss(duration));
                            }

                            ui.spacing_mut().slider_width = ui.available_width();
                            let slider = ui.add(
                                egui::Slider::new(&mut current_time, 0.0..=duration)
                                    .show_value(false)
                                    .trailing_fill(true),
                            );
                            let slider_rect = slider.rect;
                            if slider.changed()
                                && let Err(e) = self.backend.seek_to(current_time)
                            {
                                error!("Failed to seek to {current_time:03}s: {e}");
                            }

                            // Display a tooltip informing the user where a click would seek to.
                            if let Some(ppos) = ui.ctx().pointer_latest_pos()
                                && (slider_rect.contains(ppos) || slider.dragged())
                            {
                                let prev_time = ((ppos.x - slider_rect.min.x) / slider_rect.width())
                                    .clamp(0., 1.) as f64
                                    * duration;
                                egui::Tooltip::always_open(
                                    ui.ctx().clone(),
                                    ui.layer_id(),
                                    ui.id(),
                                    egui::PopupAnchor::Pointer,
                                )
                                .show(|ui| {
                                    if duration >= HOUR {
                                        ui.label(Self::format_time_hh_mm_ss(prev_time));
                                    } else {
                                        ui.label(Self::format_time_mm_ss(prev_time));
                                    }
                                });
                            }
                        });
                    })
                });
            });
        }
    }
}

impl<'a, P: ControlsIconProvider> egui::Widget for SharkPlayer<'a, P> {
    fn ui(mut self, ui: &mut egui::Ui) -> egui::Response {
        // Only take up as much space the aspect ratio requires as to remove
        // letterboxing.
        let aspect_ratio = self.backend.aspect_ratio().ok().flatten().unwrap_or(16.0 / 9.0);

        let sz = self.player_size(ui, aspect_ratio);
        let (rect, response) = ui.allocate_exact_size(sz, egui::Sense::click());

        self.player_ui(ui, rect);
        self.controls_ui(ui, rect);

        response
    }
}

fn paint(
    gl: &glow::Context,
    rect: egui::Rect,
    fb: glow::NativeFramebuffer,
    fb_size: FramebufferSize,
    info: egui::PaintCallbackInfo,
) {
    unsafe {
        let prev_read_fb = gl.get_parameter_i32(eframe::glow::READ_FRAMEBUFFER_BINDING);
        gl.bind_framebuffer(eframe::glow::READ_FRAMEBUFFER, Some(fb));

        let p_per_point = info.pixels_per_point;
        let screen_h = info.screen_size_px[1] as f32;

        gl.blit_framebuffer(
            0,
            0,
            fb_size.width,
            fb_size.height,
            (rect.min.x * p_per_point) as i32,
            (screen_h - rect.max.y * p_per_point) as i32,
            (rect.max.x * p_per_point) as i32,
            (screen_h - rect.min.y * p_per_point) as i32,
            eframe::glow::COLOR_BUFFER_BIT,
            eframe::glow::LINEAR,
        );

        let prev_fb = std::num::NonZeroU32::new(prev_read_fb as u32).map(eframe::glow::NativeFramebuffer);
        gl.bind_framebuffer(eframe::glow::READ_FRAMEBUFFER, prev_fb);
    }
}