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#[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#[derive(Debug, Clone, Copy, PartialEq)]
133pub enum Sizing {
134 Width(f32),
137 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#[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 #[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 #[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 pub fn animations(mut self, show: bool) -> Self {
237 self.animations = show;
238 self
239 }
240
241 pub fn controls_timeout(mut self, timeout: Duration) -> Self {
243 self.controls_timeout = timeout;
244 self
245 }
246
247 #[inline]
249 pub fn sizing(mut self, sizing: Sizing) -> Self {
250 self.sizing = Some(sizing);
251 self
252 }
253
254 #[inline]
256 pub fn bar_height(mut self, height: f32) -> Self {
257 self.bar_height = height;
258 self
259 }
260
261 #[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 let uv = egui::Rect::from_min_max(
354 egui::pos2(0.0, 1.0), 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 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 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 const FINISHED_THRESHOLD: f64 = 0.2;
423
424 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(¤t_volume) {
547 self.icons.low_volume()
548 } else if (33.3..=66.6).contains(¤t_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 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 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 {
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 let show_controls = is_paused
753 || ui_state
754 .hover_start
755 .is_some_and(|(start, _)| start.elapsed() < self.controls_timeout);
756
757 if is_hovered && !show_controls {
759 ui.ctx().set_cursor_icon(egui::CursorIcon::None);
760 }
761
762 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 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}