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"] }
|
winit = { version = "0.30", features = ["x11", "wayland", "rwh_06"] }
|
||||||
raw-window-handle = "0.6"
|
raw-window-handle = "0.6"
|
||||||
egui = "0.29"
|
egui = "0.29"
|
||||||
|
x11rb = "0.13" # X11 SHAPE extension (bounding shape), same version winit locks
|
||||||
egui-wgpu = "0.29"
|
egui-wgpu = "0.29"
|
||||||
wgpu = "22" # pinned to match egui-wgpu 0.29
|
wgpu = "22" # pinned to match egui-wgpu 0.29
|
||||||
pollster = "0.4" # lightweight blocking executor for wgpu init
|
pollster = "0.4" # lightweight blocking executor for wgpu init
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@
|
||||||
# memory bloat in hot-path enums like Cmd and EngineEvent.
|
# memory bloat in hot-path enums like Cmd and EngineEvent.
|
||||||
enum-variant-size-threshold = 256
|
enum-variant-size-threshold = 256
|
||||||
|
|
||||||
# Flag types with too many fields — a code smell for "split this struct".
|
# NOTE: `struct-field-size-threshold` was never a recognized clippy.toml
|
||||||
# PlaybackState has many fields by design (it's a snapshot), so it carries
|
# key on current toolchains and hard-fails config parsing (the related
|
||||||
# an explicit #[allow] where needed.
|
# struct-bloat lints are field-count based, not size based). Removed so
|
||||||
struct-field-size-threshold = 64
|
# `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
|
# 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
|
# for clarity" — but sometimes they're just noise. Keep the lint at warn
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ egui = { workspace = true }
|
||||||
egui-wgpu = { workspace = true }
|
egui-wgpu = { workspace = true }
|
||||||
wgpu = { workspace = true }
|
wgpu = { workspace = true }
|
||||||
raw-window-handle = { workspace = true }
|
raw-window-handle = { workspace = true }
|
||||||
|
x11rb = { workspace = true }
|
||||||
pollster = { workspace = true }
|
pollster = { workspace = true }
|
||||||
crossbeam-channel = { workspace = true }
|
crossbeam-channel = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
//! worker thread because `rfd` blocks while the dialog is open. The worker
|
//! worker thread because `rfd` blocks while the dialog is open. The worker
|
||||||
//! sends the result back via a channel polled from `about_to_wait`.
|
//! 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 std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
|
|
@ -27,8 +27,8 @@ use tracing::{error, info, warn};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
use winit::application::ApplicationHandler;
|
use winit::application::ApplicationHandler;
|
||||||
use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent};
|
use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent};
|
||||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||||
use winit::keyboard::Key;
|
use winit::keyboard::{Key, ModifiersState, NamedKey};
|
||||||
use winit::window::WindowId;
|
use winit::window::WindowId;
|
||||||
|
|
||||||
use player_core::cmd::LoadModeKind;
|
use player_core::cmd::LoadModeKind;
|
||||||
|
|
@ -36,6 +36,8 @@ use player_core::options::EngineOptions;
|
||||||
use player_core::{Cmd, PlayerEngine};
|
use player_core::{Cmd, PlayerEngine};
|
||||||
use player_ui::OverlayRenderer;
|
use player_ui::OverlayRenderer;
|
||||||
|
|
||||||
|
use x11rb::protocol::xproto;
|
||||||
|
|
||||||
mod keymap;
|
mod keymap;
|
||||||
mod windows;
|
mod windows;
|
||||||
|
|
||||||
|
|
@ -43,20 +45,19 @@ use windows::{WindowKind, WindowManager};
|
||||||
|
|
||||||
/// Result from a background ffmpeg export worker.
|
/// Result from a background ffmpeg export worker.
|
||||||
enum DialogResult {
|
enum DialogResult {
|
||||||
/// Export succeeded; the file was written to this path.
|
/// Export succeeded; the file was written to this path. `cropped` says
|
||||||
File(String),
|
/// whether the current zoom/pan focus area was baked in as a crop.
|
||||||
|
File { path: String, cropped: bool },
|
||||||
/// Export failed with this error message.
|
/// Export failed with this error message.
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only one background operation remains: ffmpeg video export. All file
|
/// 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 {
|
enum DialogKind {
|
||||||
ExportVideo {
|
/// Result of an ffmpeg A-B loop export worker.
|
||||||
input: String,
|
ExportVideo,
|
||||||
start: f64,
|
|
||||||
end: f64,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
|
|
@ -118,6 +119,20 @@ struct FerretApp {
|
||||||
/// When false, the overlay drops from AlwaysOnTop so it doesn't block
|
/// When false, the overlay drops from AlwaysOnTop so it doesn't block
|
||||||
/// other applications.
|
/// other applications.
|
||||||
has_focus: bool,
|
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 {
|
impl FerretApp {
|
||||||
|
|
@ -138,6 +153,9 @@ impl FerretApp {
|
||||||
dialog_result_rx,
|
dialog_result_rx,
|
||||||
dialog_result_tx,
|
dialog_result_tx,
|
||||||
has_focus: true,
|
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();
|
let mut infos: Vec<String> = Vec::new();
|
||||||
while let Ok((kind, result)) = self.dialog_result_rx.try_recv() {
|
while let Ok((kind, result)) = self.dialog_result_rx.try_recv() {
|
||||||
match (kind, result) {
|
match (kind, result) {
|
||||||
(DialogKind::ExportVideo { .. }, DialogResult::File(path)) => {
|
(DialogKind::ExportVideo, DialogResult::File { path, cropped }) => {
|
||||||
infos.push(format!("A-B loop exported to {path}"));
|
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}"));
|
infos.push(format!("Export failed: {msg}"));
|
||||||
}
|
}
|
||||||
(_, DialogResult::Error(msg)) => {
|
|
||||||
infos.push(msg);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
infos
|
infos
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_overlay(&mut self) {
|
fn render_overlay(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
let Some(engine) = self.engine.as_ref() else { return; };
|
let Some(engine) = self.engine.as_ref() else { return; };
|
||||||
|
|
||||||
// Forward commands from the overlay UI to the engine. Two commands
|
// Forward commands from the overlay UI to the engine. Three
|
||||||
// are intercepted here (not sent to the engine):
|
// commands are intercepted here (not sent to the engine):
|
||||||
// - ToggleFullscreen: main-app window concern
|
// - ToggleFullscreen: main-app window concern
|
||||||
// - ExportABLoopVideo: main-app runs ffmpeg (engine no-ops it)
|
// - 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_fullscreen_toggle = false;
|
||||||
|
let mut pending_quit = false;
|
||||||
while let Ok(cmd) = self.cmd_rx.try_recv() {
|
while let Ok(cmd) = self.cmd_rx.try_recv() {
|
||||||
match &cmd {
|
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 => {
|
Cmd::ToggleFullscreen => {
|
||||||
|
// Window-level concern — handled after the render below.
|
||||||
pending_fullscreen_toggle = true;
|
pending_fullscreen_toggle = true;
|
||||||
}
|
}
|
||||||
Cmd::ExportABLoopVideo { path } => {
|
Cmd::ExportABLoopVideo { path } => {
|
||||||
|
|
@ -259,6 +288,27 @@ impl FerretApp {
|
||||||
let a = st.marker_a;
|
let a = st.marker_a;
|
||||||
let b = st.marker_b;
|
let b = st.marker_b;
|
||||||
let input = st.path.clone();
|
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);
|
drop(st);
|
||||||
match (a, b, input) {
|
match (a, b, input) {
|
||||||
(Some(start), Some(end), Some(inp)) if end > start => {
|
(Some(start), Some(end), Some(inp)) if end > start => {
|
||||||
|
|
@ -267,16 +317,13 @@ impl FerretApp {
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
path.clone(),
|
path.clone(),
|
||||||
|
crop,
|
||||||
self.dialog_result_tx.clone(),
|
self.dialog_result_tx.clone(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let _ = self.dialog_result_tx.send((
|
let _ = self.dialog_result_tx.send((
|
||||||
DialogKind::ExportVideo {
|
DialogKind::ExportVideo,
|
||||||
input: String::new(),
|
|
||||||
start: 0.0,
|
|
||||||
end: 0.0,
|
|
||||||
},
|
|
||||||
DialogResult::Error(
|
DialogResult::Error(
|
||||||
"Set both A and B markers before exporting".into(),
|
"Set both A and B markers before exporting".into(),
|
||||||
),
|
),
|
||||||
|
|
@ -307,16 +354,79 @@ impl FerretApp {
|
||||||
}
|
}
|
||||||
self.last_overlay_render = Instant::now();
|
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 {
|
if pending_fullscreen_toggle {
|
||||||
self.toggle_fullscreen();
|
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
|
/// Request a redraw on both windows. Single dispatch point so callers
|
||||||
/// never have to repeat the `if let Some(w) = ...` dance.
|
/// never have to repeat the `if let Some(w) = ...` dance.
|
||||||
fn request_redraw_both(&self) {
|
fn request_redraw_both(&self) {
|
||||||
self.windows.video.as_ref().map(|w| w.request_redraw());
|
if let Some(w) = self.windows.video.as_ref() {
|
||||||
self.windows.overlay.as_ref().map(|w| w.request_redraw());
|
w.request_redraw();
|
||||||
|
}
|
||||||
|
if let Some(w) = self.windows.overlay.as_ref() {
|
||||||
|
w.request_redraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request a redraw on the overlay window only.
|
/// 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.
|
// `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" => {
|
Key::Character(s) if s == "q" || s == "Q" => {
|
||||||
event_loop.exit();
|
event_loop.exit();
|
||||||
return;
|
return;
|
||||||
|
|
@ -344,9 +480,14 @@ impl FerretApp {
|
||||||
// UI-derived values (loop mode, speed). Keeps `keymap.rs` decoupled
|
// UI-derived values (loop mode, speed). Keeps `keymap.rs` decoupled
|
||||||
// from `player-ui`.
|
// from `player-ui`.
|
||||||
let state = engine.state();
|
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);
|
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) {
|
fn toggle_fullscreen(&mut self) {
|
||||||
|
|
@ -409,16 +550,100 @@ impl ApplicationHandler for FerretApp {
|
||||||
self.has_focus = gained;
|
self.has_focus = gained;
|
||||||
self.update_overlay_focus();
|
self.update_overlay_focus();
|
||||||
}
|
}
|
||||||
WindowEvent::KeyboardInput {
|
WindowEvent::ModifiersChanged(m) => {
|
||||||
event:
|
self.keyboard_modifiers = m.state();
|
||||||
KeyEvent {
|
}
|
||||||
state: ElementState::Pressed,
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
logical_key,
|
// 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
|
||||||
self.handle_keyboard(&logical_key, event_loop);
|
// 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();
|
self.request_redraw_overlay();
|
||||||
}
|
}
|
||||||
WindowEvent::Resized(_) | WindowEvent::Moved(_) => {
|
WindowEvent::Resized(_) | WindowEvent::Moved(_) => {
|
||||||
|
|
@ -453,7 +678,7 @@ impl ApplicationHandler for FerretApp {
|
||||||
// owns it). But a RedrawRequested on the video window
|
// owns it). But a RedrawRequested on the video window
|
||||||
// means the WM wants us to repaint — forward it to the
|
// means the WM wants us to repaint — forward it to the
|
||||||
// overlay so the controls stay in sync.
|
// overlay so the controls stay in sync.
|
||||||
self.render_overlay();
|
self.render_overlay(event_loop);
|
||||||
self.request_redraw_overlay();
|
self.request_redraw_overlay();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -463,6 +688,9 @@ impl ApplicationHandler for FerretApp {
|
||||||
self.has_focus = gained;
|
self.has_focus = gained;
|
||||||
self.update_overlay_focus();
|
self.update_overlay_focus();
|
||||||
}
|
}
|
||||||
|
WindowEvent::ModifiersChanged(m) => {
|
||||||
|
self.keyboard_modifiers = m.state();
|
||||||
|
}
|
||||||
WindowEvent::CursorMoved { position, .. } => {
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
// The renderer sets pixels_per_point=1.0, so egui's
|
// The renderer sets pixels_per_point=1.0, so egui's
|
||||||
// coordinate system matches physical pixels directly.
|
// coordinate system matches physical pixels directly.
|
||||||
|
|
@ -470,6 +698,9 @@ impl ApplicationHandler for FerretApp {
|
||||||
self.overlay_mouse_pos = Some(pos);
|
self.overlay_mouse_pos = Some(pos);
|
||||||
if let Some(overlay) = self.overlay.as_mut() {
|
if let Some(overlay) = self.overlay.as_mut() {
|
||||||
overlay.app.push_event(egui::Event::PointerMoved(pos));
|
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();
|
self.request_redraw_overlay();
|
||||||
}
|
}
|
||||||
|
|
@ -495,6 +726,8 @@ impl ApplicationHandler for FerretApp {
|
||||||
pressed,
|
pressed,
|
||||||
modifiers: egui::Modifiers::default(),
|
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
|
// Process the click immediately so dropdown menus open
|
||||||
// without waiting for the next render cycle. The
|
// 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
|
// timer — so without this eager render, the click sits in
|
||||||
// pending_events and the dropdown never appears until the
|
// pending_events and the dropdown never appears until the
|
||||||
// mouse moves.
|
// mouse moves.
|
||||||
self.render_overlay();
|
self.render_overlay(event_loop);
|
||||||
self.request_redraw_overlay();
|
self.request_redraw_overlay();
|
||||||
}
|
}
|
||||||
WindowEvent::KeyboardInput {
|
WindowEvent::KeyboardInput { event: key_event, .. } => {
|
||||||
event:
|
self.handle_keyboard(event_loop, &key_event);
|
||||||
KeyEvent {
|
self.request_redraw_overlay();
|
||||||
state: ElementState::Pressed,
|
|
||||||
logical_key,
|
|
||||||
..
|
|
||||||
},
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
self.handle_keyboard(&logical_key, event_loop);
|
|
||||||
}
|
}
|
||||||
WindowEvent::Resized(_) | WindowEvent::Moved(_) => {
|
WindowEvent::Resized(_) | WindowEvent::Moved(_) => {
|
||||||
// The overlay itself was resized or moved. When the overlay
|
// 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
|
// limit caused skipped frames during resize bursts and
|
||||||
// delayed dropdown menu opening. wgpu's PresentMode already
|
// delayed dropdown menu opening. wgpu's PresentMode already
|
||||||
// throttles to the display refresh rate.
|
// throttles to the display refresh rate.
|
||||||
self.render_overlay();
|
self.render_overlay(event_loop);
|
||||||
}
|
}
|
||||||
WindowEvent::CloseRequested => {
|
WindowEvent::CloseRequested => {
|
||||||
event_loop.exit();
|
event_loop.exit();
|
||||||
|
|
@ -547,31 +773,168 @@ impl ApplicationHandler for FerretApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
// Re-render the overlay at ~30fps even when no input arrives,
|
// Self-sustaining ~30fps repaint ticker.
|
||||||
// promptly whenever a dialog result lands, and immediately when
|
//
|
||||||
// there are queued egui events (clicks, key presses) that the
|
// winit's default ControlFlow::Wait parks the event loop once the
|
||||||
// rate-limited RedrawRequested handler might have skipped.
|
// last input event has been processed, and egui's
|
||||||
let has_pending_events = self.overlay
|
// `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()
|
.as_ref()
|
||||||
.map(|o| !o.app.pending_events.is_empty())
|
.map(|o| !o.app.pending_events.is_empty())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let need_render = self.last_overlay_render.elapsed() > Duration::from_millis(33)
|
let tick_due = self.last_overlay_render.elapsed() > Duration::from_millis(33);
|
||||||
|| !self.dialog_result_rx.is_empty()
|
if tick_due || !self.dialog_result_rx.is_empty() || has_pending_events {
|
||||||
|| has_pending_events;
|
// Paint via the RedrawRequested path — request_redraw wakes the
|
||||||
if need_render {
|
// loop and the overlay's RedrawRequested handler does the actual
|
||||||
self.render_overlay();
|
// rendering (single render per tick, throttled by vsync).
|
||||||
self.request_redraw_overlay();
|
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.
|
/// Extract the X11 XID from a winit window.
|
||||||
fn extract_x11_xid(window: &Arc<winit::window::Window>) -> Result<u64> {
|
fn extract_x11_xid(window: &Arc<winit::window::Window>) -> Result<u64> {
|
||||||
use raw_window_handle::HasWindowHandle;
|
use raw_window_handle::HasWindowHandle;
|
||||||
let handle = window.window_handle()?.as_raw();
|
let handle = window.window_handle()?.as_raw();
|
||||||
match handle {
|
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),
|
raw_window_handle::RawWindowHandle::Xcb(x) => Ok(x.window.get() as u64),
|
||||||
other => Err(anyhow::anyhow!(
|
other => Err(anyhow::anyhow!(
|
||||||
"video window is not on X11 (got {other:?}). Wayland requires libmpv's render-context API, which is on the roadmap."
|
"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.
|
/// Set the X11 background pixel on a winit window.
|
||||||
///
|
///
|
||||||
/// winit creates windows with `background_pixel = None`, which means the X
|
/// 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
|
/// 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.
|
/// thread runs the encode and sends the result back on `tx` when done.
|
||||||
/// Runs in the background so the UI stays responsive during encoding.
|
/// 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(
|
fn spawn_ffmpeg_export(
|
||||||
input: String,
|
input: String,
|
||||||
start: f64,
|
start: f64,
|
||||||
end: f64,
|
end: f64,
|
||||||
output: String,
|
output: String,
|
||||||
|
crop: Option<player_core::crop::CropRect>,
|
||||||
tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>,
|
tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>,
|
||||||
) {
|
) {
|
||||||
|
let cropped = crop.is_some();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("ferret-ffmpeg".into())
|
.name("ferret-ffmpeg".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let result = export_video_segment(&input, start, end, &output)
|
let result = export_video_segment(&input, start, end, &output, crop)
|
||||||
.map(|_| DialogResult::File(output.clone()))
|
.map(|_| DialogResult::File { path: output.clone(), cropped })
|
||||||
.unwrap_or_else(|e| DialogResult::Error(e.to_string()));
|
.unwrap_or_else(|e| DialogResult::Error(e.to_string()));
|
||||||
let _ = tx.send((
|
let _ = tx.send((DialogKind::ExportVideo, result));
|
||||||
DialogKind::ExportVideo {
|
|
||||||
input,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
result,
|
|
||||||
));
|
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run ffmpeg to extract the video segment [start, end] from `input` into
|
/// Run ffmpeg to extract the video segment [start, end] from `input` into
|
||||||
/// `output`. Re-encodes video (libx264) for frame accuracy and maximum
|
/// `output`. Re-encodes video (libx264) for frame accuracy and maximum
|
||||||
/// compatibility. Audio is re-encoded to AAC.
|
/// compatibility. Audio is re-encoded to AAC. When `crop` is Some, the
|
||||||
fn export_video_segment(input: &str, start: f64, end: f64, output: &str) -> std::io::Result<()> {
|
/// 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;
|
let duration = end - start;
|
||||||
info!("exporting video segment: {input} [{start:.3}..{end:.3}] → {output}");
|
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
|
// Use -ss before -i for fast seeking. Re-encode video (libx264) for
|
||||||
// frame accuracy and maximum compatibility. Use -y to overwrite output.
|
// 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("-y")
|
||||||
.arg("-ss").arg(format!("{start:.3}"))
|
.arg("-ss").arg(format!("{start:.3}"))
|
||||||
.arg("-i").arg(input)
|
.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("-c:v").arg("libx264")
|
||||||
.arg("-preset").arg("fast")
|
.arg("-preset").arg("fast")
|
||||||
.arg("-crf").arg("18")
|
.arg("-crf").arg("18")
|
||||||
.arg("-c:a").arg("aac")
|
.arg("-c:a").arg("aac")
|
||||||
.arg("-b:a").arg("192k")
|
.arg("-b:a").arg("192k")
|
||||||
.arg(output)
|
.arg(output);
|
||||||
.output()?;
|
let out = ffmpeg.output()?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !out.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
let msg = stderr.lines().last().unwrap_or("unknown ffmpeg error");
|
let msg = stderr.lines().last().unwrap_or("unknown ffmpeg error");
|
||||||
return Err(std::io::Error::new(
|
return Err(std::io::Error::other(format!("ffmpeg: {msg}")));
|
||||||
std::io::ErrorKind::Other,
|
|
||||||
format!("ffmpeg: {msg}"),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(())
|
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`.)
|
/// Skip to the previous playlist entry. (mpv `playlist-prev`.)
|
||||||
PlaylistPrev,
|
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 ---------------------------------------------------------
|
// ---- Speed ---------------------------------------------------------
|
||||||
|
|
||||||
/// Set playback speed. mpv range is 0.01..=100.0; we expose 0.25..=4.0
|
/// 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.
|
/// `vflip`. Pass `true` to enable, `false` to disable.
|
||||||
SetVideoFlipV(bool),
|
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 ---------------------------------------------------
|
// ---- A/B markers ---------------------------------------------------
|
||||||
|
|
||||||
/// Drop marker A at the current playback position (mpv `time-pos`).
|
/// 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> {
|
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)?)
|
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_AID: EventId = 13;
|
||||||
const PROP_SID: EventId = 14;
|
const PROP_SID: EventId = 14;
|
||||||
const PROP_SUB_VISIBILITY: EventId = 15;
|
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
|
/// The engine. Construct with `PlayerEngine::new()`, then `start()`, then
|
||||||
/// issue commands via `send()`. Consume events via `take_event_receiver()`.
|
/// 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
|
// Subtitle visibility — when false, subtitles are hidden even if a
|
||||||
// track is selected. (mpv `sub-visibility`.)
|
// track is selected. (mpv `sub-visibility`.)
|
||||||
(PROP_SUB_VISIBILITY, "sub-visibility", Format::Flag),
|
(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 {
|
for (tag, name, fmt) in observed {
|
||||||
if let Err(e) = mpv.observe_property(tag, name, fmt) {
|
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)?;
|
mpv.command(&cmd)?;
|
||||||
Ok(())
|
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 ------------------------------------------------------
|
// ---- Speed ------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -600,6 +667,69 @@ fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()>
|
||||||
Ok(())
|
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 -----------------------------------------------
|
// ---- A/B markers -----------------------------------------------
|
||||||
|
|
||||||
Cmd::SetMarkerA => {
|
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 — track-list/count may not have fired yet.
|
||||||
refresh_audio_tracks(mpv, bus);
|
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 });
|
bus.send(EngineEvent::FileLoaded { path, title });
|
||||||
}
|
}
|
||||||
MpvEvent::EndFile { reason, error } => {
|
MpvEvent::EndFile { reason, error } => {
|
||||||
|
|
@ -831,11 +965,14 @@ fn handle_mpv_event(event: &MpvEvent, bus: &EngineEventBus, mpv: &MpvHandle) {
|
||||||
bus.send(EngineEvent::EndReached { reason: r });
|
bus.send(EngineEvent::EndReached { reason: r });
|
||||||
}
|
}
|
||||||
MpvEvent::PropertyChange { reply_userdata, name, value } => {
|
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);
|
apply_property_change(bus, *reply_userdata, name, value);
|
||||||
if want_track_refresh {
|
if want_track_refresh {
|
||||||
refresh_audio_tracks(mpv, bus);
|
refresh_audio_tracks(mpv, bus);
|
||||||
}
|
}
|
||||||
|
if want_playlist_refresh {
|
||||||
|
refresh_playlist(mpv, bus);
|
||||||
|
}
|
||||||
if changed {
|
if changed {
|
||||||
bus.send(EngineEvent::StateChanged);
|
bus.send(EngineEvent::StateChanged);
|
||||||
}
|
}
|
||||||
|
|
@ -874,15 +1011,17 @@ fn apply_property_change(
|
||||||
tag: EventId,
|
tag: EventId,
|
||||||
name: &str,
|
name: &str,
|
||||||
value: &mpv_bindings::event::PropertyValue,
|
value: &mpv_bindings::event::PropertyValue,
|
||||||
) -> (bool, bool) {
|
) -> (bool, bool, bool) {
|
||||||
use mpv_bindings::event::PropertyValue as V;
|
use mpv_bindings::event::PropertyValue as V;
|
||||||
let mut changed = true;
|
let mut changed = true;
|
||||||
let mut want_track_refresh = false;
|
let mut want_track_refresh = false;
|
||||||
|
let mut want_playlist_refresh = false;
|
||||||
bus.update_state(|s| {
|
bus.update_state(|s| {
|
||||||
match (tag, value) {
|
match (tag, value) {
|
||||||
(PROP_TIME_POS, V::Double(d)) => s.time_pos = Some(*d),
|
(PROP_TIME_POS, V::Double(d)) => s.time_pos = Some(*d),
|
||||||
(PROP_DURATION, V::Double(d)) => s.duration = Some(*d),
|
(PROP_DURATION, V::Double(d)) => s.duration = Some(*d),
|
||||||
(PROP_PAUSE, V::Flag(b)) => s.paused = *b,
|
(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_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_MUTE, V::Flag(b)) => s.muted = *b,
|
||||||
(PROP_PATH, V::String(s2)) => s.path = Some(s2.clone()),
|
(PROP_PATH, V::String(s2)) => s.path = Some(s2.clone()),
|
||||||
|
|
@ -898,6 +1037,18 @@ fn apply_property_change(
|
||||||
want_track_refresh = true;
|
want_track_refresh = true;
|
||||||
changed = false;
|
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::Double(d)) => s.marker_a = Some(*d),
|
||||||
(PROP_AB_LOOP_A, V::String(st)) => {
|
(PROP_AB_LOOP_A, V::String(st)) => {
|
||||||
// mpv returns "no" when ab-loop-a is unset, or a number string.
|
// 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_AB_LOOP_B => s.marker_b = None,
|
||||||
PROP_AID => s.current_audio_track = None,
|
PROP_AID => s.current_audio_track = None,
|
||||||
PROP_SID => s.current_subtitle_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,
|
_ => changed = false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -937,7 +1091,7 @@ fn apply_property_change(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let _ = name;
|
let _ = name;
|
||||||
(changed, want_track_refresh)
|
(changed, want_track_refresh, want_playlist_refresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enumerate every track (audio + sub) by walking `track-list/N/*`
|
/// 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);
|
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
|
/// Read one `track-list/N` entry into a `Track`. Returns `None` for video
|
||||||
/// tracks and unreadable entries — audio/sub tracks only.
|
/// tracks and unreadable entries — audio/sub tracks only.
|
||||||
fn read_track(mpv: &MpvHandle, i: i64) -> Option<Track> {
|
fn read_track(mpv: &MpvHandle, i: i64) -> Option<Track> {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
pub mod cmd;
|
pub mod cmd;
|
||||||
|
pub mod crop;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
@ -43,3 +44,44 @@ pub use event::{EngineEvent, EngineEventSender};
|
||||||
pub use state::{AudioTrack, PlaybackState, PlayerStatus, Track};
|
pub use state::{AudioTrack, PlaybackState, PlayerStatus, Track};
|
||||||
pub use options::EngineOptions;
|
pub use options::EngineOptions;
|
||||||
pub use error::{CoreError, CoreResult};
|
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()`).
|
/// MUST be set before `mpv_initialize` (i.e. before `engine.start()`).
|
||||||
pub wid: Option<String>,
|
pub wid: Option<String>,
|
||||||
|
|
||||||
/// Video output driver. `"gpu"` for embedded rendering via `wid`.
|
/// Video output driver(s). A comma-separated priority list: mpv tries
|
||||||
/// `"libmpv"` selects the render-context API (target: Wayland support).
|
/// 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,
|
pub vo: String,
|
||||||
|
|
||||||
/// Initial loop mode. Off by default. Set to File/Playlist at construction
|
/// 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.
|
/// if you want looping on startup. Can be changed at runtime via Cmd::SetLoopMode.
|
||||||
pub loop_mode: LoopMode,
|
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 {
|
impl Default for EngineOptions {
|
||||||
|
|
@ -56,8 +69,14 @@ impl Default for EngineOptions {
|
||||||
volume_max: 1.0,
|
volume_max: 1.0,
|
||||||
log_level: "warn".to_string(),
|
log_level: "warn".to_string(),
|
||||||
wid: None,
|
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,
|
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");
|
.expect("LoopMode is exhaustive over LOOP_TABLE");
|
||||||
v.push(("loop-file", file_v.into()));
|
v.push(("loop-file", file_v.into()));
|
||||||
v.push(("loop-playlist", list_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 {
|
if let Some(wid) = &self.wid {
|
||||||
v.push(("wid", wid.clone()));
|
v.push(("wid", wid.clone()));
|
||||||
}
|
}
|
||||||
v
|
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>,
|
pub duration: Option<f64>,
|
||||||
/// Is playback currently paused?
|
/// Is playback currently paused?
|
||||||
pub paused: bool,
|
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.
|
/// Volume 0..=1 (clamped). Mapped 1:1 with libmpv's 0..=100.
|
||||||
pub volume: f32,
|
pub volume: f32,
|
||||||
/// Muted?
|
/// Muted?
|
||||||
|
|
@ -94,6 +100,24 @@ pub struct PlaybackState {
|
||||||
/// Is vertical flip (upside-down) enabled? Tracked locally.
|
/// Is vertical flip (upside-down) enabled? Tracked locally.
|
||||||
pub video_flip_v: bool,
|
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.
|
/// A/B marker positions in seconds. None = not set.
|
||||||
/// Mirrored to mpv's `ab-loop-a` / `ab-loop-b` so mpv itself can drive
|
/// Mirrored to mpv's `ab-loop-a` / `ab-loop-b` so mpv itself can drive
|
||||||
/// the looping; we cache them here for UI rendering.
|
/// 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.
|
/// Current random/shuffle mode. Mirrors the engine's last `SetRandomMode` cmd.
|
||||||
pub random_mode: RandomMode,
|
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
|
// 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,
|
LoadFile,
|
||||||
LoadFolder,
|
LoadFolder,
|
||||||
LoadPlaylist,
|
LoadPlaylist,
|
||||||
|
/// Save the current queue (in the user-organized order) as an .m3u file.
|
||||||
|
SavePlaylist,
|
||||||
SaveMarkers(MarkerExportFormat),
|
SaveMarkers(MarkerExportFormat),
|
||||||
LoadSubtitle,
|
LoadSubtitle,
|
||||||
ImportMarkers,
|
ImportMarkers,
|
||||||
|
|
@ -61,6 +63,7 @@ impl FileDialogKind {
|
||||||
FileDialogKind::LoadFile => "Open File",
|
FileDialogKind::LoadFile => "Open File",
|
||||||
FileDialogKind::LoadFolder => "Open Folder",
|
FileDialogKind::LoadFolder => "Open Folder",
|
||||||
FileDialogKind::LoadPlaylist => "Open Playlist (select multiple files)",
|
FileDialogKind::LoadPlaylist => "Open Playlist (select multiple files)",
|
||||||
|
FileDialogKind::SavePlaylist => "Save Playlist As",
|
||||||
FileDialogKind::SaveMarkers(_) => "Export Markers",
|
FileDialogKind::SaveMarkers(_) => "Export Markers",
|
||||||
FileDialogKind::LoadSubtitle => "Open Subtitle File",
|
FileDialogKind::LoadSubtitle => "Open Subtitle File",
|
||||||
FileDialogKind::ImportMarkers => "Import Markers",
|
FileDialogKind::ImportMarkers => "Import Markers",
|
||||||
|
|
@ -69,7 +72,10 @@ impl FileDialogKind {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_save(&self) -> bool {
|
fn is_save(&self) -> bool {
|
||||||
matches!(self, FileDialogKind::SaveMarkers(_) | FileDialogKind::ExportVideo)
|
matches!(
|
||||||
|
self,
|
||||||
|
FileDialogKind::SaveMarkers(_) | FileDialogKind::ExportVideo | FileDialogKind::SavePlaylist
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_multi(&self) -> bool {
|
fn is_multi(&self) -> bool {
|
||||||
|
|
@ -85,6 +91,7 @@ impl FileDialogKind {
|
||||||
FileDialogKind::LoadFile => MEDIA_EXTENSIONS,
|
FileDialogKind::LoadFile => MEDIA_EXTENSIONS,
|
||||||
FileDialogKind::LoadFolder => &[],
|
FileDialogKind::LoadFolder => &[],
|
||||||
FileDialogKind::LoadPlaylist => &[], // we filter in-code (media OR playlist)
|
FileDialogKind::LoadPlaylist => &[], // we filter in-code (media OR playlist)
|
||||||
|
FileDialogKind::SavePlaylist => &["m3u", "m3u8"],
|
||||||
FileDialogKind::SaveMarkers(fmt) => match fmt {
|
FileDialogKind::SaveMarkers(fmt) => match fmt {
|
||||||
MarkerExportFormat::Text => &["txt"],
|
MarkerExportFormat::Text => &["txt"],
|
||||||
MarkerExportFormat::Json => &["json"],
|
MarkerExportFormat::Json => &["json"],
|
||||||
|
|
@ -104,6 +111,7 @@ impl FileDialogKind {
|
||||||
MEDIA_EXTENSIONS.contains(&ext) || PLAYLIST_EXTENSIONS.contains(&ext)
|
MEDIA_EXTENSIONS.contains(&ext) || PLAYLIST_EXTENSIONS.contains(&ext)
|
||||||
}
|
}
|
||||||
FileDialogKind::SaveMarkers(_) => true, // save accepts any extension
|
FileDialogKind::SaveMarkers(_) => true, // save accepts any extension
|
||||||
|
FileDialogKind::SavePlaylist => true, // save accepts any extension
|
||||||
FileDialogKind::LoadSubtitle => SUBTITLE_EXTENSIONS.contains(&ext),
|
FileDialogKind::LoadSubtitle => SUBTITLE_EXTENSIONS.contains(&ext),
|
||||||
FileDialogKind::ImportMarkers => MARKER_EXTENSIONS.contains(&ext),
|
FileDialogKind::ImportMarkers => MARKER_EXTENSIONS.contains(&ext),
|
||||||
FileDialogKind::ExportVideo => true, // save accepts any extension
|
FileDialogKind::ExportVideo => true, // save accepts any extension
|
||||||
|
|
@ -147,6 +155,13 @@ pub struct FileDialog {
|
||||||
|
|
||||||
impl FileDialog {
|
impl FileDialog {
|
||||||
pub fn open(kind: FileDialogKind) -> Self {
|
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 start_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
|
||||||
let mut dlg = Self {
|
let mut dlg = Self {
|
||||||
kind,
|
kind,
|
||||||
|
|
@ -154,7 +169,7 @@ impl FileDialog {
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
selected: None,
|
selected: None,
|
||||||
selected_multi: Vec::new(),
|
selected_multi: Vec::new(),
|
||||||
filename: String::new(),
|
filename: suggested.unwrap_or_default(),
|
||||||
error: None,
|
error: None,
|
||||||
opened_at: Instant::now(),
|
opened_at: Instant::now(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -474,3 +474,28 @@ pub fn shuffle(painter: &Painter, rect: Rect, color: Color32, active: bool) {
|
||||||
Stroke::new(thick, col),
|
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);
|
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.
|
/// Owns the wgpu surface + egui_wgpu renderer for ONE overlay window.
|
||||||
pub struct OverlayRenderer {
|
pub struct OverlayRenderer {
|
||||||
pub device: Arc<wgpu::Device>,
|
pub device: Arc<wgpu::Device>,
|
||||||
|
|
@ -26,6 +37,16 @@ pub struct OverlayRenderer {
|
||||||
pub egui_ctx: egui::Context,
|
pub egui_ctx: egui::Context,
|
||||||
pub app: OverlayApp,
|
pub app: OverlayApp,
|
||||||
pub viewport_size: [u32; 2],
|
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)
|
/// Timestamp until which the overlay must clear opaque (dark grey)
|
||||||
/// instead of transparent. Set by `resize()`, `suppress_transparency()`,
|
/// instead of transparent. Set by `resize()`, `suppress_transparency()`,
|
||||||
/// and the surface-error recovery paths. Keeps the desktop from showing
|
/// and the surface-error recovery paths. Keeps the desktop from showing
|
||||||
|
|
@ -82,9 +103,6 @@ impl OverlayRenderer {
|
||||||
|
|
||||||
let caps = surface.get_capabilities(&adapter);
|
let caps = surface.get_capabilities(&adapter);
|
||||||
// Prefer non-sRGB formats — egui warns about sRGB framebuffers
|
// 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
|
let format = caps
|
||||||
.formats
|
.formats
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -99,10 +117,31 @@ impl OverlayRenderer {
|
||||||
// broken presentation during window moves/resizes. Fifo is the
|
// broken presentation during window moves/resizes. Fifo is the
|
||||||
// most compatible mode and is required by the WebGPU spec.
|
// most compatible mode and is required by the WebGPU spec.
|
||||||
let present_mode = wgpu::PresentMode::Fifo;
|
let present_mode = wgpu::PresentMode::Fifo;
|
||||||
// Use Auto alpha mode — let the surface pick the best-supported
|
// Explicit alpha-mode selection. `CompositeAlphaMode::Auto` in wgpu
|
||||||
// compositing mode. PreMultiplied can cause artifacts on compositors
|
// can only ever resolve to Opaque or Inherit (see wgpu-core
|
||||||
// that don't fully support it (common on Xfwm4).
|
// `device/global.rs`, the `Auto` fallback list) — it will NEVER pick
|
||||||
let alpha_mode = wgpu::CompositeAlphaMode::Auto;
|
// 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 size = window.inner_size();
|
||||||
let surface_config = wgpu::SurfaceConfiguration {
|
let surface_config = wgpu::SurfaceConfiguration {
|
||||||
|
|
@ -143,6 +182,7 @@ impl OverlayRenderer {
|
||||||
egui_ctx,
|
egui_ctx,
|
||||||
app,
|
app,
|
||||||
viewport_size: [size.width.max(1), size.height.max(1)],
|
viewport_size: [size.width.max(1), size.height.max(1)],
|
||||||
|
painted_rects: Vec::new(),
|
||||||
force_opaque_until: None,
|
force_opaque_until: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +190,7 @@ impl OverlayRenderer {
|
||||||
/// Render one frame.
|
/// Render one frame.
|
||||||
pub fn render(&mut self, state: PlaybackState, mouse_pos: Option<egui::Pos2>) -> Result<()> {
|
pub fn render(&mut self, state: PlaybackState, mouse_pos: Option<egui::Pos2>) -> Result<()> {
|
||||||
self.app.update_state(state);
|
self.app.update_state(state);
|
||||||
|
self.app.pointer_pos = mouse_pos;
|
||||||
self.app.set_mouse_inside(mouse_pos.is_some());
|
self.app.set_mouse_inside(mouse_pos.is_some());
|
||||||
self.app.compute_visibility();
|
self.app.compute_visibility();
|
||||||
self.app.poll_events();
|
self.app.poll_events();
|
||||||
|
|
@ -176,6 +217,18 @@ impl OverlayRenderer {
|
||||||
self.app.draw(ctx);
|
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).
|
// Sync textures (new/updated).
|
||||||
for (id, image_delta) in &full_output.textures_delta.set {
|
for (id, image_delta) in &full_output.textures_delta.set {
|
||||||
self.egui_renderer
|
self.egui_renderer
|
||||||
|
|
@ -333,3 +386,172 @@ impl OverlayRenderer {
|
||||||
self.force_opaque_until = Some(Instant::now() + duration);
|
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