From e64adaed14e6c7a53e625940f4b52745513f7f5f Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Sun, 27 Sep 2026 01:52:10 -0400 Subject: [PATCH] Ferret: feature updates --- Cargo.toml | 1 + clippy.toml | 9 +- crates/player-app/Cargo.toml | 1 + crates/player-app/src/main.rs | 758 +++++++++++++-- crates/player-core/src/cmd.rs | 137 +++ crates/player-core/src/crop.rs | 159 +++ crates/player-core/src/engine.rs | 187 +++- crates/player-core/src/lib.rs | 42 + crates/player-core/src/options.rs | 97 +- crates/player-core/src/state.rs | 33 + crates/player-ui/src/app.rs | 1385 +++++++++++++++++++++++++-- crates/player-ui/src/file_dialog.rs | 19 +- crates/player-ui/src/icons.rs | 25 + crates/player-ui/src/renderer.rs | 236 ++++- scripts/dev-cargo.sh | 9 + 15 files changed, 2891 insertions(+), 207 deletions(-) create mode 100644 crates/player-core/src/crop.rs create mode 100644 scripts/dev-cargo.sh diff --git a/Cargo.toml b/Cargo.toml index cc95a7a..e600181 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } winit = { version = "0.30", features = ["x11", "wayland", "rwh_06"] } raw-window-handle = "0.6" egui = "0.29" +x11rb = "0.13" # X11 SHAPE extension (bounding shape), same version winit locks egui-wgpu = "0.29" wgpu = "22" # pinned to match egui-wgpu 0.29 pollster = "0.4" # lightweight blocking executor for wgpu init diff --git a/clippy.toml b/clippy.toml index e8817ea..5b9de23 100755 --- a/clippy.toml +++ b/clippy.toml @@ -6,10 +6,11 @@ # memory bloat in hot-path enums like Cmd and EngineEvent. enum-variant-size-threshold = 256 -# Flag types with too many fields — a code smell for "split this struct". -# PlaybackState has many fields by design (it's a snapshot), so it carries -# an explicit #[allow] where needed. -struct-field-size-threshold = 64 +# NOTE: `struct-field-size-threshold` was never a recognized clippy.toml +# key on current toolchains and hard-fails config parsing (the related +# struct-bloat lints are field-count based, not size based). Removed so +# `cargo clippy` runs at all; see PlaybackState's #[allow]s for the +# "too many fields" side of the original intent. # Single-use bindings are often a sign of "extract this to a named variable # for clarity" — but sometimes they're just noise. Keep the lint at warn diff --git a/crates/player-app/Cargo.toml b/crates/player-app/Cargo.toml index bf16bad..88c2c11 100755 --- a/crates/player-app/Cargo.toml +++ b/crates/player-app/Cargo.toml @@ -20,6 +20,7 @@ egui = { workspace = true } egui-wgpu = { workspace = true } wgpu = { workspace = true } raw-window-handle = { workspace = true } +x11rb = { workspace = true } pollster = { workspace = true } crossbeam-channel = { workspace = true } anyhow = { workspace = true } diff --git a/crates/player-app/src/main.rs b/crates/player-app/src/main.rs index 24e4628..a2599ec 100755 --- a/crates/player-app/src/main.rs +++ b/crates/player-app/src/main.rs @@ -18,7 +18,7 @@ //! worker thread because `rfd` blocks while the dialog is open. The worker //! sends the result back via a channel polled from `about_to_wait`. -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use anyhow::{Context as _, Result}; @@ -27,8 +27,8 @@ use tracing::{error, info, warn}; use tracing_subscriber::EnvFilter; use winit::application::ApplicationHandler; use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent}; -use winit::event_loop::{ActiveEventLoop, EventLoop}; -use winit::keyboard::Key; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::keyboard::{Key, ModifiersState, NamedKey}; use winit::window::WindowId; use player_core::cmd::LoadModeKind; @@ -36,6 +36,8 @@ use player_core::options::EngineOptions; use player_core::{Cmd, PlayerEngine}; use player_ui::OverlayRenderer; +use x11rb::protocol::xproto; + mod keymap; mod windows; @@ -43,20 +45,19 @@ use windows::{WindowKind, WindowManager}; /// Result from a background ffmpeg export worker. enum DialogResult { - /// Export succeeded; the file was written to this path. - File(String), + /// Export succeeded; the file was written to this path. `cropped` says + /// whether the current zoom/pan focus area was baked in as a crop. + File { path: String, cropped: bool }, /// Export failed with this error message. Error(String), } /// Only one background operation remains: ffmpeg video export. All file -/// selection is now in-UI (see `player_ui::file_dialog`). +/// selection is now in-UI (see `player_ui::file_dialog`). The kind tags the +/// dialog-result channel so future worker kinds can be distinguished. enum DialogKind { - ExportVideo { - input: String, - start: f64, - end: f64, - }, + /// Result of an ffmpeg A-B loop export worker. + ExportVideo, } fn main() -> Result<()> { @@ -118,6 +119,20 @@ struct FerretApp { /// When false, the overlay drops from AlwaysOnTop so it doesn't block /// other applications. has_focus: bool, + /// Latest keyboard modifier state (ctrl/alt/shift/super), tracked via + /// WindowEvent::ModifiersChanged. winit 0.30 KeyEvents do not carry + /// modifiers, so we keep the state here for key→egui translation. + keyboard_modifiers: ModifiersState, + /// The overlay bounding shape last applied to the X server. Kept so we + /// only issue an XShape request when egui's painted rects actually + /// changed (show/hide of bars, opening a dialog, tooltips, ...). + last_shape_rects: Vec, + /// Last pointer position while a pan drag is in progress on the video + /// area (the "movable object on a canvas" gesture). None = not + /// dragging. Pointer events only reach the video window through the + /// holes in the overlay's X11 shape, so a drag that starts here is by + /// construction over the video — never over the bars or sidebar. + video_pan_drag: Option<(f64, f64)>, } impl FerretApp { @@ -138,6 +153,9 @@ impl FerretApp { dialog_result_rx, dialog_result_tx, has_focus: true, + keyboard_modifiers: ModifiersState::empty(), + last_shape_rects: Vec::new(), + video_pan_drag: None, } } @@ -223,32 +241,43 @@ impl FerretApp { let mut infos: Vec = Vec::new(); while let Ok((kind, result)) = self.dialog_result_rx.try_recv() { match (kind, result) { - (DialogKind::ExportVideo { .. }, DialogResult::File(path)) => { - infos.push(format!("A-B loop exported to {path}")); + (DialogKind::ExportVideo, DialogResult::File { path, cropped }) => { + infos.push(if cropped { + format!("A-B loop exported (with zoom/pan focus area) to {path}") + } else { + format!("A-B loop exported to {path}") + }); } - (DialogKind::ExportVideo { .. }, DialogResult::Error(msg)) => { + (DialogKind::ExportVideo, DialogResult::Error(msg)) => { infos.push(format!("Export failed: {msg}")); } - (_, DialogResult::Error(msg)) => { - infos.push(msg); - } - _ => {} } } infos } - fn render_overlay(&mut self) { + fn render_overlay(&mut self, event_loop: &ActiveEventLoop) { let Some(engine) = self.engine.as_ref() else { return; }; - // Forward commands from the overlay UI to the engine. Two commands - // are intercepted here (not sent to the engine): + // Forward commands from the overlay UI to the engine. Three + // commands are intercepted here (not sent to the engine): // - ToggleFullscreen: main-app window concern // - ExportABLoopVideo: main-app runs ffmpeg (engine no-ops it) + // - Shutdown: menu Quit must exit the whole event loop — the + // engine alone only stops the playback thread and the app + // window would hang around forever. let mut pending_fullscreen_toggle = false; + let mut pending_quit = false; while let Ok(cmd) = self.cmd_rx.try_recv() { match &cmd { + Cmd::Shutdown => { + // File → Quit. Exit the winit event loop; FerretApp (and + // with it the engine + windows) is dropped on return, + // which cleanly shuts the engine thread down. + pending_quit = true; + } Cmd::ToggleFullscreen => { + // Window-level concern — handled after the render below. pending_fullscreen_toggle = true; } Cmd::ExportABLoopVideo { path } => { @@ -259,6 +288,27 @@ impl FerretApp { let a = st.marker_a; let b = st.marker_b; let input = st.path.clone(); + // Bake the current zoom/pan focus area into the clip: + // map the visible window region back to source pixels + // and crop. Only when the view is actually realigned, + // and only without rotation — the export doesn't remap + // rotated coordinates (it never did). + let crop = if st.video_rotate == 0 { + self.windows.video.as_ref().and_then(|w| { + let size = w.inner_size(); + player_core::crop::visible_crop( + st.video_width, + st.video_height, + size.width, + size.height, + st.video_zoom, + st.video_pan_x, + st.video_pan_y, + ) + }) + } else { + None + }; drop(st); match (a, b, input) { (Some(start), Some(end), Some(inp)) if end > start => { @@ -267,16 +317,13 @@ impl FerretApp { start, end, path.clone(), + crop, self.dialog_result_tx.clone(), ); } _ => { let _ = self.dialog_result_tx.send(( - DialogKind::ExportVideo { - input: String::new(), - start: 0.0, - end: 0.0, - }, + DialogKind::ExportVideo, DialogResult::Error( "Set both A and B markers before exporting".into(), ), @@ -307,16 +354,79 @@ impl FerretApp { } self.last_overlay_render = Instant::now(); + // Mirror this frame's painted rects onto the overlay window as its + // X11 bounding shape. Everything egui did NOT paint becomes a hole + // in the window — the libmpv video window underneath shows through + // unconditionally (no compositor / alpha-mode / visual involved). + // This is the structural fix for the recurring "no video output" + // class of bugs: the overlay can no longer blanket the video with a + // possibly-opaque surface. + let painted = overlay.painted_rects.clone(); + let overlay_window = self.windows.overlay.clone(); + if let Some(w) = overlay_window { + if painted != self.last_shape_rects { + apply_overlay_shape(&w, &painted); + self.last_shape_rects = painted; + } + } + + if pending_quit { + info!("quit requested via menu — exiting event loop"); + event_loop.exit(); + return; + } if pending_fullscreen_toggle { self.toggle_fullscreen(); } } + /// Forward a pointer position received on the VIDEO window into the + /// egui overlay (the overlay's bounding shape has holes over the video, + /// so those events arrive here instead). Same coordinate space. + fn forward_video_pointer(&mut self, position: winit::dpi::PhysicalPosition) { + let pos = egui::pos2(position.x as f32, position.y as f32); + self.overlay_mouse_pos = Some(pos); + if let Some(overlay) = self.overlay.as_mut() { + overlay.app.push_event(egui::Event::PointerMoved(pos)); + // Moving the mouse over the video is user activity: (re)show + // the menu bar and control bar. + overlay.app.note_user_activity(); + } + self.request_redraw_overlay(); + } + + /// Feed a video-window pointer move into an active pan drag. Deltas are + /// converted to screen fractions and sent to the engine as + /// `Cmd::AdjustVideoPan` — the video follows the pointer like an object + /// being dragged around a canvas, letting the user realign whatever + /// area they want in focus. + fn pan_video_by_drag(&mut self, position: winit::dpi::PhysicalPosition) { + let Some((last_x, last_y)) = self.video_pan_drag else { return }; + let Some(video) = self.windows.video.clone() else { return }; + let size = video.inner_size(); + if size.width == 0 || size.height == 0 { + return; + } + let dx = (position.x - last_x) as f32 / size.width as f32; + let dy = (position.y - last_y) as f32 / size.height as f32; + if dx == 0.0 && dy == 0.0 { + return; + } + self.video_pan_drag = Some((position.x, position.y)); + if let Some(engine) = self.engine.as_ref() { + let _ = engine.send(Cmd::AdjustVideoPan { dx, dy }); + } + } + /// Request a redraw on both windows. Single dispatch point so callers /// never have to repeat the `if let Some(w) = ...` dance. fn request_redraw_both(&self) { - self.windows.video.as_ref().map(|w| w.request_redraw()); - self.windows.overlay.as_ref().map(|w| w.request_redraw()); + if let Some(w) = self.windows.video.as_ref() { + w.request_redraw(); + } + if let Some(w) = self.windows.overlay.as_ref() { + w.request_redraw(); + } } /// Request a redraw on the overlay window only. @@ -326,9 +436,35 @@ impl FerretApp { } } - fn handle_keyboard(&mut self, key: &Key, event_loop: &ActiveEventLoop) { + fn handle_keyboard(&mut self, event_loop: &ActiveEventLoop, key_event: &KeyEvent) { + // If the overlay UI has a focused text field (e.g. the save-dialog + // filename input), route the keystroke into egui instead of the + // global hotkeys. Otherwise typing "q" in a filename would quit, + // space would pause, etc. + let ui_wants_keyboard = self + .overlay + .as_ref() + .map(|o| o.app.ui_wants_keyboard) + .unwrap_or(false); + if ui_wants_keyboard { + let events = key_event_to_egui_events(key_event, self.keyboard_modifiers); + if !events.is_empty() { + if let Some(overlay) = self.overlay.as_mut() { + overlay.app.push_events(events); + } + self.request_redraw_overlay(); + return; + } + } + + // Hotkeys only act on key PRESS; releases are only interesting to + // egui (handled above). + if key_event.state != ElementState::Pressed { + return; + } + // `q` and `f` are window-level concerns; they never reach the engine. - match key { + match &key_event.logical_key { Key::Character(s) if s == "q" || s == "Q" => { event_loop.exit(); return; @@ -344,9 +480,14 @@ impl FerretApp { // UI-derived values (loop mode, speed). Keeps `keymap.rs` decoupled // from `player-ui`. let state = engine.state(); - if let Some(cmd) = keymap::key_to_cmd(key, &state) { + if let Some(cmd) = keymap::key_to_cmd(&key_event.logical_key, &state) { let _ = engine.send(cmd); } + // Reveal the controls briefly (VLC-style) so the effect of the key + // (pause/play toggle, seek jump, ...) is immediately visible. + if let Some(overlay) = self.overlay.as_mut() { + overlay.app.note_user_activity(); + } } fn toggle_fullscreen(&mut self) { @@ -409,16 +550,100 @@ impl ApplicationHandler for FerretApp { self.has_focus = gained; self.update_overlay_focus(); } - WindowEvent::KeyboardInput { - event: - KeyEvent { - state: ElementState::Pressed, - logical_key, - .. - }, - .. - } => { - self.handle_keyboard(&logical_key, event_loop); + WindowEvent::ModifiersChanged(m) => { + self.keyboard_modifiers = m.state(); + } + WindowEvent::CursorMoved { position, .. } => { + // The X11 bounding shape routes pointer events that fall + // in the holes (over the video) to the VIDEO window, not + // the overlay. Re-route them into the egui overlay so + // hover state, bar auto-show, and widget interaction + // keep working over the whole window. Coordinates are + // identical: the overlay covers the video window's inner + // area exactly and pixels_per_point is 1.0. + self.forward_video_pointer(position); + // An active drag over the video pans it. + self.pan_video_by_drag(position); + } + WindowEvent::CursorLeft { .. } => { + self.overlay_mouse_pos = None; + self.video_pan_drag = None; + if let Some(overlay) = self.overlay.as_mut() { + overlay.app.push_event(egui::Event::PointerGone); + } + self.request_redraw_overlay(); + } + WindowEvent::MouseInput { state, button, .. } => { + // Click on the video area (delivered here because the + // overlay's shape has a hole there). Forward as an egui + // event so UI state (e.g. closing dropdowns) stays + // consistent with clicks on the overlay itself. + let egui_button = match button { + MouseButton::Left => egui::PointerButton::Primary, + MouseButton::Right => egui::PointerButton::Secondary, + MouseButton::Middle => egui::PointerButton::Middle, + _ => { return; } + }; + let pressed = state == ElementState::Pressed; + if let (Some(overlay), Some(pos)) = + (self.overlay.as_mut(), self.overlay_mouse_pos) + { + overlay.app.push_event(egui::Event::PointerButton { + pos, + button: egui_button, + pressed, + modifiers: egui::Modifiers::default(), + }); + // Clicking is user activity: (re)show the bars. + overlay.app.note_user_activity(); + } + // Left-drag on the video = pan (movable-object gesture). + // Only meaningful with something on screen. + if matches!(button, MouseButton::Left) { + if pressed { + let has_file = self + .engine + .as_ref() + .map(|e| e.state().path.is_some()) + .unwrap_or(false); + if has_file { + if let Some(pos) = self.overlay_mouse_pos { + self.video_pan_drag = Some((pos.x as f64, pos.y as f64)); + } + } + } else { + self.video_pan_drag = None; + } + } + self.request_redraw_overlay(); + } + WindowEvent::MouseWheel { delta, .. } => { + // Ctrl+wheel over the video zooms (next to the pan + // gesture). Without ctrl the wheel stays unused, as + // before. Wheel-up = zoom in. + if self.keyboard_modifiers.control_key() { + let step = match delta { + winit::event::MouseScrollDelta::LineDelta(_, y) => { + y * 0.1 + } + winit::event::MouseScrollDelta::PixelDelta(p) => { + (p.y as f32 / 400.0).clamp(-0.5, 0.5) + } + }; + if step != 0.0 { + if let Some(engine) = self.engine.as_ref() { + let _ = engine.send(Cmd::AdjustVideoZoom(step)); + } + // Show the bars so the zoom status is visible. + if let Some(overlay) = self.overlay.as_mut() { + overlay.app.note_user_activity(); + } + } + } + self.request_redraw_overlay(); + } + WindowEvent::KeyboardInput { event: key_event, .. } => { + self.handle_keyboard(event_loop, &key_event); self.request_redraw_overlay(); } WindowEvent::Resized(_) | WindowEvent::Moved(_) => { @@ -453,7 +678,7 @@ impl ApplicationHandler for FerretApp { // owns it). But a RedrawRequested on the video window // means the WM wants us to repaint — forward it to the // overlay so the controls stay in sync. - self.render_overlay(); + self.render_overlay(event_loop); self.request_redraw_overlay(); } _ => {} @@ -463,6 +688,9 @@ impl ApplicationHandler for FerretApp { self.has_focus = gained; self.update_overlay_focus(); } + WindowEvent::ModifiersChanged(m) => { + self.keyboard_modifiers = m.state(); + } WindowEvent::CursorMoved { position, .. } => { // The renderer sets pixels_per_point=1.0, so egui's // coordinate system matches physical pixels directly. @@ -470,6 +698,9 @@ impl ApplicationHandler for FerretApp { self.overlay_mouse_pos = Some(pos); if let Some(overlay) = self.overlay.as_mut() { overlay.app.push_event(egui::Event::PointerMoved(pos)); + // Moving the mouse is user activity: (re)show the + // menu bar and control bar. + overlay.app.note_user_activity(); } self.request_redraw_overlay(); } @@ -495,6 +726,8 @@ impl ApplicationHandler for FerretApp { pressed, modifiers: egui::Modifiers::default(), }); + // Clicking is user activity: (re)show the bars. + overlay.app.note_user_activity(); } // Process the click immediately so dropdown menus open // without waiting for the next render cycle. The @@ -503,19 +736,12 @@ impl ApplicationHandler for FerretApp { // timer — so without this eager render, the click sits in // pending_events and the dropdown never appears until the // mouse moves. - self.render_overlay(); + self.render_overlay(event_loop); self.request_redraw_overlay(); } - WindowEvent::KeyboardInput { - event: - KeyEvent { - state: ElementState::Pressed, - logical_key, - .. - }, - .. - } => { - self.handle_keyboard(&logical_key, event_loop); + WindowEvent::KeyboardInput { event: key_event, .. } => { + self.handle_keyboard(event_loop, &key_event); + self.request_redraw_overlay(); } WindowEvent::Resized(_) | WindowEvent::Moved(_) => { // The overlay itself was resized or moved. When the overlay @@ -536,7 +762,7 @@ impl ApplicationHandler for FerretApp { // limit caused skipped frames during resize bursts and // delayed dropdown menu opening. wgpu's PresentMode already // throttles to the display refresh rate. - self.render_overlay(); + self.render_overlay(event_loop); } WindowEvent::CloseRequested => { event_loop.exit(); @@ -547,31 +773,168 @@ impl ApplicationHandler for FerretApp { } } - fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { - // Re-render the overlay at ~30fps even when no input arrives, - // promptly whenever a dialog result lands, and immediately when - // there are queued egui events (clicks, key presses) that the - // rate-limited RedrawRequested handler might have skipped. - let has_pending_events = self.overlay + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + // Self-sustaining ~30fps repaint ticker. + // + // winit's default ControlFlow::Wait parks the event loop once the + // last input event has been processed, and egui's + // `Context::request_repaint_after` is not wired to winit here. Before + // this ticker existed the overlay only repainted while events kept + // arriving, which caused four visible bugs: a freshly loaded video + // stayed black behind the last (opaque) startup frame until the mouse + // moved, the progress bar / time display froze, the pause button icon + // never flipped after clicking it, and the auto-hide never triggered. + // Scheduling a WaitUntil wakeup at every frame boundary keeps engine + // state flowing into the UI even with zero user input. + if self.overlay.is_none() || self.engine.is_none() { + // Setup hasn't completed — nothing to tick. + event_loop.set_control_flow(ControlFlow::Wait); + return; + } + + let has_pending_events = self + .overlay .as_ref() .map(|o| !o.app.pending_events.is_empty()) .unwrap_or(false); - let need_render = self.last_overlay_render.elapsed() > Duration::from_millis(33) - || !self.dialog_result_rx.is_empty() - || has_pending_events; - if need_render { - self.render_overlay(); + let tick_due = self.last_overlay_render.elapsed() > Duration::from_millis(33); + if tick_due || !self.dialog_result_rx.is_empty() || has_pending_events { + // Paint via the RedrawRequested path — request_redraw wakes the + // loop and the overlay's RedrawRequested handler does the actual + // rendering (single render per tick, throttled by vsync). self.request_redraw_overlay(); } + // Wake up at the next frame boundary even without input events. + let wake_at = (self.last_overlay_render + Duration::from_millis(33)).max(Instant::now()); + event_loop.set_control_flow(ControlFlow::WaitUntil(wake_at)); } } +/// Translate a winit `KeyEvent` into egui input events, so the overlay's +/// text fields (save-dialog filename) receive keyboard input. Mirrors the +/// essential parts of egui-winit's translation: +/// +/// * printable text (no ctrl held) → `Event::Text` +/// * named keys (Enter, Backspace, arrows, ...) → `Event::Key`, on both +/// press and release so egui's key-down tracking stays consistent +/// * ctrl/alt/meta + character → `Event::Key` with the character mapped to +/// `egui::Key`, so shortcuts like ctrl+A / C / V / X work in text fields +/// +/// The modifiers come from the caller's tracked `ModifiersState` (winit +/// delivers modifiers as separate `ModifiersChanged` events). +fn key_event_to_egui_events( + event: &KeyEvent, + mods: ModifiersState, +) -> Vec { + translate_key( + &event.logical_key, + event.text.as_deref(), + event.state == ElementState::Pressed, + event.repeat, + mods, + ) +} + +/// The testable core of the key translation: takes the logical key, the +/// text winit produced for it, press state, repeat flag and modifier +/// state. Split out from `key_event_to_egui_events` because winit's +/// `KeyEvent` cannot be constructed outside the crate (it has a +/// `pub(crate)` field), which would make it untestable. +fn translate_key( + logical: &Key, + text: Option<&str>, + pressed: bool, + repeat: bool, + mods: ModifiersState, +) -> Vec { + let egui_mods = egui::Modifiers { + alt: mods.alt_key(), + ctrl: mods.control_key(), + shift: mods.shift_key(), + mac_cmd: false, + // Linux: ctrl is the "command" key for egui's shortcut matching. + command: mods.control_key(), + }; + let mut events: Vec = Vec::new(); + + match logical { + Key::Named(named) => { + let key = match named { + NamedKey::Enter => Some(egui::Key::Enter), + NamedKey::Backspace => Some(egui::Key::Backspace), + NamedKey::Escape => Some(egui::Key::Escape), + NamedKey::Tab => Some(egui::Key::Tab), + NamedKey::Space => Some(egui::Key::Space), + NamedKey::ArrowLeft => Some(egui::Key::ArrowLeft), + NamedKey::ArrowRight => Some(egui::Key::ArrowRight), + NamedKey::ArrowUp => Some(egui::Key::ArrowUp), + NamedKey::ArrowDown => Some(egui::Key::ArrowDown), + NamedKey::Delete => Some(egui::Key::Delete), + NamedKey::Home => Some(egui::Key::Home), + NamedKey::End => Some(egui::Key::End), + NamedKey::PageUp => Some(egui::Key::PageUp), + NamedKey::PageDown => Some(egui::Key::PageDown), + NamedKey::Insert => Some(egui::Key::Insert), + _ => None, + }; + if let Some(key) = key { + events.push(egui::Event::Key { + key, + physical_key: None, + pressed, + repeat, + modifiers: egui_mods, + }); + } + // egui's text insertion only reacts to Event::Text — a bare + // Key::Space event inserts nothing. Emit the text too so typing + // spaces into the save-dialog filename works. + if pressed && matches!(named, NamedKey::Space) { + events.push(egui::Event::Text(" ".into())); + } + } + Key::Character(ch) => { + if mods.control_key() || mods.alt_key() || mods.super_key() { + // Shortcut combo (ctrl+A, ctrl+C, ...). Emit a Key event so + // egui's text editing shortcuts engage. Only single + // characters map cleanly to egui::Key. + if ch.chars().count() == 1 { + if let Some(key) = egui::Key::from_name(&ch.to_lowercase()) { + events.push(egui::Event::Key { + key, + physical_key: None, + pressed, + repeat, + modifiers: egui_mods, + }); + } + } + } else if pressed { + // Plain typing — forward the produced text as-is. + let text = match text.filter(|t| !t.is_empty()) { + Some(t) => Some(t), + None => (!ch.is_empty()).then_some(ch.as_str()), + }; + if let Some(t) = text { + if !t.chars().any(|c| c.is_control()) { + events.push(egui::Event::Text(t.to_owned())); + } + } + } + } + _ => {} + } + + events +} + + /// Extract the X11 XID from a winit window. fn extract_x11_xid(window: &Arc) -> Result { use raw_window_handle::HasWindowHandle; let handle = window.window_handle()?.as_raw(); match handle { - raw_window_handle::RawWindowHandle::Xlib(x) => Ok(x.window as u64), + raw_window_handle::RawWindowHandle::Xlib(x) => Ok(x.window), raw_window_handle::RawWindowHandle::Xcb(x) => Ok(x.window.get() as u64), other => Err(anyhow::anyhow!( "video window is not on X11 (got {other:?}). Wayland requires libmpv's render-context API, which is on the roadmap." @@ -579,6 +942,125 @@ fn extract_x11_xid(window: &Arc) -> Result { } } +// --------------------------------------------------------------------------- +// X11 bounding shape (XShape) +// --------------------------------------------------------------------------- +// +// The overlay window used to cover the video window with a full-screen +// wgpu surface and relied on *transparency* (ARGB visual + compositor + +// surface alpha mode) for the video to show through. That chain broke in +// the field over and over — wgpu's `CompositeAlphaMode::Auto` only ever +// resolves to Opaque/Inherit (never a transparent mode), some drivers +// write opaque alpha, some setups run without a compositor — and each +// break produced the same user-visible bug: "app has no video output" +// while the UI kept working. +// +// The bounding shape removes the dependency on that entire chain. Every +// frame, egui's actually-painted rects (see `renderer::painted_pixel_rects`) +// become the overlay window's X11 *bounding region*. Pixels outside the +// region are a literal hole in the X window: the video window underneath +// shows through because the overlay simply does not exist there, whatever +// the GPU, driver, compositor or alpha mode may say. The input region +// defaults to the bounding region, so pointer events in the holes are +// delivered to the video window — we re-route them into egui (see +// `forward_video_pointer`) to keep hover/auto-show behavior. + +/// Dedicated XCB connection for shape requests. Separate from winit's +/// connection so we never interleave requests on its socket. +static SHAPE_CONN: OnceLock>> = OnceLock::new(); + +fn shape_conn() -> Option<&'static Arc> { + SHAPE_CONN + .get_or_init(|| { + match x11rb::connect(None) { + Ok((conn, _screen)) => Some(Arc::new(conn)), + Err(e) => { + warn!("X11 shape: cannot open X connection: {e}"); + None + } + } + }) + .as_ref() +} + +/// The overlay window's X11 window ID as the X server knows it. +fn overlay_xid(overlay: &Arc) -> Option { + use raw_window_handle::HasWindowHandle; + let handle = overlay.window_handle().ok()?.as_raw(); + match handle { + raw_window_handle::RawWindowHandle::Xlib(x) => Some(x.window as u32), + raw_window_handle::RawWindowHandle::Xcb(x) => Some(x.window.get()), + _ => None, + } +} + +/// Convert an egui rect (window-local pixels, pixels_per_point = 1.0) into +/// an X11 protocol rectangle. Window dims are clamped to 16384 upstream, +/// so the protocol's i16/u16 ranges always hold. +fn to_x_rectangle(r: egui::Rect) -> xproto::Rectangle { + let x = r.min.x.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16; + let y = r.min.y.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16; + // Width/height are span deltas, always >= 0, clamped to the protocol max. + let w = (r.max.x.round() - r.min.x.round()).clamp(0.0, u16::MAX as f32) as u16; + let h = (r.max.y.round() - r.min.y.round()).clamp(0.0, u16::MAX as f32) as u16; + xproto::Rectangle { x, y, width: w, height: h } +} + +/// Set the overlay window's X11 *bounding shape* to exactly `rects` +/// (window-local pixel rects). Pixels outside the union of the rects are a +/// hole: the video window beneath shows through unconditionally, and +/// pointer events in the holes go to the video window. An empty list is a +/// well-defined *empty* region (the server unions zero rectangles): the +/// overlay becomes fully invisible and fully click-through — e.g. while +/// the bars are auto-hidden. +fn apply_overlay_shape(overlay: &Arc, rects: &[egui::Rect]) { + use x11rb::protocol::shape::{self as shape_ext, SK, SO}; + use x11rb::protocol::xproto::ClipOrdering; + + let Some(xid) = overlay_xid(overlay) else { return; }; + let Some(conn) = shape_conn() else { return; }; + + let xrects: Vec = rects.iter().copied().map(to_x_rectangle).collect(); + match shape_ext::rectangles( + conn.as_ref(), + SO::SET, + SK::BOUNDING, + ClipOrdering::UNSORTED, + xid, + 0, + 0, + &xrects, + ) { + Ok(cookie) => { + if let Err(e) = cookie.check() { + warn!("X11 shape update error: {e}"); + } + } + Err(e) => warn!("X11 shape request failed: {e}"), + } +} + +#[cfg(test)] +mod shape_tests { + use super::*; + + #[test] + fn x_rectangle_rounding_and_clamping() { + let r = egui::Rect::from_min_max(egui::pos2(10.4, 20.6), egui::pos2(60.2, 70.8)); + let x = to_x_rectangle(r); + assert_eq!((x.x, x.y, x.width, x.height), (10, 21, 50, 50)); + } + + #[test] + fn x_rectangle_saturates_on_origin_clamp() { + // Negative origins (shouldn't occur — egui clips to the window — + // but must not wrap into huge u16s). + let r = egui::Rect::from_min_max(egui::pos2(-5.0, -5.0), egui::pos2(5.0, 5.0)); + let x = to_x_rectangle(r); + assert_eq!((x.x, x.y, x.width, x.height), (-5, -5, 10, 10)); + } +} + /// Set the X11 background pixel on a winit window. /// /// winit creates windows with `background_pixel = None`, which means the X @@ -806,35 +1288,40 @@ fn set_x11_overlay_hints( /// Spawn a worker thread to run ffmpeg for A-B loop video export. The /// thread runs the encode and sends the result back on `tx` when done. /// Runs in the background so the UI stays responsive during encoding. +/// `crop` optionally carries a source-space rectangle (x, y, w, h) to crop +/// the output to — the zoom/pan focus area the user had aligned. fn spawn_ffmpeg_export( input: String, start: f64, end: f64, output: String, + crop: Option, tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>, ) { + let cropped = crop.is_some(); std::thread::Builder::new() .name("ferret-ffmpeg".into()) .spawn(move || { - let result = export_video_segment(&input, start, end, &output) - .map(|_| DialogResult::File(output.clone())) + let result = export_video_segment(&input, start, end, &output, crop) + .map(|_| DialogResult::File { path: output.clone(), cropped }) .unwrap_or_else(|e| DialogResult::Error(e.to_string())); - let _ = tx.send(( - DialogKind::ExportVideo { - input, - start, - end, - }, - result, - )); + let _ = tx.send((DialogKind::ExportVideo, result)); }) .ok(); } /// Run ffmpeg to extract the video segment [start, end] from `input` into /// `output`. Re-encodes video (libx264) for frame accuracy and maximum -/// compatibility. Audio is re-encoded to AAC. -fn export_video_segment(input: &str, start: f64, end: f64, output: &str) -> std::io::Result<()> { +/// compatibility. Audio is re-encoded to AAC. When `crop` is Some, the +/// output is cropped to that source-pixel rectangle first — used to bake +/// the zoom/pan focus area into exported A-B clips. +fn export_video_segment( + input: &str, + start: f64, + end: f64, + output: &str, + crop: Option, +) -> std::io::Result<()> { let duration = end - start; info!("exporting video segment: {input} [{start:.3}..{end:.3}] → {output}"); @@ -852,27 +1339,122 @@ fn export_video_segment(input: &str, start: f64, end: f64, output: &str) -> std: // Use -ss before -i for fast seeking. Re-encode video (libx264) for // frame accuracy and maximum compatibility. Use -y to overwrite output. - let output = std::process::Command::new("ffmpeg") + let mut ffmpeg = std::process::Command::new("ffmpeg"); + ffmpeg .arg("-y") .arg("-ss").arg(format!("{start:.3}")) .arg("-i").arg(input) - .arg("-t").arg(format!("{duration:.3}")) + .arg("-t").arg(format!("{duration:.3}")); + if let Some((cx, cy, cw, ch)) = crop { + ffmpeg.arg("-vf").arg(format!("crop={cw}:{ch}:{cx}:{cy}")); + } + ffmpeg .arg("-c:v").arg("libx264") .arg("-preset").arg("fast") .arg("-crf").arg("18") .arg("-c:a").arg("aac") .arg("-b:a").arg("192k") - .arg(output) - .output()?; + .arg(output); + let out = ffmpeg.output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); let msg = stderr.lines().last().unwrap_or("unknown ffmpeg error"); - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("ffmpeg: {msg}"), - )); + return Err(std::io::Error::other(format!("ffmpeg: {msg}"))); } Ok(()) } +#[cfg(test)] +mod key_tests { + use super::*; + use winit::keyboard::ModifiersState; + + fn char_key(ch: &str) -> Key { + Key::Character(ch.into()) + } + + fn is_text(events: &[egui::Event], t: &str) -> bool { + events.iter().any(|e| matches!(e, egui::Event::Text(s) if s == t)) + } + + fn is_key(events: &[egui::Event], k: egui::Key, pressed: bool) -> bool { + events.iter().any( + |e| matches!(e, egui::Event::Key { key, pressed: p, .. } if *key == k && *p == pressed), + ) + } + + #[test] + fn plain_characters_become_text() { + // Typing 'q' in the filename field must go to egui — NOT quit. + let ev = translate_key(&char_key("q"), Some("q"), true, false, ModifiersState::empty()); + assert!(is_text(&ev, "q")); + assert_eq!(ev.len(), 1); + } + + #[test] + fn space_types_a_space() { + let ev = translate_key( + &Key::Named(NamedKey::Space), + Some(" "), + true, + false, + ModifiersState::empty(), + ); + assert!(is_text(&ev, " ")); + assert!(is_key(&ev, egui::Key::Space, true)); + } + + #[test] + fn named_keys_map_to_egui_keys() { + let ev = translate_key( + &Key::Named(NamedKey::Backspace), + None, + true, + false, + ModifiersState::empty(), + ); + assert!(is_key(&ev, egui::Key::Backspace, true)); + // Releases are forwarded too so egui's key-down tracking stays sane. + let ev = translate_key( + &Key::Named(NamedKey::Backspace), + None, + false, + false, + ModifiersState::empty(), + ); + assert!(is_key(&ev, egui::Key::Backspace, false)); + } + + #[test] + fn enter_maps_to_key_not_text() { + // winit gives Enter text "\r" — egui wants Key::Enter, no Text. + let ev = translate_key( + &Key::Named(NamedKey::Enter), + Some("\r"), + true, + false, + ModifiersState::empty(), + ); + assert!(is_key(&ev, egui::Key::Enter, true)); + assert!(!events_have_text(&ev)); + } + + #[test] + fn ctrl_char_maps_to_key_event() { + // ctrl+A must reach egui as a Key event for select-all to work. + let ev = translate_key( + &char_key("a"), + None, + true, + false, + ModifiersState::CONTROL, + ); + assert!(is_key(&ev, egui::Key::A, true)); + assert!(!events_have_text(&ev)); + } + + fn events_have_text(events: &[egui::Event]) -> bool { + events.iter().any(|e| matches!(e, egui::Event::Text(_))) + } +} diff --git a/crates/player-core/src/cmd.rs b/crates/player-core/src/cmd.rs index 840fcd5..e791138 100755 --- a/crates/player-core/src/cmd.rs +++ b/crates/player-core/src/cmd.rs @@ -180,6 +180,26 @@ pub enum Cmd { /// Skip to the previous playlist entry. (mpv `playlist-prev`.) PlaylistPrev, + /// Move the queue entry at index `from` so that it takes the place of the + /// entry currently at index `to` (mpv `playlist-move` semantics: the moved + /// entry is inserted *before* the entry at `to`; `to == playlist.len()` + /// appends to the end; after `playlist-move i j` with `i < j` the entry + /// lands at `j - 1`). Indices are 0-based. + PlaylistMove { + from: usize, + to: usize, + }, + + /// Remove the queue entry at `index`. (mpv `playlist-remove`.) + PlaylistRemove { + index: usize, + }, + + /// Jump to playing the queue entry at `index`. (mpv `playlist-play-index`.) + PlaylistPlayIndex { + index: usize, + }, + // ---- Speed --------------------------------------------------------- /// Set playback speed. mpv range is 0.01..=100.0; we expose 0.25..=4.0 @@ -222,6 +242,35 @@ pub enum Cmd { /// `vflip`. Pass `true` to enable, `false` to disable. SetVideoFlipV(bool), + // ---- Video zoom / pan ---------------------------------------------- + + /// Set the video zoom directly, in log2 units (mpv `video-zoom`): + /// 0 = fit-to-window, 1.0 = 2×, -1.0 = ½×. Clamped to + /// [`VIDEO_ZOOM_RANGE`]. + SetVideoZoom(f32), + + /// Adjust zoom by a delta in log2 units (menu steps, Ctrl+wheel). + /// Clamped to [`VIDEO_ZOOM_RANGE`]. + AdjustVideoZoom(f32), + + /// Set the video pan directly, in screen-fraction units (mpv + /// `video-pan-x` / `video-pan-y`). Positive x = right, positive + /// y = down. Each axis is clamped to ±[`VIDEO_PAN_LIMIT`]. + SetVideoPan { + x: f32, + y: f32, + }, + + /// Pan by deltas in screen-fraction units — the drag-the-video + /// gesture. Positive dx = right, positive dy = down (window coords). + AdjustVideoPan { + dx: f32, + dy: f32, + }, + + /// Reset zoom and pan to neutral (zoom 0, pan 0,0). + ResetVideoPanZoom, + // ---- A/B markers --------------------------------------------------- /// Drop marker A at the current playback position (mpv `time-pos`). @@ -394,3 +443,91 @@ pub(crate) fn build_loadfile(path: &str, opts: &LoadOptions) -> CoreResult CoreResult { Ok(mpv_bindings::command::Command::seek(target, mode, flags)?) } + +/// Translate "move the entry at `from` so its **final** index is `final_pos`" +/// into the `(from, to)` pair used by `Cmd::PlaylistMove` (mpv's +/// insert-before semantics). `len` is the current queue length; `to` may come +/// out as `len`, which mpv interprets as "append at the end". +/// +/// Used by the queue sidebar's type-a-number reordering. +pub fn playlist_move_args_for_final(from: usize, final_pos: usize, len: usize) -> (usize, usize) { + if final_pos <= from { + (from, final_pos) + } else { + (from, (final_pos + 1).min(len)) + } +} + +// ---- Video zoom / pan limits ------------------------------------------- + +/// Allowed zoom range in log2 units: -1.0 = half size, 2.0 = 4×. +/// mpv itself accepts (much) wider values; this keeps the UI from losing +/// the video off-canvas. +pub const VIDEO_ZOOM_RANGE: (f32, f32) = (-1.0, 2.0); + +/// Allowed per-axis pan range, in screen fractions. ±1.0 already moves the +/// video a full window across — beyond that there is nothing to look at. +pub const VIDEO_PAN_LIMIT: f32 = 1.0; + +/// Clamp a zoom value (log2 units) into [`VIDEO_ZOOM_RANGE`]. +pub fn clamp_video_zoom(v: f32) -> f32 { + v.clamp(VIDEO_ZOOM_RANGE.0, VIDEO_ZOOM_RANGE.1) +} + +/// Clamp a pan axis value into ±[`VIDEO_PAN_LIMIT`]. +pub fn clamp_video_pan(v: f32) -> f32 { + v.clamp(-VIDEO_PAN_LIMIT, VIDEO_PAN_LIMIT) +} + +#[cfg(test)] +mod playlist_move_tests { + use super::playlist_move_args_for_final as args; + + #[test] + fn moving_up_inserts_before_target() { + // Entry 4 → final index 1: takes the place of the entry at 1. + assert_eq!(args(4, 1, 6), (4, 1)); + } + + #[test] + fn moving_down_lands_after_target() { + // Entry 1 → final index 3 must insert before the entry currently at 4. + assert_eq!(args(1, 3, 6), (1, 4)); + } + + #[test] + fn move_to_end_clamps_to_len() { + // Entry 0 → final index 5 of 6 = last slot; insert-before 6 == append. + assert_eq!(args(0, 5, 6), (0, 6)); + // Even an out-of-range request clamps instead of overflowing. + assert_eq!(args(0, 99, 6), (0, 6)); + } + + #[test] + fn same_position_is_noop() { + assert_eq!(args(2, 2, 6), (2, 2)); + } +} + +#[cfg(test)] +mod zoom_pan_tests { + use super::{clamp_video_pan, clamp_video_zoom, VIDEO_PAN_LIMIT, VIDEO_ZOOM_RANGE}; + + #[test] + fn zoom_clamps_to_range() { + assert_eq!(clamp_video_zoom(5.0), VIDEO_ZOOM_RANGE.1); + assert_eq!(clamp_video_zoom(-9.0), VIDEO_ZOOM_RANGE.0); + assert_eq!(clamp_video_zoom(0.5), 0.5); + // -1.0 log2 = half size, 2.0 log2 = 4x — both reachable exactly. + assert_eq!(clamp_video_zoom(-1.0), -1.0); + assert_eq!(clamp_video_zoom(2.0), 2.0); + } + + #[test] + fn pan_clamps_symmetric() { + assert_eq!(clamp_video_pan(3.0), VIDEO_PAN_LIMIT); + assert_eq!(clamp_video_pan(-3.0), -VIDEO_PAN_LIMIT); + assert_eq!(clamp_video_pan(0.25), 0.25); + assert_eq!(clamp_video_pan(0.0), 0.0); + } +} diff --git a/crates/player-core/src/crop.rs b/crates/player-core/src/crop.rs new file mode 100644 index 0000000..73a62e4 --- /dev/null +++ b/crates/player-core/src/crop.rs @@ -0,0 +1,159 @@ +//! Map the on-screen zoom/pan view onto a source-space crop rectangle. +//! +//! mpv positions the video in the window with (per axis, see +//! `src_dst_split_scaling` in mpv's `video/out/aspect.c`): +//! +//! ```text +//! scaled_size = aspect_fit_size * 2^zoom +//! dst_start = (window_size - scaled_size) / 2 + pan * scaled_size +//! ``` +//! +//! i.e. the video is aspect-fit into the window (letterbox/pillarbox, +//! panscan 0, no margins), zoomed around the window center, and pan is +//! measured in fractions of the *scaled* video size. +//! +//! The functions here reproduce that transform to answer: "which rectangle +//! of source pixels is currently visible in the window?" — used by the +//! A-B clip export so a saved clip contains exactly the focus area the +//! user aligned, instead of the full frame. + +/// A crop rectangle in source pixels: (x, y, width, height). +pub type CropRect = (u32, u32, u32, u32); + +/// Compute the source-space rectangle visible in a `win_w` × `win_h` +/// window given the current zoom (log2 units) and pan (screen fractions). +/// +/// Returns `None` when the whole frame is visible (no zoom in, no pan, or +/// the panned video still covers the window) — then no crop is needed. +/// Also `None` for degenerate inputs (unknown source or window size). +/// +/// Dimensions are rounded to even numbers (yuv420p/libx264 friendly) by +/// shrinking, and coordinates are clamped inside the frame. +pub fn visible_crop( + src_w: u32, + src_h: u32, + win_w: u32, + win_h: u32, + zoom: f32, + pan_x: f32, + pan_y: f32, +) -> Option { + if src_w == 0 || src_h == 0 || win_w == 0 || win_h == 0 { + return None; + } + let (sw, sh, ww, wh) = (src_w as f64, src_h as f64, win_w as f64, win_h as f64); + if !zoom.is_finite() || !pan_x.is_finite() || !pan_y.is_finite() { + return None; + } + + // Aspect-fit scale (mpv `aspect_calc_panscan`, panscan 0, no margins). + let fit = (ww / sw).min(wh / sh); + let scale = fit * 2.0_f32.powf(zoom) as f64; + + // Scaled display size and top-left corner (centered + pan, pan in + // units of the scaled size — exactly mpv's arithmetic). + let dw = sw * scale; + let dh = sh * scale; + let x0 = (ww - dw) / 2.0 + (pan_x as f64) * dw; + let y0 = (wh - dh) / 2.0 + (pan_y as f64) * dh; + + // Visible overlap of the video rect with the window, in window px. + let vx0 = x0.max(0.0); + let vx1 = (x0 + dw).min(ww); + let vy0 = y0.max(0.0); + let vy1 = (y0 + dh).min(wh); + + // Map the overlap back to source pixels. + let sx = ((vx0 - x0) / dw * sw).floor().clamp(0.0, sw) as u32; + let ex = ((vx1 - x0) / dw * sw).ceil().clamp(0.0, sw) as u32; + let sy = ((vy0 - y0) / dh * sh).floor().clamp(0.0, sh) as u32; + let ey = ((vy1 - y0) / dh * sh).ceil().clamp(0.0, sh) as u32; + + // Full frame visible → nothing to crop. + let full = sx == 0 && sy == 0 && ex == src_w && ey == src_h; + if full { + return None; + } + + // Even dimensions for yuv420p, kept inside the frame. + let mut w = ex - sx; + let mut h = ey - sy; + w -= w % 2; + h -= h % 2; + if w == 0 || h == 0 { + return None; + } + Some((sx, sy, w, h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// src 100×100 in a 100×100 window: fit scale 1, no letterbox. + #[test] + fn no_zoom_no_pan_is_full_frame() { + assert_eq!(visible_crop(100, 100, 100, 100, 0.0, 0.0, 0.0), None); + } + + #[test] + fn degenerate_inputs_are_none() { + assert_eq!(visible_crop(0, 100, 100, 100, 1.0, 0.0, 0.0), None); + assert_eq!(visible_crop(100, 100, 0, 100, 0.0, 0.5, 0.0), None); + } + + /// 2× zoom, square video, square window: the centered half is visible. + #[test] + fn zoom_2x_shows_center_quarter() { + let (x, y, w, h) = visible_crop(100, 100, 100, 100, 1.0, 0.0, 0.0).unwrap(); + assert_eq!((x, y, w, h), (25, 25, 50, 50)); + } + + /// Pan right by half the scaled size slides the video right, so the + /// window ends up over the video's LEFT half (mpv: pan is relative to + /// the scaled video size, not the window). + #[test] + fn pan_right_shows_left_half_of_source() { + let (x, y, w, h) = visible_crop(100, 100, 100, 100, 0.0, 0.5, 0.0).unwrap(); + assert_eq!((x, y, w, h), (0, 0, 50, 100)); + } + + /// Pan beyond the window keeps the crop clamped to the frame edge: only + /// the first 10 source columns remain visible. + #[test] + fn pan_off_screen_clamps() { + let (x, _y, w, _h) = visible_crop(100, 100, 100, 100, 0.0, 0.9, 0.0).unwrap(); + assert_eq!((x, w), (0, 10)); + } + + /// Zoom *out* (0.5×) always leaves the whole frame visible — the + /// letterbox bars belong to the window, not the source. + #[test] + fn zoom_out_needs_no_crop() { + assert_eq!(visible_crop(100, 100, 100, 100, -1.0, 0.0, 0.0), None); + // Panning a zoomed-out video can still push part of it off-screen. + assert!(visible_crop(100, 100, 100, 100, -1.0, 0.9, 0.0).is_some()); + } + + /// Letterboxed fit: src 200×100 in a 400×100 window fits by height + /// (scale 1), so 2× zoom makes x exactly fill the window while y + /// overflows — the crop takes only the centered vertical half. + #[test] + fn letterboxed_fit_drives_scale() { + let (x, y, w, h) = visible_crop(200, 100, 400, 100, 1.0, 0.0, 0.0).unwrap(); + assert_eq!((x, y, w, h), (0, 25, 200, 50)); + } + + /// Real-world-ish sizes stay even and in bounds. 2× zoom on 1920×1080 + /// shows source x 480..1440; panning right by a quarter of the scaled + /// size slides the visible window back to x 0..960 (clamped at the + /// frame edge), y stays the centered half 270..810. + #[test] + fn odd_sizes_round_to_even() { + let (x, y, w, h) = visible_crop(1920, 1080, 1920, 1080, 1.0, 0.25, 0.0).unwrap(); + assert_eq!(w % 2, 0); + assert_eq!(h % 2, 0); + assert!(x + w <= 1920 && y + h <= 1080); + assert_eq!((x, y, w, h), (0, 270, 960, 540)); + } +} diff --git a/crates/player-core/src/engine.rs b/crates/player-core/src/engine.rs index bbe08c2..f15c102 100755 --- a/crates/player-core/src/engine.rs +++ b/crates/player-core/src/engine.rs @@ -53,6 +53,14 @@ const PROP_AB_LOOP_B: EventId = 12; const PROP_AID: EventId = 13; const PROP_SID: EventId = 14; const PROP_SUB_VISIBILITY: EventId = 15; +const PROP_VO_CONFIGURED: EventId = 16; +const PROP_PLAYLIST_COUNT: EventId = 17; +const PROP_PLAYLIST_POS: EventId = 18; +const PROP_VIDEO_ZOOM: EventId = 19; +const PROP_VIDEO_PAN_X: EventId = 20; +const PROP_VIDEO_PAN_Y: EventId = 21; +const PROP_VIDEO_WIDTH: EventId = 22; +const PROP_VIDEO_HEIGHT: EventId = 23; /// The engine. Construct with `PlayerEngine::new()`, then `start()`, then /// issue commands via `send()`. Consume events via `take_event_receiver()`. @@ -251,6 +259,29 @@ fn engine_main( // Subtitle visibility — when false, subtitles are hidden even if a // track is selected. (mpv `sub-visibility`.) (PROP_SUB_VISIBILITY, "sub-visibility", Format::Flag), + // Video output health. Flips true once mpv has actually initialized + // a VO and presented a frame; if it stays false after a file loads, + // the VO failed (no GL context, bad driver, ...) and the UI can warn + // the user instead of showing a silent black window. + (PROP_VO_CONFIGURED, "vo-configured", Format::Flag), + // Queue/playlist size — fires when entries are added or removed, + // prompting a full re-enumeration of playlist/N/filename (see + // `refresh_playlist`). Reorders don't change the count, so the + // Playlist* commands also refresh explicitly. + (PROP_PLAYLIST_COUNT, "playlist-count", Format::Int64), + // Currently-playing queue index — changes as playback advances + // through the queue, and when the user jumps to another entry. + (PROP_PLAYLIST_POS, "playlist-playing-pos", Format::Int64), + // Video zoom / pan — the movable-object-on-a-canvas controls. Zoom + // is log2 units (1 = 2x); pan is screen fractions. Observed so the + // state mirror stays true even if mpv changes them itself. + (PROP_VIDEO_ZOOM, "video-zoom", Format::Double), + (PROP_VIDEO_PAN_X, "video-pan-x", Format::Double), + (PROP_VIDEO_PAN_Y, "video-pan-y", Format::Double), + // Source video dimensions — needed to map the on-screen zoom/pan + // focus area back to source pixels for the A-B clip export. + (PROP_VIDEO_WIDTH, "width", Format::Int64), + (PROP_VIDEO_HEIGHT, "height", Format::Int64), ]; for (tag, name, fmt) in observed { if let Err(e) = mpv.observe_property(tag, name, fmt) { @@ -514,6 +545,42 @@ fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()> mpv.command(&cmd)?; Ok(()) } + Cmd::PlaylistMove { from, to } => { + let count = mpv.get_property_i64("playlist-count").unwrap_or(0).max(0) as usize; + let from = (*from).min(count.saturating_sub(1)); + let to = (*to).min(count); + if from != to { + let cmd = mpv_bindings::command::Command::new() + .arg("playlist-move")? + .arg(from.to_string())? + .arg(to.to_string())?; + mpv.command(&cmd)?; + // playlist-count doesn't change on a move, so the observer + // won't fire — refresh the mirror explicitly. + refresh_playlist(mpv, bus); + } + Ok(()) + } + Cmd::PlaylistRemove { index } => { + let count = mpv.get_property_i64("playlist-count").unwrap_or(0).max(0) as usize; + if *index < count { + let cmd = mpv_bindings::command::Command::new() + .arg("playlist-remove")? + .arg(index.to_string())?; + mpv.command(&cmd)?; + // The count observer fires on removal too, but refreshing + // here keeps the UI in the same frame as the click. + refresh_playlist(mpv, bus); + } + Ok(()) + } + Cmd::PlaylistPlayIndex { index } => { + let cmd = mpv_bindings::command::Command::new() + .arg("playlist-play-index")? + .arg(index.to_string())?; + mpv.command(&cmd)?; + Ok(()) + } // ---- Speed ------------------------------------------------------ @@ -600,6 +667,69 @@ fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()> Ok(()) } + // ---- Video zoom / pan -------------------------------------------- + + Cmd::SetVideoZoom(v) => { + let v = crate::cmd::clamp_video_zoom(*v); + mpv.set_property(&Property::double("video-zoom", v as f64))?; + bus.update_state(|s| s.video_zoom = v); + bus.send(EngineEvent::StateChanged); + Ok(()) + } + Cmd::AdjustVideoZoom(d) => { + let cur = bus.snapshot().video_zoom; + let v = crate::cmd::clamp_video_zoom(cur + *d); + mpv.set_property(&Property::double("video-zoom", v as f64))?; + bus.update_state(|s| s.video_zoom = v); + bus.send(EngineEvent::StateChanged); + Ok(()) + } + Cmd::SetVideoPan { x, y } => { + let x = crate::cmd::clamp_video_pan(*x); + let y = crate::cmd::clamp_video_pan(*y); + mpv.set_property(&Property::double("video-pan-x", x as f64))?; + mpv.set_property(&Property::double("video-pan-y", y as f64))?; + bus.update_state(|s| { + s.video_pan_x = x; + s.video_pan_y = y; + }); + bus.send(EngineEvent::StateChanged); + Ok(()) + } + Cmd::AdjustVideoPan { dx, dy } => { + let (px, py) = { + let st = bus.snapshot(); + (st.video_pan_x, st.video_pan_y) + }; + let x = crate::cmd::clamp_video_pan(px + *dx); + let y = crate::cmd::clamp_video_pan(py + *dy); + if x != px { + mpv.set_property(&Property::double("video-pan-x", x as f64))?; + } + if y != py { + mpv.set_property(&Property::double("video-pan-y", y as f64))?; + } + bus.update_state(|s| { + s.video_pan_x = x; + s.video_pan_y = y; + }); + bus.send(EngineEvent::StateChanged); + Ok(()) + } + Cmd::ResetVideoPanZoom => { + mpv.set_property(&Property::double("video-zoom", 0.0))?; + mpv.set_property(&Property::double("video-pan-x", 0.0))?; + mpv.set_property(&Property::double("video-pan-y", 0.0))?; + bus.update_state(|s| { + s.video_zoom = 0.0; + s.video_pan_x = 0.0; + s.video_pan_y = 0.0; + }); + bus.send(EngineEvent::StateChanged); + info!("video zoom/pan reset"); + Ok(()) + } + // ---- A/B markers ----------------------------------------------- Cmd::SetMarkerA => { @@ -814,6 +944,10 @@ fn handle_mpv_event(event: &MpvEvent, bus: &EngineEventBus, mpv: &MpvHandle) { }); // Refresh audio tracks — track-list/count may not have fired yet. refresh_audio_tracks(mpv, bus); + // Same for the queue: the first loadfile replaces the (empty) + // playlist, later ones append; either way the mirror should be + // correct the moment the file comes up. + refresh_playlist(mpv, bus); bus.send(EngineEvent::FileLoaded { path, title }); } MpvEvent::EndFile { reason, error } => { @@ -831,11 +965,14 @@ fn handle_mpv_event(event: &MpvEvent, bus: &EngineEventBus, mpv: &MpvHandle) { bus.send(EngineEvent::EndReached { reason: r }); } MpvEvent::PropertyChange { reply_userdata, name, value } => { - let (changed, want_track_refresh) = + let (changed, want_track_refresh, want_playlist_refresh) = apply_property_change(bus, *reply_userdata, name, value); if want_track_refresh { refresh_audio_tracks(mpv, bus); } + if want_playlist_refresh { + refresh_playlist(mpv, bus); + } if changed { bus.send(EngineEvent::StateChanged); } @@ -874,15 +1011,17 @@ fn apply_property_change( tag: EventId, name: &str, value: &mpv_bindings::event::PropertyValue, -) -> (bool, bool) { +) -> (bool, bool, bool) { use mpv_bindings::event::PropertyValue as V; let mut changed = true; let mut want_track_refresh = false; + let mut want_playlist_refresh = false; bus.update_state(|s| { match (tag, value) { (PROP_TIME_POS, V::Double(d)) => s.time_pos = Some(*d), (PROP_DURATION, V::Double(d)) => s.duration = Some(*d), (PROP_PAUSE, V::Flag(b)) => s.paused = *b, + (PROP_VO_CONFIGURED, V::Flag(b)) => s.vo_configured = *b, (PROP_VOLUME, V::Double(d)) => s.volume = (*d as f32 / 100.0).clamp(0.0, 1.0), (PROP_MUTE, V::Flag(b)) => s.muted = *b, (PROP_PATH, V::String(s2)) => s.path = Some(s2.clone()), @@ -898,6 +1037,18 @@ fn apply_property_change( want_track_refresh = true; changed = false; } + // Queue size changed — re-enumerate the playlist mirror. + // refresh_playlist() does the actual state update. + (PROP_PLAYLIST_COUNT, V::Int64(_)) => { + want_playlist_refresh = true; + changed = false; + } + (PROP_PLAYLIST_POS, V::Int64(i)) => s.playlist_pos = *i, + (PROP_VIDEO_ZOOM, V::Double(d)) => s.video_zoom = *d as f32, + (PROP_VIDEO_PAN_X, V::Double(d)) => s.video_pan_x = *d as f32, + (PROP_VIDEO_PAN_Y, V::Double(d)) => s.video_pan_y = *d as f32, + (PROP_VIDEO_WIDTH, V::Int64(w)) => s.video_width = (*w).max(0) as u32, + (PROP_VIDEO_HEIGHT, V::Int64(h)) => s.video_height = (*h).max(0) as u32, (PROP_AB_LOOP_A, V::Double(d)) => s.marker_a = Some(*d), (PROP_AB_LOOP_A, V::String(st)) => { // mpv returns "no" when ab-loop-a is unset, or a number string. @@ -928,6 +1079,9 @@ fn apply_property_change( PROP_AB_LOOP_B => s.marker_b = None, PROP_AID => s.current_audio_track = None, PROP_SID => s.current_subtitle_track = None, + PROP_PLAYLIST_POS => s.playlist_pos = -1, + PROP_VIDEO_WIDTH => s.video_width = 0, + PROP_VIDEO_HEIGHT => s.video_height = 0, _ => changed = false, } } @@ -937,7 +1091,7 @@ fn apply_property_change( } }); let _ = name; - (changed, want_track_refresh) + (changed, want_track_refresh, want_playlist_refresh) } /// Enumerate every track (audio + sub) by walking `track-list/N/*` @@ -977,6 +1131,33 @@ fn refresh_audio_tracks(mpv: &MpvHandle, bus: &EngineEventBus) { bus.send(EngineEvent::StateChanged); } +/// Mirror mpv's playlist (the queue) into the shared state snapshot. +/// Called whenever the playlist changes shape (count observer, FileLoaded) +/// and explicitly after PlaylistMove/PlaylistRemove — a reorder keeps the +/// count constant, so the observer alone would miss it. +fn refresh_playlist(mpv: &MpvHandle, bus: &EngineEventBus) { + let Some(count) = mpv.get_property_i64("playlist-count").ok() else { + return; + }; + let count = count.max(0); + let mut entries = Vec::with_capacity(count as usize); + for i in 0..count { + // Keep index alignment with mpv even if an entry fails to read. + let name = mpv + .get_property_string(&format!("playlist/{i}/filename")) + .ok() + .flatten() + .unwrap_or_default(); + entries.push(name); + } + let pos = mpv.get_property_i64("playlist-playing-pos").unwrap_or(-1); + bus.update_state(|s| { + s.playlist = entries; + s.playlist_pos = pos; + }); + bus.send(EngineEvent::StateChanged); +} + /// Read one `track-list/N` entry into a `Track`. Returns `None` for video /// tracks and unreadable entries — audio/sub tracks only. fn read_track(mpv: &MpvHandle, i: i64) -> Option { diff --git a/crates/player-core/src/lib.rs b/crates/player-core/src/lib.rs index d3ad842..495bff6 100755 --- a/crates/player-core/src/lib.rs +++ b/crates/player-core/src/lib.rs @@ -25,6 +25,7 @@ #![allow(dead_code)] pub mod cmd; +pub mod crop; pub mod engine; pub mod event; pub mod state; @@ -43,3 +44,44 @@ pub use event::{EngineEvent, EngineEventSender}; pub use state::{AudioTrack, PlaybackState, PlayerStatus, Track}; pub use options::EngineOptions; pub use error::{CoreError, CoreResult}; + +#[cfg(test)] +mod engine_tests { + use crate::options::EngineOptions; + use crate::{Cmd, EngineEvent, PlayerEngine}; + use std::time::Duration; + + /// The engine must come up with the default options — including the new + /// system-locale-derived `alang` (an invalid option value would abort + /// libmpv init with MPV_ERROR_OPTION_ERROR). Uses vo=null so it runs + /// headless (no GPU / X11 required). + #[test] + fn engine_starts_with_default_options_including_alang() { + let opts = EngineOptions { + vo: "null".into(), + wid: None, + ..EngineOptions::default() + }; + + let mut engine = PlayerEngine::new(opts).expect("engine construct"); + engine.start().expect("engine start (libmpv init + alang accepted)"); + + // The engine publishes Ready on its event channel once running. + let rx = engine.take_event_receiver().expect("event receiver"); + let mut ready = false; + for _ in 0..20 { + if let Ok(ev) = rx.recv_timeout(Duration::from_millis(250)) { + if matches!(ev, EngineEvent::Ready) { + ready = true; + break; + } + } + } + assert!(ready, "engine never reported Ready"); + + // And it accepts commands (this also exercises the drop/shutdown + // path, which joins the engine thread). + engine.send(Cmd::PlayPause).expect("send cmd"); + engine.shutdown().expect("shutdown"); + } +} diff --git a/crates/player-core/src/options.rs b/crates/player-core/src/options.rs index 341ac11..e4026db 100755 --- a/crates/player-core/src/options.rs +++ b/crates/player-core/src/options.rs @@ -36,13 +36,26 @@ pub struct EngineOptions { /// MUST be set before `mpv_initialize` (i.e. before `engine.start()`). pub wid: Option, - /// Video output driver. `"gpu"` for embedded rendering via `wid`. - /// `"libmpv"` selects the render-context API (target: Wayland support). + /// Video output driver(s). A comma-separated priority list: mpv tries + /// each in order, falling back to the next if one fails to initialize. + /// Default `"gpu,xv,x11"` — `gpu` for the normal GL path, then Xv, and + /// finally the software `x11` driver which works on ANY X server. This + /// means a broken GL stack degrades to slow-but-visible video instead + /// of a silent black window. `"libmpv"` selects the render-context API + /// (target: Wayland support). pub vo: String, /// Initial loop mode. Off by default. Set to File/Playlist at construction /// if you want looping on startup. Can be changed at runtime via Cmd::SetLoopMode. pub loop_mode: LoopMode, + + /// Preferred audio languages for track auto-selection (mpv `alang`), as a + /// comma-separated list of ISO language codes (e.g. "en", "en,fr"). + /// When set, mpv prefers these languages over the container's + /// "default"-flagged track — the default comes from the system locale + /// environment instead of the video's own metadata. `None` leaves mpv's + /// default behavior (container default flag first). + pub alang: Option, } impl Default for EngineOptions { @@ -56,8 +69,14 @@ impl Default for EngineOptions { volume_max: 1.0, log_level: "warn".to_string(), wid: None, - vo: "gpu".to_string(), + // VO fallback chain: GL first, Xv second, software X11 last. + // See the field docs — this is what keeps video visible when + // the GPU/GL path fails on exotic setups. + vo: "gpu,xv,x11".to_string(), loop_mode: LoopMode::Off, + // Default the audio language to the system environment's locale, + // NOT the video container's "default" flag. + alang: system_locale_alang(), } } } @@ -101,9 +120,81 @@ impl EngineOptions { .expect("LoopMode is exhaustive over LOOP_TABLE"); v.push(("loop-file", file_v.into())); v.push(("loop-playlist", list_v.into())); + // Audio language preference: system-locale derived (see + // `system_locale_alang`). mpv falls back to the container default + // when no track matches any listed language. + if let Some(alang) = &self.alang { + v.push(("alang", alang.clone())); + } if let Some(wid) = &self.wid { v.push(("wid", wid.clone())); } v } } + +/// Derive an mpv `alang` value from the system locale environment. +/// +/// Consults `LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, and `LANG` (in that order) +/// and extracts the language codes from each entry ("en_US.UTF-8" → "en", +/// "fr_CA" → "fr", "C"/"POSIX" → ignored). `LANGUAGE` is a colon-separated +/// priority list on GNU systems, so every entry is kept in order — the +/// result is a comma-separated preference list for mpv. +/// +/// Returns `None` when nothing usable is set (mpv then falls back to its own +/// defaults). +fn system_locale_alang() -> Option { + let mut langs: Vec = Vec::new(); + + // LANGUAGE is an ordered, colon-separated list (GNU gettext convention). + if let Ok(v) = std::env::var("LANGUAGE") { + for tag in v.split(':') { + push_locale_tag(&mut langs, tag); + } + } + for var in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(v) = std::env::var(var) { + push_locale_tag(&mut langs, &v); + } + } + + if langs.is_empty() { + None + } else { + Some(langs.join(",")) + } +} + +/// Append one locale tag's language code to `langs` (deduped, lowercased). +/// Accepts "en_US.UTF-8", "fr_CA", "de_DE@euro", "en"; rejects "C", +/// "POSIX", and empty tags. +fn push_locale_tag(langs: &mut Vec, tag: &str) { + let tag = tag.trim(); + // Strip country / encoding suffixes: "en_US.UTF-8" → "en". + let lang = tag.split(['_', '.', '@']).next().unwrap_or(""); + // Accept plausible ISO 639 codes (2-3 letters); reject "C", "POSIX". + let plausible = + (2..=3).contains(&lang.len()) && lang.chars().all(|c| c.is_ascii_alphabetic()); + if plausible && !langs.iter().any(|l| l.eq_ignore_ascii_case(lang)) { + langs.push(lang.to_ascii_lowercase()); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn parses_common_locale_forms() { + fn one(tag: &str) -> Option { + let mut v = Vec::new(); + super::push_locale_tag(&mut v, tag); + v.into_iter().next() + } + assert_eq!(one("en_US.UTF-8").as_deref(), Some("en")); + assert_eq!(one("fr_CA").as_deref(), Some("fr")); + assert_eq!(one("de_DE@euro").as_deref(), Some("de")); + assert_eq!(one("C").as_deref(), None); + assert_eq!(one("POSIX").as_deref(), None); + assert_eq!(one("").as_deref(), None); + assert_eq!(one("en").as_deref(), Some("en")); + } +} diff --git a/crates/player-core/src/state.rs b/crates/player-core/src/state.rs index 2483b17..2eca424 100755 --- a/crates/player-core/src/state.rs +++ b/crates/player-core/src/state.rs @@ -62,6 +62,12 @@ pub struct PlaybackState { pub duration: Option, /// Is playback currently paused? pub paused: bool, + + /// Whether mpv has initialized a video output and presented at least + /// one frame (`vo-configured`). When a file is loaded but this stays + /// false, the VO failed — the UI warns the user instead of showing a + /// silent black window. + pub vo_configured: bool, /// Volume 0..=1 (clamped). Mapped 1:1 with libmpv's 0..=100. pub volume: f32, /// Muted? @@ -94,6 +100,24 @@ pub struct PlaybackState { /// Is vertical flip (upside-down) enabled? Tracked locally. pub video_flip_v: bool, + /// Video zoom in log2 units (mpv `video-zoom`): 0 = fit-to-window, + /// 1.0 = 2×, -1.0 = ½×. Combined with the pan fields this lets the + /// user treat the video as a movable object on a canvas to realign a + /// focus area. + pub video_zoom: f32, + /// Video pan in screen-fraction units (mpv `video-pan-x`). + /// Positive moves the video right. + pub video_pan_x: f32, + /// Video pan in screen-fraction units (mpv `video-pan-y`). + /// Positive moves the video down. + pub video_pan_y: f32, + + /// Source video dimensions in pixels (mpv `width` / `height`). + /// 0 = unknown / no file loaded. Needed to map the on-screen zoom/pan + /// focus area back to source pixels when exporting an A-B clip. + pub video_width: u32, + pub video_height: u32, + /// A/B marker positions in seconds. None = not set. /// Mirrored to mpv's `ab-loop-a` / `ab-loop-b` so mpv itself can drive /// the looping; we cache them here for UI rendering. @@ -104,6 +128,15 @@ pub struct PlaybackState { /// Current random/shuffle mode. Mirrors the engine's last `SetRandomMode` cmd. pub random_mode: RandomMode, + + /// The mpv playlist (the queue), in play order. One entry per queued + /// file — the exact string passed to `loadfile` (an absolute path for + /// local files). Mirrored from mpv's `playlist/N/filename` properties; + /// kept in `PlaybackState` so the UI can render/reorder/save the queue. + pub playlist: Vec, + /// Index into `playlist` of the currently-playing entry, or -1 when + /// nothing is playing. Mirrors mpv's `playlist-playing-pos`. + pub playlist_pos: i64, } // Serialize Track for the JSON marker export. We do it manually so the diff --git a/crates/player-ui/src/app.rs b/crates/player-ui/src/app.rs index a7ba559..9692a72 100755 --- a/crates/player-ui/src/app.rs +++ b/crates/player-ui/src/app.rs @@ -5,19 +5,27 @@ //! │ [≡] │ //! │ 00:12 ████████●░░░░░░░░░░░░░░░░░░ 01:30 ← seek bar w/ time │ //! │ ┌────┬────┬────┬────┬────┐ ┌─────────┐ ┌────────────┐ │ -//! │ │ ▶ │ ■ │ ⏮ │ ⏭ │ ↙ │ 00:12 │ 🔊━━━━○ │ │ ⛶ fullscr │ │ +//! │ │ ▶ │ ■ │ ⏮ │ ⏭ │ ↙ │ 00:12 │ 🔊━━━━○ │ │ ⛶ fullscr │ │ //! │ └────┴────┴────┴────┴────┘ └─────────┘ └────────────┘ │ //! │ ┌────┬────┬────┬────┬────┬────┬────┐ │ //! │ │ A │ B │ AB │ ⟳ │ ½× │ 1× │ 2× │ Audio: [eng ▾] │ //! │ └────┴────┴────┴────┴────┴────┴────┘ │ //! └──────────────────────────────────────────────────────────────────┘ //! -//! - Top-left ≡ hamburger opens the File menu (Load File / Folder / Playlist, -//! Export Markers, Quit). +//! - Top-left ≡ hamburger opens the File menu (Load File / Folder / +//! Playlist, Save Playlist As..., Queue Sidebar, 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. +//! - The queue button (next to shuffle in the transport row) toggles a +//! persistent right-hand sidebar listing the playlist in play order; +//! entries can be reordered by drag-and-drop or by typing a new +//! position number, played on click, removed, and the whole queue saved +//! as an .m3u playlist. A-B markers live under Playback → Loop / +//! A-B Markers. +//! - The video acts as a movable object on a canvas: drag it to pan, +//! Ctrl+wheel to zoom (Video → Zoom / Pan for steps, values, reset). use std::time::Instant; @@ -37,6 +45,8 @@ pub struct OverlayApp { pub event_rx: Receiver, pub cmd_tx: crossbeam_channel::Sender, pub theme: Theme, + /// Timestamp of the last user activity (mouse move/click or keypress). + /// Drives auto-hide of the menu bar and control bar. pub last_mouse_move: Instant, pub auto_hide_secs: f64, pub visible: bool, @@ -47,9 +57,24 @@ pub struct OverlayApp { pub error_expiry: Option, /// Is the cursor currently inside the overlay window? pub mouse_inside: bool, + /// Latest pointer position in overlay coordinates (None = cursor left). + pub pointer_pos: Option, /// Are we in fullscreen mode? (Mirrored from main app.) pub fullscreen: bool, + /// True when egui currently has a focused widget that wants keyboard + /// input (e.g. the save-dialog filename `TextEdit`). While true, the + /// main app routes keystrokes into egui instead of the global hotkeys — + /// so typing "q" in a filename field doesn't quit. + /// Updated by the renderer after each egui pass. + pub ui_wants_keyboard: bool, + + /// True when a dropdown menu (File/Playback/...) or combo popup was open + /// at the end of the previous frame. Acts as a sticky condition for + /// visibility: an open menu must never auto-hide its own bar out from + /// under the user. + pub menu_popup_open: bool, + /// Sticky info toast (e.g. "Markers exported to ..."). pub info_msg: Option, pub info_expiry: Option, @@ -69,10 +94,41 @@ pub struct OverlayApp { /// version, author, website, and license info. pub about_visible: bool, + /// Is the queue sidebar (right-hand panel) visible? Toggled by the + /// File → Window menu, or the queue button in the control bar. The + /// sidebar shows the mpv playlist in order and supports reordering by + /// drag-and-drop or by typing a new position number, plus click-to-play + /// and per-entry removal. Drawn like the About panel: persistent, + /// independent of the auto-hiding bars. + pub queue_visible: bool, + + /// Active position-edit in the queue sidebar: (row index, text buffer). + /// While Some, that row's position number renders as a `TextEdit`; + /// committing (Enter or focus lost) moves the entry to the typed + /// 1-based position. None = no row is being edited. + queue_edit: Option<(usize, String)>, + /// One-frame flag: request keyboard focus for the queue position edit + /// on the frame it opens (the TextEdit must exist before it can be + /// focused, so the click that opens the edit can't focus it directly). + queue_edit_focus: bool, + /// Queue sidebar drag-and-drop state: index of the row being dragged. + queue_drag_from: Option, + /// Queue sidebar drag-and-drop state: index the drag hovers over + /// (insert-before target). `Some(len)` = below the last row = append. + queue_drag_to: Option, + /// 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, + + /// When the current file finished loading (FileLoaded event). Used by + /// the VO health check: if mpv still reports `vo-configured == false` + /// a couple of seconds after load, the video output failed and the user + /// gets a visible warning instead of a silent black window. + file_loaded_at: Option, + /// One-shot guard so the VO-failure warning only fires once per file. + vo_warned: bool, } impl OverlayApp { @@ -95,13 +151,23 @@ impl OverlayApp { error_msg: None, error_expiry: None, mouse_inside: false, + pointer_pos: None, fullscreen: false, + ui_wants_keyboard: false, + menu_popup_open: false, info_msg: None, info_expiry: None, speed_drag: None, pending_events: Vec::new(), about_visible: false, + queue_visible: false, + queue_edit: None, + queue_edit_focus: false, + queue_drag_from: None, + queue_drag_to: None, file_dialog: None, + file_loaded_at: None, + vo_warned: false, } } @@ -112,6 +178,11 @@ impl OverlayApp { self.pending_events.push(event); } + /// Push several egui input events at once (e.g. a translated keypress). + pub fn push_events(&mut self, events: impl IntoIterator) { + self.pending_events.extend(events); + } + /// Drain all pending egui events. Called by the renderer before building /// RawInput for the next frame. pub fn drain_events(&mut self) -> Vec { @@ -125,6 +196,8 @@ impl OverlayApp { EngineEvent::FileLoaded { path, title } => { self.state.path = Some(path); self.state.title = title; + self.file_loaded_at = Some(Instant::now()); + self.vo_warned = false; } EngineEvent::StateChanged => {} EngineEvent::EndReached { reason: _ } => { @@ -165,32 +238,64 @@ impl OverlayApp { } } - pub fn note_mouse_activity(&mut self) { + /// Record user activity (mouse move/click, keypress, widget hover). + /// Resets the auto-hide timer and re-shows the menu bar + control bar. + pub fn note_user_activity(&mut self) { self.last_mouse_move = Instant::now(); self.visible = true; } pub fn set_mouse_inside(&mut self, inside: bool) { + // Only record cursor presence. Do NOT reset the auto-hide timer + // here: a cursor resting anywhere over the video is NOT activity. + // The timer advances only from real input (see note_user_activity), + // otherwise the bars would never hide while the mouse sits still + // inside the window. self.mouse_inside = inside; - if inside { - self.note_mouse_activity(); - } } pub fn set_fullscreen(&mut self, fs: bool) { self.fullscreen = fs; } + /// Recompute menu-bar/control-bar visibility. Hides both after + /// `auto_hide_secs` without input, unless a sticky condition holds + /// (open dropdown/dialog/panel/toast) or the cursor is resting on top + /// of the currently-visible bars. pub fn compute_visibility(&mut self) -> bool { - if self.mouse_inside || self.error_msg.is_some() || self.info_msg.is_some() { + let sticky = self.error_msg.is_some() + || self.info_msg.is_some() + || self.file_dialog.is_some() + || self.about_visible + || self.menu_popup_open; + if sticky { 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; + // Keep the bars up while the cursor rests ON them (even in the + // gaps between widgets) — but a cursor resting over the video + // area lets them hide. + let resting_on_ui = self.mouse_inside + && self + .pointer_pos + .map(|p| Self::in_menu_strip(self.window_size, p) || Self::in_control_bar(self.window_size, p)) + .unwrap_or(false); + if !resting_on_ui { + self.visible = false; + } } self.visible } + /// Pointer is within the top menu-bar strip. + fn in_menu_strip(win: Vec2, p: egui::Pos2) -> bool { + p.y <= 32.0 && p.x >= 0.0 && p.x <= win.x + } + + /// Pointer is within the bottom control-bar zone (118px tall). + fn in_control_bar(win: Vec2, p: egui::Pos2) -> bool { + p.y >= win.y - 118.0 && p.y <= win.y + } + fn send(&self, cmd: Cmd) { let _ = self.cmd_tx.send(cmd); } @@ -206,13 +311,38 @@ impl OverlayApp { 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() { + // VO health check: a file is loaded but mpv never configured a + // video output (bad GL context, driver issue, ...). Warn visibly — + // a silent black window is the worst failure mode and historically + // got reported as "no video output" with no hint of the cause. + if self.state.path.is_some() + && !self.state.vo_configured + && !self.vo_warned + && self.error_msg.is_none() + && self.info_msg.is_none() + { + if let Some(t) = self.file_loaded_at { + if t.elapsed() > std::time::Duration::from_secs(2) { + self.show_info( + "Video output failed to initialize (vo=gpu,xv,x11 all failed?) — \ + check GL drivers / try software rendering", + ); + self.vo_warned = true; + } + } + } + + // The menu bar now auto-hides together with the control bar: it is + // only drawn while the UI is "visible" (mouse moved within the last + // `auto_hide_secs`, or a sticky condition holds — see + // `compute_visibility`). Moving the mouse brings it back. A modal + // file dialog replaces all other interaction while it is open. + let menu_visible = self.visible || self.about_visible; + if self.file_dialog.is_none() && menu_visible { self.draw_file_menu(ctx); + } else { + // No menus were drawn this frame — nothing can be open. + self.menu_popup_open = false; } // If a file dialog is open, render it as a modal and process its @@ -231,6 +361,14 @@ impl OverlayApp { let visible = self.visible; if !visible { + // The About panel and queue sidebar are persistent panels, not + // tied to the bars. + if self.queue_visible { + self.draw_queue_panel(ctx); + } + if self.about_visible { + self.draw_about_panel(ctx); + } return; } @@ -278,7 +416,11 @@ impl OverlayApp { .order(egui::Order::Foreground) .show(ctx, |ui| { let painter = ui.painter(); - painter.rect_filled(bar_rect, 0.0, self.theme.bg_color32()); + // Semi-transparent fill — the video shows through the control + // bar at all times while it is visible. + let bg = self.theme.bg_color32(); + let bg = egui::Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 175); + painter.rect_filled(bar_rect, 0.0, bg); let top_line = egui::Rect::from_min_size( bar_rect.min, @@ -334,20 +476,42 @@ impl OverlayApp { }); }); } + + // ----- Queue sidebar (persistent, independent of bar visibility) ----- + if self.queue_visible { + self.draw_queue_panel(ctx); + } + + // ----- About panel (persistent, independent of bar visibility) ----- + if self.about_visible { + self.draw_about_panel(ctx); + } + + // Track combo-box popups (audio track dropdown) for visibility + // stickiness — the audio dropdown lives in the control bar. + if self.visible && ctx.memory(|m| m.any_popup_open()) { + self.menu_popup_open = true; + } } - /// 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. + /// Draw the menu bar (File / Playback / Audio / Subtitles / Video / Help) + /// at the top-left corner. Auto-hides together with the control bar (see + /// `compute_visibility`); an open dropdown keeps the whole UI visible. + /// 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; + // Toggle the queue sidebar (File → Window). Applied after the pass. + let mut pending_queue_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; + // True when any dropdown menu is open this frame — sticky condition + // so the bar never auto-hides while the user is reading a menu. + let mut menu_open = false; // Snapshot the theme colors we need so the closure doesn't borrow self. let fg = self.theme.fg_color32(); @@ -360,7 +524,12 @@ impl OverlayApp { 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 video_zoom = self.state.video_zoom; + let video_pan_x = self.state.video_pan_x; + let video_pan_y = self.state.video_pan_y; let about_visible = self.about_visible; + let queue_visible = self.queue_visible; + let marker_loop = self.state.marker_loop_enabled; egui::Area::new(egui::Id::new("ferret_menu_bar")) .fixed_pos(egui::pos2(4.0, 4.0)) @@ -381,7 +550,7 @@ impl OverlayApp { ui.spacing_mut().item_spacing.x = 2.0; // ---- File menu ---- - ui.menu_button( + let file_resp = ui.menu_button( egui::RichText::new("File").color(fg).size(13.0), |ui| { ui.set_min_width(200.0); @@ -405,37 +574,8 @@ impl OverlayApp { 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); + if ui.button("Save Playlist As...").clicked() { + pending_dialog = Some(crate::file_dialog::FileDialogKind::SavePlaylist); ui.close_menu(); } @@ -450,6 +590,10 @@ impl OverlayApp { pending_cmds.push(Cmd::ToggleFullscreen); ui.close_menu(); } + if ui.selectable_label(queue_visible, "Queue Sidebar").clicked() { + pending_queue_toggle = true; + ui.close_menu(); + } ui.separator(); @@ -459,9 +603,12 @@ impl OverlayApp { } }, ); + if file_resp.inner.is_some() { + menu_open = true; + } // ---- Playback menu ---- - ui.menu_button( + let playback_resp = ui.menu_button( egui::RichText::new("Playback").color(fg).size(13.0), |ui| { ui.set_min_width(220.0); @@ -567,6 +714,38 @@ impl OverlayApp { pending_cmds.push(Cmd::SetLoopMode(loop_mode.cycle())); ui.close_menu(); } + if ui.selectable_label(marker_loop, "A-B Loop").clicked() { + pending_cmds.push(Cmd::ToggleMarkerLoop); + ui.close_menu(); + } + + ui.separator(); + + ui.label( + egui::RichText::new("A-B 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("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(); @@ -605,6 +784,9 @@ impl OverlayApp { .color(fg_dim).size(10.0).strong(), ); ui.add_space(2.0); + // No discrete preset entries here — the + // control-bar slider (0.25×–4×) plus these + // up/down steps cover the whole range. if ui.button("Speed Up +0.25× (=)").clicked() { pending_cmds.push(Cmd::SetSpeed((speed + 0.25).min(4.0))); ui.close_menu(); @@ -613,22 +795,17 @@ impl OverlayApp { 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(); - } - } }, ); + if playback_resp.inner.is_some() { + menu_open = true; + } // ---- 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( + let audio_resp = ui.menu_button( egui::RichText::new("Audio").color(fg).size(13.0), |ui| { ui.set_min_width(180.0); @@ -649,13 +826,16 @@ impl OverlayApp { } }, ); + if audio_resp.inner.is_some() { + menu_open = true; + } } // ---- Subtitles menu ---- { let tracks = self.state.subtitle_tracks.clone(); let current = self.state.current_subtitle_track; - ui.menu_button( + let subs_resp = ui.menu_button( egui::RichText::new("Subtitles").color(fg).size(13.0), |ui| { ui.set_min_width(200.0); @@ -695,10 +875,13 @@ impl OverlayApp { } }, ); + if subs_resp.inner.is_some() { + menu_open = true; + } } // ---- Video menu (rotate + flip) ---- - ui.menu_button( + let video_resp = ui.menu_button( egui::RichText::new("Video").color(fg).size(13.0), |ui| { ui.set_min_width(180.0); @@ -733,11 +916,47 @@ impl OverlayApp { pending_cmds.push(Cmd::SetVideoFlipV(!video_flip_v)); ui.close_menu(); } + + ui.separator(); + + ui.label( + egui::RichText::new("Zoom / Pan") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + if ui.button("Zoom In (Ctrl+Wheel)").clicked() { + pending_cmds.push(Cmd::AdjustVideoZoom(0.25)); + ui.close_menu(); + } + if ui.button("Zoom Out (Ctrl+Wheel)").clicked() { + pending_cmds.push(Cmd::AdjustVideoZoom(-0.25)); + ui.close_menu(); + } + if ui.button("Reset Zoom & Pan").clicked() { + pending_cmds.push(Cmd::ResetVideoPanZoom); + ui.close_menu(); + } + ui.label( + egui::RichText::new(format!( + "zoom {:.2}× · pan {:+.2} / {:+.2}", + 2.0_f32.powf(video_zoom), + video_pan_x, + video_pan_y, + )) + .color(fg_dim).size(10.0), + ); + ui.label( + egui::RichText::new("drag the video to pan") + .color(fg_dim).size(10.0), + ); }, ); + if video_resp.inner.is_some() { + menu_open = true; + } // ---- Help menu ---- - ui.menu_button( + let help_resp = ui.menu_button( egui::RichText::new("Help").color(fg).size(13.0), |ui| { ui.set_min_width(200.0); @@ -758,6 +977,9 @@ impl OverlayApp { ); }, ); + if help_resp.inner.is_some() { + menu_open = true; + } // ---- Status line ---- ui.add_space(8.0); @@ -778,14 +1000,31 @@ impl OverlayApp { } else { format!(" {}", random_mode.short_label()) }; + let zoom_str = if video_zoom.abs() > 0.001 { + format!(" zoom:{:.2}x", 2.0_f32.powf(video_zoom)) + } else { + String::new() + }; + let pan_str = if video_pan_x.abs() > 0.005 || video_pan_y.abs() > 0.005 { + format!(" pan:{:+.2},{:+.2}", video_pan_x, video_pan_y) + } else { + String::new() + }; ui.label( - egui::RichText::new(format!("{status}{speed_str}{loop_str}{rand_str}")) + egui::RichText::new(format!( + "{status}{speed_str}{loop_str}{rand_str}{zoom_str}{pan_str}" + )) .color(fg_dim).size(10.0), ); }); }); }); + // Remember whether any dropdown menu was open this frame — used as a + // sticky condition by compute_visibility so an open menu never hides + // its own bar while the user reads it. + self.menu_popup_open = menu_open; + // Send any commands that were collected during the UI pass. for cmd in pending_cmds { self.send(cmd); @@ -794,6 +1033,10 @@ impl OverlayApp { if pending_about_toggle { self.about_visible = !self.about_visible; } + // Same for the queue sidebar toggle. + if pending_queue_toggle { + self.queue_visible = !self.queue_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. @@ -801,7 +1044,14 @@ impl OverlayApp { 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)); + // Suggest a default clip filename derived from the + // input file and the A/B timestamps so the user can + // just hit Save (or type their own — the filename + // field takes keyboard input now). + let suggestion = build_export_suggestion(&self.state); + self.file_dialog = Some( + crate::file_dialog::FileDialog::open_with_filename(kind, suggestion), + ); } _ => { self.show_info("Set both A and B markers before exporting"); @@ -811,10 +1061,6 @@ impl OverlayApp { 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 @@ -881,45 +1127,96 @@ impl OverlayApp { self.show_info(format!("Markers imported from {path}")); } FileDialogKind::ExportVideo => { + // ffmpeg needs a container extension to pick a muxer — if + // the user typed a filename without one (or with an + // unknown one), default to .mp4 instead of failing. + let path = ensure_video_extension(path); // 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::SavePlaylist => { + // Save the queue — in exactly the order the user + // organized it — as an .m3u playlist. + if self.state.playlist.is_empty() { + self.show_info("Queue is empty — nothing to save"); + } else { + let path = ensure_m3u_extension(path.clone()); + let count = self.state.playlist.len(); + match write_m3u(&path, &self.state.playlist) { + Ok(()) => self.show_info(format!( + "Playlist saved — {count} entries → {path}" + )), + Err(e) => self.show_info(format!("Playlist save failed: {e}")), + } + } + } 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() { + // but handle gracefully). A playlist file expands to its + // entries; a media file loads directly. + let (expanded, _) = expand_playlist_selection(std::slice::from_ref(&path)); + if expanded.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() { + for (i, p) in expanded.iter().enumerate() { let mode = if i == 0 { LoadModeKind::Replace } else { LoadModeKind::AppendPlay }; - if self.cmd_tx.try_send(Cmd::LoadFile { + let _ = 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())); + self.show_info(format!("Loaded {} files", expanded.len())); } } - _ => {} }, + FileDialogResult::Paths(paths) => { + // Multi-select arrives only for Load Playlist. Any .m3u / + // .m3u8 / .pls selections expand to their entries in order. + if let FileDialogKind::LoadPlaylist = kind { + if paths.is_empty() { + self.show_info("No files selected"); + } else { + // Expand any .m3u/.m3u8/.pls selections into their + // entries (preserving order), then load the result. + let (expanded, skipped) = expand_playlist_selection(&paths); + if expanded.is_empty() { + self.show_info("Playlist contained no entries"); + } 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 expanded.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; + } + } + let skipped_note = if skipped > 0 { + format!(" ({skipped} playlist file(s) unreadable)") + } else { + String::new() + }; + self.show_info(format!( + "Loaded {}/{} files{skipped_note}", + sent, + expanded.len() + )); + } + } + } + } } } @@ -1020,6 +1317,390 @@ impl OverlayApp { }); } + /// Draw the queue sidebar: a persistent right-hand panel showing the mpv + /// playlist in play order. Reordering works two ways: + /// + /// * **Drag and drop** — grab any row (or its grip) and drop it on the + /// row it should precede, or below the last row to append. + /// * **Type a position** — click a row's number, type a new 1-based + /// position, press Enter (Esc cancels; clicking away commits). + /// + /// Clicking a row's name plays that entry; the × removes it. The Save + /// button writes the queue — in exactly the order shown — to an .m3u + /// file via the save dialog. + fn draw_queue_panel(&mut self, ctx: &Context) { + enum QueueAction { + Close, + SaveAs, + Play(usize), + Remove(usize), + Move { from: usize, to: usize }, + StartEdit(usize), + } + + let win = self.window_size; + let panel_w = 300.0_f32.min(win.x - 24.0).max(140.0); + let panel_x = (win.x - panel_w - 8.0).max(8.0); + let panel_y = 36.0; + // Stop above where the control bar sits (118px) so nothing overlaps. + let panel_h = ((win.y - 126.0) - panel_y).max(140.0); + + // Snapshots so the Area closure never borrows self. + let theme = self.theme; + let fg = theme.fg_color32(); + let fg_dim = theme.fg_dim_color32(); + let bg = theme.bg_color32(); + let accent = theme.accent_color32(); + let entries: Vec = self.state.playlist.clone(); + let playing_pos = self.state.playlist_pos; + let len = entries.len(); + + let mut actions: Vec = Vec::new(); + let mut edit = self.queue_edit.take(); + let mut edit_focus = self.queue_edit_focus; + let mut drag_from = self.queue_drag_from; + let mut drag_to = self.queue_drag_to; + let mut hovered_any = false; + + egui::Area::new(egui::Id::new("ferret_queue_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(), 235)) + .rounding(6.0) + .stroke(egui::Stroke::new(1.0_f32, accent)) + .inner_margin(egui::Margin::symmetric(8.0, 6.0)) + .show(ui, |ui| { + ui.set_min_width(panel_w - 18.0); + ui.set_max_width(panel_w - 18.0); + + // ---- Header ---- + ui.horizontal(|ui| { + let n_label = if len == 1 { "entry" } else { "entries" }; + ui.label( + egui::RichText::new(format!("Queue — {len} {n_label}")) + .color(fg) + .size(13.0) + .strong(), + ); + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("×").clicked() { + actions.push(QueueAction::Close); + } + if ui.button("Save...").clicked() { + actions.push(QueueAction::SaveAs); + } + }); + }); + ui.separator(); + + // ---- Rows ---- + egui::ScrollArea::vertical() + .max_height((panel_h - 96.0).max(80.0)) + // Row drags reorder; they must not scroll. + .drag_to_scroll(false) + .show(ui, |ui| { + if entries.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new("(queue is empty — load files or a playlist)") + .color(fg_dim) + .size(11.0), + ); + } + + let mut last_bottom: Option = None; + for (i, path) in entries.iter().enumerate() { + let row_h = 26.0; + let (rect, resp) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), row_h), + egui::Sense::click_and_drag(), + ); + last_bottom = Some(rect.max.y); + let is_playing = i as i64 == playing_pos; + let is_source = drag_from == Some(i); + let is_target = drag_to == Some(i) && !is_source; + + // Sub-rects: [ # ] [ name ....... ] [≡] [×] + let num_rect = egui::Rect::from_min_max( + egui::pos2(rect.min.x + 2.0, rect.min.y), + egui::pos2(rect.min.x + 36.0, rect.max.y), + ); + let x_rect = egui::Rect::from_min_max( + egui::pos2(rect.max.x - 20.0, rect.min.y), + egui::pos2(rect.max.x, rect.max.y), + ); + let grip_rect = egui::Rect::from_min_max( + egui::pos2(rect.max.x - 40.0, rect.min.y), + egui::pos2(rect.max.x - 20.0, rect.max.y), + ); + let name_rect = egui::Rect::from_min_max( + egui::pos2(num_rect.max.x + 6.0, rect.min.y), + egui::pos2(grip_rect.min.x - 4.0, rect.max.y), + ); + + let painter = ui.painter_at(rect); + let bg_row = if is_source { + egui::Color32::from_rgba_unmultiplied( + theme.accent[0], theme.accent[1], theme.accent[2], 48, + ) + } else if is_playing { + egui::Color32::from_rgba_unmultiplied( + theme.accent[0], theme.accent[1], theme.accent[2], 26, + ) + } else if resp.hovered() { + theme.button_bg_hover_color32() + } else { + theme.button_bg_color32() + }; + painter.rect_filled(rect, 3.0, bg_row); + if is_playing { + painter.rect_filled( + egui::Rect::from_min_size( + rect.min, + Vec2::new(3.0, rect.height()), + ), + 1.0, + accent, + ); + } + // Insert-before indicator on the target row. + if is_target { + painter.line_segment( + [ + egui::pos2(rect.min.x + 2.0, rect.min.y), + egui::pos2(rect.max.x - 2.0, rect.min.y), + ], + egui::Stroke::new(2.0_f32, accent), + ); + } + + // Position number (skipped while its TextEdit + // is overlaid on this row). + let editing_this = + edit.as_ref().map(|(ei, _)| *ei) == Some(i); + if !editing_this { + painter.text( + num_rect.center(), + egui::Align2::CENTER_CENTER, + format!("{}", i + 1), + egui::FontId::monospace(11.0), + if is_playing { accent } else { fg_dim }, + ); + } + + // File name. + painter.text( + name_rect.left_center(), + egui::Align2::LEFT_CENTER, + truncate_to_width(&file_basename(path), name_rect.width()), + egui::FontId::proportional(11.0), + fg, + ); + + // Drag grip (three short lines). + let gc = grip_rect.center(); + for dy in [-3.0_f32, 0.0, 3.0] { + painter.line_segment( + [ + egui::pos2(gc.x - 5.0, gc.y + dy), + egui::pos2(gc.x + 5.0, gc.y + dy), + ], + egui::Stroke::new(1.5_f32, fg_dim), + ); + } + + // Remove button (red on hover). + let x_hover = ui + .input(|inp| inp.pointer.hover_pos()) + .map(|p| x_rect.contains(p)) + .unwrap_or(false); + painter.text( + x_rect.center(), + egui::Align2::CENTER_CENTER, + "×", + egui::FontId::proportional(13.0), + if x_hover { + egui::Color32::from_rgb(220, 80, 80) + } else { + fg_dim + }, + ); + + if resp.hovered() { + hovered_any = true; + } + + // ---- Interactions ---- + if resp.drag_started() { + drag_from = Some(i); + drag_to = None; + } + if drag_from.is_some() { + if let Some(p) = ui.input(|inp| inp.pointer.hover_pos()) { + if rect.contains(p) { + drag_to = Some(i); + } + } + } + if resp.drag_stopped() { + let from = drag_from.take(); + let to = drag_to.take(); + drag_to = None; + if let (Some(f), Some(t)) = (from, to) { + let to = t.min(len); + if f != to { + actions.push(QueueAction::Move { from: f, to }); + } + } + } + if resp.clicked() { + if let Some(p) = resp.interact_pointer_pos() { + if x_rect.contains(p) { + actions.push(QueueAction::Remove(i)); + } else if num_rect.contains(p) && !editing_this { + actions.push(QueueAction::StartEdit(i)); + } else { + actions.push(QueueAction::Play(i)); + } + } + } + + // ---- Position TextEdit overlay ---- + if editing_this { + let (_, buf) = edit.as_mut().expect("editing_this implies Some"); + let te = egui::TextEdit::singleline(buf) + .desired_width(34.0) + .font(egui::FontId::monospace(11.0)); + let te_resp = ui.put(num_rect, te); + if edit_focus { + te_resp.request_focus(); + edit_focus = false; + } + let enter = + ui.input(|inp| inp.key_pressed(egui::Key::Enter)); + let escape = + ui.input(|inp| inp.key_pressed(egui::Key::Escape)); + if escape { + edit = None; + te_resp.surrender_focus(); + } else if enter { + let final_pos = buf + .trim() + .parse::() + .ok() + .filter(|n| *n >= 1 && *n <= len) + .map(|n| n - 1); + if let Some(t) = final_pos { + let (from, to) = + player_core::cmd::playlist_move_args_for_final( + i, t, len, + ); + if from != to { + actions.push(QueueAction::Move { from, to }); + } + } + edit = None; + te_resp.surrender_focus(); + } else if te_resp.lost_focus() { + // Clicking elsewhere commits the edit. + let final_pos = buf + .trim() + .parse::() + .ok() + .filter(|n| *n >= 1 && *n <= len) + .map(|n| n - 1); + if let Some(t) = final_pos { + let (from, to) = + player_core::cmd::playlist_move_args_for_final( + i, t, len, + ); + if from != to { + actions.push(QueueAction::Move { from, to }); + } + } + edit = None; + } + } + } + + // Drop below the last row = append at the end. + if drag_from.is_some() { + if let Some(p) = ui.input(|inp| inp.pointer.hover_pos()) { + if let Some(bottom) = last_bottom { + if p.y > bottom && p.y < bottom + 40.0 { + drag_to = Some(len); + } + } + } + if drag_to == Some(len) { + if let Some(bottom) = last_bottom { + let w = ui.min_rect().width(); + ui.painter().line_segment( + [ + egui::pos2(2.0, bottom + 3.0), + egui::pos2(w - 2.0, bottom + 3.0), + ], + egui::Stroke::new(2.0_f32, accent), + ); + } + } + } + }); + + // ---- Footer hint ---- + ui.add_space(2.0); + ui.label( + egui::RichText::new( + "drag to reorder · click # to type a position · click a name to play", + ) + .color(fg_dim) + .size(9.0), + ); + }); + }); + + // Write back the interaction mirrors. + self.queue_edit = edit; + self.queue_edit_focus = edit_focus; + self.queue_drag_from = drag_from; + self.queue_drag_to = drag_to; + + // Apply collected actions. + for action in actions { + match action { + QueueAction::Close => self.queue_visible = false, + QueueAction::SaveAs => { + if self.state.playlist.is_empty() { + self.show_info("Queue is empty — nothing to save"); + } else { + self.file_dialog = Some(crate::file_dialog::FileDialog::open_with_filename( + crate::file_dialog::FileDialogKind::SavePlaylist, + Some("playlist.m3u".to_string()), + )); + } + } + QueueAction::Play(i) => self.send(Cmd::PlaylistPlayIndex { index: i }), + QueueAction::Remove(i) => { + self.send(Cmd::PlaylistRemove { index: i }); + // Cancel a position-edit on the row that just vanished. + if self.queue_edit.as_ref().map(|(ei, _)| *ei) == Some(i) { + self.queue_edit = None; + } + } + QueueAction::Move { from, to } => self.send(Cmd::PlaylistMove { from, to }), + QueueAction::StartEdit(i) => { + self.queue_edit = Some((i, format!("{}", i + 1))); + self.queue_edit_focus = true; + } + } + } + if hovered_any { + self.note_user_activity(); + } + } + fn draw_controls(&mut self, ui: &mut Ui) { ui.vertical(|ui| { // ===== Row 1: Seek bar with time labels on either side ===== @@ -1060,7 +1741,7 @@ impl OverlayApp { } if response.hovered() || response.dragged() { - self.note_mouse_activity(); + self.note_user_activity(); } if let Some(frac) = new_pos { self.seeking = response.dragged(); @@ -1140,6 +1821,15 @@ impl OverlayApp { if rand_resp.clicked() { self.send(Cmd::SetRandomMode(self.state.random_mode.cycle())); } rand_resp.on_hover_text(format!("Cycle Random Mode — {}", self.state.random_mode.label())); + // Queue sidebar toggle — shows the playlist panel for + // reordering / saving. Persistent (not auto-hidden). + let queue_active = self.queue_visible; + let queue_resp = self.icon_button_toggled(ui, "btn_queue", btn_size, queue_active, |p, r, c| { + icons::queue(p, r, c) + }); + if queue_resp.clicked() { self.queue_visible = !self.queue_visible; } + queue_resp.on_hover_text("Toggle Queue Sidebar — reorder & save the queue"); + // Center: time display ui.with_layout(Layout::centered_and_justified(egui::Direction::TopDown), |ui| { let time_str = self.state.time_str(); @@ -1155,7 +1845,7 @@ impl OverlayApp { 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(); } + if fs_resp.hovered() { self.note_user_activity(); } ui.add_space(8.0); let mut vol_pct = self.state.volume * 100.0; @@ -1167,7 +1857,7 @@ impl OverlayApp { self.send(Cmd::SetVolume(vol_pct / 100.0)); self.state.volume = vol_pct / 100.0; } - if slider_resp.hovered() { self.note_mouse_activity(); } + if slider_resp.hovered() { self.note_user_activity(); } ui.add_space(4.0); let vol_resp = self.icon_button(ui, "btn_vol", btn_size, move |p, r, c| { @@ -1262,7 +1952,7 @@ impl OverlayApp { self.send(Cmd::SetSpeed(v)); } } - if slider_resp.hovered() { self.note_mouse_activity(); } + if slider_resp.hovered() { self.note_user_activity(); } // Current speed label. ui.label( @@ -1333,7 +2023,7 @@ impl OverlayApp { ) -> egui::Response { let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); if response.hovered() { - self.note_mouse_activity(); + self.note_user_activity(); } if ui.is_rect_visible(rect) { let painter = ui.painter_at(rect); @@ -1366,7 +2056,7 @@ impl OverlayApp { ) -> egui::Response { let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); if response.hovered() { - self.note_mouse_activity(); + self.note_user_activity(); } if ui.is_rect_visible(rect) { let painter = ui.painter_at(rect); @@ -1407,7 +2097,7 @@ impl OverlayApp { ) -> egui::Response { let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); if response.hovered() { - self.note_mouse_activity(); + self.note_user_activity(); } if ui.is_rect_visible(rect) { let painter = ui.painter_at(rect); @@ -1454,7 +2144,7 @@ impl OverlayApp { ) -> egui::Response { let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); if response.hovered() { - self.note_mouse_activity(); + self.note_user_activity(); } if ui.is_rect_visible(rect) { let painter = ui.painter_at(rect); @@ -1511,6 +2201,204 @@ fn format_time(secs: f64) -> String { } } +/// Build a suggested default filename for the A-B loop video export: +/// `_-.mp4`. Returns None unless both +/// markers are set (the caller pre-checks anyway). +fn build_export_suggestion(state: &PlaybackState) -> Option { + let a = state.marker_a?; + let b = state.marker_b?; + let stem = state + .path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_stem()) + .and_then(|s| s.to_str()) + .map(sanitize_filename_stem) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "clip".to_string()); + Some(format!("{}_{}-{}.mp4", stem, a.max(0.0) as u64, b.max(0.0) as u64)) +} + +/// Keep only filename-safe characters ([A-Za-z0-9._-]); replace the rest +/// with underscores so the suggestion works across filesystems. +fn sanitize_filename_stem(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +/// Ensure the export path ends in a video container extension ffmpeg can +/// infer a muxer from. Appends `.mp4` when the extension is missing or not +/// one of the supported save formats (mp4/mkv/webm). +fn ensure_video_extension(path: String) -> String { + const KNOWN: &[&str] = &["mp4", "mkv", "webm"]; + let ext = std::path::Path::new(&path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + match ext.as_deref() { + Some(e) if KNOWN.contains(&e) => path, + _ => format!("{path}.mp4"), + } +} + +/// Final path component of a file path (the name shown in the queue rows). +fn file_basename(p: &str) -> String { + std::path::Path::new(p) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| p.to_string()) +} + +/// Truncate a label to fit `width` px, appending "…" when cut. Rough +/// per-character estimate (~6px at 11px proportional) — only used for row +/// labels, where being a char off is invisible. +fn truncate_to_width(s: &str, width: f32) -> String { + let max_chars = ((width / 6.0).floor() as usize).max(1); + if s.chars().count() <= max_chars { + s.to_string() + } else { + let mut t: String = s.chars().take(max_chars.saturating_sub(1)).collect(); + t.push('…'); + t + } +} + +/// Does this path look like a playlist file we can parse (.m3u/.m3u8/.pls)? +fn is_playlist_file(path: &str) -> bool { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default(); + matches!(ext.as_str(), "m3u" | "m3u8" | "pls") +} + +/// Resolve one playlist entry against the directory the playlist lives in. +/// Absolute paths and URLs pass through untouched. +fn resolve_playlist_entry(base_dir: &std::path::Path, entry: &str) -> String { + let trimmed = entry.trim(); + if trimmed.starts_with('/') + || trimmed.contains("://") + || std::path::Path::new(trimmed).is_absolute() + { + return trimmed.to_string(); + } + base_dir + .join(trimmed) + .to_string_lossy() + .into_owned() +} + +/// Parse .m3u/.m3u8 content: one path per line, `#`-lines are directives +/// (EXTM3U, EXTINF, ...) and are skipped. Relative entries resolve against +/// `base_dir`. +fn parse_m3u_content(content: &str, base_dir: &std::path::Path) -> Vec { + content + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(|l| resolve_playlist_entry(base_dir, l)) + .collect() +} + +/// Parse .pls content: `File1=path`, `File2=path`, ... entries (key matched +/// case-insensitively; indices keep their numeric order even if the file +/// lists them out of order). Relative entries resolve against `base_dir`. +fn parse_pls_content(content: &str, base_dir: &std::path::Path) -> Vec { + let mut numbered: Vec<(usize, String)> = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('[') || line.starts_with('#') { + continue; + } + let Some((key, val)) = line.split_once('=') else { continue }; + let key = key.trim().to_ascii_lowercase(); + let idx = key.strip_prefix("file").and_then(|n| n.parse::().ok()); + if let Some(n) = idx { + numbered.push((n, resolve_playlist_entry(base_dir, val))); + } + } + numbered.sort_by_key(|(n, _)| *n); + numbered.into_iter().map(|(_, p)| p).collect() +} + +/// Read a playlist file from disk and expand it to its entries. Returns +/// None when the file can't be read or the extension is unknown. +fn read_playlist_file(path: &str) -> Option> { + let content = std::fs::read_to_string(path).ok()?; + let base_dir = std::path::Path::new(path).parent()?.to_path_buf(); + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "m3u" | "m3u8" => Some(parse_m3u_content(&content, &base_dir)), + "pls" => Some(parse_pls_content(&content, &base_dir)), + _ => None, + } +} + +/// Expand a "Load Playlist" selection: plain media files pass through; +/// .m3u/.m3u8/.pls files are replaced by their parsed entries (in order). +/// Returns (paths, skipped) where `skipped` counts playlist files that +/// could not be read or parsed. +fn expand_playlist_selection(paths: &[String]) -> (Vec, usize) { + let mut out = Vec::new(); + let mut skipped = 0usize; + for p in paths { + if is_playlist_file(p) { + match read_playlist_file(p) { + Some(entries) if !entries.is_empty() => out.extend(entries), + _ => skipped += 1, + } + } else { + out.push(p.clone()); + } + } + (out, skipped) +} + +/// Write an extended .m3u playlist: a header, then an EXTINF line (title = +/// file name) followed by the path, per entry, in the exact order given. +/// Paths are written as-is, so absolute queues stay portable across mounts. +fn write_m3u(path: &str, entries: &[String]) -> std::io::Result<()> { + use std::io::Write; + let mut out = String::from("#EXTM3U\n"); + for e in entries { + let title = file_basename(e); + out.push_str(&format!("#EXTINF:-1,{title}\n{e}\n")); + } + if let Some(parent) = std::path::Path::new(path).parent() { + if !parent.as_os_str().is_empty() { + let _ = std::fs::create_dir_all(parent); + } + } + let mut f = std::fs::File::create(path)?; + f.write_all(out.as_bytes())?; + Ok(()) +} + +/// Ensure a save path ends in a playlist extension; appends `.m3u` when the +/// extension is missing or isn't one of m3u/m3u8. +fn ensure_m3u_extension(path: String) -> String { + const KNOWN: &[&str] = &["m3u", "m3u8"]; + let ext = std::path::Path::new(&path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + match ext.as_deref() { + Some(e) if KNOWN.contains(&e) => path, + _ => format!("{path}.m3u"), + } +} + /// 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. @@ -1534,3 +2422,300 @@ fn collect_folder_as_playlist(dir: &str) -> Option> { paths.sort(); Some(paths) } + +#[cfg(test)] +mod queue_playlist_tests { + use super::*; + + /// Unique temp dir for one test run. + fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!( + "ferret_test_{}_{}_{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn m3u_round_trip_preserves_order() { + let dir = temp_dir("roundtrip"); + let path = dir.join("list.m3u"); + let path_str = path.to_string_lossy().into_owned(); + let entries = vec![ + "/media/a.mp4".to_string(), + "/media/z ones/b.mkv".to_string(), + "/media/c.mp3".to_string(), + ]; + write_m3u(&path_str, &entries).unwrap(); + assert_eq!(read_playlist_file(&path_str).unwrap(), entries); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn m3u_parse_skips_directives_and_resolves_relative() { + let base = std::path::Path::new("/media/show"); + let content = "#EXTM3U\n\ + #EXTINF:-1,Episode 1\n\ + /abs/one.mp4\n\ + ep2.mkv\n\ + \n\ + # comment\n\ + http://example.com/stream\n"; + assert_eq!( + parse_m3u_content(content, base), + vec![ + "/abs/one.mp4".to_string(), + "/media/show/ep2.mkv".to_string(), + "http://example.com/stream".to_string(), + ] + ); + } + + #[test] + fn pls_parse_orders_by_file_index() { + let base = std::path::Path::new("/music"); + let content = "[playlist]\n\ + NumberOfEntries=2\n\ + File2=second.ogg\n\ + Title1=ignored\n\ + file1=/abs/first.ogg\n"; + assert_eq!( + parse_pls_content(content, base), + vec![ + "/abs/first.ogg".to_string(), + "/music/second.ogg".to_string(), + ] + ); + } + + #[test] + fn expand_selection_mixes_media_and_playlists() { + let dir = temp_dir("expand"); + let pl = dir.join("pl.m3u"); + std::fs::write(&pl, "#EXTM3U\n/x/a.mp4\n/x/b.mp4\n").unwrap(); + let bad = dir.join("bad.m3u"); // header only → no entries. + std::fs::write(&bad, "#EXTM3U\n").unwrap(); + let missing = dir.join("missing.m3u"); // does not exist → skipped. + + let (expanded, skipped) = expand_playlist_selection(&[ + "/direct/file.mp4".to_string(), + pl.to_string_lossy().into_owned(), + bad.to_string_lossy().into_owned(), + missing.to_string_lossy().into_owned(), + ]); + assert_eq!( + expanded, + vec!["/direct/file.mp4".to_string(), "/x/a.mp4".to_string(), "/x/b.mp4".to_string()] + ); + // The header-only playlist yields no entries and the missing file + // can't be read — both count as skipped. + assert_eq!(skipped, 2); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn m3u_extension_is_ensured_on_save() { + assert_eq!(ensure_m3u_extension("/tmp/list".into()), "/tmp/list.m3u"); + assert_eq!(ensure_m3u_extension("/tmp/list.dat".into()), "/tmp/list.dat.m3u"); + assert_eq!(ensure_m3u_extension("/tmp/list.m3u".into()), "/tmp/list.m3u"); + assert_eq!(ensure_m3u_extension("/tmp/list.M3U8".into()), "/tmp/list.M3U8"); + } + + #[test] + fn position_edit_converts_final_index_to_mpv_args() { + // The queue sidebar's number entry feeds this converter; mpv's + // insert-before semantics are exercised in player-core's tests. + // Typing "1" on row 3 (of 4) inserts before the entry at 0. + assert_eq!(player_core::cmd::playlist_move_args_for_final(3, 0, 4), (3, 0)); + // Typing "4" on row 0 (of 4) = last slot = append. + assert_eq!(player_core::cmd::playlist_move_args_for_final(0, 3, 4), (0, 4)); + } + + #[test] + fn labels_truncate_with_ellipsis() { + assert_eq!(truncate_to_width("abc", 100.0), "abc"); + let short = truncate_to_width("a_very_long_filename.mp4", 30.0); + assert!(short.ends_with('…')); + assert!(short.chars().count() < "a_very_long_filename.mp4".chars().count()); + } +} + +#[cfg(test)] +mod visibility_tests { + use super::*; + use crossbeam_channel::unbounded; + use std::time::Duration; + + fn make_app() -> OverlayApp { + let (_event_tx, event_rx) = unbounded(); + let (cmd_tx, _cmd_rx) = unbounded(); + OverlayApp::new(PlaybackState::default(), event_rx, cmd_tx) + } + + /// Simulate the idle timer having run out `secs` seconds ago. + fn idle_for(app: &mut OverlayApp, secs: u64) { + app.last_mouse_move = Instant::now() - Duration::from_secs(secs); + } + + #[test] + fn hides_after_idle_without_input() { + let mut app = make_app(); + app.auto_hide_secs = 3.0; + app.visible = true; + idle_for(&mut app, 5); + assert!(!app.compute_visibility()); + } + + #[test] + fn stationary_cursor_over_video_still_hides() { + // Regression: the old code reset the idle timer on every frame while + // the cursor was merely INSIDE the window, so the bars never hid + // after the mouse stopped moving ("doesn't rehide after mouse + // movement"). + let mut app = make_app(); + app.auto_hide_secs = 3.0; + app.visible = true; + app.set_mouse_inside(true); + app.pointer_pos = Some(egui::pos2(640.0, 360.0)); // middle of the video + idle_for(&mut app, 5); + assert!(!app.compute_visibility()); + } + + #[test] + fn resting_cursor_on_control_bar_keeps_bars_visible() { + // A cursor parked ON the visible control bar should not have the bar + // vanish beneath it. + let mut app = make_app(); + app.auto_hide_secs = 3.0; + app.visible = true; + app.window_size = egui::vec2(1280.0, 720.0); + app.set_mouse_inside(true); + app.pointer_pos = Some(egui::pos2(640.0, 720.0 - 60.0)); + idle_for(&mut app, 5); + assert!(app.compute_visibility()); + } + + #[test] + fn resting_cursor_on_menu_strip_keeps_bars_visible() { + let mut app = make_app(); + app.auto_hide_secs = 3.0; + app.visible = true; + app.window_size = egui::vec2(1280.0, 720.0); + app.set_mouse_inside(true); + app.pointer_pos = Some(egui::pos2(10.0, 10.0)); + idle_for(&mut app, 5); + assert!(app.compute_visibility()); + } + + #[test] + fn activity_reveals_bars() { + // Regression: moving the mouse must re-show the bars. + let mut app = make_app(); + app.visible = false; + app.note_user_activity(); + assert!(app.visible); + assert!(app.compute_visibility()); + } + + #[test] + fn cursor_leaving_does_not_pin_bars_visible() { + // set_mouse_inside(false) must not count as activity either. + let mut app = make_app(); + app.auto_hide_secs = 3.0; + app.visible = true; + app.set_mouse_inside(false); + app.pointer_pos = None; + idle_for(&mut app, 5); + assert!(!app.compute_visibility()); + } + + #[test] + fn open_menu_is_sticky() { + // A dropdown menu being open must never auto-hide the menu bar out + // from under the user. + let mut app = make_app(); + app.auto_hide_secs = 3.0; + app.menu_popup_open = true; + idle_for(&mut app, 60); + assert!(app.compute_visibility()); + } + + #[test] + fn about_panel_is_sticky() { + let mut app = make_app(); + app.auto_hide_secs = 3.0; + app.about_visible = true; + idle_for(&mut app, 60); + assert!(app.compute_visibility()); + } + + #[test] + fn open_file_dialog_is_sticky() { + let mut app = make_app(); + app.auto_hide_secs = 3.0; + app.file_dialog = + Some(crate::file_dialog::FileDialog::open(crate::file_dialog::FileDialogKind::LoadFile)); + idle_for(&mut app, 60); + assert!(app.compute_visibility()); + } +} + +#[cfg(test)] +mod export_name_tests { + use super::*; + + fn state_with(path: Option<&str>, a: Option, b: Option) -> PlaybackState { + PlaybackState { + path: path.map(|p| p.to_string()), + marker_a: a, + marker_b: b, + ..PlaybackState::default() + } + } + + #[test] + fn export_suggestion_uses_stem_and_times() { + let st = state_with(Some("/home/u/movies/My Video!.mp4"), Some(1.5), Some(83.4)); + assert_eq!( + build_export_suggestion(&st).as_deref(), + Some("My_Video__1-83.mp4") + ); + } + + #[test] + fn export_suggestion_needs_both_markers() { + assert!(build_export_suggestion(&state_with(Some("/a/b.mp4"), Some(1.0), None)).is_none()); + assert!(build_export_suggestion(&state_with(Some("/a/b.mp4"), None, Some(2.0))).is_none()); + } + + #[test] + fn export_suggestion_falls_back_to_clip() { + let st = state_with(None, Some(1.0), Some(2.0)); + // No path, but both markers → the pre-check in draw_file_menu + // requires a loaded file anyway; the helper stays total. + // (state_with(None, ..) with markers: helper needs marker_a/b only.) + assert_eq!(build_export_suggestion(&st).as_deref(), Some("clip_1-2.mp4")); + } + + #[test] + fn missing_extension_defaults_to_mp4() { + assert_eq!(ensure_video_extension("/tmp/out".into()), "/tmp/out.mp4"); + assert_eq!( + ensure_video_extension("/tmp/out.dat".into()), + "/tmp/out.dat.mp4" + ); + } + + #[test] + fn known_extensions_are_kept() { + assert_eq!(ensure_video_extension("/tmp/out.mp4".into()), "/tmp/out.mp4"); + assert_eq!(ensure_video_extension("/tmp/out.MKV".into()), "/tmp/out.MKV"); + assert_eq!(ensure_video_extension("/tmp/out.webm".into()), "/tmp/out.webm"); + } +} diff --git a/crates/player-ui/src/file_dialog.rs b/crates/player-ui/src/file_dialog.rs index 9454fc4..ee7e78c 100644 --- a/crates/player-ui/src/file_dialog.rs +++ b/crates/player-ui/src/file_dialog.rs @@ -49,6 +49,8 @@ pub enum FileDialogKind { LoadFile, LoadFolder, LoadPlaylist, + /// Save the current queue (in the user-organized order) as an .m3u file. + SavePlaylist, SaveMarkers(MarkerExportFormat), LoadSubtitle, ImportMarkers, @@ -61,6 +63,7 @@ impl FileDialogKind { FileDialogKind::LoadFile => "Open File", FileDialogKind::LoadFolder => "Open Folder", FileDialogKind::LoadPlaylist => "Open Playlist (select multiple files)", + FileDialogKind::SavePlaylist => "Save Playlist As", FileDialogKind::SaveMarkers(_) => "Export Markers", FileDialogKind::LoadSubtitle => "Open Subtitle File", FileDialogKind::ImportMarkers => "Import Markers", @@ -69,7 +72,10 @@ impl FileDialogKind { } fn is_save(&self) -> bool { - matches!(self, FileDialogKind::SaveMarkers(_) | FileDialogKind::ExportVideo) + matches!( + self, + FileDialogKind::SaveMarkers(_) | FileDialogKind::ExportVideo | FileDialogKind::SavePlaylist + ) } fn is_multi(&self) -> bool { @@ -85,6 +91,7 @@ impl FileDialogKind { FileDialogKind::LoadFile => MEDIA_EXTENSIONS, FileDialogKind::LoadFolder => &[], FileDialogKind::LoadPlaylist => &[], // we filter in-code (media OR playlist) + FileDialogKind::SavePlaylist => &["m3u", "m3u8"], FileDialogKind::SaveMarkers(fmt) => match fmt { MarkerExportFormat::Text => &["txt"], MarkerExportFormat::Json => &["json"], @@ -104,6 +111,7 @@ impl FileDialogKind { MEDIA_EXTENSIONS.contains(&ext) || PLAYLIST_EXTENSIONS.contains(&ext) } FileDialogKind::SaveMarkers(_) => true, // save accepts any extension + FileDialogKind::SavePlaylist => true, // save accepts any extension FileDialogKind::LoadSubtitle => SUBTITLE_EXTENSIONS.contains(&ext), FileDialogKind::ImportMarkers => MARKER_EXTENSIONS.contains(&ext), FileDialogKind::ExportVideo => true, // save accepts any extension @@ -147,6 +155,13 @@ pub struct FileDialog { impl FileDialog { pub fn open(kind: FileDialogKind) -> Self { + Self::open_with_filename(kind, None) + } + + /// Open a dialog, optionally pre-filling the filename field (save + /// dialogs). `suggested` gives the user a one-click default instead of + /// forcing them to either type blind or click an existing file. + pub fn open_with_filename(kind: FileDialogKind, suggested: Option) -> Self { let start_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); let mut dlg = Self { kind, @@ -154,7 +169,7 @@ impl FileDialog { entries: Vec::new(), selected: None, selected_multi: Vec::new(), - filename: String::new(), + filename: suggested.unwrap_or_default(), error: None, opened_at: Instant::now(), }; diff --git a/crates/player-ui/src/icons.rs b/crates/player-ui/src/icons.rs index 9a37f68..6e0487c 100755 --- a/crates/player-ui/src/icons.rs +++ b/crates/player-ui/src/icons.rs @@ -474,3 +474,28 @@ pub fn shuffle(painter: &Painter, rect: Rect, color: Color32, active: bool) { Stroke::new(thick, col), ); } + +/// Queue/playlist icon — three stacked lines of decreasing length with a +/// leading bullet each (VLC-style playlist glyph). Used by the control-bar +/// button that toggles the queue sidebar. +pub fn queue(painter: &Painter, rect: Rect, color: Color32) { + let size = rect.height().min(rect.width()) * 0.6; + let cx = rect.center().x; + let cy = rect.center().y; + let line_thick = size * 0.12; + let gap = size * 0.28; + let bullet_r = size * 0.09; + for (offset, shrink) in [(-1.0_f32, 0.0_f32), (0.0, 0.15), (1.0, 0.3)] { + let y = cy + offset * gap; + // Leading bullet. + let bx = cx - size * 0.32; + painter.circle_filled(Pos2::new(bx, y), bullet_r, color); + // Line of decreasing length. + let x0 = bx + size * 0.18; + let x1 = cx + size * 0.42 - shrink * size * 0.5; + painter.line_segment( + [Pos2::new(x0, y), Pos2::new(x1, y)], + Stroke::new(line_thick, color), + ); + } +} diff --git a/crates/player-ui/src/renderer.rs b/crates/player-ui/src/renderer.rs index a4cee21..f93c391 100755 --- a/crates/player-ui/src/renderer.rs +++ b/crates/player-ui/src/renderer.rs @@ -16,6 +16,17 @@ use crate::app::OverlayApp; static START_TIME: LazyLock = LazyLock::new(Instant::now); +/// Padding (pixels) added around every painted rect before it becomes part +/// of the overlay's X11 bounding shape. Covers glyph antialiasing bleed and +/// sub-pixel rounding so no painted pixel falls outside the shape. +pub const SHAPE_PAD_PX: f32 = 2.0; + +/// Upper bound on the number of rects sent to XShapeCombineRectangles per +/// frame. Above it we degrade to a single coarse union rect — the X server +/// unions the list anyway, so the only cost of coarseness is a slightly +/// larger see-through-blocking region for one frame. +const MAX_SHAPE_RECTS: usize = 64; + /// Owns the wgpu surface + egui_wgpu renderer for ONE overlay window. pub struct OverlayRenderer { pub device: Arc, @@ -26,6 +37,16 @@ pub struct OverlayRenderer { pub egui_ctx: egui::Context, pub app: OverlayApp, pub viewport_size: [u32; 2], + /// Pixel-space rects egui actually painted last frame (window-local). + /// The main app mirrors these onto the overlay window as an X11 + /// *bounding shape* (XShape), so the overlay is visually present ONLY + /// where UI chrome exists. The rest of the window is a literal hole in + /// the X window — the video window underneath shows through with NO + /// dependence on compositors, EGL/Vulkan alpha modes, or window + /// visuals. This is what makes "no video output" structurally + /// impossible: the overlay can no longer blanket the video with a + /// possibly-opaque surface. + pub painted_rects: Vec, /// Timestamp until which the overlay must clear opaque (dark grey) /// instead of transparent. Set by `resize()`, `suppress_transparency()`, /// and the surface-error recovery paths. Keeps the desktop from showing @@ -82,9 +103,6 @@ impl OverlayRenderer { let caps = surface.get_capabilities(&adapter); // Prefer non-sRGB formats — egui warns about sRGB framebuffers - // ("Detected a linear (sRGBA aware) framebuffer Bgra8UnormSrgb. - // egui prefers Rgba8Unorm or Bgra8Unorm"). Non-sRGB avoids color - // management issues during window operations. let format = caps .formats .iter() @@ -99,10 +117,31 @@ impl OverlayRenderer { // broken presentation during window moves/resizes. Fifo is the // most compatible mode and is required by the WebGPU spec. let present_mode = wgpu::PresentMode::Fifo; - // Use Auto alpha mode — let the surface pick the best-supported - // compositing mode. PreMultiplied can cause artifacts on compositors - // that don't fully support it (common on Xfwm4). - let alpha_mode = wgpu::CompositeAlphaMode::Auto; + // Explicit alpha-mode selection. `CompositeAlphaMode::Auto` in wgpu + // can only ever resolve to Opaque or Inherit (see wgpu-core + // `device/global.rs`, the `Auto` fallback list) — it will NEVER pick + // PreMultiplied/PostMultiplied, so on a Vulkan-backed surface (any + // real GPU) an `Auto` overlay presents OPAQUE black over the video: + // the recurring "app has no video output" bug. Prefer an actually + // transparent composite mode when the surface reports one; fall + // back to Opaque (harmless — the X11 bounding shape is what + // guarantees the video is visible, and the bars look fine opaque). + let alpha_mode = caps + .alpha_modes + .iter() + .copied() + .find(|m| { + matches!( + m, + wgpu::CompositeAlphaMode::PreMultiplied + | wgpu::CompositeAlphaMode::PostMultiplied + ) + }) + .unwrap_or(wgpu::CompositeAlphaMode::Opaque); + info!( + "overlay alpha modes: supported={:?} chosen={:?}", + caps.alpha_modes, alpha_mode + ); let size = window.inner_size(); let surface_config = wgpu::SurfaceConfiguration { @@ -143,6 +182,7 @@ impl OverlayRenderer { egui_ctx, app, viewport_size: [size.width.max(1), size.height.max(1)], + painted_rects: Vec::new(), force_opaque_until: None, }) } @@ -150,6 +190,7 @@ impl OverlayRenderer { /// Render one frame. pub fn render(&mut self, state: PlaybackState, mouse_pos: Option) -> Result<()> { self.app.update_state(state); + self.app.pointer_pos = mouse_pos; self.app.set_mouse_inside(mouse_pos.is_some()); self.app.compute_visibility(); self.app.poll_events(); @@ -176,6 +217,18 @@ impl OverlayRenderer { self.app.draw(ctx); }); + // Remember whether egui now has a focused text field (e.g. the + // save-dialog filename input). The main app consults this flag on + // the next physical keypress to decide whether the keystroke goes + // to the text field (translated to egui events) or to the global + // hotkey map. + self.app.ui_wants_keyboard = self.egui_ctx.wants_keyboard_input(); + + // Record what was actually painted, in window-local pixel coords. + // The main app applies this as the overlay's X11 bounding shape + // (see `painted_pixel_rects`). + self.painted_rects = painted_pixel_rects(&full_output.shapes, SHAPE_PAD_PX); + // Sync textures (new/updated). for (id, image_delta) in &full_output.textures_delta.set { self.egui_renderer @@ -333,3 +386,172 @@ impl OverlayRenderer { self.force_opaque_until = Some(Instant::now() + duration); } } + +// --------------------------------------------------------------------------- +// Painted-rect extraction (feeds the X11 bounding shape) +// --------------------------------------------------------------------------- + +/// Compute the window-local pixel rects egui actually painted this frame. +/// +/// For every clipped shape we take the shape's visual bounding box, clip it +/// to the shape's clip rect, pad it by `pad`, and then coalesce the list +/// into a small set of disjoint rects. Empty input (nothing painted — +/// controls auto-hidden, no dialogs) yields an empty Vec, which the caller +/// turns into an empty bounding region: the overlay becomes fully +/// see-through AND click-through, and the video window beneath receives +/// the input events. +pub fn painted_pixel_rects(shapes: &[egui::epaint::ClippedShape], pad: f32) -> Vec { + let pad_v = egui::vec2(pad, pad); + let mut rects: Vec = shapes + .iter() + .filter_map(|cs| { + let bounds = cs.shape.visual_bounding_rect(); + // Rect::NOTHING (and any non-finite garbage) means "paints + // nothing visible". + if bounds.is_negative() || !bounds.min.is_finite() || !bounds.max.is_finite() { + return None; + } + let clipped = bounds.intersect(cs.clip_rect); + if clipped.is_negative() || clipped.width() <= 0.0 || clipped.height() <= 0.0 { + return None; + } + Some(egui::Rect::from_min_max( + clipped.min - pad_v, + clipped.max + pad_v, + )) + }) + .collect(); + + coalesce_rects(&mut rects, 4); + + if rects.len() > MAX_SHAPE_RECTS { + // Too fragmented — collapse to the overall union. The X server + // unions the rect list anyway, so this is purely a protocol-cost + // guard. + let mut iter = rects.into_iter(); + let Some(first) = iter.next() else { + return Vec::new(); + }; + let union = iter.fold(first, |acc, r| acc.union(r)); + return vec![union]; + } + rects +} + +/// Merge overlapping rects until stable (bounded passes). Adjacent glyph +/// runs and widget clusters collapse into a handful of rects this way, so +/// the X11 shape request stays tiny. +fn coalesce_rects(rects: &mut Vec, max_passes: usize) { + for _ in 0..max_passes { + let mut out: Vec = Vec::with_capacity(rects.len()); + let mut merged_any = false; + for r in rects.iter().copied() { + match out.iter_mut().find(|o| o.intersects(r)) { + Some(o) => { + *o = o.union(r); + merged_any = true; + } + None => out.push(r), + } + } + *rects = out; + if !merged_any { + break; + } + } +} + +#[cfg(test)] +mod shape_tests { + use super::*; + use egui::{Color32, Pos2, Rect, Shape, Vec2}; + + fn clipped(clip: Rect, shape: Shape) -> egui::epaint::ClippedShape { + egui::epaint::ClippedShape { clip_rect: clip, shape } + } + + fn full_screen() -> Rect { + Rect::from_min_size(Pos2::ZERO, Vec2::new(1280.0, 720.0)) + } + + fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect { + Rect::from_min_size(Pos2::new(x, y), Vec2::new(w, h)) + } + + #[test] + fn nothing_painted_yields_empty() { + // No shapes at all -> no rects. + assert!(painted_pixel_rects(&[], 2.0).is_empty()); + // A Noop shape paints nothing. + let shapes = vec![clipped(full_screen(), Shape::Noop)]; + assert!(painted_pixel_rects(&shapes, 2.0).is_empty()); + } + + #[test] + fn single_painted_rect_is_padded() { + let shapes = vec![clipped( + full_screen(), + Shape::rect_filled(rect(100.0, 200.0, 50.0, 20.0), 0.0, Color32::GRAY), + )]; + let out = painted_pixel_rects(&shapes, 2.0); + assert_eq!(out.len(), 1); + assert_eq!(out[0], rect(98.0, 198.0, 54.0, 24.0)); + } + + #[test] + fn overlapping_rects_coalesce() { + let shapes = vec![ + clipped(full_screen(), Shape::rect_filled(rect(0.0, 0.0, 100.0, 30.0), 0.0, Color32::GRAY)), + clipped(full_screen(), Shape::rect_filled(rect(50.0, 10.0, 100.0, 30.0), 0.0, Color32::GRAY)), + ]; + let out = painted_pixel_rects(&shapes, 0.0); + assert_eq!(out.len(), 1); + assert_eq!(out[0], rect(0.0, 0.0, 150.0, 40.0)); + } + + #[test] + fn disjoint_rects_stay_disjoint() { + // Top menu strip and bottom control bar must not merge into a + // full-window rect — that would re-create the "overlay covers the + // video" bug the shape exists to prevent. + let shapes = vec![ + clipped(full_screen(), Shape::rect_filled(rect(0.0, 0.0, 1280.0, 32.0), 0.0, Color32::GRAY)), + clipped(full_screen(), Shape::rect_filled(rect(0.0, 602.0, 1280.0, 118.0), 0.0, Color32::GRAY)), + ]; + let out = painted_pixel_rects(&shapes, 2.0); + assert_eq!(out.len(), 2); + assert!((out[0].center().y - 16.0).abs() < 0.5); + assert!((out[1].center().y - 661.0).abs() < 0.5); + } + + #[test] + fn clip_rect_trims_shape_bounds() { + // A shape whose bounds exceed its clip rect must be trimmed, so the + // shape region never claims area egui was not allowed to paint. + let shapes = vec![clipped( + rect(0.0, 0.0, 100.0, 100.0), + Shape::rect_filled(rect(0.0, 0.0, 1280.0, 720.0), 0.0, Color32::GRAY), + )]; + let out = painted_pixel_rects(&shapes, 0.0); + assert_eq!(out.len(), 1); + assert_eq!(out[0], rect(0.0, 0.0, 100.0, 100.0)); + } + + #[test] + fn rect_cap_collapses_to_union() { + // >MAX_SHAPE_RECTS disjoint rects (all within the clip window!) -> + // one coarse union rect. + let mut shapes = Vec::new(); + for i in 0..(MAX_SHAPE_RECTS + 10) { + let x = (i as f32) * 3.0; + shapes.push(clipped( + full_screen(), + Shape::rect_filled(rect(x, 0.0, 1.0, 10.0), 0.0, Color32::GRAY), + )); + } + let out = painted_pixel_rects(&shapes, 0.0); + assert_eq!(out.len(), 1); + // 74 rects (MAX + 10) at 3px pitch: last spans 219..220px. + assert!((out[0].width() - 220.0).abs() < 0.5); + } +} diff --git a/scripts/dev-cargo.sh b/scripts/dev-cargo.sh new file mode 100644 index 0000000..848978d --- /dev/null +++ b/scripts/dev-cargo.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Convenience wrapper: source the rootless libmpv prefix env and run cargo. +# Usage: bash scripts/dev-cargo.sh check|build|test|clippy [args...] +set -euo pipefail +ROOT="/home/z/my-project/ferret" +source "$ROOT/mpv-prefix/env.sh" +source "$HOME/.cargo/env" +cd "$ROOT" +exec cargo "$@"