Skip to main content

egui_sharkplayer/
widget.rs

1use std::rc::Rc;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use crate::BackendError;
6use crate::backend::{self, FramebufferSize, PlayerState};
7use eframe::egui::Key;
8use eframe::glow::HasContext as _;
9use eframe::{egui, glow};
10use egui::{Color32, Label, WidgetText};
11use tracing::error;
12
13const SECOND: f64 = 1.0;
14const MINUTE: f64 = SECOND * 60.0;
15const HOUR: f64 = MINUTE * 60.0;
16
17macro_rules! info_prop {
18    ($self:expr, String => $prop:ident, $display:literal) => {{
19        let str = $self.backend.$prop().ok().unwrap_or_else(|| "Unknown".into());
20        info_prop!($display, str)
21    }};
22    ($self:expr, OptionalString => $prop:ident, $display:literal) => {{
23        let str = $self
24            .backend
25            .$prop()
26            .ok()
27            .flatten()
28            .unwrap_or_else(|| "Unknown".into());
29        info_prop!($display, str)
30    }};
31    ($self:expr, Dimensions => $prop:ident, $display:literal) => {{
32        let str = $self
33            .backend
34            .dimensions()
35            .ok()
36            .flatten()
37            .map(|(w, h)| Rc::from(format!("{w}x{h}")))
38            .unwrap_or_else(|| "Unknown".into());
39        info_prop!($display, str)
40    }};
41    ($self:expr, Number => $prop:ident, $display:literal) => {{
42        let str = Rc::from(
43            $self
44                .backend
45                .$prop()
46                .ok()
47                .flatten()
48                .unwrap_or_default()
49                .to_string(),
50        );
51
52        info_prop!($display, str)
53    }};
54    ($display:expr, $str:expr) => {{ [Rc::from($display), $str] }};
55}
56
57macro_rules! info_props {
58    ($self:expr; $($type:tt => $prop:ident, $display:literal);+ $(;)?) => {{
59        [
60            $( info_prop!($self, $type => $prop, $display) ),+
61        ]
62    }};
63}
64
65pub trait ControlsIconProvider {
66    fn play(&self) -> WidgetText { "▶".into() }
67    fn pause(&self) -> WidgetText { "⏸".into() }
68    fn skip_backward(&self) -> WidgetText { "<<".into() }
69    fn skip_forward(&self) -> WidgetText { ">>".into() }
70    fn info(&self) -> WidgetText { "i".into() }
71    fn muted_volume(&self) -> WidgetText { "🔇".into() }
72    fn low_volume(&self) -> WidgetText { "🔈".into() }
73    fn medium_volume(&self) -> WidgetText { "🔉".into() }
74    fn high_volume(&self) -> WidgetText { "🔊".into() }
75    fn fullscreen(&self) -> WidgetText { "⛶".into() }
76    fn fullscreen_exit(&self) -> WidgetText { "🗗".into() }
77}
78
79/// The default [`ControlsIconProvider`].
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub struct DefaultControlsIconProvider;
82impl ControlsIconProvider for DefaultControlsIconProvider {}
83
84#[derive(Debug, thiserror::Error)]
85#[error("{cause}: {error}")]
86pub struct Error {
87    pub error: BackendError,
88    pub cause: ErrorCause,
89}
90
91impl Error {
92    #[inline]
93    pub fn new(error: impl Into<BackendError>, cause: ErrorCause) -> Self {
94        Self {
95            error: error.into(),
96            cause,
97        }
98    }
99}
100
101#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq)]
102#[non_exhaustive]
103pub enum ErrorCause {
104    #[error("Failed to get current video framebuffer")]
105    GetFramebuffer,
106    #[error("Failed to seek to the start of the video")]
107    SeekStart,
108    #[error("Failed to start playback")]
109    Play,
110    #[error("Failed to toggle playback status")]
111    TogglePlayback,
112    #[error("Failed to seek backward {0} seconds")]
113    SeekBack(f64),
114    #[error("Failed to seek forward {0} seconds")]
115    SeekForward(f64),
116    #[error("Failed to toggle mute")]
117    ToggleMute,
118    #[error("Failed to increase volume by {0}%")]
119    IncreaseVolume(f64),
120    #[error("Failed to decrease volume by {0}%")]
121    DecreaseVolume(f64),
122    #[error("Failed to set volume to {0}%")]
123    SetVolume(f64),
124    #[error("Failed to seek to {0} seconds")]
125    Seek(f64),
126}
127
128/// Constrain [`SharkPlayer`] dimensions. To maintain the aspect ratio, only the
129/// width or height can be specified.
130///
131/// See [`SharkPlayer::sizing`].
132#[derive(Debug, Clone, Copy, PartialEq)]
133pub enum Sizing {
134    /// Set the desired width. To maintain aspect ratio, the height is
135    /// calculated automatically.
136    Width(f32),
137    /// Set the desired height. To maintain aspect ratio, the width is
138    /// calculated automatically.
139    Height(f32),
140}
141
142type Binding = &'static [egui::Key];
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub struct Keybinds {
145    pub toggle_pause:      Binding,
146    pub toggle_fullscreen: Binding,
147    pub toggle_info:       Binding,
148    pub toggle_mute:       Binding,
149    pub skip_forward:      Binding,
150    pub skip_backward:     Binding,
151    pub volume_up:         Binding,
152    pub volume_down:       Binding,
153}
154
155impl Default for Keybinds {
156    fn default() -> Self {
157        Self {
158            toggle_pause:      &[Key::Space, Key::K],
159            toggle_info:       &[Key::I],
160            toggle_mute:       &[Key::M],
161            toggle_fullscreen: &[Key::F],
162            skip_forward:      &[Key::ArrowRight, Key::L],
163            skip_backward:     &[Key::ArrowLeft, Key::J],
164            volume_up:         &[Key::ArrowUp],
165            volume_down:       &[Key::ArrowDown],
166        }
167    }
168}
169
170#[derive(Clone, Default)]
171struct UiState {
172    hover_start:             Option<(Instant, egui::Pos2)>,
173    show_info:               bool,
174    show_volume_slider:      bool,
175    dragged_time:            Option<f64>,
176    dragged_volume:          Option<f64>,
177    fullscreen:              bool,
178    requested_initial_focus: bool,
179}
180
181/// The video player widget. This is what actually get's created in the `ui`
182/// function.
183#[must_use]
184pub struct SharkPlayer<'a, P: ControlsIconProvider = DefaultControlsIconProvider> {
185    backend:            &'a mut PlayerState,
186    icons:              P,
187    sizing:             Option<Sizing>,
188    bar_height:         f32,
189    default_info_width: f32,
190    skip_seconds:       f64,
191    volume_step:        f64,
192    background_color:   Color32,
193    controls_timeout:   Duration,
194    animations:         bool,
195    keybinds:           Keybinds,
196    on_error:           Rc<dyn Fn(Error)>,
197}
198
199impl<'a> SharkPlayer<'a> {
200    /// Create a new [`SharkPlayer`]. To use a custom icons provider, see
201    /// [`SharkPlayer::new_with_icons`].
202    #[inline]
203    pub fn new(backend: &'a mut PlayerState) -> Self {
204        SharkPlayer::new_with_icons(backend, DefaultControlsIconProvider)
205    }
206}
207
208impl<'a, P: ControlsIconProvider> SharkPlayer<'a, P> {
209    /// Create a new [`SharkPlayer`] using the provied [`ControlsIconProvider`].
210    #[inline]
211    pub fn new_with_icons(backend: &'a mut PlayerState, icons: P) -> Self {
212        Self {
213            backend,
214            icons,
215            sizing: None,
216            bar_height: 40.0,
217            default_info_width: 369.,
218            skip_seconds: 10.0,
219            volume_step: 5.0,
220            background_color: Color32::from_black_alpha(169),
221            controls_timeout: Duration::from_secs(3),
222            animations: true,
223            keybinds: Keybinds::default(),
224            on_error: Rc::new(|e| {
225                error!("{e}");
226            }),
227        }
228    }
229
230    pub fn keybindings(mut self, keybindings: Keybinds) -> Self {
231        self.keybinds = keybindings;
232        self
233    }
234
235    /// Whether to use animations.
236    pub fn animations(mut self, show: bool) -> Self {
237        self.animations = show;
238        self
239    }
240
241    /// Set how long to wait for pointer movement before hiding the controls.
242    pub fn controls_timeout(mut self, timeout: Duration) -> Self {
243        self.controls_timeout = timeout;
244        self
245    }
246
247    /// Constrain player size.
248    #[inline]
249    pub fn sizing(mut self, sizing: Sizing) -> Self {
250        self.sizing = Some(sizing);
251        self
252    }
253
254    /// Set the control bar height.
255    #[inline]
256    pub fn bar_height(mut self, height: f32) -> Self {
257        self.bar_height = height;
258        self
259    }
260
261    /// Set how many seconds the skip buttons seek by.
262    #[inline]
263    pub fn skip_seconds(mut self, seconds: f64) -> Self {
264        self.skip_seconds = seconds.abs();
265        self
266    }
267
268    #[inline]
269    pub fn background_color(mut self, color: Color32) -> Self {
270        self.background_color = color;
271        self
272    }
273
274    #[inline]
275    pub fn default_info_width(mut self, width: f32) -> Self {
276        self.default_info_width = width;
277        self
278    }
279
280    #[inline]
281    pub fn error_callback(mut self, f: Rc<dyn Fn(Error)>) -> Self {
282        self.on_error = f;
283        self
284    }
285
286    fn format_time_mm_ss(time: f64) -> String {
287        format!("{m:02.0}:{s:05.3}", m = (time / 60.).floor(), s = time % 60.)
288    }
289
290    fn format_time_hh_mm_ss(time: f64) -> String {
291        format!(
292            "{h:02.0}:{m:02.0}:{s:05.3}",
293            h = (time / 3600.).floor(),
294            m = ((time % 3600.) / 60.).floor(),
295            s = time % 60.,
296        )
297    }
298
299    fn player_size(&self, ui: &mut egui::Ui, aspect_ratio: f32) -> egui::Vec2 {
300        match self.sizing {
301            Some(Sizing::Width(w)) => egui::vec2(w, w / aspect_ratio),
302            Some(Sizing::Height(h)) => egui::vec2(h * aspect_ratio, h),
303            None => {
304                let available_size = ui.available_size();
305                let (max_w, max_h) = (available_size.x, available_size.y);
306
307                let (width, height) = if max_w / aspect_ratio <= max_h {
308                    (max_w, max_w / aspect_ratio)
309                } else {
310                    (max_h * aspect_ratio, max_h)
311                };
312                egui::vec2(width, height)
313            }
314        }
315    }
316
317    #[expect(clippy::cast_possible_truncation)]
318    fn player_ui(&mut self, ui: &mut egui::Ui, rect: egui::Rect) {
319        let ppp = ui.ctx().pixels_per_point();
320        let size = FramebufferSize {
321            width:  (rect.width() * ppp).floor() as i32,
322            height: (rect.height() * ppp).floor() as i32,
323        };
324
325        let rendered_frame = match self.backend.render_frame(ui.ctx(), size) {
326            Ok(res) => res,
327            Err(e) => {
328                (self.on_error)(Error::new(e, ErrorCause::GetFramebuffer));
329                return;
330            }
331        };
332
333        match rendered_frame {
334            backend::RenderedFrame::GlFramebuffer(res) => {
335                let fb = res.framebuffer();
336                let fb_size = res.framebuffer_size();
337
338                let cb = eframe::egui_glow::CallbackFn::new(move |info, painter| {
339                    let gl = painter.gl();
340                    paint(gl, rect, fb, fb_size, &info);
341                });
342
343                ui.painter().add(egui::PaintCallback {
344                    rect,
345                    callback: Arc::new(cb),
346                });
347            }
348            #[cfg(feature = "wgpu")]
349            backend::RenderedFrame::EguiTexture(texture_id) => {
350                // UV coordinates map the entire texture to our widget rect
351                // let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0,
352                // 1.0));
353                let uv = egui::Rect::from_min_max(
354                    egui::pos2(0.0, 1.0), // Flipped Y
355                    egui::pos2(1.0, 0.0),
356                );
357                ui.painter().image(texture_id, rect, uv, egui::Color32::WHITE);
358            }
359        }
360    }
361
362    fn info_window(&self, ui: &mut egui::Ui, rect: egui::Rect, player_id: egui::Id) {
363        let row_fn = |ui: &mut egui::Ui, key: &str, val: &str| {
364            ui.add(Label::new(key).selectable(false));
365            ui.label(val);
366            ui.end_row();
367        };
368
369        egui::Window::new("Info")
370            .id(player_id.with("Info"))
371            .frame(egui::Frame::window(ui.style()).fill(self.background_color))
372            .default_width(self.default_info_width)
373            .default_pos(egui::pos2(rect.max.x - self.default_info_width, rect.min.y))
374            .title_bar(false)
375            .collapsible(false)
376            .drag_area(egui::WindowDrag::Anywhere)
377            .vscroll(true)
378            .show(ui, |ui| {
379                // Take up remaining space so there's no gap on the right.
380                ui.set_width(ui.available_width());
381
382                let i_love_cum = info_props!(
383                    self;
384                    OptionalString => media_title, "Title";
385                    OptionalString => filename, "File Name";
386                    Dimensions => dimensions, "Dimensions";
387                    OptionalString => file_format, "Format";
388                    OptionalString => video_format, "Video Format";
389                    OptionalString => video_codec, "Video Codec";
390                    Number => container_fps, "FPS";
391                    OptionalString => colormatrix, "Color Matrix";
392                    OptionalString => hwdec_current, "Hardware Decoding";
393                    Number => frame_drop_count, "Frames Dropped";
394                );
395
396                let font = egui::TextStyle::Body.resolve(ui.style());
397                // Measure the rendered width of the key column
398                let key_col_width = i_love_cum
399                    .iter()
400                    .map(|[key, _]| {
401                        ui.painter()
402                            .layout_no_wrap(key.to_string(), font.clone(), Color32::WHITE)
403                            .size()
404                            .x
405                    })
406                    .fold(0.0_f32, f32::max);
407                let value_wrap_width = ui.available_width() - key_col_width - ui.spacing().item_spacing.x;
408
409                egui::Grid::new(ui.id().with("info-grid"))
410                    .max_col_width(value_wrap_width)
411                    .show(ui, |ui| {
412                        for [key, value] in i_love_cum {
413                            row_fn(ui, key.as_ref(), value.as_ref());
414                        }
415                    });
416            });
417    }
418
419    fn action_toggle_payback(&self, ui: &mut egui::Ui, current_time: f64, duration: f64) {
420        /// The threshold, in seconds, by which to determine whether the video
421        /// has finished playing.
422        const FINISHED_THRESHOLD: f64 = 0.2;
423
424        // restart playback upon request when completed
425        if (current_time - duration).abs() < FINISHED_THRESHOLD {
426            if let Err(e) = self.backend.seek_to(0.0) {
427                (self.on_error)(Error::new(e, ErrorCause::SeekStart));
428            }
429            if let Err(e) = self.backend.play() {
430                (self.on_error)(Error::new(e, ErrorCause::Play));
431            }
432        } else if let Err(e) = self.backend.toggle_pause() {
433            (self.on_error)(Error::new(e, ErrorCause::TogglePlayback));
434        }
435        ui.request_repaint();
436    }
437
438    fn action_seek_back(&self, current_time: &mut f64) {
439        *current_time = (*current_time - self.skip_seconds).min(0.);
440        if let Err(e) = self.backend.seek_relative(-self.skip_seconds) {
441            (self.on_error)(Error::new(e, ErrorCause::SeekBack(self.skip_seconds)));
442        }
443    }
444
445    fn action_seek_forward(&self, current_time: &mut f64) {
446        *current_time = (*current_time + self.skip_seconds).max(0.);
447        if let Err(e) = self.backend.seek_relative(self.skip_seconds) {
448            (self.on_error)(Error::new(e, ErrorCause::SeekForward(self.skip_seconds)));
449        }
450    }
451
452    fn action_toggle_mute(&self, ui: &egui::Ui) {
453        if let Err(e) = self.backend.toggle_mute() {
454            (self.on_error)(Error::new(e, ErrorCause::ToggleMute));
455        }
456        ui.request_repaint();
457    }
458
459    fn playback_toggle_button(&self, ui: &mut egui::Ui, current_time: f64, duration: f64) {
460        let is_paused = self.backend.paused().unwrap_or(true);
461
462        if ui
463            .button(if is_paused {
464                self.icons.play()
465            } else {
466                self.icons.pause()
467            })
468            .clicked()
469        {
470            self.action_toggle_payback(ui, current_time, duration);
471        }
472    }
473
474    fn handle_keybinds(
475        &self,
476        ui: &mut egui::Ui,
477        player_response: &egui::Response,
478        ui_state: &mut UiState,
479        current_time: &mut f64,
480        duration: f64,
481        player_id: egui::Id,
482    ) {
483        if !player_response.has_focus() {
484            return;
485        }
486
487        if ui.input(|i| self.keybinds.toggle_pause.iter().any(|&key| i.key_pressed(key))) {
488            self.action_toggle_payback(ui, *current_time, duration);
489        }
490
491        if ui.input(|i| self.keybinds.skip_forward.iter().any(|&key| i.key_pressed(key))) {
492            self.action_seek_forward(current_time);
493        }
494        if ui.input(|i| self.keybinds.skip_backward.iter().any(|&key| i.key_pressed(key))) {
495            self.action_seek_back(current_time);
496        }
497        if ui.input(|i| self.keybinds.toggle_mute.iter().any(|&key| i.key_pressed(key))) {
498            self.action_toggle_mute(ui);
499        }
500
501        if ui.input(|i| self.keybinds.volume_up.iter().any(|&key| i.key_pressed(key))) {
502            let vol = self.backend.volume().unwrap_or(100.0) + self.volume_step;
503            if let Err(e) = self.backend.set_volume(vol) {
504                (self.on_error)(Error::new(e, ErrorCause::IncreaseVolume(self.volume_step)));
505            }
506        }
507        if ui.input(|i| self.keybinds.volume_down.iter().any(|&key| i.key_pressed(key))) {
508            let vol = self.backend.volume().unwrap_or(100.0) - self.volume_step;
509            if let Err(e) = self.backend.set_volume(vol) {
510                (self.on_error)(Error::new(e, ErrorCause::DecreaseVolume(self.volume_step)));
511            }
512        }
513
514        if ui.input(|i| self.keybinds.toggle_info.iter().any(|&key| i.key_pressed(key))) {
515            ui_state.show_info ^= true;
516        }
517
518        if ui.input(|i| {
519            self.keybinds
520                .toggle_fullscreen
521                .iter()
522                .any(|&key| i.key_pressed(key))
523        }) {
524            Self::toggle_fullscreen(ui, ui_state, player_id);
525        }
526    }
527
528    fn skip_buttons(&self, ui: &mut egui::Ui, current_time: &mut f64) {
529        if ui.button(self.icons.skip_backward()).clicked() {
530            self.action_seek_back(current_time);
531        }
532        if ui.button(self.icons.skip_forward()).clicked() {
533            self.action_seek_forward(current_time);
534        }
535    }
536
537    fn volume_control(&self, ui: &mut egui::Ui, ui_state: &mut UiState) {
538        let is_muted = self.backend.muted().unwrap_or(false);
539
540        let mut current_volume = ui_state
541            .dragged_volume
542            .unwrap_or_else(|| self.backend.volume().unwrap_or(100.0));
543
544        let volume_icon = if is_muted {
545            self.icons.muted_volume()
546        } else if (0.0..=33.3).contains(&current_volume) {
547            self.icons.low_volume()
548        } else if (33.3..=66.6).contains(&current_volume) {
549            self.icons.medium_volume()
550        } else {
551            self.icons.high_volume()
552        };
553
554        let volume_button = ui.button(volume_icon);
555        if volume_button.clicked() {
556            self.action_toggle_mute(ui);
557        }
558        if volume_button.hovered() || ui_state.show_volume_slider {
559            ui_state.show_volume_slider = true;
560            let slider = ui.add(
561                egui::Slider::new(&mut current_volume, 0.0..=100.0)
562                    .integer()
563                    .show_value(true)
564                    .trailing_fill(true),
565            );
566
567            let interaction_rect = volume_button
568                .rect
569                .union(slider.rect)
570                .expand2(egui::vec2(0.0, 10.0));
571
572            if !ui.rect_contains_pointer(interaction_rect) && !slider.dragged() {
573                ui_state.show_volume_slider = false;
574            }
575
576            if slider.dragged() {
577                ui_state.dragged_volume = Some(current_volume);
578            }
579
580            if slider.drag_stopped() || (slider.changed() && !slider.dragged()) {
581                ui_state.dragged_volume = None;
582                if let Err(e) = self.backend.set_volume(current_volume) {
583                    (self.on_error)(Error::new(e, ErrorCause::SetVolume(current_volume)));
584                }
585            }
586        }
587    }
588
589    fn time_label(ui: &mut egui::Ui, current_time: f64, duration: f64) {
590        // Current time label
591        if duration >= HOUR {
592            ui.label(Self::format_time_hh_mm_ss(current_time));
593        } else {
594            ui.label(Self::format_time_mm_ss(current_time));
595        }
596    }
597
598    fn duration_label(ui: &mut egui::Ui, duration: f64) {
599        if duration >= HOUR {
600            ui.label(Self::format_time_hh_mm_ss(duration));
601        } else {
602            ui.label(Self::format_time_mm_ss(duration));
603        }
604    }
605
606    fn info_button(&self, ui: &mut egui::Ui, ui_state: &mut UiState) {
607        if ui.button(self.icons.info()).clicked() {
608            ui_state.show_info ^= true;
609        }
610    }
611
612    fn seekbar(&self, ui: &mut egui::Ui, ui_state: &mut UiState, current_time: &mut f64, duration: f64) {
613        ui.spacing_mut().slider_width = ui.available_width();
614        let seekbar = ui.add(
615            egui::Slider::new(current_time, 0.0..=duration)
616                .show_value(false)
617                .trailing_fill(true),
618        );
619        let slider_rect = seekbar.rect;
620
621        if seekbar.dragged() {
622            ui_state.dragged_time = Some(*current_time);
623        }
624        if seekbar.drag_stopped() || (seekbar.changed() && !seekbar.dragged()) {
625            if let Err(e) = self.backend.seek_to(*current_time) {
626                (self.on_error)(Error::new(e, ErrorCause::Seek(*current_time)));
627            }
628            ui_state.dragged_time = None;
629        }
630
631        // Display a tooltip informing the user where a click would seek to.
632        if let Some(ppos) = ui.ctx().pointer_latest_pos()
633            && (slider_rect.contains(ppos) || seekbar.dragged())
634        {
635            let prev_time =
636                f64::from(((ppos.x - slider_rect.min.x) / slider_rect.width()).clamp(0., 1.)) * duration;
637            egui::Tooltip::always_open(
638                ui.ctx().clone(),
639                ui.layer_id(),
640                ui.id(),
641                egui::PopupAnchor::Pointer,
642            )
643            .show(|ui| {
644                ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
645                egui::Frame::NONE
646                    .inner_margin(egui::Margin::same(6))
647                    .show(ui, |ui| {
648                        if duration >= HOUR {
649                            ui.label(Self::format_time_hh_mm_ss(prev_time));
650                        } else {
651                            ui.label(Self::format_time_mm_ss(prev_time));
652                        }
653                    })
654            });
655        }
656    }
657
658    fn toggle_fullscreen(ui: &egui::Ui, ui_state: &mut UiState, player_id: egui::Id) {
659        ui_state.fullscreen ^= true;
660        ui.ctx()
661            .send_viewport_cmd(egui::ViewportCommand::Fullscreen(ui_state.fullscreen));
662        ui.ctx().memory_mut(|mem| mem.request_focus(player_id));
663    }
664
665    fn fullscreen_button(&self, ui: &mut egui::Ui, ui_state: &mut UiState, player_id: egui::Id) {
666        let icon = if ui_state.fullscreen {
667            self.icons.fullscreen_exit()
668        } else {
669            self.icons.fullscreen()
670        };
671        if ui.button(icon).clicked() {
672            Self::toggle_fullscreen(ui, ui_state, player_id);
673        }
674    }
675
676    fn controls_ui(
677        &self,
678        ui: &mut egui::Ui,
679        rect: egui::Rect,
680        ui_state: &mut UiState,
681        mut current_time: f64,
682        duration: f64,
683        player_id: egui::Id,
684    ) {
685        let controls_rect =
686            egui::Rect::from_min_max(egui::pos2(rect.min.x, rect.max.y - self.bar_height), rect.max);
687        ui.scope_builder(egui::UiBuilder::new().max_rect(controls_rect), |ui| {
688            egui::Frame::menu(ui.style())
689                .corner_radius(0.)
690                .fill(self.background_color)
691                .show(ui, |ui| {
692                    ui.horizontal_centered(|ui| {
693                        self.playback_toggle_button(ui, current_time, duration);
694                        self.skip_buttons(ui, &mut current_time);
695                        self.volume_control(ui, ui_state);
696                        Self::time_label(ui, current_time, duration);
697
698                        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
699                            self.fullscreen_button(ui, ui_state, player_id);
700                            self.info_button(ui, ui_state);
701                            Self::duration_label(ui, duration);
702                            self.seekbar(ui, ui_state, &mut current_time, duration);
703                        });
704                    });
705                });
706        });
707    }
708
709    fn show_in_rect(
710        &mut self,
711        ui: &mut egui::Ui,
712        rect: egui::Rect,
713        ui_state: &mut UiState,
714        player_id: egui::Id,
715    ) {
716        let player_response = ui.interact(rect, player_id, egui::Sense::FOCUSABLE | egui::Sense::click());
717        if player_response.clicked() {
718            player_response.request_focus();
719        }
720
721        self.player_ui(ui, rect);
722
723        let mut current_time = ui_state
724            .dragged_time
725            .unwrap_or_else(|| self.backend.time_pos().ok().flatten().unwrap_or(0.0));
726        let duration = self.backend.duration().ok().flatten().unwrap_or(0.0);
727        self.handle_keybinds(
728            ui,
729            &player_response,
730            ui_state,
731            &mut current_time,
732            duration,
733            player_id,
734        );
735        // Determine whether to show controls
736        {
737            let is_hovered = ui.rect_contains_pointer(rect);
738            let is_paused = self.backend.paused().unwrap_or(true);
739
740            if is_hovered {
741                if ui_state.hover_start.is_none_or(|(_, prev_pos)| {
742                    ui.pointer_hover_pos()
743                        .is_none_or(|current_pos| current_pos != prev_pos)
744                }) {
745                    ui_state.hover_start = ui.pointer_hover_pos().map(|pos| (Instant::now(), pos));
746                }
747            } else {
748                ui_state.hover_start = None;
749            }
750
751            // Hide the controls if the pointer is stationary for a period of time.
752            let show_controls = is_paused
753                || ui_state
754                    .hover_start
755                    .is_some_and(|(start, _)| start.elapsed() < self.controls_timeout);
756
757            // Hide the cursor when the controls are hidden for an unobtstructed view.
758            if is_hovered && !show_controls {
759                ui.ctx().set_cursor_icon(egui::CursorIcon::None);
760            }
761
762            // Animate fade-in/fade-out
763            let opacity = if self.animations {
764                ui.ctx()
765                    .animate_bool_responsive(ui.id().with("controls_fade"), show_controls)
766            } else {
767                1.0
768            };
769            if opacity > 0.0 {
770                ui.scope(|ui| {
771                    ui.multiply_opacity(opacity);
772
773                    self.controls_ui(ui, rect, ui_state, current_time, duration, player_id);
774                });
775            }
776        }
777
778        if ui_state.show_info {
779            self.info_window(ui, rect, player_id);
780        }
781    }
782}
783
784impl<P: ControlsIconProvider> egui::Widget for SharkPlayer<'_, P> {
785    #[expect(clippy::cast_possible_truncation)]
786    fn ui(mut self, ui: &mut egui::Ui) -> egui::Response {
787        let ui_state_id = ui.id().with("sharkplayer_ui_state");
788        let player_id = ui.id().with("sharkplayer_player");
789        let mut ui_state = ui
790            .ctx()
791            .data(|d| d.get_temp::<UiState>(ui_state_id).unwrap_or_default());
792
793        if !ui_state.requested_initial_focus {
794            ui.ctx().memory_mut(|mem| mem.request_focus(player_id));
795            ui_state.requested_initial_focus = true;
796        }
797
798        let response = if ui_state.fullscreen {
799            let rect = ui.ctx().content_rect();
800            egui::Area::new(ui.id().with("sharkplayer_fullscreen"))
801                .order(egui::Order::Foreground)
802                .fixed_pos(rect.min)
803                .show(ui.ctx(), |ui| {
804                    ui.set_min_size(rect.size());
805                    self.show_in_rect(ui, rect, &mut ui_state, player_id);
806                });
807
808            ui.allocate_exact_size(egui::Vec2::ZERO, egui::Sense::hover()).1
809        } else {
810            // Only take up as much space the aspect ratio requires as to remove
811            // letterboxing.
812            let aspect_ratio = self.backend.aspect_ratio().ok().flatten().unwrap_or(16.0 / 9.0);
813
814            let sz = self.player_size(ui, aspect_ratio as f32);
815            let (rect, response) = ui.allocate_exact_size(sz, egui::Sense::FOCUSABLE | egui::Sense::click());
816
817            self.show_in_rect(ui, rect, &mut ui_state, player_id);
818            response
819        };
820
821        ui.ctx().data_mut(|data| data.insert_temp(ui_state_id, ui_state));
822        response
823    }
824}
825
826#[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
827fn paint(
828    gl: &glow::Context,
829    rect: egui::Rect,
830    fb: glow::NativeFramebuffer,
831    fb_size: FramebufferSize,
832    info: &egui::PaintCallbackInfo,
833) {
834    unsafe {
835        let prev_read_fb = gl
836            .get_parameter_i32(eframe::glow::READ_FRAMEBUFFER_BINDING)
837            .cast_unsigned();
838        gl.bind_framebuffer(eframe::glow::READ_FRAMEBUFFER, Some(fb));
839
840        let p_per_point = info.pixels_per_point;
841        let screen_h = info.screen_size_px[1] as f32;
842
843        gl.blit_framebuffer(
844            0,
845            0,
846            fb_size.width,
847            fb_size.height,
848            (rect.min.x * p_per_point) as i32,
849            (screen_h - rect.max.y * p_per_point) as i32,
850            (rect.max.x * p_per_point) as i32,
851            (screen_h - rect.min.y * p_per_point) as i32,
852            eframe::glow::COLOR_BUFFER_BIT,
853            eframe::glow::LINEAR,
854        );
855
856        let prev_fb = std::num::NonZeroU32::new(prev_read_fb).map(eframe::glow::NativeFramebuffer);
857        gl.bind_framebuffer(eframe::glow::READ_FRAMEBUFFER, prev_fb);
858    }
859}