Ferret: feature updates
This commit is contained in:
parent
6e71d72d1f
commit
e64adaed14
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<egui::Rect>,
|
||||
/// 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<String> = 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<f64>) {
|
||||
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<f64>) {
|
||||
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<egui::Event> {
|
||||
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<egui::Event> {
|
||||
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<egui::Event> = 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<winit::window::Window>) -> Result<u64> {
|
||||
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<winit::window::Window>) -> Result<u64> {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<Option<Arc<x11rb::rust_connection::RustConnection>>> = OnceLock::new();
|
||||
|
||||
fn shape_conn() -> Option<&'static Arc<x11rb::rust_connection::RustConnection>> {
|
||||
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<winit::window::Window>) -> Option<u32> {
|
||||
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<winit::window::Window>, 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<xproto::Rectangle> = 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<player_core::crop::CropRect>,
|
||||
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<player_core::crop::CropRect>,
|
||||
) -> 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(_)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<mpv_b
|
|||
pub(crate) fn build_seek(target: f64, mode: SeekMode, flags: SeekFlags) -> CoreResult<mpv_bindings::command::Command> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CropRect> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Track> {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,13 +36,26 @@ pub struct EngineOptions {
|
|||
/// MUST be set before `mpv_initialize` (i.e. before `engine.start()`).
|
||||
pub wid: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
let mut langs: Vec<String> = 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<String>, 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<String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ pub struct PlaybackState {
|
|||
pub duration: Option<f64>,
|
||||
/// 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<String>,
|
||||
/// 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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<String>) -> 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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ use crate::app::OverlayApp;
|
|||
|
||||
static START_TIME: LazyLock<Instant> = 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<wgpu::Device>,
|
||||
|
|
@ -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<egui::Rect>,
|
||||
/// 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<egui::Pos2>) -> 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<egui::Rect> {
|
||||
let pad_v = egui::vec2(pad, pad);
|
||||
let mut rects: Vec<egui::Rect> = 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<egui::Rect>, max_passes: usize) {
|
||||
for _ in 0..max_passes {
|
||||
let mut out: Vec<egui::Rect> = 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
Loading…
Reference in New Issue