//! The overlay UI logic — VLC-styled. //! //! Layout (bottom of screen): //! ┌──────────────────────────────────────────────────────────────────┐ //! │ [≡] │ //! │ 00:12 ████████●░░░░░░░░░░░░░░░░░░ 01:30 ← seek bar w/ time │ //! │ ┌────┬────┬────┬────┬────┐ ┌─────────┐ ┌────────────┐ │ //! │ │ ▶ │ ■ │ ⏮ │ ⏭ │ ↙ │ 00:12 │ 🔊━━━━○ │ │ ⛶ fullscr │ │ //! │ └────┴────┴────┴────┴────┘ └─────────┘ └────────────┘ │ //! │ ┌────┬────┬────┬────┬────┬────┬────┐ │ //! │ │ A │ B │ AB │ ⟳ │ ½× │ 1× │ 2× │ Audio: [eng ▾] │ //! │ └────┴────┴────┴────┴────┴────┴────┘ │ //! └──────────────────────────────────────────────────────────────────┘ //! //! - Top-left ≡ hamburger opens the File menu (Load File / Folder / Playlist, //! Export Markers, Quit). //! - Seek bar with A/B marker pins rendered on top. //! - Bottom row 1: transport | time | volume + fullscreen. //! - Bottom row 2: A/B markers, AB-loop toggle, loop-mode toggle, speed //! presets + slider, audio track dropdown. use std::time::Instant; use crossbeam_channel::Receiver; use egui::{Color32, Context, Layout, Ui, Vec2}; use player_core::event::EngineEvent; use player_core::state::PlaybackState; use player_core::{Cmd, LoopMode}; use crate::icons; use crate::theme::Theme; use crate::widgets::seek_bar; pub struct OverlayApp { pub state: PlaybackState, pub event_rx: Receiver, pub cmd_tx: crossbeam_channel::Sender, pub theme: Theme, pub last_mouse_move: Instant, pub auto_hide_secs: f64, pub visible: bool, pub seeking: bool, pub seek_drag_pos: f64, pub window_size: Vec2, pub error_msg: Option, pub error_expiry: Option, /// Is the cursor currently inside the overlay window? pub mouse_inside: bool, /// Are we in fullscreen mode? (Mirrored from main app.) pub fullscreen: bool, /// Sticky info toast (e.g. "Markers exported to ..."). pub info_msg: Option, pub info_expiry: Option, /// Local speed slider value while dragging — avoids fighting with the /// engine's property-change echo. Reset to None when not dragging. pub speed_drag: Option, /// Buffer of egui input events (mouse moved, clicked, etc.) accumulated /// since the last frame. The renderer drains this into RawInput.events /// at the start of each render. Without this, egui never sees mouse /// events and no buttons/sliders/dropdowns respond to clicks. pub pending_events: Vec, /// Is the About panel visible? Toggled by the Help → About menu item. /// When true, a persistent panel renders in the overlay showing ferret /// version, author, website, and license info. pub about_visible: bool, /// Active in-UI file dialog (if any). When `Some`, the dialog renders as /// a modal overlay covering the controls. Replaces external zenity/kdialog /// /rfd dialogs which popped under the overlay's AlwaysOnTop window. pub file_dialog: Option, } impl OverlayApp { pub fn new( state: PlaybackState, event_rx: Receiver, cmd_tx: crossbeam_channel::Sender, ) -> Self { Self { state, event_rx, cmd_tx, theme: Theme::vlc_dark(), last_mouse_move: Instant::now(), auto_hide_secs: 3.0, visible: true, seeking: false, seek_drag_pos: 0.0, window_size: Vec2::new(1280.0, 720.0), error_msg: None, error_expiry: None, mouse_inside: false, fullscreen: false, info_msg: None, info_expiry: None, speed_drag: None, pending_events: Vec::new(), about_visible: false, file_dialog: None, } } /// Push an egui input event (mouse move, click, etc.) into the buffer. /// Called by the main app's window event handler. The renderer drains /// these into RawInput at the start of each frame. pub fn push_event(&mut self, event: egui::Event) { self.pending_events.push(event); } /// Drain all pending egui events. Called by the renderer before building /// RawInput for the next frame. pub fn drain_events(&mut self) -> Vec { std::mem::take(&mut self.pending_events) } pub fn poll_events(&mut self) { while let Ok(ev) = self.event_rx.try_recv() { match ev { EngineEvent::Ready | EngineEvent::StartFile => {} EngineEvent::FileLoaded { path, title } => { self.state.path = Some(path); self.state.title = title; } EngineEvent::StateChanged => {} EngineEvent::EndReached { reason: _ } => { self.state.paused = true; self.state.time_pos = None; } EngineEvent::Error { message } => { self.error_msg = Some(message); self.error_expiry = Some(Instant::now() + std::time::Duration::from_secs(5)); self.visible = true; self.last_mouse_move = Instant::now(); } EngineEvent::Log { .. } | EngineEvent::Shutdown => {} } } if let Some(exp) = self.error_expiry { if Instant::now() > exp { self.error_msg = None; self.error_expiry = None; } } if let Some(exp) = self.info_expiry { if Instant::now() > exp { self.info_msg = None; self.info_expiry = None; } } } pub fn update_state(&mut self, s: PlaybackState) { if !self.seeking { self.state = s; } else { // Preserve time_pos so the seek thumb doesn't snap back while dragging. let old_pos = self.state.time_pos; self.state = s; self.state.time_pos = old_pos; } } pub fn note_mouse_activity(&mut self) { self.last_mouse_move = Instant::now(); self.visible = true; } pub fn set_mouse_inside(&mut self, inside: bool) { self.mouse_inside = inside; if inside { self.note_mouse_activity(); } } pub fn set_fullscreen(&mut self, fs: bool) { self.fullscreen = fs; } pub fn compute_visibility(&mut self) -> bool { if self.mouse_inside || self.error_msg.is_some() || self.info_msg.is_some() { self.visible = true; self.last_mouse_move = Instant::now(); } else if self.last_mouse_move.elapsed().as_secs_f64() > self.auto_hide_secs { self.visible = false; } self.visible } fn send(&self, cmd: Cmd) { let _ = self.cmd_tx.send(cmd); } /// Show a transient info toast (e.g. "Markers exported to ..."). pub fn show_info(&mut self, msg: impl Into) { self.info_msg = Some(msg.into()); self.info_expiry = Some(Instant::now() + std::time::Duration::from_secs(4)); self.visible = true; self.last_mouse_move = Instant::now(); } pub fn draw(&mut self, ctx: &Context) { ctx.request_repaint_after(std::time::Duration::from_millis(33)); // The File menu is ALWAYS visible — it must be reachable even when the // rest of the overlay has auto-hidden. This is the primary way to open // files, so it can't disappear after 3 seconds of mouse inactivity. // But if a file dialog is open, skip the menu — the modal handles all // interaction. if self.file_dialog.is_none() { self.draw_file_menu(ctx); } // If a file dialog is open, render it as a modal and process its // result. This replaces all external (zenity/kdialog/rfd) dialogs. if let Some(dialog) = &mut self.file_dialog { // Capture the kind BEFORE we clear the dialog — handle_file_dialog_result // needs it to know which Cmd to send. If we clear first, the kind is lost. let kind = dialog.kind.clone(); let result = dialog.draw(ctx, &self.theme); if let Some(res) = result { self.file_dialog = None; self.handle_file_dialog_result(res, kind); } return; } let visible = self.visible; if !visible { return; } let win_w = self.window_size.x; let win_h = self.window_size.y; // ----- Title strip (top center, only when there's a file loaded) ----- if let Some(title) = self.state.title.clone().or(self.state.path.clone()) { let title_short = if title.len() > 80 { format!("{}…", &title[..77]) } else { title.clone() }; let title_w = (title_short.len() as f32 * 7.5).min(win_w - 80.0).max(200.0); egui::Area::new(egui::Id::new("ferret_title_strip")) .fixed_pos(egui::pos2((win_w - title_w) / 2.0, 12.0)) .order(egui::Order::Foreground) .show(ctx, |ui| { let bg = self.theme.bg_color32(); let bg = egui::Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 200); egui::Frame::none() .fill(bg) .rounding(6.0) .inner_margin(egui::Margin::symmetric(12.0, 6.0)) .show(ui, |ui| { ui.set_min_width(title_w); ui.label( egui::RichText::new(title_short) .color(self.theme.fg_color32()) .size(12.0), ); }); }); } // ----- Bottom control bar ----- let bar_h = 118.0; let bar_rect = egui::Rect::from_min_size( egui::pos2(0.0, win_h - bar_h), Vec2::new(win_w, bar_h), ); egui::Area::new(egui::Id::new("ferret_overlay")) .fixed_pos(bar_rect.min) .order(egui::Order::Foreground) .show(ctx, |ui| { let painter = ui.painter(); painter.rect_filled(bar_rect, 0.0, self.theme.bg_color32()); let top_line = egui::Rect::from_min_size( bar_rect.min, Vec2::new(bar_rect.width(), 1.0), ); painter.rect_filled(top_line, 0.0, self.theme.accent_color32()); let inner = bar_rect.shrink2(Vec2::new(16.0, 8.0)); ui.allocate_new_ui(egui::UiBuilder::new().max_rect(inner), |ui| { self.draw_controls(ui); }); }); // ----- Error toast (top center) ----- if let Some(msg) = self.error_msg.clone() { let toast_w: f32 = 600.0_f32.min(win_w - 40.0); egui::Area::new(egui::Id::new("ferret_error_toast")) .fixed_pos(egui::pos2((win_w - toast_w) / 2.0, 48.0)) .order(egui::Order::Foreground) .show(ctx, |ui| { egui::Frame::popup(ui.style()) .fill(egui::Color32::from_rgb(120, 30, 30)) .rounding(6.0) .show(ui, |ui| { ui.set_min_width(toast_w); ui.label( egui::RichText::new(msg) .color(egui::Color32::WHITE) .size(13.0), ); }); }); } // ----- Info toast (below error toast if any) ----- if let Some(msg) = self.info_msg.clone() { let toast_w: f32 = 500.0_f32.min(win_w - 40.0); let y = if self.error_msg.is_some() { 88.0 } else { 48.0 }; egui::Area::new(egui::Id::new("ferret_info_toast")) .fixed_pos(egui::pos2((win_w - toast_w) / 2.0, y)) .order(egui::Order::Foreground) .show(ctx, |ui| { egui::Frame::popup(ui.style()) .fill(egui::Color32::from_rgb(20, 60, 30)) .rounding(6.0) .show(ui, |ui| { ui.set_min_width(toast_w); ui.label( egui::RichText::new(msg) .color(egui::Color32::WHITE) .size(12.0), ); }); }); } } /// Draw the File menu bar at the top-left corner. ALWAYS visible — not /// subject to auto-hide. Uses egui's built-in `menu_button` which handles /// popup open/close, click-outside-to-dismiss, and Escape-to-close /// automatically. fn draw_file_menu(&mut self, ctx: &Context) { // Collect commands to send after the UI closure (can't borrow self // for send() while the closure also borrows self for theme/state). let mut pending_cmds: Vec = Vec::new(); let mut pending_about_toggle = false; // Collect file-dialog-open requests — can't open the dialog inside // the closure because it borrows self for the theme snapshot. let mut pending_dialog: Option = None; // Snapshot the theme colors we need so the closure doesn't borrow self. let fg = self.theme.fg_color32(); let fg_dim = self.theme.fg_dim_color32(); let bg = self.theme.bg_color32(); let loop_mode = self.state.loop_mode; let speed = self.state.speed; let has_audio = !self.state.audio_tracks.is_empty(); let sub_vis = self.state.sub_visibility; let video_rotate = self.state.video_rotate; let video_flip_h = self.state.video_flip_h; let video_flip_v = self.state.video_flip_v; let about_visible = self.about_visible; egui::Area::new(egui::Id::new("ferret_menu_bar")) .fixed_pos(egui::pos2(4.0, 4.0)) .order(egui::Order::Foreground) .show(ctx, |ui| { // Use a Frame for the background — it auto-sizes to the content // and paints the fill behind the widgets. (Painting manually // before the widgets doesn't work because ui.max_rect() is // empty at that point.) egui::Frame::none() .fill(egui::Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 220)) .rounding(4.0) .inner_margin(egui::Margin::symmetric(6.0, 3.0)) .show(ui, |ui| { // Horizontal layout: menus sit side by side, left to right. ui.horizontal(|ui| { ui.spacing_mut().button_padding = egui::vec2(8.0, 4.0); ui.spacing_mut().item_spacing.x = 2.0; // ---- File menu ---- ui.menu_button( egui::RichText::new("File").color(fg).size(13.0), |ui| { ui.set_min_width(200.0); ui.style_mut().visuals.override_text_color = Some(fg); ui.label( egui::RichText::new("Open") .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); if ui.button("Load File...").clicked() { pending_dialog = Some(crate::file_dialog::FileDialogKind::LoadFile); ui.close_menu(); } if ui.button("Load Folder...").clicked() { pending_dialog = Some(crate::file_dialog::FileDialogKind::LoadFolder); ui.close_menu(); } if ui.button("Load Playlist...").clicked() { pending_dialog = Some(crate::file_dialog::FileDialogKind::LoadPlaylist); ui.close_menu(); } ui.separator(); ui.label( egui::RichText::new("Markers") .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); if ui.button("Set A Marker ([)").clicked() { pending_cmds.push(Cmd::SetMarkerA); ui.close_menu(); } if ui.button("Set B Marker (])").clicked() { pending_cmds.push(Cmd::SetMarkerB); ui.close_menu(); } if ui.button("Clear Markers (\\)").clicked() { pending_cmds.push(Cmd::ClearMarkers); ui.close_menu(); } if ui.button("Toggle A-B Loop").clicked() { pending_cmds.push(Cmd::ToggleMarkerLoop); ui.close_menu(); } if ui.button("Export A-B Loop Video...").clicked() { pending_dialog = Some(crate::file_dialog::FileDialogKind::ExportVideo); ui.close_menu(); } if ui.button("Import Markers...").clicked() { pending_dialog = Some(crate::file_dialog::FileDialogKind::ImportMarkers); ui.close_menu(); } ui.separator(); ui.label( egui::RichText::new("Window") .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); if ui.button("Toggle Fullscreen (F)").clicked() { pending_cmds.push(Cmd::ToggleFullscreen); ui.close_menu(); } ui.separator(); if ui.button("Quit (Q)").clicked() { pending_cmds.push(Cmd::Shutdown); ui.close_menu(); } }, ); // ---- Playback menu ---- ui.menu_button( egui::RichText::new("Playback").color(fg).size(13.0), |ui| { ui.set_min_width(220.0); ui.style_mut().visuals.override_text_color = Some(fg); if ui.button("Play / Pause (Space)").clicked() { pending_cmds.push(Cmd::PlayPause); ui.close_menu(); } if ui.button("Stop").clicked() { pending_cmds.push(Cmd::Stop); ui.close_menu(); } ui.separator(); ui.label( egui::RichText::new("Seek") .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); if ui.button("Forward 5s (→)").clicked() { pending_cmds.push(Cmd::Seek { target_secs: 5.0, mode: mpv_bindings::command::SeekMode::Relative, flags: mpv_bindings::command::SeekFlags::Keyframes, }); ui.close_menu(); } if ui.button("Backward 5s (←)").clicked() { pending_cmds.push(Cmd::Seek { target_secs: -5.0, mode: mpv_bindings::command::SeekMode::Relative, flags: mpv_bindings::command::SeekFlags::Keyframes, }); ui.close_menu(); } if ui.button("Frame Step Forward (.)").clicked() { pending_cmds.push(Cmd::FrameStep); ui.close_menu(); } if ui.button("Frame Step Backward (,)").clicked() { pending_cmds.push(Cmd::FrameBackStep); ui.close_menu(); } ui.separator(); ui.label( egui::RichText::new("Playlist") .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); if ui.button("Next (N)").clicked() { pending_cmds.push(Cmd::PlaylistNext); ui.close_menu(); } if ui.button("Previous (P)").clicked() { pending_cmds.push(Cmd::PlaylistPrev); ui.close_menu(); } ui.separator(); ui.label( egui::RichText::new("Volume") .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); if ui.button("Volume Up 5% (↑)").clicked() { pending_cmds.push(Cmd::AdjustVolume(0.05)); ui.close_menu(); } if ui.button("Volume Down 5% (↓)").clicked() { pending_cmds.push(Cmd::AdjustVolume(-0.05)); ui.close_menu(); } if ui.button("Toggle Mute (M)").clicked() { pending_cmds.push(Cmd::ToggleMute); ui.close_menu(); } ui.separator(); ui.label( egui::RichText::new("Loop") .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); let modes = [ ("Off", LoopMode::Off), ("Loop File", LoopMode::File), ("Loop Playlist", LoopMode::Playlist), ]; for (label, mode) in modes { let checked = loop_mode == mode; if ui.selectable_label(checked, label).clicked() { pending_cmds.push(Cmd::SetLoopMode(mode)); ui.close_menu(); } } if ui.button("Cycle Loop Mode (L)").clicked() { pending_cmds.push(Cmd::SetLoopMode(loop_mode.cycle())); ui.close_menu(); } ui.separator(); ui.label( egui::RichText::new("Speed") .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); if ui.button("Speed Up +0.25× (=)").clicked() { pending_cmds.push(Cmd::SetSpeed((speed + 0.25).min(4.0))); ui.close_menu(); } if ui.button("Speed Down -0.25× (-)").clicked() { pending_cmds.push(Cmd::SetSpeed((speed - 0.25).max(0.25))); ui.close_menu(); } ui.separator(); for &s in &[0.25_f32, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0] { let checked = (speed - s).abs() < 0.01; if ui.selectable_label(checked, format!("{:.2}x", s)).clicked() { pending_cmds.push(Cmd::SetSpeed(s)); ui.close_menu(); } } }, ); // ---- Audio menu (only if tracks are available) ---- if has_audio { let tracks = self.state.audio_tracks.clone(); let current = self.state.current_audio_track; ui.menu_button( egui::RichText::new("Audio").color(fg).size(13.0), |ui| { ui.set_min_width(180.0); ui.style_mut().visuals.override_text_color = Some(fg); let auto_checked = current.is_none(); if ui.selectable_label(auto_checked, "Auto").clicked() { pending_cmds.push(Cmd::SetAudioTrack(None)); ui.close_menu(); } ui.separator(); for track in &tracks { let checked = current == Some(track.id); if ui.selectable_label(checked, track.label()).clicked() { pending_cmds.push(Cmd::SetAudioTrack(Some(track.id))); ui.close_menu(); } } }, ); } // ---- Subtitles menu ---- { let tracks = self.state.subtitle_tracks.clone(); let current = self.state.current_subtitle_track; ui.menu_button( egui::RichText::new("Subtitles").color(fg).size(13.0), |ui| { ui.set_min_width(200.0); ui.style_mut().visuals.override_text_color = Some(fg); if ui.button("Load Subtitle File...").clicked() { pending_dialog = Some(crate::file_dialog::FileDialogKind::LoadSubtitle); ui.close_menu(); } ui.separator(); if ui.selectable_label(sub_vis, "Show Subtitles (V)").clicked() { pending_cmds.push(Cmd::ToggleSubVisibility); ui.close_menu(); } ui.separator(); let none_checked = current.is_none(); if ui.selectable_label(none_checked, "None").clicked() { pending_cmds.push(Cmd::SetSubtitleTrack(None)); ui.close_menu(); } if !tracks.is_empty() { ui.separator(); for track in &tracks { let checked = current == Some(track.id); if ui.selectable_label(checked, track.label()).clicked() { pending_cmds.push(Cmd::SetSubtitleTrack(Some(track.id))); ui.close_menu(); } } } else { ui.label( egui::RichText::new("(no embedded subtitle tracks)") .color(fg_dim).size(10.0), ); } }, ); } // ---- Video menu (rotate + flip) ---- ui.menu_button( egui::RichText::new("Video").color(fg).size(13.0), |ui| { ui.set_min_width(180.0); ui.style_mut().visuals.override_text_color = Some(fg); ui.label( egui::RichText::new("Rotate") .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); for ° in &[0u16, 90, 180, 270] { let checked = video_rotate == deg; let label = if deg == 0 { "0° (normal)".to_string() } else { format!("{}°", deg) }; if ui.selectable_label(checked, label).clicked() { pending_cmds.push(Cmd::SetVideoRotate(deg)); ui.close_menu(); } } ui.separator(); ui.label( egui::RichText::new("Flip") .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); if ui.selectable_label(video_flip_h, "Flip Horizontal (mirror)").clicked() { pending_cmds.push(Cmd::SetVideoFlipH(!video_flip_h)); ui.close_menu(); } if ui.selectable_label(video_flip_v, "Flip Vertical (upside-down)").clicked() { pending_cmds.push(Cmd::SetVideoFlipV(!video_flip_v)); ui.close_menu(); } }, ); // ---- Help menu ---- ui.menu_button( egui::RichText::new("Help").color(fg).size(13.0), |ui| { ui.set_min_width(200.0); ui.style_mut().visuals.override_text_color = Some(fg); if ui.selectable_label(about_visible, "About ferret").clicked() { pending_about_toggle = true; ui.close_menu(); } ui.separator(); ui.label( egui::RichText::new("ferret 1.0.0") .color(fg_dim).size(10.0), ); ui.label( egui::RichText::new("GPL-2.0-or-later") .color(fg_dim).size(10.0), ); }, ); // ---- Status line ---- ui.add_space(8.0); let status = if self.state.paused { "paused" } else { "playing" }; let speed_str = if (speed - 1.0).abs() < 0.01 { String::new() } else { format!(" {:.2}x", speed) }; let loop_str = match loop_mode { LoopMode::Off => String::new(), LoopMode::File => " loop:file".into(), LoopMode::Playlist => " loop:list".into(), }; ui.label( egui::RichText::new(format!("{status}{speed_str}{loop_str}")) .color(fg_dim).size(10.0), ); }); }); }); // Send any commands that were collected during the UI pass. for cmd in pending_cmds { self.send(cmd); } // Apply the About toggle after the closure (can't mutate self inside). if pending_about_toggle { self.about_visible = !self.about_visible; } // Open the in-UI file dialog if a menu item requested one. if let Some(kind) = pending_dialog { // For ExportVideo, pre-check that A/B markers are set. if matches!(kind, crate::file_dialog::FileDialogKind::ExportVideo) { let (a, b) = (self.state.marker_a, self.state.marker_b); match (a, b) { (Some(start), Some(end)) if end > start => { self.file_dialog = Some(crate::file_dialog::FileDialog::open(kind)); } _ => { self.show_info("Set both A and B markers before exporting"); } } } else { self.file_dialog = Some(crate::file_dialog::FileDialog::open(kind)); } } // Draw the About panel if visible. if self.about_visible { self.draw_about_panel(ctx); } } /// Handle the result of a completed file dialog. Sends the appropriate /// `Cmd` (or Cmds) to the engine via `cmd_tx`. The `kind` is passed in /// because the dialog has already been cleared from `self.file_dialog` /// by the time this is called. fn handle_file_dialog_result( &mut self, result: crate::file_dialog::FileDialogResult, kind: crate::file_dialog::FileDialogKind, ) { use crate::file_dialog::{FileDialogKind, FileDialogResult}; use player_core::cmd::{LoadModeKind, LoadOptions}; match result { FileDialogResult::Cancel => {} FileDialogResult::Path(path) => match kind { FileDialogKind::LoadFile => { let _ = self.cmd_tx.send(Cmd::LoadFile { path: path.clone(), options: LoadOptions { mode: LoadModeKind::Replace, pause: false }, }); self.show_info(format!("Loading: {path}")); } FileDialogKind::LoadFolder => { // Enumerate media files in the folder, load as playlist. if let Some(paths) = collect_folder_as_playlist(&path) { if paths.is_empty() { self.show_info("No video files found in folder"); } else { // Use try_send (non-blocking) — if the engine's // command channel is full (64 slots), skip the // remaining files rather than blocking the UI thread. let mut sent = 0usize; for (i, p) in paths.iter().enumerate() { let mode = if i == 0 { LoadModeKind::Replace } else { LoadModeKind::AppendPlay }; if self.cmd_tx.try_send(Cmd::LoadFile { path: p.clone(), options: LoadOptions { mode, pause: false }, }).is_ok() { sent += 1; } } self.show_info(format!("Loaded {}/{} files from folder", sent, paths.len())); } } else { self.show_info(format!("Could not read folder: {path}")); } } FileDialogKind::SaveMarkers(fmt) => { let _ = self.cmd_tx.send(Cmd::ExportMarkers { path: path.clone(), format: fmt }); self.show_info(format!("Markers exported to {path}")); } FileDialogKind::LoadSubtitle => { let _ = self.cmd_tx.send(Cmd::LoadSubtitleFile { path }); self.show_info("Subtitle file loaded"); } FileDialogKind::ImportMarkers => { let _ = self.cmd_tx.send(Cmd::ImportMarkers { path: path.clone() }); self.show_info(format!("Markers imported from {path}")); } FileDialogKind::ExportVideo => { // Send the real export command with the chosen path. // Main app intercepts all ExportABLoopVideo Cmds and runs ffmpeg. let _ = self.cmd_tx.send(Cmd::ExportABLoopVideo { path }); } FileDialogKind::LoadPlaylist => { // Single file from a multi-select dialog (shouldn't happen, // but handle gracefully). let _ = self.cmd_tx.send(Cmd::LoadFile { path, options: LoadOptions { mode: LoadModeKind::Replace, pause: false }, }); } }, FileDialogResult::Paths(paths) => match kind { FileDialogKind::LoadPlaylist => { if paths.is_empty() { self.show_info("No files selected"); } else { // Use try_send (non-blocking) to avoid UI deadlock // if the engine's command channel is full. let mut sent = 0usize; for (i, p) in paths.iter().enumerate() { let mode = if i == 0 { LoadModeKind::Replace } else { LoadModeKind::AppendPlay }; if self.cmd_tx.try_send(Cmd::LoadFile { path: p.clone(), options: LoadOptions { mode, pause: false }, }).is_ok() { sent += 1; } } self.show_info(format!("Loaded {}/{} files", sent, paths.len())); } } _ => {} }, } } /// Draw a persistent About panel in the overlay. This is NOT a popup — /// it stays visible until the user toggles it off via Help → About. /// Positioned at top-right so it doesn't overlap the menu bar. fn draw_about_panel(&mut self, ctx: &Context) { let win_w = self.window_size.x; let panel_w = 320.0_f32.min(win_w - 20.0); let panel_x = win_w - panel_w - 10.0; let panel_y = 40.0; let fg = self.theme.fg_color32(); let fg_dim = self.theme.fg_dim_color32(); let bg = self.theme.bg_color32(); let accent = self.theme.accent_color32(); egui::Area::new(egui::Id::new("ferret_about_panel")) .fixed_pos(egui::pos2(panel_x, panel_y)) .order(egui::Order::Foreground) .show(ctx, |ui| { egui::Frame::none() .fill(egui::Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 240)) .rounding(6.0) .inner_margin(egui::Margin::symmetric(14.0, 10.0)) .stroke(egui::Stroke::new(1.0_f32, accent)) .show(ui, |ui| { ui.set_min_width(panel_w - 28.0); ui.set_max_width(panel_w - 28.0); // Title ui.horizontal(|ui| { ui.label( egui::RichText::new("ferret") .color(accent) .size(20.0) .strong(), ); ui.label( egui::RichText::new("1.0.0") .color(fg_dim) .size(13.0), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("×").clicked() { self.about_visible = false; } }); }); ui.add_space(4.0); ui.separator(); ui.add_space(4.0); // Description ui.label( egui::RichText::new("A modern, accuracy-first video player for Linux.") .color(fg).size(12.0), ); ui.add_space(6.0); // Author ui.horizontal(|ui| { ui.label(egui::RichText::new("Author:").color(fg_dim).size(11.0)); ui.label(egui::RichText::new("Jeremy Anderson").color(fg).size(11.0)); }); // Website ui.horizontal(|ui| { ui.label(egui::RichText::new("Website:").color(fg_dim).size(11.0)); ui.label( egui::RichText::new("http://git.dcos.net/dcosnet/ferret") .color(accent).size(11.0), ); }); // License ui.horizontal(|ui| { ui.label(egui::RichText::new("License:").color(fg_dim).size(11.0)); ui.label(egui::RichText::new("GPL-2.0-or-later").color(fg).size(11.0)); }); ui.add_space(6.0); ui.separator(); ui.add_space(4.0); // Tech credits ui.label( egui::RichText::new("Built with Rust, libmpv, egui, wgpu, and winit.") .color(fg_dim).size(10.0), ); ui.label( egui::RichText::new("Copyright © 2026 Jeremy Anderson.") .color(fg_dim).size(10.0), ); }); }); } fn draw_controls(&mut self, ui: &mut Ui) { ui.vertical(|ui| { // ===== Row 1: Seek bar with time labels on either side ===== ui.horizontal(|ui| { let cur_time = self .state .time_pos .map(|t| format_time(t)) .unwrap_or_else(|| "00:00".to_string()); ui.label( egui::RichText::new(cur_time) .color(self.theme.fg_color32()) .size(11.0) .monospace(), ); ui.add_space(8.0); let progress = self.state.progress(); let (response, new_pos) = seek_bar(ui, progress, None, 18.0); // Draw A/B marker pins on top of the seek bar. if let Some(dur) = self.state.duration { if dur > 0.0 { let bar_rect = response.rect; let track_y = bar_rect.center().y; let track_w = bar_rect.width() - 4.0; let track_min_x = bar_rect.min.x + 2.0; let painter = ui.painter_at(bar_rect); if let Some(a) = self.state.marker_a { let x = track_min_x + (a / dur).clamp(0.0, 1.0) as f32 * track_w; draw_marker_pin(&painter, egui::pos2(x, track_y - 8.0), Color32::from_rgb(255, 100, 100)); } if let Some(b) = self.state.marker_b { let x = track_min_x + (b / dur).clamp(0.0, 1.0) as f32 * track_w; draw_marker_pin(&painter, egui::pos2(x, track_y - 8.0), Color32::from_rgb(100, 180, 255)); } } } if response.hovered() || response.dragged() { self.note_mouse_activity(); } if let Some(frac) = new_pos { self.seeking = response.dragged(); if let Some(dur) = self.state.duration { self.seek_drag_pos = dur * (frac as f64); self.state.time_pos = Some(self.seek_drag_pos); if !response.dragged() { self.send(Cmd::Seek { target_secs: self.seek_drag_pos, mode: mpv_bindings::command::SeekMode::Absolute, flags: mpv_bindings::command::SeekFlags::Exact, }); self.seeking = false; } } } else if !response.dragged() { self.seeking = false; } ui.add_space(8.0); let dur_str = self .state .duration .map(|t| format_time(t)) .unwrap_or_else(|| "--:--".to_string()); ui.label( egui::RichText::new(dur_str) .color(self.theme.fg_color32()) .size(11.0) .monospace(), ); }); ui.add_space(4.0); // ===== Row 2: Transport buttons | time | volume + fullscreen ===== ui.horizontal(|ui| { let btn_size = Vec2::new(32.0, 24.0); let paused = self.state.paused; let muted = self.state.muted; let volume = self.state.volume; // Play / Pause let play_resp = self.icon_button(ui, "btn_play", btn_size, move |p, r, c| { if paused { icons::play(p, r, c) } else { icons::pause(p, r, c) } }); if play_resp.clicked() { self.send(Cmd::PlayPause); } // Stop let stop_resp = self.icon_button(ui, "btn_stop", btn_size, |p, r, c| icons::stop(p, r, c)); if stop_resp.clicked() { self.send(Cmd::Stop); } // Previous (playlist) let prev_resp = self.icon_button(ui, "btn_prev", btn_size, |p, r, c| icons::previous(p, r, c)); if prev_resp.clicked() { self.send(Cmd::PlaylistPrev); } // Next (playlist) let next_resp = self.icon_button(ui, "btn_next", btn_size, |p, r, c| icons::next(p, r, c)); if next_resp.clicked() { self.send(Cmd::PlaylistNext); } ui.add_space(6.0); // Frame step back / forward let fb_resp = self.icon_button(ui, "btn_frame_back", btn_size, |p, r, c| icons::frame_back(p, r, c)); if fb_resp.clicked() { self.send(Cmd::FrameBackStep); } let ff_resp = self.icon_button(ui, "btn_frame_fwd", btn_size, |p, r, c| icons::frame_forward(p, r, c)); if ff_resp.clicked() { self.send(Cmd::FrameStep); } // Center: time display ui.with_layout(Layout::centered_and_justified(egui::Direction::TopDown), |ui| { let time_str = self.state.time_str(); ui.label( egui::RichText::new(time_str) .color(self.theme.fg_color32()) .size(12.0) .monospace(), ); }); // Right: volume + fullscreen ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { let fs_resp = self.icon_button(ui, "btn_fullscreen", btn_size, |p, r, c| icons::fullscreen(p, r, c)); if fs_resp.clicked() { self.send(Cmd::ToggleFullscreen); } if fs_resp.hovered() { self.note_mouse_activity(); } ui.add_space(8.0); let mut vol_pct = self.state.volume * 100.0; let slider = egui::Slider::new(&mut vol_pct, 0.0..=100.0) .show_value(false) .fixed_decimals(0); let slider_resp = ui.add_sized(Vec2::new(100.0, 18.0), slider); if slider_resp.changed() { self.send(Cmd::SetVolume(vol_pct / 100.0)); self.state.volume = vol_pct / 100.0; } if slider_resp.hovered() { self.note_mouse_activity(); } ui.add_space(4.0); let vol_resp = self.icon_button(ui, "btn_vol", btn_size, move |p, r, c| { let level = if muted { 0.0 } else { volume }; icons::volume(p, r, c, level, muted) }); if vol_resp.clicked() { self.send(Cmd::ToggleMute); } }); }); ui.add_space(4.0); // ===== Row 3: A/B markers | loop | speed presets | audio track ===== ui.horizontal(|ui| { let btn_size = Vec2::new(32.0, 24.0); // --- A marker button (with current position label) --- let a_label = self.state.marker_a.map(format_time).unwrap_or_else(|| " A ".into()); let a_resp = self.labeled_button(ui, "btn_marker_a", btn_size, &a_label, Color32::from_rgb(255, 100, 100)); if a_resp.clicked() { self.send(Cmd::SetMarkerA); } // --- B marker button --- let b_label = self.state.marker_b.map(format_time).unwrap_or_else(|| " B ".into()); let b_resp = self.labeled_button(ui, "btn_marker_b", btn_size, &b_label, Color32::from_rgb(100, 180, 255)); if b_resp.clicked() { self.send(Cmd::SetMarkerB); } // --- AB-loop toggle --- let ab_active = self.state.marker_loop_enabled; let ab_resp = self.icon_button_toggled(ui, "btn_ab_loop", btn_size, ab_active, |p, r, c| { icons::marker_loop(p, r, c, ab_active) }); if ab_resp.clicked() { self.send(Cmd::ToggleMarkerLoop); } ui.add_space(8.0); // --- Loop mode toggle (off/file/playlist) --- let loop_mode = self.state.loop_mode; let loop_label = loop_mode.label(); let loop_active = loop_mode != LoopMode::Off; // Table-driven loop-mode → icon variant mapping. Indices // mirror the contract documented on `icons::loop_icon`. const LOOP_ICON_IDX: [(LoopMode, u8); 3] = [ (LoopMode::Off, 0), (LoopMode::File, 1), (LoopMode::Playlist, 2), ]; let icon_idx = LOOP_ICON_IDX .iter() .copied() .find(|(m, _)| *m == loop_mode) .map(|(_, i)| i) .expect("LoopMode is exhaustive over LOOP_ICON_IDX"); let loop_resp = self.icon_button_toggled(ui, "btn_loop", btn_size, loop_active, |p, r, c| { icons::loop_icon(p, r, c, icon_idx) }); if loop_resp.clicked() { self.send(Cmd::SetLoopMode(loop_mode.cycle())); } // Tooltip showing current mode. loop_resp.on_hover_text(loop_label); ui.add_space(8.0); // --- Speed presets: 0.5×, 1×, 1.5×, 2× --- let presets = [0.5_f32, 1.0, 1.5, 2.0]; let current_speed = self.speed_drag.unwrap_or(self.state.speed); for &p in &presets { let active = (current_speed - p).abs() < 0.01; let label = format!("{p:.2}×"); let resp = self.text_button(ui, &format!("btn_speed_{p}"), btn_size, &label, active); if resp.clicked() { self.send(Cmd::SetSpeed(p)); self.speed_drag = Some(p); } } // Fine-grained speed slider (0.25×–4×). let mut speed_val = self.speed_drag.unwrap_or(self.state.speed); let slider = egui::Slider::new(&mut speed_val, 0.25..=4.0) .show_value(false) .fixed_decimals(2) .clamping(egui::SliderClamping::Always); let slider_resp = ui.add_sized(Vec2::new(110.0, 18.0), slider); if slider_resp.drag_started() { self.speed_drag = Some(speed_val); } if slider_resp.dragged() { self.speed_drag = Some(speed_val); } if slider_resp.drag_stopped() { if let Some(v) = self.speed_drag.take() { self.send(Cmd::SetSpeed(v)); } } if slider_resp.hovered() { self.note_mouse_activity(); } // Current speed label. ui.label( egui::RichText::new(format!("{:.2}×", current_speed)) .color(self.theme.fg_color32()) .size(11.0) .monospace(), ); ui.add_space(8.0); // --- Audio track dropdown --- ui.label( egui::RichText::new("Audio:") .color(self.theme.fg_dim_color32()) .size(11.0), ); egui::ComboBox::from_id_salt("ferret_audio_track") .selected_text(self.current_audio_label()) .width(140.0) .show_ui(ui, |ui| { // "Auto" option. let auto_selected = self.state.current_audio_track.is_none(); if ui.selectable_label(auto_selected, "auto").clicked() { self.send(Cmd::SetAudioTrack(None)); } ui.separator(); for track in &self.state.audio_tracks { let label = track.label(); let selected = self.state.current_audio_track == Some(track.id); if ui.selectable_label(selected, label).clicked() { self.send(Cmd::SetAudioTrack(Some(track.id))); } } if self.state.audio_tracks.is_empty() { ui.label( egui::RichText::new("(no audio tracks)") .color(self.theme.fg_dim_color32()) .size(10.0), ); } }); }); }); } fn current_audio_label(&self) -> String { match self.state.current_audio_track { None => "auto".to_string(), Some(id) => { self.state .audio_tracks .iter() .find(|t| t.id == id) .map(|t| t.label()) .unwrap_or_else(|| format!("track {id}")) } } } /// Draw an icon button with hover state. `draw_fn` receives (painter, rect, color). fn icon_button( &mut self, ui: &mut Ui, _id: &str, size: Vec2, draw_fn: impl Fn(&egui::Painter, egui::Rect, egui::Color32), ) -> egui::Response { let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); if response.hovered() { self.note_mouse_activity(); } if ui.is_rect_visible(rect) { let painter = ui.painter_at(rect); let bg = if response.hovered() || response.has_focus() { self.theme.button_bg_hover_color32() } else { self.theme.button_bg_color32() }; painter.rect_filled(rect, 4.0, bg); let icon_color = if response.hovered() { self.theme.accent_hover_color32() } else { self.theme.fg_color32() }; let icon_rect = rect.shrink2(Vec2::splat(5.0)); draw_fn(&painter, icon_rect, icon_color); } response } /// Like `icon_button` but with a "toggled on" visual state — brighter bg /// and accent icon color. fn icon_button_toggled( &mut self, ui: &mut Ui, _id: &str, size: Vec2, toggled: bool, draw_fn: impl Fn(&egui::Painter, egui::Rect, egui::Color32), ) -> egui::Response { let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); if response.hovered() { self.note_mouse_activity(); } if ui.is_rect_visible(rect) { let painter = ui.painter_at(rect); let bg = if toggled { egui::Color32::from_rgba_unmultiplied( self.theme.accent[0], self.theme.accent[1], self.theme.accent[2], 60, ) } else if response.hovered() || response.has_focus() { self.theme.button_bg_hover_color32() } else { self.theme.button_bg_color32() }; painter.rect_filled(rect, 4.0, bg); let icon_color = if toggled { self.theme.accent_color32() } else if response.hovered() { self.theme.accent_hover_color32() } else { self.theme.fg_color32() }; let icon_rect = rect.shrink2(Vec2::splat(5.0)); draw_fn(&painter, icon_rect, icon_color); } response } /// A small button with a text label (used for A/B markers + speed presets). fn text_button( &mut self, ui: &mut Ui, _id: &str, size: Vec2, label: &str, active: bool, ) -> egui::Response { let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); if response.hovered() { self.note_mouse_activity(); } if ui.is_rect_visible(rect) { let painter = ui.painter_at(rect); let bg = if active { egui::Color32::from_rgba_unmultiplied( self.theme.accent[0], self.theme.accent[1], self.theme.accent[2], 60, ) } else if response.hovered() { self.theme.button_bg_hover_color32() } else { self.theme.button_bg_color32() }; painter.rect_filled(rect, 4.0, bg); let text_color = if active { self.theme.accent_color32() } else if response.hovered() { self.theme.accent_hover_color32() } else { self.theme.fg_color32() }; painter.text( rect.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(11.0), text_color, ); } response } /// A button that shows a small colored pin + a time label below it. /// Used for A/B markers. fn labeled_button( &mut self, ui: &mut Ui, _id: &str, size: Vec2, label: &str, pin_color: egui::Color32, ) -> egui::Response { let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); if response.hovered() { self.note_mouse_activity(); } if ui.is_rect_visible(rect) { let painter = ui.painter_at(rect); let bg = if response.hovered() { self.theme.button_bg_hover_color32() } else { self.theme.button_bg_color32() }; painter.rect_filled(rect, 4.0, bg); // Pin (small triangle at top). let pin_rect = egui::Rect::from_min_size( egui::pos2(rect.center().x - 6.0, rect.min.y + 3.0), Vec2::new(12.0, 8.0), ); let pin_pts = vec![ egui::pos2(pin_rect.min.x, pin_rect.min.y), egui::pos2(pin_rect.max.x, pin_rect.min.y), egui::pos2(pin_rect.center().x, pin_rect.max.y), ]; painter.add(egui::Shape::convex_polygon(pin_pts, pin_color, egui::Stroke::NONE)); // Label. painter.text( egui::pos2(rect.center().x, rect.max.y - 4.0), egui::Align2::CENTER_BOTTOM, label, egui::FontId::proportional(10.0), self.theme.fg_color32(), ); } response } } /// Draw a small downward-pointing pin on the seek bar at (x, y). fn draw_marker_pin(painter: &egui::Painter, pos: egui::Pos2, color: egui::Color32) { let pts = vec![ egui::pos2(pos.x - 4.0, pos.y), egui::pos2(pos.x + 4.0, pos.y), egui::pos2(pos.x, pos.y + 6.0), ]; painter.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE)); } /// Format a duration in seconds as "HH:MM:SS" if >= 1 hour, else "MM:SS". fn format_time(secs: f64) -> String { let s = secs.max(0.0) as u64; let h = s / 3600; let m = (s % 3600) / 60; let sec = s % 60; if h > 0 { format!("{h}:{m:02}:{sec:02}") } else { format!("{m:02}:{sec:02}") } } /// Enumerate a directory for video/audio files. Returns absolute paths /// sorted alphabetically. Used by the in-UI "Load Folder" dialog to build /// an implicit playlist. fn collect_folder_as_playlist(dir: &str) -> Option> { use std::fs; let entries = fs::read_dir(dir).ok()?; const EXTENSIONS: &[&str] = &[ "mp4", "mkv", "webm", "avi", "mov", "flv", "mp3", "ogg", "wav", "flac", "aac", "m4a", "ts", "m2ts", "vob", "wmv", "3gp", ]; let mut paths: Vec = entries .filter_map(|e| e.ok()) .filter_map(|e| { let p = e.path(); let ext = p.extension()?.to_str()?.to_lowercase(); EXTENSIONS .contains(&ext.as_str()) .then(|| p.to_string_lossy().into_owned()) }) .collect(); paths.sort(); Some(paths) }