ferret is a desktop video player built on libmpv, with a custom egui overlay UI rendered in a transparent always-on-top window. The engine runs on its own thread; the UI never blocks playback. File dialogs are drawn inside the overlay — no external dialog processes, no z-order conflicts.
This commit is contained in:
commit
cf2224c964
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Rust build artifacts
|
||||||
|
/target
|
||||||
|
**/target
|
||||||
|
|
||||||
|
# libmpv local prefix (generated by scripts/setup-libmpv.sh)
|
||||||
|
/mpv-prefix
|
||||||
|
|
||||||
|
# Editor / OS cruft
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Test fixtures
|
||||||
|
*.mp4
|
||||||
|
*.mkv
|
||||||
|
*.webm
|
||||||
|
*.avi
|
||||||
|
*.mov
|
||||||
|
|
||||||
|
# Logs / runtime
|
||||||
|
*.log
|
||||||
|
|
@ -0,0 +1,503 @@
|
||||||
|
# ferret Development Blog
|
||||||
|
|
||||||
|
A narrative history of ferret's development, design decisions, and the bugs
|
||||||
|
that shaped the codebase. This is not a changelog (see git log for that) —
|
||||||
|
it's the story behind the code.
|
||||||
|
|
||||||
|
**Author:** Jeremy Anderson
|
||||||
|
**Website:** http://git.dcos.net/dcosnet/ferret
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why I built ferret
|
||||||
|
|
||||||
|
I watch a lot of video. Documentaries, lectures, archival footage. And I'm
|
||||||
|
picky about playback quality — I notice frame drops, audio drift, and
|
||||||
|
smearing on corrupt frames. VLC was fine until around 2018, when it started
|
||||||
|
stuttering on my hardware. mpv was better, but its UI is minimalist to a
|
||||||
|
fault — I wanted something between VLC's cluttered chrome and mpv's bare
|
||||||
|
keyboard-driven interface.
|
||||||
|
|
||||||
|
The SMPlayer approach (separate frontend process talking to mpv) always
|
||||||
|
appealed to me, but SMPlayer itself is a Qt app that feels dated. I wanted
|
||||||
|
something modern, written in a memory-safe language, with the accuracy-first
|
||||||
|
philosophy of mpv baked in from the start.
|
||||||
|
|
||||||
|
Rust + egui + libmpv seemed like the right combination. ferret is the result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.1 — The MVP (the option-error bug)
|
||||||
|
|
||||||
|
The first version was a four-crate workspace:
|
||||||
|
|
||||||
|
- `mpv-bindings` — bindgen FFI to libmpv
|
||||||
|
- `player-core` — headless engine with command/event channels
|
||||||
|
- `player-ui` — egui + wgpu overlay renderer
|
||||||
|
- `player-app` — binary, multi-window winit event loop
|
||||||
|
|
||||||
|
The architecture was sound: libmpv owns decode + VO + AO, the engine runs on
|
||||||
|
its own thread, the UI renders in a transparent always-on-top overlay
|
||||||
|
window. Threading model was thread-per-subsystem with crossbeam channels —
|
||||||
|
no async, just message passing.
|
||||||
|
|
||||||
|
### The msg-level bug
|
||||||
|
|
||||||
|
The very first run crashed with `libmpv init: mpv: option error`. The
|
||||||
|
overlay window appeared with a red error toast and a GNOME "Force Quit?"
|
||||||
|
dialog — the whole app hung.
|
||||||
|
|
||||||
|
It took a C probe to find the culprit: `msg-level=warn` is rejected by
|
||||||
|
libmpv 2.x with `MPV_ERROR_OPTION_ERROR`. The `msg-level` parser requires
|
||||||
|
`module=level` form — a bare level string like `warn` was accepted by older
|
||||||
|
mpv builds but is now an error. The fix was `msg-level=all=warn`.
|
||||||
|
|
||||||
|
**Lesson:** Always validate libmpv option strings against the actual library
|
||||||
|
version. The mpv docs are a reference, not a contract — behavior changes
|
||||||
|
between major versions.
|
||||||
|
|
||||||
|
### The 1-second hang
|
||||||
|
|
||||||
|
There was a secondary bug hiding behind the option error. `engine.start()`
|
||||||
|
polled a shared `Mutex<Option<Arc<MpvHandle>>>` for up to 1 second waiting
|
||||||
|
for the engine thread to publish its handle. When libmpv init failed, the
|
||||||
|
engine thread exited without publishing, so the main thread blocked
|
||||||
|
pointlessly for 1 second while the window was already non-responsive — that's
|
||||||
|
what triggered GNOME's "Force Quit?" dialog.
|
||||||
|
|
||||||
|
The fix was a one-shot channel: the engine thread sends `Ok(handle)` or
|
||||||
|
`Err(message)` the moment init completes (success or failure). `start()`
|
||||||
|
returns as soon as it gets the message — never longer than the libmpv init
|
||||||
|
time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.2 — The winit panic (the outer_size bug)
|
||||||
|
|
||||||
|
With the option error fixed, the next run panicked inside winit:
|
||||||
|
|
||||||
|
```
|
||||||
|
thread 'main' panicked at winit-0.30.13/src/platform_impl/linux/x11/window.rs:318:41:
|
||||||
|
called `Result::unwrap()` on an `Err` value: TryFromIntError(PosOverflow)
|
||||||
|
```
|
||||||
|
|
||||||
|
Line 318 was `dimensions.1.try_into().unwrap()` — winit converting the
|
||||||
|
window height from `u32` to `u16` (X11's `CreateWindow` protocol uses
|
||||||
|
`u16` width/height). `PosOverflow` meant the value was bigger than 65535.
|
||||||
|
|
||||||
|
The value came from `video.outer_size()`, which queries the window
|
||||||
|
manager's `_NET_FRAME_EXTENTS` property. After libmpv attached to the
|
||||||
|
video window via `wid`, Xfwm4 started returning garbage extents. The bogus
|
||||||
|
extents got `saturating_add`-ed to the inner size, capping at `u32::MAX`,
|
||||||
|
which then overflowed the `u32 → u16` cast.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
|
||||||
|
Two changes:
|
||||||
|
|
||||||
|
1. **Use `inner_size()` / `inner_position()` instead of `outer_size()` /
|
||||||
|
`outer_position()`.** These query `XGetGeometry` / `XTranslateCoordinates`
|
||||||
|
directly, never the WM-supplied frame extents. They're robust against
|
||||||
|
the libmpv/WM race.
|
||||||
|
|
||||||
|
2. **Hard clamp dimensions to 16384.** Even if a bogus value sneaks through,
|
||||||
|
the clamp prevents the overflow panic.
|
||||||
|
|
||||||
|
**Lesson:** WM-supplied properties are unreliable. libmpv attaching to a
|
||||||
|
window changes its properties in ways the WM doesn't always track correctly.
|
||||||
|
Prefer X11 core protocol queries over EWMH hints when the WM might be
|
||||||
|
confused.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.3 — Feature build-out (loop, speed, A/B markers, subtitles)
|
||||||
|
|
||||||
|
With playback stable, I built out the feature set. The design principle
|
||||||
|
was: every feature should work via both UI (menu/button) and keyboard, and
|
||||||
|
the UI should never block the engine.
|
||||||
|
|
||||||
|
### Loop modes
|
||||||
|
|
||||||
|
mpv has two separate properties: `loop-file` (repeat current file) and
|
||||||
|
`loop-playlist` (repeat entire playlist). I modeled this as a `LoopMode`
|
||||||
|
enum (`Off` / `File` / `Playlist`) with a `cycle()` method — click the
|
||||||
|
loop button to cycle through the three states.
|
||||||
|
|
||||||
|
### Speed control
|
||||||
|
|
||||||
|
Speed was straightforward — `mpv speed` property, observed so the UI
|
||||||
|
stays in sync. I added both preset buttons (0.5×, 1×, 1.5×, 2×) and a
|
||||||
|
fine-grained slider (0.25×–4×). The slider uses a local `speed_drag` value
|
||||||
|
during dragging to avoid fighting with the engine's property-change echo.
|
||||||
|
|
||||||
|
### A/B markers
|
||||||
|
|
||||||
|
This is where I learned that mpv's `ab-loop` is not a property — it's a
|
||||||
|
**command**. `mpv_set_property_string("ab-loop", "yes")` returns
|
||||||
|
`MPV_ERROR_PROPERTY_NOT_FOUND`. The actual semantics:
|
||||||
|
|
||||||
|
- `ab-loop-a` and `ab-loop-b` are properties (set to a timestamp or `"no"`)
|
||||||
|
- When both are set to non-`"no"` values, mpv loops between them automatically
|
||||||
|
- There's no separate "enable" flag
|
||||||
|
|
||||||
|
So `ToggleMarkerLoop` became: if turning on, require both markers set; if
|
||||||
|
turning off, clear both markers (which actually stops the loop). The
|
||||||
|
`marker_loop_enabled` flag is purely for UI display.
|
||||||
|
|
||||||
|
### Audio + subtitle tracks
|
||||||
|
|
||||||
|
I refactored `AudioTrack` into a unified `Track` struct (with `kind: "audio"
|
||||||
|
| "sub"` and a `forced` flag for subtitle tracks). The engine's
|
||||||
|
`refresh_audio_tracks` function (now conceptually "refresh all tracks")
|
||||||
|
enumerates `track-list/N/*` for each track N, splits them into audio and
|
||||||
|
subtitle vectors, and publishes both in `PlaybackState`.
|
||||||
|
|
||||||
|
For subtitle visibility, I observed `sub-visibility` (a flag property) so
|
||||||
|
the "Show Subtitles" checkmark stays in sync. Selecting a track via
|
||||||
|
`SetSubtitleTrack` also forces visibility on — selecting a track you can't
|
||||||
|
see is a UX dead end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.4 — The menu that wouldn't lay out horizontally
|
||||||
|
|
||||||
|
This was the most embarrassing bug in the project. I added a File menu using
|
||||||
|
`egui::menu::menu_button`, and it worked — but the menu buttons stacked
|
||||||
|
vertically instead of horizontally. I wanted a horizontal menu bar
|
||||||
|
(File | Playback | Audio | Subtitles across the top), not a vertical stack.
|
||||||
|
|
||||||
|
### First attempt (wrong)
|
||||||
|
|
||||||
|
I wrapped everything in `ui.vertical()`, which made it... still vertical.
|
||||||
|
|
||||||
|
### Second attempt (wrong)
|
||||||
|
|
||||||
|
I removed the `ui.vertical()` wrapper. Still vertical. The problem wasn't
|
||||||
|
the wrapper — it was that `egui::Area::show()` gives you a `ui` with
|
||||||
|
`Layout::TopDown` (vertical) by default. Every `ui.menu_button()` call
|
||||||
|
stacked on top of the previous one.
|
||||||
|
|
||||||
|
### Final fix
|
||||||
|
|
||||||
|
Two changes:
|
||||||
|
|
||||||
|
1. **Use `egui::Frame` for the background.** `Frame::none().fill(...).show(
|
||||||
|
ui, |ui| { ... })` auto-sizes to its content and paints the fill behind
|
||||||
|
the widgets. No manual painting needed.
|
||||||
|
|
||||||
|
2. **Wrap menu buttons in `ui.horizontal()`.** This overrides the Area's
|
||||||
|
default vertical layout and lays out buttons left-to-right.
|
||||||
|
|
||||||
|
**Lesson:** egui's layout defaults are not always what you want. `Area` is
|
||||||
|
vertical by default; if you want horizontal, you must explicitly ask for it.
|
||||||
|
And don't try to paint backgrounds manually before widgets exist — use
|
||||||
|
`Frame`, which handles sizing for you.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.5 — Overlay covering file dialogs
|
||||||
|
|
||||||
|
After the menu layout was fixed, the user reported that file dialogs were
|
||||||
|
covered by the overlay window. The overlay was created with
|
||||||
|
`WindowLevel::AlwaysOnTop`, which meant it stayed above everything —
|
||||||
|
including the zenity file picker.
|
||||||
|
|
||||||
|
### The half-fix (v0.5)
|
||||||
|
|
||||||
|
I added a `dialogs_open` counter and an `update_overlay_window_level()`
|
||||||
|
method that drops the overlay to `WindowLevel::Normal` while any dialog is
|
||||||
|
open, then restores `AlwaysOnTop` when all dialogs close.
|
||||||
|
|
||||||
|
But I only called `update_overlay_window_level()` when dialogs *close*
|
||||||
|
(inside `poll_dialog_results`). I forgot to call it when dialogs *open*. So
|
||||||
|
the overlay stayed `AlwaysOnTop` the entire time a dialog was open.
|
||||||
|
|
||||||
|
### The full fix (v0.6)
|
||||||
|
|
||||||
|
Added the call right before spawning the worker threads. The order is
|
||||||
|
critical — `update_overlay_window_level()` must run BEFORE
|
||||||
|
`spawn_dialog_worker()` because the dialog opens immediately in the worker
|
||||||
|
thread. Now the overlay drops to `Normal` before the zenity process starts,
|
||||||
|
so the picker appears on top.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.6 — Polish and documentation
|
||||||
|
|
||||||
|
This version focused on:
|
||||||
|
|
||||||
|
### Dark grey background when no video loaded
|
||||||
|
|
||||||
|
v0.5 cleared the overlay to transparent. With no video loaded, this meant
|
||||||
|
the overlay showed the desktop through the window — confusing and ugly.
|
||||||
|
v0.6 makes the clear color conditional: `#1a1a1d` (matching the VLC dark
|
||||||
|
theme bg) when `state.path.is_none()`, transparent when a file is loaded so
|
||||||
|
libmpv's video shows through.
|
||||||
|
|
||||||
|
### A-B loop video export
|
||||||
|
|
||||||
|
The original "Export Markers" feature exported marker *metadata* (A/B
|
||||||
|
timestamps to a .txt or .json file). The user clarified: they wanted to
|
||||||
|
export the actual *video segment* between A and B. Completely different
|
||||||
|
feature.
|
||||||
|
|
||||||
|
I added "Export A-B Loop Video...", which reads A/B markers + file path
|
||||||
|
from engine state, opens a save dialog, then spawns ffmpeg to render the
|
||||||
|
clip. ffmpeg must be in PATH. The command re-encodes video (libx264, CRF 18)
|
||||||
|
for frame accuracy and maximum compatibility.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
v0.6 adds proper documentation: README.md, QUICKSTART.md, and this BLOG.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0 — Production readiness
|
||||||
|
|
||||||
|
The jump to v1.0 was driven by a quality assurance pass and several rounds
|
||||||
|
of bug fixing. Here's what happened:
|
||||||
|
|
||||||
|
### The QA audit
|
||||||
|
|
||||||
|
A senior QA team audited the codebase against PEP 868, POSIX, SEI CERT, and
|
||||||
|
MISRA coding standards. The main findings:
|
||||||
|
|
||||||
|
1. **Nested-if / branch-heavy code** → refactored to `const TABLE` lookups
|
||||||
|
(log-level parser, loop-mode projector, video-rotate clamp, EndFileReason
|
||||||
|
mapper, keyboard dispatcher).
|
||||||
|
2. **`for`/`while` loops** → replaced with iterators (`try_iter`,
|
||||||
|
`filter_map().partition()`, `[-1.0, 0.0, 1.0].iter().for_each()`).
|
||||||
|
3. **Dead code** → deleted `input.rs` (fully shadowed by `main.rs`'s
|
||||||
|
keyboard handler), `_silence_warn` hack, `let _ = rect; // suppress unused`.
|
||||||
|
4. **Decisive-language cleanup** → purged "restored/brought back/falls
|
||||||
|
back/kept for compatibility/non-breaking/previously" from all code
|
||||||
|
comments and docs. Everything rewritten as decisive present-tense
|
||||||
|
design statements.
|
||||||
|
5. **Step-down logic** → the file dialog backends (zenity/kdialog/rfd)
|
||||||
|
refactored into `or_else` chains following the Unix step-down philosophy.
|
||||||
|
|
||||||
|
### Compile errors along the way
|
||||||
|
|
||||||
|
Three compile errors surfaced during the QA refactor, each teaching a lesson:
|
||||||
|
|
||||||
|
1. **`&u16` vs `u16`** — when matching on `&Cmd`, every captured field is a
|
||||||
|
reference. `SetVideoRotate(deg)` binds `deg` as `&u16`, so `.then_some(deg)`
|
||||||
|
produces `Option<&u16>`, and `.unwrap_or(0)` fails. Fixed with `*deg`.
|
||||||
|
|
||||||
|
2. **Stray paren** — a `)` left at the end of a format string in
|
||||||
|
`extract_x11_xid`. The bracket-balance audit (`scripts/audit_brackets.py`)
|
||||||
|
now catches this in milliseconds.
|
||||||
|
|
||||||
|
3. **`wgpu::SurfaceError::Suboptimal`** — doesn't exist in wgpu 22 (folded
|
||||||
|
into `Outdated`). The error recovery path was updated.
|
||||||
|
|
||||||
|
### Complete hotkey coverage
|
||||||
|
|
||||||
|
Every keyboard shortcut now has a menu entry and/or a control-bar button.
|
||||||
|
Menu items display their shortcut in parentheses (e.g. `Play / Pause (Space)`).
|
||||||
|
The keyboard dispatch table was extracted to `keymap.rs`, decoupled from
|
||||||
|
`player-ui` by taking `&PlaybackState` instead of `&Option<OverlayRenderer>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.1–v1.0.4 — The resize/move "mirrored desktop" saga
|
||||||
|
|
||||||
|
After v1.0, the user reported that dragging or resizing the window caused
|
||||||
|
the entire UI to corrupt with "mirrored desktop" artifacts — the window
|
||||||
|
showed stale framebuffer content from behind it.
|
||||||
|
|
||||||
|
This took four rounds to fix completely. Each round revealed a deeper root
|
||||||
|
cause.
|
||||||
|
|
||||||
|
### Round 1: Boolean flag (v1.0.1)
|
||||||
|
|
||||||
|
First attempt: a `surface_just_reconfigured` boolean flag that forced the
|
||||||
|
overlay to clear opaque for one frame after every surface reconfiguration.
|
||||||
|
|
||||||
|
**Why it failed:** during a window *move* (not resize), the overlay's size
|
||||||
|
doesn't change, so `resize()` never fires and the flag never gets set. The
|
||||||
|
overlay stays transparent throughout the drag.
|
||||||
|
|
||||||
|
### Round 2: Timestamp grace period (v1.0.2)
|
||||||
|
|
||||||
|
Second attempt: replaced the boolean with `force_opaque_until: Option<Instant>`
|
||||||
|
— a timestamp set to `now + 150ms` on every `Moved` or `Resized` event.
|
||||||
|
|
||||||
|
**Why it failed:** the render was being skipped (surface `Outdated`), so the
|
||||||
|
old transparent frame stayed visible even with the grace period set.
|
||||||
|
|
||||||
|
### Round 3: X11 background pixel (v1.0.3)
|
||||||
|
|
||||||
|
Third attempt — I found the actual root cause. winit creates windows with
|
||||||
|
`background_pixel = None`. This means the X server does NOT fill the window
|
||||||
|
on resize — it shows whatever is in the framebuffer (stale content, GPU
|
||||||
|
garbage). That's the "mirror."
|
||||||
|
|
||||||
|
**Fix:** `XSetWindowBackground` via FFI, setting the video window's
|
||||||
|
background to `#141416` (dark grey, matching the VLC theme). Called in
|
||||||
|
`setup()` before libmpv attaches via `wid`. Now during a resize, the X
|
||||||
|
server fills the video window with dark grey instead of garbage.
|
||||||
|
|
||||||
|
Also fixed: overlay set to `WindowLevel::AlwaysOnTop` (it had regressed to
|
||||||
|
`Normal`), and `resize()` made a no-op when the size hasn't changed (to
|
||||||
|
avoid destroying the framebuffer on every `Moved` event).
|
||||||
|
|
||||||
|
### Round 4: wgpu presentation fixes (v1.0.4)
|
||||||
|
|
||||||
|
The runtime logs revealed the final piece:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARN Unrecognized present mode 1000361000
|
||||||
|
WARN EGL says it can present to the window but not natively
|
||||||
|
WARN Detected a linear (sRGBA aware) framebuffer Bgra8UnormSrgb
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixes:**
|
||||||
|
1. **Reconfigure + retry on `Outdated`** (revert Round 2's skip-on-Outdated).
|
||||||
|
The render path now ALWAYS produces a fresh frame — no stale transparent
|
||||||
|
frames left visible.
|
||||||
|
2. **Force `PresentMode::Fifo`** — Mailbox was broken on this X11/EGL setup.
|
||||||
|
3. **Force `CompositeAlphaMode::Auto`** — PreMultiplied caused compositing
|
||||||
|
artifacts on Xfwm4.
|
||||||
|
4. **Prefer non-sRGB surface format** — egui warned about sRGB framebuffers.
|
||||||
|
5. **Handle the overlay's own `Moved` event** — when the overlay moves
|
||||||
|
(because we called `set_outer_position()`), its surface can become stale.
|
||||||
|
|
||||||
|
**The lesson:** all previous fixes managed the overlay's transparency
|
||||||
|
*timing* but assumed the render would actually *succeed*. When the surface
|
||||||
|
was `Outdated`, the render was skipped and the old transparent frame stayed.
|
||||||
|
The fix was to always reconfigure + retry, ensuring a fresh frame is always
|
||||||
|
painted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.5 — In-UI file browser (eliminating the pop-under)
|
||||||
|
|
||||||
|
The final issue: the overlay's `AlwaysOnTop` setting meant external file
|
||||||
|
dialogs (zenity/kdialog/rfd) popped *under* the overlay — invisible to the
|
||||||
|
user. Dropping to `Normal` during dialogs was a half-fix that reintroduced
|
||||||
|
the resize/move bugs.
|
||||||
|
|
||||||
|
**The fix:** replace all external file dialogs with an in-UI file browser
|
||||||
|
drawn inside the egui overlay. This eliminates the z-order conflict entirely
|
||||||
|
— the browser IS part of the overlay, so it's always visible and always on
|
||||||
|
top of the video window.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
New module `crates/player-ui/src/file_dialog.rs` — a complete file browser:
|
||||||
|
- Directory listing with sorted entries (dirs first, then files)
|
||||||
|
- Extension filtering per dialog kind (media, subtitle, marker, video export)
|
||||||
|
- Single-select, multi-select (Load Playlist), and save modes (filename input)
|
||||||
|
- Path bar with Up / Home navigation
|
||||||
|
- Modal overlay: dims the background, blocks interaction with controls behind it
|
||||||
|
- Seven dialog kinds: LoadFile, LoadFolder, LoadPlaylist, SaveMarkers,
|
||||||
|
LoadSubtitle, ImportMarkers, ExportVideo
|
||||||
|
|
||||||
|
Menu buttons now open the in-UI dialog directly instead of sending magic
|
||||||
|
Cmd strings. The `dialogs.rs` module (zenity/kdialog/rfd) was deleted
|
||||||
|
entirely, and the `rfd` dependency was removed from `Cargo.toml`.
|
||||||
|
|
||||||
|
### The borrow-after-move bug
|
||||||
|
|
||||||
|
First compile of the in-UI browser had a classic Rust ownership bug: I
|
||||||
|
cleared `self.file_dialog = None` before calling
|
||||||
|
`handle_file_dialog_result()`, but that function tried to read the kind from
|
||||||
|
`self.file_dialog.as_ref()` — which was already `None`. No file ever loaded.
|
||||||
|
|
||||||
|
**Fix:** capture `dialog.kind.clone()` before clearing the dialog, and pass
|
||||||
|
it as a parameter to `handle_file_dialog_result(res, kind)`.
|
||||||
|
|
||||||
|
### The channel deadlock
|
||||||
|
|
||||||
|
The `LoadFolder` and `LoadPlaylist` handlers sent dozens of `Cmd::LoadFile`
|
||||||
|
commands in a tight loop using blocking `send()`. The engine's command
|
||||||
|
channel is bounded at 64 slots — if the engine was busy loading the first
|
||||||
|
file, the channel filled up and `send()` blocked, freezing the UI.
|
||||||
|
|
||||||
|
**Fix:** switched to `try_send()` (non-blocking). If the channel is full,
|
||||||
|
remaining files are skipped. The info toast shows `"Loaded 3/47 files"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design decisions worth recording
|
||||||
|
|
||||||
|
### Why thread-per-subsystem, not async?
|
||||||
|
|
||||||
|
The engine owns libmpv on a dedicated thread. Commands flow UI → engine via
|
||||||
|
a `crossbeam` channel; events flow engine → UI via another channel + shared
|
||||||
|
`Arc<Mutex<PlaybackState>>`. No async runtime, no futures, no tokio.
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
1. **libmpv is not async-friendly.** It's a C library with a blocking event
|
||||||
|
loop. Wrapping it in async would add complexity without benefit.
|
||||||
|
2. **Debuggability.** A thread-per-subsystem model has a simple call stack
|
||||||
|
— you can `gdb attach` and see exactly what each thread is doing. Async
|
||||||
|
stacks are spread across executors and harder to reason about.
|
||||||
|
3. **Backpressure.** Bounded channels give natural backpressure. If the UI
|
||||||
|
can't keep up with events, the engine blocks on send rather than dropping
|
||||||
|
or queuing unboundedly.
|
||||||
|
|
||||||
|
### Why a separate overlay window?
|
||||||
|
|
||||||
|
The overlay is a transparent, borderless, `AlwaysOnTop` window sized to
|
||||||
|
exactly cover the video window. egui + wgpu renders controls into it. The
|
||||||
|
video window is handed to libmpv via `wid` — libmpv renders directly into
|
||||||
|
it using Vulkan/OpenGL.
|
||||||
|
|
||||||
|
The two-window approach has one significant downside: mouse events don't
|
||||||
|
pass through the overlay to the video window. Clicking outside the control
|
||||||
|
bar does nothing (doesn't toggle pause). This is a known limitation;
|
||||||
|
planned fix is the libmpv render-context API (single-window compositing).
|
||||||
|
|
||||||
|
### Why in-UI file dialogs instead of zenity/kdialog/rfd?
|
||||||
|
|
||||||
|
The overlay is `AlwaysOnTop`, which means external file dialogs (separate
|
||||||
|
OS windows) pop under the overlay — invisible to the user. Drawing the file
|
||||||
|
browser inside the egui overlay eliminates this z-order conflict entirely.
|
||||||
|
|
||||||
|
The downside is that the in-UI browser doesn't have all the features of a
|
||||||
|
native file dialog (bookmarks, recent files, search). But it's always
|
||||||
|
visible, always on top of the video window, and has no external dependencies.
|
||||||
|
|
||||||
|
### Why GPL-2.0?
|
||||||
|
|
||||||
|
ferret links against libmpv, which is LGPL-2.1+ (or GPL-2+ at your option).
|
||||||
|
Dynamic linking keeps ferret's license compatible with either. I chose
|
||||||
|
GPL-2.0-or-later because:
|
||||||
|
|
||||||
|
1. **It's a media player.** The media player ecosystem has a tradition of
|
||||||
|
copyleft (VLC is GPL-2.0+, mpv is GPL-2.0+).
|
||||||
|
2. **It prevents proprietary forks.** If someone builds on ferret, they have
|
||||||
|
to share their changes.
|
||||||
|
3. **"Or later" clause** allows future compatibility with GPL-3.0 if needed.
|
||||||
|
|
||||||
|
### Why hard-cap volume at 100%?
|
||||||
|
|
||||||
|
VLC's software amplification (going above 100%) conflicts with PipeWire's
|
||||||
|
logarithmic volume curves. The result is audio that clips or distorts at
|
||||||
|
high volumes. By hard-capping at 100% and mapping the slider 1:1 to the
|
||||||
|
system sink, ferret avoids the clash entirely. If you need louder, turn up
|
||||||
|
your system volume — that's what it's there for.
|
||||||
|
|
||||||
|
### Why force `PresentMode::Fifo`?
|
||||||
|
|
||||||
|
During the resize/move debugging, the runtime logs showed "Unrecognized
|
||||||
|
present mode 1000361000" — wgpu was trying to use Mailbox, which the
|
||||||
|
X11/EGL driver doesn't support. This caused broken presentation during
|
||||||
|
window operations. `Fifo` (vsync) is the WebGPU-required mode and works
|
||||||
|
everywhere. The slight latency cost is worth the stability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
The roadmap is in the README. The big one is Wayland support via
|
||||||
|
`mpv_render_context`, which would eliminate the X11 `wid` dependency and
|
||||||
|
unblock macOS/Windows. After that: config file, playlist UI, MPRIS.
|
||||||
|
|
||||||
|
ferret is a hobby project — I work on it when I have time. Patches are
|
||||||
|
welcome at <http://git.dcos.net/dcosnet/ferret>.
|
||||||
|
|
||||||
|
— Jeremy Anderson, 2026
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
# Contributing to ferret
|
||||||
|
|
||||||
|
Patches are welcome at <http://git.dcos.net/dcosnet/ferret>.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://git.dcos.net/dcosnet/ferret.git
|
||||||
|
cd ferret
|
||||||
|
./scripts/setup-libmpv.sh
|
||||||
|
source ./mpv-prefix/env.sh
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
See [QUICKSTART.md](QUICKSTART.md) for the 5-minute get-started guide and
|
||||||
|
[README.md](README.md) for full architecture documentation.
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
- **Rust 2021 edition, MSRV 1.75.** No features requiring a newer compiler.
|
||||||
|
- **`cargo fmt` before committing.** No manual formatting.
|
||||||
|
- **`cargo clippy --release -- -D warnings` must be clean.** The CI lint
|
||||||
|
script (`./scripts/build.sh --lint`) enforces this.
|
||||||
|
- **All FFI lives in `mpv-bindings`.** No `unsafe` blocks outside that crate
|
||||||
|
except where documented with a SAFETY comment explaining the invariant.
|
||||||
|
- **No `async`.** Thread-per-subsystem with `crossbeam` channels. See
|
||||||
|
`BLOG.md` → "Why thread-per-subsystem, not async?" for the rationale.
|
||||||
|
- **Table-driven dispatch over branch ladders.** See "Coding standards"
|
||||||
|
below.
|
||||||
|
|
||||||
|
## Coding standards
|
||||||
|
|
||||||
|
This codebase follows four standards, adapted to Rust where the standard
|
||||||
|
is language-specific:
|
||||||
|
|
||||||
|
| Standard | Application |
|
||||||
|
|---|---|
|
||||||
|
| PEP 868 | Idiomatic clarity: data over branches, comprehensions over loops, one statement per line |
|
||||||
|
| POSIX | Shell scripts: `set -euo pipefail`, double-quoted expansions, `command -v` for tool detection |
|
||||||
|
| SEI CERT | Defensive FFI: every `unsafe` block carries a SAFETY comment; no ad-hoc `extern "C"` outside `mpv-bindings::sys` |
|
||||||
|
| MISRA | Table-driven control flow; closed-set `match` → `const TABLE` lookup; single exit per logical path |
|
||||||
|
|
||||||
|
### Table-driven dispatch (PEP 868 + MISRA)
|
||||||
|
|
||||||
|
When you have a closed-set `match` on an enum or a known string set, use a
|
||||||
|
`const TABLE` lookup instead of a `match` ladder. Example:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// PREFERRED: table-driven
|
||||||
|
const TABLE: &[(LoopMode, &str, &str)] = &[
|
||||||
|
(LoopMode::Off, "no", "no"),
|
||||||
|
(LoopMode::File, "inf", "no"),
|
||||||
|
(LoopMode::Playlist, "no", "inf"),
|
||||||
|
];
|
||||||
|
let (_, file_v, list_v) = TABLE
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|(m, _, _)| *m == mode)
|
||||||
|
.expect("LoopMode is exhaustive over TABLE");
|
||||||
|
|
||||||
|
// AVOID: branch ladder
|
||||||
|
match mode {
|
||||||
|
LoopMode::Off => { /* ... */ }
|
||||||
|
LoopMode::File => { /* ... */ }
|
||||||
|
LoopMode::Playlist => { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** the table is data — it can be inspected, serialized, and extended
|
||||||
|
without touching control flow. New variants add one row, not one arm.
|
||||||
|
|
||||||
|
### Step-down logic (Unix philosophy)
|
||||||
|
|
||||||
|
When you have a fork of choices (multiple backends, multiple formats,
|
||||||
|
multiple fallbacks), use `or_else` chaining — not `if/else` ladders. Each
|
||||||
|
backend is a single-purpose function returning `Option<T>`; the entry point
|
||||||
|
is a flat chain.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// PREFERRED: step-down chain
|
||||||
|
pub fn pick_file(title: &str, filters: &[(&str, &[&str])]) -> Option<String> {
|
||||||
|
pick_file_zenity(title, filters)
|
||||||
|
.or_else(|| pick_file_kdialog(title, filters))
|
||||||
|
.or_else(|| pick_file_rfd(title, filters))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AVOID: nested if/else
|
||||||
|
pub fn pick_file(title: &str, filters: &[(&str, &[&str])]) -> Option<String> {
|
||||||
|
if let Some(p) = pick_file_zenity(title, filters) {
|
||||||
|
Some(p)
|
||||||
|
} else if let Some(p) = pick_file_kdialog(title, filters) {
|
||||||
|
Some(p)
|
||||||
|
} else {
|
||||||
|
pick_file_rfd(title, filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** the chain is composable, testable per-backend, and reads top-to-bottom
|
||||||
|
as "try this, then this, then this". Adding a new backend is one line.
|
||||||
|
|
||||||
|
### Decisive language
|
||||||
|
|
||||||
|
Code comments and docs use present-tense, decisive statements. Avoid
|
||||||
|
phrasing that suggests back-and-forth or indecision:
|
||||||
|
|
||||||
|
- ❌ "falls back to", "restored", "brought back", "kept for compatibility",
|
||||||
|
"non-breaking", "previously", "original behavior was"
|
||||||
|
- ✅ "steps down to", "routes through", "uses", "selects"
|
||||||
|
|
||||||
|
Comments should read as design decisions, not as a record of changes.
|
||||||
|
|
||||||
|
## Pre-commit checks
|
||||||
|
|
||||||
|
Run before pushing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build.sh --lint # clippy + bracket audit + deref audit
|
||||||
|
./scripts/build.sh --test # cargo test
|
||||||
|
./scripts/build.sh --ci # both + build (full CI pass)
|
||||||
|
```
|
||||||
|
|
||||||
|
The bracket audit (`scripts/audit_brackets.py`) is a Rust-aware tokenizer
|
||||||
|
that catches unbalanced `()`/`{}`/`[]` in milliseconds — faster than
|
||||||
|
waiting for `cargo check` to reach the affected crate.
|
||||||
|
|
||||||
|
The deref audit (`scripts/audit_deref.py`) flags `match` arms on `&Enum`
|
||||||
|
where a captured reference might be forwarded without `*` — the bug class
|
||||||
|
that caused the `SetVideoRotate` compile error in §9 of the QA report.
|
||||||
|
|
||||||
|
## Adding a new hotkey
|
||||||
|
|
||||||
|
1. Add the `Key` → `Cmd` row to `const TABLE` in
|
||||||
|
`crates/player-app/src/keymap.rs`.
|
||||||
|
2. Add a menu entry for the hotkey in `crates/player-ui/src/app.rs`
|
||||||
|
(File / Playback / Audio / Subtitles / Video / Help menu). Append the
|
||||||
|
shortcut key in parentheses to the label: `"Play / Pause (Space)"`.
|
||||||
|
3. If the hotkey has no existing control-bar button, consider whether it
|
||||||
|
needs one. Every hotkey must have a menu entry and/or a button — no
|
||||||
|
orphan hotkeys.
|
||||||
|
4. Update the Keyboard Shortcuts table in `README.md`.
|
||||||
|
5. Run `./scripts/build.sh --lint` to verify the table compiles clean.
|
||||||
|
|
||||||
|
## Adding a new mpv property
|
||||||
|
|
||||||
|
1. Add a `const PROP_<NAME>: EventId = N;` to
|
||||||
|
`crates/player-core/src/engine.rs` (next available integer).
|
||||||
|
2. Add the property to the `observed` array in `engine_main`.
|
||||||
|
3. Add a `(PROP_<NAME>, PropertyValue::Variant(value))` arm to
|
||||||
|
`apply_property_change`.
|
||||||
|
4. Add the field to `PlaybackState` in `crates/player-core/src/state.rs`.
|
||||||
|
5. Expose the state in the UI if user-visible
|
||||||
|
(`crates/player-ui/src/app.rs`).
|
||||||
|
|
||||||
|
## Reporting bugs
|
||||||
|
|
||||||
|
Please include:
|
||||||
|
|
||||||
|
- ferret version (`ferret --version`)
|
||||||
|
- libmpv version (`pkg-config --modversion mpv`)
|
||||||
|
- Desktop environment / window manager
|
||||||
|
- Steps to reproduce
|
||||||
|
- Log output (`RUST_LOG=info ferret ...`)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions are licensed under the
|
||||||
|
[GPL-2.0-or-later](LICENSE) license that covers the project.
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,63 @@
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/mpv-bindings",
|
||||||
|
"crates/player-core",
|
||||||
|
"crates/player-ui",
|
||||||
|
"crates/player-app",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.75"
|
||||||
|
license = "GPL-2.0-or-later"
|
||||||
|
authors = ["Jeremy Anderson <jeremy@dcos.net>"]
|
||||||
|
repository = "http://git.dcos.net/dcosnet/ferret"
|
||||||
|
homepage = "http://git.dcos.net/dcosnet/ferret"
|
||||||
|
documentation = "http://git.dcos.net/dcosnet/ferret/blob/master/README.md"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Internal crates
|
||||||
|
mpv-bindings = { path = "crates/mpv-bindings" }
|
||||||
|
player-core = { path = "crates/player-core" }
|
||||||
|
player-ui = { path = "crates/player-ui" }
|
||||||
|
|
||||||
|
# FFI / system (we generate our own bindgen output in mpv-bindings/build.rs)
|
||||||
|
bindgen = "0.70"
|
||||||
|
pkg-config = "0.3"
|
||||||
|
|
||||||
|
# Concurrency
|
||||||
|
crossbeam-channel = "0.5"
|
||||||
|
crossbeam-utils = "0.8"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
|
||||||
|
# Errors / logging
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
|
||||||
|
# Window / GL
|
||||||
|
winit = { version = "0.30", features = ["x11", "wayland", "rwh_06"] }
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
egui = "0.29"
|
||||||
|
egui-wgpu = "0.29"
|
||||||
|
wgpu = "22" # pinned to match egui-wgpu 0.29
|
||||||
|
pollster = "0.4" # lightweight blocking executor for wgpu init
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = "symbols"
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 1 # wgpu/shaders benefit from at least -O1
|
||||||
|
debug = 1
|
||||||
|
|
@ -0,0 +1,358 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
|
software--to make sure the software is free for all its users. This
|
||||||
|
General Public License applies to most of the Free Software
|
||||||
|
Foundation's software and to any other program whose authors commit to
|
||||||
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
|
the GNU Lesser General Public License instead.) You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
this service if you wish), that you receive source code or can get it
|
||||||
|
if you want it, that you can change the software or use pieces of it
|
||||||
|
in new free programs; and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
|
These restrictions translate to certain responsibilities for you if you
|
||||||
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
|
you have. You must make sure that they, too, receive or can get the
|
||||||
|
source code. And you must show them these terms so they know their
|
||||||
|
rights.
|
||||||
|
|
||||||
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
|
distribute and/or modify the software.
|
||||||
|
|
||||||
|
Also, for each author's protection and ours, we want to make certain
|
||||||
|
that everyone understands that there is no warranty for this free
|
||||||
|
software. If the software is modified by someone else and passed on, we
|
||||||
|
want its recipients to know that what they have is not the original, so
|
||||||
|
that any problems introduced by others will not reflect on the original
|
||||||
|
authors' reputations.
|
||||||
|
|
||||||
|
Finally, any free program is threatened constantly by software
|
||||||
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
|
program will individually obtain patent licenses, in effect making the
|
||||||
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
|
refers to any such program or work, and a "work based on the Program"
|
||||||
|
means either the Program or any derivative work under copyright law:
|
||||||
|
that is to say, a work containing the Program or a portion of it,
|
||||||
|
either verbatim or with modifications and/or translated into another
|
||||||
|
language. (Hereinafter, translation is included without limitation in
|
||||||
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running the Program is not restricted, and the output from the Program
|
||||||
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
|
Whether that is true depends on what the Program does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
|
source code as you receive it, in any medium, provided that you
|
||||||
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
|
notices that refer to this License and to the absence of any warranty;
|
||||||
|
and give any other recipients of the Program a copy of this License
|
||||||
|
along with the Program.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
|
of it, thus forming a work based on the Program, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) You must cause the modified files to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
b) You must cause any work that you distribute or publish, that in
|
||||||
|
whole or in part contains or is derived from the Program or any
|
||||||
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
|
|
||||||
|
c) If the modified program normally reads commands interactively
|
||||||
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
|
announcement including an appropriate copyright notice and a
|
||||||
|
notice that there is no warranty (or else, saying that you provide
|
||||||
|
a warranty) and that users may redistribute the program under
|
||||||
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
|
does not normally print such an announcement, your work based on
|
||||||
|
the Program is not required to print an announcement.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Program,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Program.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Program
|
||||||
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
|
under Section 2) in object code or executable form under the terms of
|
||||||
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
|
source code, which must be distributed under the terms of Sections
|
||||||
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
|
b) Accompany it with a written offer, valid for at least three
|
||||||
|
years, to give any third party, for a charge no more than your
|
||||||
|
cost of physically performing source distribution, a complete
|
||||||
|
machine-readable copy of the corresponding source code, to be
|
||||||
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
|
customarily used for software interchange; or,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to state thoroughly and unambiguously what is
|
||||||
|
believed to be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
convey the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along
|
||||||
|
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program is interactive, make it output a short notice like this
|
||||||
|
when it starts in an interactive mode:
|
||||||
|
|
||||||
|
Gnomovision version 69, Copyright (C) year name of author
|
||||||
|
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, the commands you use may
|
||||||
|
be called something other than `show w' and `show c'; they could even be
|
||||||
|
mouse-clicks or menu items--whatever suits your program.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||||
|
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||||
|
|
||||||
|
<signature of Ty Coon>, 1 April 1989
|
||||||
|
Ty Coon, President of Vice
|
||||||
|
|
||||||
|
This General Public License does not permit incorporating your program into
|
||||||
|
proprietary programs. If your program is a subroutine library, you may
|
||||||
|
consider it more useful to permit linking proprietary applications with the
|
||||||
|
library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ferret - a modern, accuracy-first video player for Linux
|
||||||
|
Copyright (C) 2026 Jeremy Anderson
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along
|
||||||
|
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
# Quickstart
|
||||||
|
|
||||||
|
**Get ferret running in under 5 minutes.**
|
||||||
|
|
||||||
|
This guide walks you through installing dependencies, building ferret, and
|
||||||
|
playing your first video. For full documentation, see [README.md](README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Install Rust
|
||||||
|
|
||||||
|
ferret requires Rust 1.75 or later. Install via [rustup](https://rustup.rs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
rustc --version # should print 1.75.0 or higher
|
||||||
|
```
|
||||||
|
|
||||||
|
If you already have Rust, update to the latest stable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup update stable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Get the source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://git.dcos.net/dcosnet/ferret.git
|
||||||
|
cd ferret
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Set up libmpv (one-time)
|
||||||
|
|
||||||
|
ferret links against libmpv 2.x. The `setup-libmpv.sh` script downloads the
|
||||||
|
libmpv `.deb` packages and extracts them into a local prefix — **no root
|
||||||
|
required**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/setup-libmpv.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This takes about 30 seconds. When it finishes, source the env script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ./mpv-prefix/env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
You need to source this script in **every terminal** before building or
|
||||||
|
running ferret (or add it to your `~/.bashrc`).
|
||||||
|
|
||||||
|
### What the script installs
|
||||||
|
|
||||||
|
- `libmpv2`, `libmpv-dev` — the libmpv shared library and headers
|
||||||
|
- `libxkbcommon-x11-0`, `libxcb-xkb1`, `xkb-data` — winit keyboard support
|
||||||
|
- `mesa-vulkan-drivers`, `libgl1-mesa-dri` — GPU drivers (software fallback)
|
||||||
|
- `libclang1-19`, `libllvm19` — for bindgen (FFI generation)
|
||||||
|
- `xvfb`, `xauth` — for headless testing (optional)
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkg-config --modversion mpv # should print "2.5.0" or similar
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
First build takes about 2 minutes (compiles bindgen + winit + egui + wgpu).
|
||||||
|
Subsequent builds are incremental.
|
||||||
|
|
||||||
|
The binary is at `target/release/ferret`. The build bakes in an rpath to
|
||||||
|
`mpv-prefix/usr/lib/x86_64-linux-gnu/`, so you don't need
|
||||||
|
`LD_LIBRARY_PATH` at runtime.
|
||||||
|
|
||||||
|
### Build flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build.sh --debug # debug build
|
||||||
|
./scripts/build.sh --clean # clean + rebuild
|
||||||
|
./scripts/build.sh --test # run cargo test
|
||||||
|
./scripts/build.sh --lint # clippy + bracket audit + deref audit
|
||||||
|
./scripts/build.sh --ci # full CI pass (lint + test + build)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Install runtime dependencies
|
||||||
|
|
||||||
|
ferret needs one external tool at runtime:
|
||||||
|
|
||||||
|
### ffmpeg (for A-B loop video export)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian/Ubuntu
|
||||||
|
sudo apt install ffmpeg
|
||||||
|
|
||||||
|
# Fedora
|
||||||
|
sudo dnf install ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
ffmpeg is only needed for **File → Export A-B Loop Video...**. If you don't
|
||||||
|
plan to use that feature, you can skip it.
|
||||||
|
|
||||||
|
File dialogs are built into the UI — no zenity, kdialog, or rfd required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Play a video
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/ferret /path/to/video.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
|
||||||
|
1. A dark grey window (1280×720 by default)
|
||||||
|
2. A menu bar at the top-left: **File Playback Subtitles Video Help** + a status line
|
||||||
|
3. The video starts playing immediately
|
||||||
|
|
||||||
|
If you launch with no arguments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/ferret
|
||||||
|
```
|
||||||
|
|
||||||
|
You get an empty dark grey window. Click **File → Load File...** to open the
|
||||||
|
in-UI file browser and pick a file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Basic controls
|
||||||
|
|
||||||
|
| Action | How |
|
||||||
|
|--------|-----|
|
||||||
|
| Play / Pause | Space, or click the ▶/⏸ button |
|
||||||
|
| Seek | Drag the seek bar, or ← / → keys |
|
||||||
|
| Volume | Drag the slider, or ↑ / ↓ keys |
|
||||||
|
| Mute | M key, or click the speaker icon |
|
||||||
|
| Fullscreen | F key, or click the ⛶ button |
|
||||||
|
| Quit | Q key, or File → Quit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Try the A/B loop export
|
||||||
|
|
||||||
|
This is ferret's signature feature — extract a clip from a video using
|
||||||
|
A/B markers:
|
||||||
|
|
||||||
|
1. Play the video to the start of the segment you want.
|
||||||
|
2. Press `[` to set marker A.
|
||||||
|
3. Play to the end of the segment.
|
||||||
|
4. Press `]` to set marker B.
|
||||||
|
5. Click **File → Export A-B Loop Video...**
|
||||||
|
6. The in-UI file browser opens — pick where to save the clip.
|
||||||
|
7. ffmpeg runs in the background and renders the segment.
|
||||||
|
|
||||||
|
You'll see red and blue pins on the seek bar marking A and B. An info toast
|
||||||
|
appears when the export is done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Load a folder as a playlist
|
||||||
|
|
||||||
|
```
|
||||||
|
File → Load Folder... → pick a directory
|
||||||
|
```
|
||||||
|
|
||||||
|
ferret scans the folder for video files (`.mp4`, `.mkv`, `.webm`, `.avi`,
|
||||||
|
`.mov`, `.flv`, `.mp3`, `.ogg`, `.wav`, `.flac`, etc.), sorts them
|
||||||
|
alphabetically, and loads them as a playlist. Use **N** / **P** keys (or
|
||||||
|
the ⏭ / ⏮ buttons) to move between entries.
|
||||||
|
|
||||||
|
To loop the whole playlist: **Playback → Loop → Loop Playlist**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "failed to locate libmpv via pkg-config"
|
||||||
|
|
||||||
|
You forgot to source the env script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ./mpv-prefix/env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### "libmpv.so.2: cannot open shared object file"
|
||||||
|
|
||||||
|
Same fix — source the env script. Or set `LD_LIBRARY_PATH` manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LD_LIBRARY_PATH=./mpv-prefix/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
### "ffmpeg not found in PATH"
|
||||||
|
|
||||||
|
Install ffmpeg (see step 5). This only affects A-B loop video export.
|
||||||
|
|
||||||
|
### Window opens but is transparent / shows desktop
|
||||||
|
|
||||||
|
This happens when no video is loaded — the overlay clears to dark grey
|
||||||
|
(`#1a1a1d`). If you're seeing the desktop instead, make sure you're running
|
||||||
|
on X11 (Wayland is not yet supported — see [README.md](README.md#roadmap)).
|
||||||
|
|
||||||
|
### File dialogs don't appear
|
||||||
|
|
||||||
|
File dialogs are built into the ferret UI — no external tools needed. If
|
||||||
|
the dialog doesn't open, check that the overlay is receiving mouse events
|
||||||
|
(move the mouse over the window).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
- Read the full [README.md](README.md) for architecture details and the
|
||||||
|
complete keyboard shortcut reference
|
||||||
|
- Read [BLOG.md](BLOG.md) for the development history and design decisions
|
||||||
|
- File bugs at <http://git.dcos.net/dcosnet/ferret/issues>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
ferret doesn't install anything system-wide. To remove:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf /path/to/ferret # removes source + build + mpv-prefix
|
||||||
|
```
|
||||||
|
|
||||||
|
The `mpv-prefix/` directory contains the extracted libmpv packages — it's
|
||||||
|
self-contained and safe to delete.
|
||||||
|
|
@ -0,0 +1,591 @@
|
||||||
|
# ferret
|
||||||
|
|
||||||
|
**A modern, accuracy-first video player for Linux, written in Rust.**
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](#)
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
|
||||||
|
ferret is a desktop video player built on libmpv, with a custom egui overlay UI
|
||||||
|
rendered in a transparent always-on-top window. The engine runs on its own
|
||||||
|
thread; the UI never blocks playback. File dialogs are drawn inside the overlay
|
||||||
|
— no external dialog processes, no z-order conflicts.
|
||||||
|
|
||||||
|
- **Author:** Jeremy Anderson
|
||||||
|
- **Website:** http://git.dcos.net/dcosnet/ferret
|
||||||
|
- **License:** GPL-2.0-or-later
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Why ferret?](#why-ferret)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Features](#features)
|
||||||
|
- [Crates](#crates)
|
||||||
|
- [Build](#build)
|
||||||
|
- [Run](#run)
|
||||||
|
- [Keyboard Shortcuts](#keyboard-shortcuts)
|
||||||
|
- [Menu Reference](#menu-reference)
|
||||||
|
- [Control Bar](#control-bar)
|
||||||
|
- [A/B Markers and Loop Export](#ab-markers-and-loop-export)
|
||||||
|
- [Subtitles](#subtitles)
|
||||||
|
- [Video Transforms](#video-transforms)
|
||||||
|
- [Accuracy-First Error Policy](#accuracy-first-error-policy)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Roadmap](#roadmap)
|
||||||
|
- [Contributing](#contributing)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why ferret?
|
||||||
|
|
||||||
|
ferret sidesteps three root causes of playback stutter on Linux:
|
||||||
|
|
||||||
|
1. **Copy-back hardware decoding** shuttling frames over the PCIe bus. ferret
|
||||||
|
sets `hwdec=auto-safe` — only zero-copy hardware decode paths
|
||||||
|
(VA-API/NVDEC/Vulkan video decode), never copy-back modes.
|
||||||
|
2. **Software audio amplification** conflicting with PipeWire's logarithmic
|
||||||
|
volume curves. ferret hard-caps volume at 100% (`volume_max=1.0`); the
|
||||||
|
volume slider maps 1:1 with the system sink.
|
||||||
|
3. **Push-through error handling** causing visual smearing on corrupt frames.
|
||||||
|
ferret sets `framedrop=vo` — drop frames only at display, never on decode.
|
||||||
|
Corrupt frames are dropped, never smeared through.
|
||||||
|
|
||||||
|
The engine thread owns libmpv exclusively; UI thread hangs never cause AV
|
||||||
|
stutter because the engine has no knowledge of the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ ferret binary (player-app) │
|
||||||
|
│ │
|
||||||
|
│ winit event loop (main thread) │
|
||||||
|
│ ├─ Video window ──wid──▶ libmpv ──▶ FFmpeg/Vulkan │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ├─ decode │
|
||||||
|
│ │ ├─ AO (PipeWire/PulseAudio) │
|
||||||
|
│ │ └─ VO (Vulkan/OpenGL) │
|
||||||
|
│ │ │
|
||||||
|
│ └─ Overlay window (egui + wgpu, transparent, AlwaysOnTop) │
|
||||||
|
│ ├─ Control bar (play/pause, seek, volume, markers) │
|
||||||
|
│ ├─ Menu bar (File / Playback / Audio / Subtitles / etc) │
|
||||||
|
│ └─ In-UI file browser (replaces external file dialogs) │
|
||||||
|
│ │
|
||||||
|
│ Engine thread (named "ferret-engine") │
|
||||||
|
│ └─ owns MpvHandle, drains libmpv event queue, │
|
||||||
|
│ publishes EngineEvents on a bounded channel │
|
||||||
|
│ │
|
||||||
|
│ ffmpeg worker thread (named "ferret-ffmpeg") │
|
||||||
|
│ └─ spawned for A-B loop video export, sends result back │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Threading model:** thread-per-subsystem with `crossbeam` channels. No async.
|
||||||
|
- **Cmd channel** (UI → engine): `bounded::<Cmd>(64)` — bounded for natural backpressure.
|
||||||
|
- **Event channel** (engine → UI): `bounded::<EngineEvent>(256)` — single consumer.
|
||||||
|
- **State snapshot**: `Arc<Mutex<PlaybackState>>` (parking_lot) shared between threads.
|
||||||
|
|
||||||
|
**Two-window model:** libmpv renders directly into the video window via X11
|
||||||
|
`wid` embedding. The overlay window is transparent, borderless, and
|
||||||
|
`AlwaysOnTop` — it covers the video window and renders the UI with egui + wgpu.
|
||||||
|
Mouse clicks outside the control bar don't pass through (known limitation;
|
||||||
|
fix requires the libmpv render-context API — on the roadmap).
|
||||||
|
|
||||||
|
**X11 background pixel:** the video window's X11 background pixel is set to
|
||||||
|
`#141416` via `XSetWindowBackground` before libmpv attaches. This prevents the
|
||||||
|
X server from showing framebuffer garbage during window resize/move (the
|
||||||
|
"mirrored desktop" artifact).
|
||||||
|
|
||||||
|
**wgpu presentation:** forced to `PresentMode::Fifo` (vsync) for compatibility.
|
||||||
|
Surface format prefers non-sRGB (`Bgra8Unorm` → `Rgba8Unorm` → sRGB variants)
|
||||||
|
to avoid egui color-management warnings. Alpha mode is `Auto`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core Playback
|
||||||
|
|
||||||
|
- **Play / Pause / Stop** — buttons + keyboard + menu
|
||||||
|
- **Seek bar** — drag-to-scrub, click-to-jump, hover preview line, A/B marker pins
|
||||||
|
- **Frame stepping** — step forward/backward one frame at a time
|
||||||
|
- **Volume** — slider + mute toggle, hard-capped at 100%
|
||||||
|
- **Fullscreen** — borderless fullscreen toggle
|
||||||
|
|
||||||
|
### Loop Modes
|
||||||
|
|
||||||
|
- **Off** — play once, stop at EOF
|
||||||
|
- **Loop File** — repeat the current file indefinitely (`loop-file=inf`)
|
||||||
|
- **Loop Playlist** — repeat the entire playlist indefinitely (`loop-playlist=inf`)
|
||||||
|
|
||||||
|
### Speed Control
|
||||||
|
|
||||||
|
- **Presets** — 0.25×, 0.50×, 0.75×, 1.00×, 1.25×, 1.50×, 2.00×, 3.00×, 4.00× (menu)
|
||||||
|
- **Quick presets** — 0.50×, 1.00×, 1.50×, 2.00× (control bar buttons)
|
||||||
|
- **Fine slider** — 0.25× to 4.0× in 0.01 increments (control bar)
|
||||||
|
- **Keyboard** — `-` / `=` nudge by 0.25×
|
||||||
|
|
||||||
|
### A/B Markers
|
||||||
|
|
||||||
|
- **Set A / Set B** — drop markers at current playback position
|
||||||
|
- **Marker pins** — drawn on the seek bar (red `#FF6464` for A, blue `#64B4FF` for B)
|
||||||
|
- **Toggle A-B Loop** — loop between markers (mpv's `ab-loop-a` / `ab-loop-b`)
|
||||||
|
- **Export A-B Loop Video** — renders the segment to a new file via ffmpeg
|
||||||
|
- **Import/Export Markers** — save/load marker positions as `.txt` or `.json`
|
||||||
|
|
||||||
|
### File Menu
|
||||||
|
|
||||||
|
- **Load File** — in-UI file browser (media extensions filtered)
|
||||||
|
- **Load Folder** — enumerate video files in a folder as a playlist
|
||||||
|
- **Load Playlist** — multi-select files (media + `.m3u`/`.m3u8`/`.pls`)
|
||||||
|
- **Export A-B Loop Video** — ffmpeg-rendered clip (MP4/MKV/WebM)
|
||||||
|
- **Import Markers** — load saved A/B positions from `.txt` or `.json`
|
||||||
|
- **Toggle Fullscreen**
|
||||||
|
- **Quit**
|
||||||
|
|
||||||
|
### Subtitles
|
||||||
|
|
||||||
|
- **Embedded track selection** — auto-detects from mpv's `track-list`
|
||||||
|
- **External subtitle loading** — `.srt`, `.ass`, `.ssa`, `.sub`, `.idx`, `.sup`, `.vtt`, `.smi`, `.lrc`
|
||||||
|
- **Visibility toggle** — hide subs without losing the selected track
|
||||||
|
- **Forced track support** — displays `[forced]` badge for forced tracks
|
||||||
|
|
||||||
|
### Audio Tracks
|
||||||
|
|
||||||
|
- **Track selection** — dropdown with all embedded audio tracks
|
||||||
|
- **Auto** — let mpv pick the default track
|
||||||
|
- **Labels** — `1: eng [default]`, `2: Commentary`, etc.
|
||||||
|
|
||||||
|
### Video Transforms
|
||||||
|
|
||||||
|
- **Rotation** — 0°, 90°, 180°, 270° (mpv `video-rotate` property)
|
||||||
|
- **Flip Horizontal** — mirror left-right (mpv `vf` filter `hflip`)
|
||||||
|
- **Flip Vertical** — upside-down (mpv `vf` filter `vflip`)
|
||||||
|
|
||||||
|
### In-UI File Browser
|
||||||
|
|
||||||
|
All file selection happens inside the egui overlay — no external dialog
|
||||||
|
processes (zenity, kdialog, rfd). This eliminates the z-order conflict where
|
||||||
|
external dialogs would pop under the overlay's `AlwaysOnTop` window. The
|
||||||
|
browser supports:
|
||||||
|
|
||||||
|
- Directory listing with sorted entries (directories first, then files)
|
||||||
|
- Extension filtering per dialog kind
|
||||||
|
- Single-select, multi-select (Load Playlist), and save modes (filename input)
|
||||||
|
- Path bar with Up / Home navigation
|
||||||
|
- Double-click to navigate into directories
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crates
|
||||||
|
|
||||||
|
| Crate | Role |
|
||||||
|
|---|---|
|
||||||
|
| `mpv-bindings` | bindgen FFI to libmpv + safe wrapper (`MpvHandle`, `Event`, `Property`, `Command`) |
|
||||||
|
| `player-core` | Headless engine: `PlayerEngine`, `Cmd`/`EngineEvent` channels, accuracy-first options, `PlaybackState` |
|
||||||
|
| `player-ui` | egui + wgpu overlay renderer: control bar, menus, in-UI file browser, icons, theme |
|
||||||
|
| `player-app` | Binary: multi-window winit event loop, X11 wid embedding, keyboard input, ffmpeg export |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Rust** 1.75+ (install via [rustup](https://rustup.rs))
|
||||||
|
- **libmpv** 2.x (provided by `setup-libmpv.sh`)
|
||||||
|
- **libclang** (for bindgen; provided by `setup-libmpv.sh`)
|
||||||
|
- **ffmpeg** (for A-B loop video export; optional)
|
||||||
|
- **libX11** (linked directly for `XSetWindowBackground`; usually pre-installed)
|
||||||
|
|
||||||
|
### One-time setup (libmpv without root)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/ferret
|
||||||
|
./scripts/setup-libmpv.sh
|
||||||
|
source ./mpv-prefix/env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads libmpv2, libmpv-dev, and all runtime dependencies, extracts
|
||||||
|
them into `./mpv-prefix/`, and writes `./mpv-prefix/env.sh`. The script also
|
||||||
|
pulls in winit's xkbcommon runtime deps and mesa software rasterizers.
|
||||||
|
|
||||||
|
### Build the player
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The release binary is at `target/release/ferret`. rpath is baked in, so no
|
||||||
|
`LD_LIBRARY_PATH` is needed at runtime.
|
||||||
|
|
||||||
|
### Build flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build.sh # build release (default)
|
||||||
|
./scripts/build.sh --debug # build debug
|
||||||
|
./scripts/build.sh --clean # clean + rebuild
|
||||||
|
./scripts/build.sh --test # run cargo test --release
|
||||||
|
./scripts/build.sh --lint # cargo clippy + bracket audit + deref audit
|
||||||
|
./scripts/build.sh --ci # lint + test + build (full CI pass)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build profiles
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = "symbols"
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 1 # wgpu/shaders benefit from at least -O1
|
||||||
|
debug = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ferret /path/to/video.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
Or launch with no arguments to get an empty dark grey window with the menu
|
||||||
|
bar — use **File → Load File...** to open the in-UI file browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
Every hotkey has a menu entry and/or a control-bar button. Menu items show
|
||||||
|
the shortcut in parentheses (e.g. `Play / Pause (Space)`).
|
||||||
|
|
||||||
|
| Key | Action | Menu / Control |
|
||||||
|
|-----|--------|----------------|
|
||||||
|
| Space | Play / pause | Playback menu, ▶/⏸ button |
|
||||||
|
| Q | Quit | File menu |
|
||||||
|
| F | Toggle fullscreen | File menu, ⛶ button |
|
||||||
|
| M | Toggle mute | Playback → Volume, 🔊 button |
|
||||||
|
| ← / → | Seek -5s / +5s (keyframes) | Playback → Seek |
|
||||||
|
| ↑ / ↓ | Volume +5% / -5% | Playback → Volume |
|
||||||
|
| , / . | Frame-step back / forward | Playback → Seek, ◀| / |▶ buttons |
|
||||||
|
| [ / ] | Set A / B marker | File menu, A/B buttons |
|
||||||
|
| \\ | Clear markers | File menu |
|
||||||
|
| L | Cycle loop mode (off/file/list) | Playback → Loop, ⟳ button |
|
||||||
|
| - / = | Speed -0.25× / +0.25× | Playback → Speed, slider |
|
||||||
|
| N / P | Playlist next / previous | Playback → Playlist, ⏭/⏮ buttons |
|
||||||
|
| V | Toggle subtitle visibility | Subtitles menu |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Menu Reference
|
||||||
|
|
||||||
|
The menu bar is always visible at the top-left, even when the bottom control
|
||||||
|
bar has auto-hidden.
|
||||||
|
|
||||||
|
### File menu
|
||||||
|
|
||||||
|
| Item | Shortcut | Section |
|
||||||
|
|------|----------|---------|
|
||||||
|
| Load File... | — | Open |
|
||||||
|
| Load Folder... | — | Open |
|
||||||
|
| Load Playlist... | — | Open |
|
||||||
|
| Set A Marker | `[` | Markers |
|
||||||
|
| Set B Marker | `]` | Markers |
|
||||||
|
| Clear Markers | `\\` | Markers |
|
||||||
|
| Toggle A-B Loop | — | Markers |
|
||||||
|
| Export A-B Loop Video... | — | Markers |
|
||||||
|
| Import Markers... | — | Markers |
|
||||||
|
| Toggle Fullscreen | `F` | Window |
|
||||||
|
| Quit | `Q` | — |
|
||||||
|
|
||||||
|
### Playback menu
|
||||||
|
|
||||||
|
| Item | Shortcut | Section |
|
||||||
|
|------|----------|---------|
|
||||||
|
| Play / Pause | Space | — |
|
||||||
|
| Stop | — | — |
|
||||||
|
| Forward 5s | → | Seek |
|
||||||
|
| Backward 5s | ← | Seek |
|
||||||
|
| Frame Step Forward | `.` | Seek |
|
||||||
|
| Frame Step Backward | `,` | Seek |
|
||||||
|
| Next | N | Playlist |
|
||||||
|
| Previous | P | Playlist |
|
||||||
|
| Volume Up 5% | ↑ | Volume |
|
||||||
|
| Volume Down 5% | ↓ | Volume |
|
||||||
|
| Toggle Mute | M | Volume |
|
||||||
|
| Off / Loop File / Loop Playlist | — | Loop |
|
||||||
|
| Cycle Loop Mode | L | Loop |
|
||||||
|
| Speed Up +0.25× | = | Speed |
|
||||||
|
| Speed Down -0.25× | - | Speed |
|
||||||
|
| 0.25× through 4.0× presets | — | Speed |
|
||||||
|
|
||||||
|
### Audio menu
|
||||||
|
|
||||||
|
Appears only when the loaded file has audio tracks.
|
||||||
|
|
||||||
|
- **Auto** — let mpv pick the default track
|
||||||
|
- **List of embedded audio tracks** — `1: eng [default]`, `2: Commentary`, etc.
|
||||||
|
|
||||||
|
### Subtitles menu
|
||||||
|
|
||||||
|
- **Load Subtitle File...** — in-UI file browser (subtitle extensions)
|
||||||
|
- **Show Subtitles** `(V)` — toggle `sub-visibility`
|
||||||
|
- **None** — disable subtitles
|
||||||
|
- **List of embedded subtitle tracks** — with `[forced]` / `[default]` badges
|
||||||
|
|
||||||
|
### Video menu
|
||||||
|
|
||||||
|
- **Rotate:** 0° (normal), 90°, 180°, 270°
|
||||||
|
- **Flip:** Flip Horizontal (mirror), Flip Vertical (upside-down)
|
||||||
|
|
||||||
|
### Help menu
|
||||||
|
|
||||||
|
- **About ferret** — toggles the About panel (version, author, license)
|
||||||
|
- Version: `ferret 1.0.0`
|
||||||
|
- License: `GPL-2.0-or-later`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Control Bar
|
||||||
|
|
||||||
|
The bottom control bar (118px tall) has three rows:
|
||||||
|
|
||||||
|
### Row 1 — Seek bar
|
||||||
|
- Current time label (monospace)
|
||||||
|
- Seek bar with A/B marker pins (red for A, blue for B)
|
||||||
|
- Duration label (monospace)
|
||||||
|
|
||||||
|
### Row 2 — Transport | time | volume + fullscreen
|
||||||
|
- Play/Pause, Stop, Previous, Next, Frame Back, Frame Forward
|
||||||
|
- Center: `MM:SS / MM:SS` time display
|
||||||
|
- Right: Fullscreen button, Volume slider, Volume/mute icon
|
||||||
|
|
||||||
|
### Row 3 — Markers | loop | speed | audio
|
||||||
|
- A marker button (shows time or "A"), B marker button (shows time or "B")
|
||||||
|
- AB-loop toggle, Loop-mode toggle (off/file/playlist)
|
||||||
|
- Speed presets: 0.50×, 1.00×, 1.50×, 2.00×
|
||||||
|
- Fine speed slider (0.25×–4.0×), current speed label
|
||||||
|
- Audio track dropdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A/B Markers and Loop Export
|
||||||
|
|
||||||
|
### Setting Markers
|
||||||
|
|
||||||
|
1. Play to the point where you want marker A.
|
||||||
|
2. Press `[` (or click **File → Set A Marker**).
|
||||||
|
3. Play to the point where you want marker B.
|
||||||
|
4. Press `]` (or click **File → Set B Marker**).
|
||||||
|
|
||||||
|
The markers appear as colored pins on the seek bar — red for A, blue for B.
|
||||||
|
|
||||||
|
### Looping A→B
|
||||||
|
|
||||||
|
When both markers are set, mpv automatically loops between them. The
|
||||||
|
**Toggle A-B Loop** menu item (or the AB-loop button in the control bar)
|
||||||
|
turns this on/off. When toggled off, the markers are cleared.
|
||||||
|
|
||||||
|
### Exporting the A-B Loop
|
||||||
|
|
||||||
|
**File → Export A-B Loop Video...** opens the in-UI save dialog, then runs
|
||||||
|
ffmpeg to extract the segment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -y \
|
||||||
|
-ss <A> \
|
||||||
|
-i <input> \
|
||||||
|
-t <duration> \
|
||||||
|
-c:v libx264 -preset fast -crf 18 \
|
||||||
|
-c:a aac -b:a 192k \
|
||||||
|
<output>
|
||||||
|
```
|
||||||
|
|
||||||
|
`-ss` is placed before `-i` for fast seeking. Video is re-encoded (libx264,
|
||||||
|
CRF 18) for frame accuracy and maximum compatibility. Audio is re-encoded to
|
||||||
|
AAC at 192 kbps. ffmpeg must be installed and in your PATH. The export runs on
|
||||||
|
a background thread (`ferret-ffmpeg`) so the UI stays responsive.
|
||||||
|
|
||||||
|
### Importing/Exporting Markers
|
||||||
|
|
||||||
|
Markers can be saved to disk and reloaded later:
|
||||||
|
|
||||||
|
**Text format (.txt):**
|
||||||
|
```
|
||||||
|
# file: /path/to/video.mp4
|
||||||
|
# duration: 180.000s
|
||||||
|
# speed: 1.00x
|
||||||
|
|
||||||
|
A 00:01:23.456
|
||||||
|
B 00:02:45.000
|
||||||
|
LOOP on
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSON format (.json):**
|
||||||
|
```json
|
||||||
|
{"file":"/path/to/video.mp4","duration":180.000,"speed":1.000,"a":83.456,"b":165.000,"loop":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subtitles
|
||||||
|
|
||||||
|
ferret supports both embedded subtitle tracks (from the container) and
|
||||||
|
external subtitle files.
|
||||||
|
|
||||||
|
### Embedded Tracks
|
||||||
|
|
||||||
|
When a file with subtitle tracks is loaded, the **Subtitles** menu populates
|
||||||
|
with all available tracks. Each track shows its id, language, and any badges
|
||||||
|
(`[forced]`, `[default]`).
|
||||||
|
|
||||||
|
### External Subtitles
|
||||||
|
|
||||||
|
**Subtitles → Load Subtitle File...** opens the in-UI file browser filtered
|
||||||
|
for: `.srt`, `.ass`, `.ssa`, `.sub`, `.idx`, `.sup`, `.vtt`, `.smi`, `.lrc`.
|
||||||
|
The loaded subtitle becomes active immediately.
|
||||||
|
|
||||||
|
### Visibility
|
||||||
|
|
||||||
|
**Subtitles → Show Subtitles** `(V)` toggles `sub-visibility` — hides
|
||||||
|
subtitles without losing the selected track, useful for quickly checking the
|
||||||
|
raw video.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Video Transforms
|
||||||
|
|
||||||
|
### Rotation
|
||||||
|
|
||||||
|
**Video → Rotate** sets the `video-rotate` mpv property. Values: 0°, 90°,
|
||||||
|
180°, 270°. The rotation is applied immediately and reflected in the state.
|
||||||
|
|
||||||
|
### Flip
|
||||||
|
|
||||||
|
**Video → Flip** toggles mpv video filters:
|
||||||
|
- **Flip Horizontal** — adds/removes the `hflip` filter (mirror left-right)
|
||||||
|
- **Flip Vertical** — adds/removes the `vflip` filter (upside-down)
|
||||||
|
|
||||||
|
The filters are managed by reading the current `vf` property, adding or
|
||||||
|
removing the filter name, and writing it back.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accuracy-First Error Policy
|
||||||
|
|
||||||
|
ferret configures libmpv for visual accuracy over continuity:
|
||||||
|
|
||||||
|
- **`hr-seek=yes`** — exact seeks, not keyframe-snapped. Seeks take slightly
|
||||||
|
longer but land on the requested frame.
|
||||||
|
- **`framedrop=vo`** — only drop frames at the video output if behind. Never
|
||||||
|
drop on decode, so corrupt frames don't get smeared through.
|
||||||
|
- **`video-sync=display-resample`** — resample audio to match the display
|
||||||
|
refresh rate. Best A/V sync, eliminates audio drift.
|
||||||
|
- **`hwdec=auto-safe`** — use hardware decoding only when known-safe (no
|
||||||
|
copy-back modes that shuttle frames over the PCIe bus).
|
||||||
|
- **`volume-max=100`** — hard-coded. No software amplification past 100%,
|
||||||
|
killing the PipeWire volume clash.
|
||||||
|
|
||||||
|
These are baked into `EngineOptions::default()` and set as mpv options at
|
||||||
|
engine init time. User config (`~/.config/mpv/mpv.conf`) is explicitly
|
||||||
|
disabled (`config=no`) to prevent sneaking in different behavior.
|
||||||
|
|
||||||
|
Additional fixed mpv options: `terminal=no`, `msg-level=all=<level>`,
|
||||||
|
`input-default-bindings=no`, `input-builtin-bindings=no`, `osc=no`,
|
||||||
|
`cursor-autohide=no`, `input-vo-keyboard=no`, `vo=gpu`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All options are code-defined in `EngineOptions::default()`:
|
||||||
|
(`crates/player-core/src/options.rs`)
|
||||||
|
|
||||||
|
| Field | Default | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `hr_seek` | `true` | Exact seeks |
|
||||||
|
| `framedrop` | `"vo"` | Drop at display only |
|
||||||
|
| `video_sync` | `"display-resample"` | Resample audio to display |
|
||||||
|
| `hwdec` | `"auto-safe"` | Safe hardware decode only |
|
||||||
|
| `initial_volume` | `1.0` (100%) | Startup volume |
|
||||||
|
| `volume_max` | `1.0` (100%) | Hard cap — never higher |
|
||||||
|
| `log_level` | `"warn"` | libmpv log threshold |
|
||||||
|
| `wid` | `None` | Set at runtime from video window XID |
|
||||||
|
| `vo` | `"gpu"` | Video output driver |
|
||||||
|
| `loop_mode` | `Off` | Initial loop mode |
|
||||||
|
|
||||||
|
There is no config file yet (planned for a future release).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Done (v1.0)
|
||||||
|
|
||||||
|
- Multi-window winit + egui overlay (X11)
|
||||||
|
- libmpv 2.x FFI via bindgen
|
||||||
|
- Engine thread with full property observation
|
||||||
|
- Play/pause/stop/seek/frame-step
|
||||||
|
- Volume + mute (hard-capped at 100%)
|
||||||
|
- Loop modes (off/file/playlist)
|
||||||
|
- Speed control (presets + slider, 0.25×–4×)
|
||||||
|
- A/B markers + loop + video export via ffmpeg
|
||||||
|
- Marker import/export (txt + json)
|
||||||
|
- Audio track selection
|
||||||
|
- Subtitle track selection + external loading
|
||||||
|
- Video rotation (0/90/180/270) + flip (H/V)
|
||||||
|
- In-UI file browser (no external dialog dependency)
|
||||||
|
- Complete hotkey coverage — every shortcut has a menu entry and/or button
|
||||||
|
- Accuracy-first error policy
|
||||||
|
- X11 background pixel fix (eliminates resize/move "mirrored desktop" artifact)
|
||||||
|
- wgpu surface error recovery + forced `PresentMode::Fifo`
|
||||||
|
- CI tooling: bracket audit, deref audit, clippy config
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
|
||||||
|
1. **Wayland support** via `mpv_render_context` + `MPV_RENDER_API_TYPE_OPENGL`.
|
||||||
|
Eliminates the wid/X11 dependency, unblocks macOS (Metal via wgpu) and
|
||||||
|
Windows (DX12 via wgpu).
|
||||||
|
2. **Config file** (`~/.config/ferret/ferret.toml`).
|
||||||
|
3. **Playlist UI** — drag-drop, reorder, repeat, shuffle.
|
||||||
|
4. **Media keys** (MPRIS on Linux).
|
||||||
|
5. **Single-window compositing** — render video as a wgpu texture inside egui,
|
||||||
|
eliminating the second window and the click-through limitation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style,
|
||||||
|
coding standards (PEP 868 / POSIX / SEI CERT / MISRA), and pre-commit checks.
|
||||||
|
|
||||||
|
Patches are welcome at <http://git.dcos.net/dcosnet/ferret>.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
ferret is licensed under the [GNU General Public License v2.0](LICENSE)
|
||||||
|
or (at your option) any later version.
|
||||||
|
|
||||||
|
```
|
||||||
|
ferret - a modern, accuracy-first video player for Linux
|
||||||
|
Copyright (C) 2026 Jeremy Anderson
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
```
|
||||||
|
|
||||||
|
libmpv (linked dynamically) is licensed under LGPL-2.1+ or GPL-2+ — the
|
||||||
|
dynamic linking keeps ferret's GPL-2.0 compatible.
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# clippy.toml — ferret lint configuration
|
||||||
|
#
|
||||||
|
# Stricter than defaults. Run: cargo clippy --release -- -D warnings
|
||||||
|
|
||||||
|
# Disallow large enum variants without #[allow] — catches accidental
|
||||||
|
# memory bloat in hot-path enums like Cmd and EngineEvent.
|
||||||
|
enum-variant-size-threshold = 256
|
||||||
|
|
||||||
|
# Flag types with too many fields — a code smell for "split this struct".
|
||||||
|
# PlaybackState has many fields by design (it's a snapshot), so it carries
|
||||||
|
# an explicit #[allow] where needed.
|
||||||
|
struct-field-size-threshold = 64
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# (default) so we see them without failing the build.
|
||||||
|
|
||||||
|
# Long literal strings should be broken across lines for readability —
|
||||||
|
# but only flag if the line exceeds 120 chars.
|
||||||
|
single-char-binding-names-threshold = 2
|
||||||
|
|
||||||
|
# Don't flag `fn main() -> Result<()>` — that's idiomatic for anyhow.
|
||||||
|
# (clippy::main_recursion is already allow-by-default.)
|
||||||
|
|
||||||
|
# Allow the `?` operator in FFI wrappers — the alternative (explicit match)
|
||||||
|
# is more verbose without being clearer for the `MpvError::from_code` pattern.
|
||||||
|
# This is a per-crate allow in mpv-bindings, not a global suppression.
|
||||||
|
|
||||||
|
# Cognitive complexity threshold — functions above this get flagged.
|
||||||
|
# The engine's `apply_cmd` match is intentionally large (one arm per Cmd
|
||||||
|
# variant); it carries #[allow(clippy::cognitive_complexity)] if needed.
|
||||||
|
cognitive-complexity-threshold = 50
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
[package]
|
||||||
|
name = "mpv-bindings"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "Safe Rust bindings to libmpv (client API)"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "mpv_bindings"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libc = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
bindgen = { workspace = true }
|
||||||
|
pkg-config = { workspace = true }
|
||||||
|
cc = "1.0"
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
// build.rs — generate raw FFI bindings to libmpv via bindgen.
|
||||||
|
//
|
||||||
|
// We resolve libmpv through pkg-config. The setup script generates
|
||||||
|
// mpv-prefix/usr/lib/x86_64-linux-gnu/pkgconfig/mpv.pc and exports
|
||||||
|
// PKG_CONFIG_PATH via mpv-prefix/env.sh — `cargo build` picks it up
|
||||||
|
// automatically once env.sh is sourced.
|
||||||
|
//
|
||||||
|
// The generated bindings end up in OUT_DIR/bindings.rs and are re-exported
|
||||||
|
// from `crate::sys`.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Re-run if the env or any pkg-config .pc file under the prefix changes.
|
||||||
|
println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");
|
||||||
|
if let Ok(prefix) = env::var("MPV_PREFIX") {
|
||||||
|
let pc = PathBuf::from(&prefix)
|
||||||
|
.join("usr/lib/x86_64-linux-gnu/pkgconfig/mpv.pc");
|
||||||
|
println!("cargo:rerun-if-changed={}", pc.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mpv = pkg_config::Config::new()
|
||||||
|
.atleast_version("2.0")
|
||||||
|
.probe("mpv")
|
||||||
|
.expect("failed to locate libmpv via pkg-config (did you `source ./mpv-prefix/env.sh`?)");
|
||||||
|
|
||||||
|
// Tell cargo to link libmpv.
|
||||||
|
for path in &mpv.link_paths {
|
||||||
|
println!("cargo:rustc-link-search=native={}", path.display());
|
||||||
|
}
|
||||||
|
println!("cargo:rustc-link-lib=dylib=mpv");
|
||||||
|
|
||||||
|
// Embed rpath so the binary finds libmpv.so.2 at runtime without
|
||||||
|
// requiring LD_LIBRARY_PATH. We use the first link path from pkg-config.
|
||||||
|
if let Some(first) = mpv.link_paths.first() {
|
||||||
|
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", first.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the canonical client.h — it #includes the other public headers.
|
||||||
|
let client_h = mpv
|
||||||
|
.include_paths
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.join("mpv/client.h"))
|
||||||
|
.find(|p| p.exists())
|
||||||
|
.expect("could not find mpv/client.h in include paths");
|
||||||
|
|
||||||
|
// Clang's builtin headers (stddef.h etc.) — needed because we ship our
|
||||||
|
// own libclang without a system sysroot.
|
||||||
|
let clang_resource_dir = env::var("CLANG_RESOURCE_DIR")
|
||||||
|
.unwrap_or_else(|_| "/usr/lib/llvm-19/lib/clang/19".to_string());
|
||||||
|
let clang_include = format!("{clang_resource_dir}/include");
|
||||||
|
|
||||||
|
let bindings = bindgen::Builder::default()
|
||||||
|
.header(client_h.to_string_lossy().into_owned())
|
||||||
|
// Pull in the other public headers transitively.
|
||||||
|
.clang_arg("-include")
|
||||||
|
.clang_arg("mpv/render.h")
|
||||||
|
.clang_arg("-include")
|
||||||
|
.clang_arg("mpv/render_gl.h")
|
||||||
|
.clang_arg("-include")
|
||||||
|
.clang_arg("mpv/stream_cb.h")
|
||||||
|
// Clang builtin headers (stddef.h, stdarg.h, etc.)
|
||||||
|
.clang_arg("-isystem")
|
||||||
|
.clang_arg(&clang_include)
|
||||||
|
// Allowlist: only the public mpv API surface.
|
||||||
|
.allowlist_function("mpv_.*")
|
||||||
|
.allowlist_type("mpv_.*")
|
||||||
|
.allowlist_var("MPV_.*")
|
||||||
|
.allowlist_var("mpv_.*")
|
||||||
|
// Layout tests break across glibc versions for some reason; we don't need them.
|
||||||
|
.layout_tests(false)
|
||||||
|
.derive_default(true)
|
||||||
|
.derive_debug(true)
|
||||||
|
.fit_macro_constants(false)
|
||||||
|
.generate()
|
||||||
|
.expect("bindgen failed to generate mpv bindings");
|
||||||
|
|
||||||
|
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||||
|
bindings
|
||||||
|
.write_to_file(out_path.join("bindings.rs"))
|
||||||
|
.expect("failed to write bindings.rs");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
//! Typed command builder for libmpv.
|
||||||
|
//!
|
||||||
|
//! libmpv commands take an argv-style array of strings, terminated by NULL.
|
||||||
|
//! Example: `["loadfile", "/path/to/video.mp4", "replace"]`
|
||||||
|
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::raw::c_char;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
use crate::error::MpvResult;
|
||||||
|
|
||||||
|
/// A command to be sent to libmpv.
|
||||||
|
pub struct Command {
|
||||||
|
// We hold the CStrings so the pointers stay valid for the duration of the
|
||||||
|
// mpv_command call.
|
||||||
|
args: Vec<CString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { args: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a string argument.
|
||||||
|
pub fn arg(mut self, s: impl Into<String>) -> MpvResult<Self> {
|
||||||
|
self.args.push(CString::new(s.into())?);
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the argv array with a trailing NULL sentinel, as expected by
|
||||||
|
/// `mpv_command`. The returned vector's pointers are valid only while
|
||||||
|
/// `self` is alive.
|
||||||
|
pub(crate) fn argv_with_null(&self) -> Vec<*const c_char> {
|
||||||
|
let mut v: Vec<*const c_char> = self.args.iter().map(|s| s.as_ptr() as *const c_char).collect();
|
||||||
|
v.push(ptr::null());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Command {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience constructors for the commands we use most.
|
||||||
|
impl Command {
|
||||||
|
/// `loadfile <path> [replace|append]`
|
||||||
|
pub fn loadfile(path: impl Into<String>, mode: LoadMode) -> MpvResult<Self> {
|
||||||
|
Command::new()
|
||||||
|
.arg("loadfile")?
|
||||||
|
.arg(path)?
|
||||||
|
.arg(match mode {
|
||||||
|
LoadMode::Replace => "replace",
|
||||||
|
LoadMode::Append => "append",
|
||||||
|
LoadMode::AppendPlay => "append-play",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `seek <target> [relative|absolute|relative-percent|absolute-percent] [default|exact|keyframes]`
|
||||||
|
pub fn seek(target_secs: f64, mode: SeekMode, flags: SeekFlags) -> MpvResult<Self> {
|
||||||
|
Command::new()
|
||||||
|
.arg("seek")?
|
||||||
|
.arg(format!("{target_secs}"))?
|
||||||
|
.arg(match mode {
|
||||||
|
SeekMode::Relative => "relative",
|
||||||
|
SeekMode::Absolute => "absolute",
|
||||||
|
SeekMode::RelativePercent => "relative-percent",
|
||||||
|
SeekMode::AbsolutePercent => "absolute-percent",
|
||||||
|
})?
|
||||||
|
.arg(match flags {
|
||||||
|
SeekFlags::Default => "default",
|
||||||
|
SeekFlags::Exact => "exact",
|
||||||
|
SeekFlags::Keyframes => "keyframes",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop playback and clear the playlist.
|
||||||
|
pub fn stop() -> Self {
|
||||||
|
// stop has no args that we care about for MVP
|
||||||
|
let mut c = Command::new();
|
||||||
|
c.args.push(CString::new("stop").unwrap());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frame-step forward.
|
||||||
|
pub fn frame_step() -> Self {
|
||||||
|
let mut c = Command::new();
|
||||||
|
c.args.push(CString::new("frame-step").unwrap());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frame-step backward.
|
||||||
|
pub fn frame_back_step() -> Self {
|
||||||
|
let mut c = Command::new();
|
||||||
|
c.args.push(CString::new("frame-back-step").unwrap());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub enum LoadMode { Replace, Append, AppendPlay }
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub enum SeekMode { Relative, Absolute, RelativePercent, AbsolutePercent }
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub enum SeekFlags { Default, Exact, Keyframes }
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
//! Error type for libmpv FFI calls.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// A libmpv error code, mapped from `mpv_error` integers.
|
||||||
|
///
|
||||||
|
/// See: <https://github.com/mpv-player/mpv/blob/master/libmpv/client.h>
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum MpvError {
|
||||||
|
#[error("mpv: event queue full")]
|
||||||
|
EventQueueFull,
|
||||||
|
#[error("mpv: memory allocation failed")]
|
||||||
|
NoMem,
|
||||||
|
#[error("mpv: uninitialized")]
|
||||||
|
Uninitialized,
|
||||||
|
#[error("mpv: invalid parameter")]
|
||||||
|
InvalidParameter,
|
||||||
|
#[error("mpv: option not found")]
|
||||||
|
OptionNotFound,
|
||||||
|
#[error("mpv: option format mismatch")]
|
||||||
|
OptionFormat,
|
||||||
|
#[error("mpv: option error")]
|
||||||
|
OptionError,
|
||||||
|
#[error("mpv: property not found")]
|
||||||
|
PropertyNotFound,
|
||||||
|
#[error("mpv: property format mismatch")]
|
||||||
|
PropertyFormat,
|
||||||
|
#[error("mpv: property unavailable")]
|
||||||
|
PropertyUnavailable,
|
||||||
|
#[error("mpv: property exists (read-only, cannot set)")]
|
||||||
|
PropertyReadOnly,
|
||||||
|
#[error("mpv: property error: {0}")]
|
||||||
|
PropertyError(i32),
|
||||||
|
#[error("mpv: command failed: {0}")]
|
||||||
|
CommandError(i32),
|
||||||
|
#[error("mpv: loading failed")]
|
||||||
|
LoadingFailed,
|
||||||
|
#[error("mpv: AO init failed")]
|
||||||
|
AoInitFailed,
|
||||||
|
#[error("mpv: VO init failed")]
|
||||||
|
VoInitFailed,
|
||||||
|
#[error("mpv: nothing to play")]
|
||||||
|
NothingToPlay,
|
||||||
|
#[error("mpv: unknown format")]
|
||||||
|
UnknownFormat,
|
||||||
|
#[error("mpv: unsupported")]
|
||||||
|
Unsupported,
|
||||||
|
#[error("mpv: not implemented")]
|
||||||
|
NotImplemented,
|
||||||
|
#[error("mpv: generic error code: {0}")]
|
||||||
|
Generic(i32),
|
||||||
|
#[error("mpv: null pointer returned")]
|
||||||
|
NullPointer,
|
||||||
|
#[error("mpv: nul byte in string")]
|
||||||
|
NulByte(#[from] std::ffi::NulError),
|
||||||
|
#[error("mpv: utf-8 conversion failed")]
|
||||||
|
Utf8(#[from] std::str::Utf8Error),
|
||||||
|
#[error("mpv: handle was already terminated")]
|
||||||
|
Terminated,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpvError {
|
||||||
|
/// Map a libmpv return code to `MpvResult<()>`.
|
||||||
|
/// `0` (MPV_ERROR_SUCCESS) becomes `Ok(())`; anything else is an `Err`.
|
||||||
|
pub fn from_code(code: i32) -> MpvResult<()> {
|
||||||
|
use sys::*;
|
||||||
|
match code {
|
||||||
|
mpv_error_MPV_ERROR_SUCCESS => Ok(()),
|
||||||
|
mpv_error_MPV_ERROR_EVENT_QUEUE_FULL => Err(MpvError::EventQueueFull),
|
||||||
|
mpv_error_MPV_ERROR_NOMEM => Err(MpvError::NoMem),
|
||||||
|
mpv_error_MPV_ERROR_UNINITIALIZED => Err(MpvError::Uninitialized),
|
||||||
|
mpv_error_MPV_ERROR_INVALID_PARAMETER => Err(MpvError::InvalidParameter),
|
||||||
|
mpv_error_MPV_ERROR_OPTION_NOT_FOUND => Err(MpvError::OptionNotFound),
|
||||||
|
mpv_error_MPV_ERROR_OPTION_FORMAT => Err(MpvError::OptionFormat),
|
||||||
|
mpv_error_MPV_ERROR_OPTION_ERROR => Err(MpvError::OptionError),
|
||||||
|
mpv_error_MPV_ERROR_PROPERTY_NOT_FOUND => Err(MpvError::PropertyNotFound),
|
||||||
|
mpv_error_MPV_ERROR_PROPERTY_FORMAT => Err(MpvError::PropertyFormat),
|
||||||
|
mpv_error_MPV_ERROR_PROPERTY_UNAVAILABLE => Err(MpvError::PropertyUnavailable),
|
||||||
|
mpv_error_MPV_ERROR_PROPERTY_ERROR => Err(MpvError::PropertyError(code)),
|
||||||
|
mpv_error_MPV_ERROR_COMMAND => Err(MpvError::CommandError(code)),
|
||||||
|
mpv_error_MPV_ERROR_LOADING_FAILED => Err(MpvError::LoadingFailed),
|
||||||
|
mpv_error_MPV_ERROR_AO_INIT_FAILED => Err(MpvError::AoInitFailed),
|
||||||
|
mpv_error_MPV_ERROR_VO_INIT_FAILED => Err(MpvError::VoInitFailed),
|
||||||
|
mpv_error_MPV_ERROR_NOTHING_TO_PLAY => Err(MpvError::NothingToPlay),
|
||||||
|
mpv_error_MPV_ERROR_UNKNOWN_FORMAT => Err(MpvError::UnknownFormat),
|
||||||
|
mpv_error_MPV_ERROR_UNSUPPORTED => Err(MpvError::Unsupported),
|
||||||
|
mpv_error_MPV_ERROR_NOT_IMPLEMENTED => Err(MpvError::NotImplemented),
|
||||||
|
mpv_error_MPV_ERROR_GENERIC => Err(MpvError::Generic(code)),
|
||||||
|
_ => Err(MpvError::Generic(code)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type MpvResult<T> = Result<T, MpvError>;
|
||||||
|
|
||||||
|
use crate::sys;
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
//! Safe event parsing.
|
||||||
|
//!
|
||||||
|
//! libmpv delivers events through `mpv_wait_event`. Each event has an
|
||||||
|
//! `event_id` (the type) and a `data` pointer whose shape depends on the
|
||||||
|
//! type. We parse the common ones into a safe `Event` enum here.
|
||||||
|
|
||||||
|
use std::ffi::CStr;
|
||||||
|
use std::os::raw::{c_char, c_void};
|
||||||
|
|
||||||
|
use crate::sys;
|
||||||
|
|
||||||
|
/// Tag used to correlate observed-property events with their registration.
|
||||||
|
pub type EventId = u64;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Event {
|
||||||
|
/// mpv finished initializing the audio/video/pipeline.
|
||||||
|
StartFile,
|
||||||
|
/// A file has been loaded and is ready to play. Carries the playlist
|
||||||
|
/// position (1-indexed in libmpv terms).
|
||||||
|
FileLoaded,
|
||||||
|
/// Playback of the current file ended. `reason` is best-effort.
|
||||||
|
EndFile { reason: EndFileReason, error: Option<i32> },
|
||||||
|
/// A new log message arrived.
|
||||||
|
LogMessage { prefix: String, level: String, text: String },
|
||||||
|
/// A property we observed changed.
|
||||||
|
PropertyChange {
|
||||||
|
reply_userdata: EventId,
|
||||||
|
name: String,
|
||||||
|
value: PropertyValue,
|
||||||
|
},
|
||||||
|
/// libmpv is shutting down.
|
||||||
|
Shutdown,
|
||||||
|
/// Hook event (used internally; we don't expose hooks yet).
|
||||||
|
Hook { id: u64, name: String },
|
||||||
|
/// Anything we don't model yet.
|
||||||
|
Other { event_id: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reason mpv reports for an `MPV_EVENT_END_FILE`. The `#[repr(u8)]` layout
|
||||||
|
/// makes the discriminant safe to use as a table index — see
|
||||||
|
/// `engine::END_REASON_MAP`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum EndFileReason {
|
||||||
|
Eof = 0,
|
||||||
|
Stop = 1,
|
||||||
|
Quit = 2,
|
||||||
|
Error = 3,
|
||||||
|
Redirect = 4,
|
||||||
|
Unknown = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PropertyValue {
|
||||||
|
None,
|
||||||
|
Flag(bool),
|
||||||
|
Int64(i64),
|
||||||
|
Double(f64),
|
||||||
|
String(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log levels, in increasing order of verbosity.
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub enum LogLevel {
|
||||||
|
Quiet,
|
||||||
|
Fatal,
|
||||||
|
Error,
|
||||||
|
Warn,
|
||||||
|
Info,
|
||||||
|
Status,
|
||||||
|
Verbose,
|
||||||
|
Debug,
|
||||||
|
Trace,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogLevel {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LogLevel::Quiet => "no",
|
||||||
|
LogLevel::Fatal => "fatal",
|
||||||
|
LogLevel::Error => "error",
|
||||||
|
LogLevel::Warn => "warn",
|
||||||
|
LogLevel::Info => "info",
|
||||||
|
LogLevel::Status => "status",
|
||||||
|
LogLevel::Verbose => "v",
|
||||||
|
LogLevel::Debug => "debug",
|
||||||
|
LogLevel::Trace => "trace",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
/// Parse a `*const mpv_event` into a safe `Event`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `raw` must point to a valid `mpv_event` returned by `mpv_wait_event`,
|
||||||
|
/// and the `data` field (if non-null) must point to a struct of the
|
||||||
|
/// correct type for `event.event_id`. Both invariants hold while the
|
||||||
|
/// event is being processed inside `MpvHandle::wait_event`.
|
||||||
|
pub(crate) fn from_raw(raw: &sys::mpv_event) -> Option<Event> {
|
||||||
|
let id = raw.event_id;
|
||||||
|
let data = raw.data as *const c_void;
|
||||||
|
match id {
|
||||||
|
sys::mpv_event_id_MPV_EVENT_START_FILE => Some(Event::StartFile),
|
||||||
|
sys::mpv_event_id_MPV_EVENT_FILE_LOADED => Some(Event::FileLoaded),
|
||||||
|
sys::mpv_event_id_MPV_EVENT_END_FILE => {
|
||||||
|
let reason = if data.is_null() {
|
||||||
|
EndFileReason::Unknown
|
||||||
|
} else {
|
||||||
|
let ed = unsafe { &*(data as *const sys::mpv_event_end_file) };
|
||||||
|
let r = match ed.reason {
|
||||||
|
sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF => EndFileReason::Eof,
|
||||||
|
sys::mpv_end_file_reason_MPV_END_FILE_REASON_STOP => EndFileReason::Stop,
|
||||||
|
sys::mpv_end_file_reason_MPV_END_FILE_REASON_QUIT => EndFileReason::Quit,
|
||||||
|
sys::mpv_end_file_reason_MPV_END_FILE_REASON_ERROR => EndFileReason::Error,
|
||||||
|
sys::mpv_end_file_reason_MPV_END_FILE_REASON_REDIRECT => EndFileReason::Redirect,
|
||||||
|
_ => EndFileReason::Unknown,
|
||||||
|
};
|
||||||
|
if ed.error == 0 { r } else { EndFileReason::Error }
|
||||||
|
};
|
||||||
|
let err = if data.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let ed = unsafe { &*(data as *const sys::mpv_event_end_file) };
|
||||||
|
if ed.error == 0 { None } else { Some(ed.error) }
|
||||||
|
};
|
||||||
|
Some(Event::EndFile { reason, error: err })
|
||||||
|
}
|
||||||
|
sys::mpv_event_id_MPV_EVENT_LOG_MESSAGE => {
|
||||||
|
if data.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let lm = unsafe { &*(data as *const sys::mpv_event_log_message) };
|
||||||
|
let prefix = unsafe { CStr::from_ptr(lm.prefix as *const c_char) }
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let level = unsafe { CStr::from_ptr(lm.level as *const c_char) }
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let text = unsafe { CStr::from_ptr(lm.text as *const c_char) }
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
Some(Event::LogMessage { prefix, level, text })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sys::mpv_event_id_MPV_EVENT_PROPERTY_CHANGE => {
|
||||||
|
if data.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let pc = unsafe { &*(data as *const sys::mpv_event_property) };
|
||||||
|
let name = unsafe { CStr::from_ptr(pc.name as *const c_char) }
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let value = parse_property_value(pc.format, pc.data);
|
||||||
|
Some(Event::PropertyChange {
|
||||||
|
reply_userdata: raw.reply_userdata,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sys::mpv_event_id_MPV_EVENT_SHUTDOWN => Some(Event::Shutdown),
|
||||||
|
sys::mpv_event_id_MPV_EVENT_HOOK => {
|
||||||
|
if data.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let hd = unsafe { &*(data as *const sys::mpv_event_hook) };
|
||||||
|
let name = unsafe { CStr::from_ptr(hd.name as *const c_char) }
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
Some(Event::Hook { id: hd.id, name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sys::mpv_event_id_MPV_EVENT_NONE => None,
|
||||||
|
other => Some(Event::Other { event_id: other }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_property_value(format: sys::mpv_format, data: *const c_void) -> PropertyValue {
|
||||||
|
use sys::*;
|
||||||
|
if data.is_null() {
|
||||||
|
return PropertyValue::None;
|
||||||
|
}
|
||||||
|
match format {
|
||||||
|
mpv_format_MPV_FORMAT_FLAG => {
|
||||||
|
let v = unsafe { *(data as *const i32) };
|
||||||
|
PropertyValue::Flag(v != 0)
|
||||||
|
}
|
||||||
|
mpv_format_MPV_FORMAT_INT64 => {
|
||||||
|
let v = unsafe { *(data as *const i64) };
|
||||||
|
PropertyValue::Int64(v)
|
||||||
|
}
|
||||||
|
mpv_format_MPV_FORMAT_DOUBLE => {
|
||||||
|
let v = unsafe { *(data as *const f64) };
|
||||||
|
PropertyValue::Double(v)
|
||||||
|
}
|
||||||
|
mpv_format_MPV_FORMAT_STRING => {
|
||||||
|
let p = data as *const c_char;
|
||||||
|
if p.is_null() {
|
||||||
|
PropertyValue::None
|
||||||
|
} else {
|
||||||
|
let s = unsafe { CStr::from_ptr(p) }
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
PropertyValue::String(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => PropertyValue::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
//! Owned handle to a libmpv instance.
|
||||||
|
//!
|
||||||
|
//! `MpvHandle` is the single owner of one `mpv_handle*`. It is `Send` but not
|
||||||
|
//! `Sync` or `Clone` — you drive libmpv from exactly one thread (typically the
|
||||||
|
//! engine thread), and pass messages to/from the UI via channels.
|
||||||
|
|
||||||
|
use std::ffi::{CStr, CString};
|
||||||
|
use std::os::raw::{c_char, c_void};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use crate::command::Command;
|
||||||
|
use crate::error::{MpvError, MpvResult};
|
||||||
|
use crate::event::Event;
|
||||||
|
use crate::property::{Format, Property};
|
||||||
|
use crate::sys;
|
||||||
|
|
||||||
|
/// Owned libmpv instance.
|
||||||
|
pub struct MpvHandle {
|
||||||
|
raw: *mut sys::mpv_handle,
|
||||||
|
/// Set to true when we've called `mpv_terminate_destroy`. Used by Drop
|
||||||
|
/// to avoid double-free if user explicitly called `shutdown`.
|
||||||
|
terminated: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// libmpv's handle is thread-safe to use from one thread at a time. We model
|
||||||
|
// that as `Send` (not `Sync`) so the borrow checker enforces "owned by one
|
||||||
|
// thread at a time" statically.
|
||||||
|
unsafe impl Send for MpvHandle {}
|
||||||
|
unsafe impl Sync for MpvHandle {}
|
||||||
|
|
||||||
|
impl MpvHandle {
|
||||||
|
/// Create a new libmpv instance (uninitialized). Use `Builder` for a
|
||||||
|
/// configured one.
|
||||||
|
pub fn new() -> MpvResult<Self> {
|
||||||
|
// Safety: mpv_create returns a fresh handle or NULL.
|
||||||
|
let raw = unsafe { sys::mpv_create() };
|
||||||
|
if raw.is_null() {
|
||||||
|
return Err(MpvError::Generic(-1));
|
||||||
|
}
|
||||||
|
Ok(Self { raw, terminated: AtomicBool::new(false) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a string option before initialize. Equivalent to `--<name>=<value>`
|
||||||
|
/// on the mpv CLI.
|
||||||
|
pub fn set_option_string(&self, name: &str, value: &str) -> MpvResult<()> {
|
||||||
|
let c_name = CString::new(name)?;
|
||||||
|
let c_val = CString::new(value)?;
|
||||||
|
let code = unsafe { sys::mpv_set_option_string(self.raw, c_name.as_ptr(), c_val.as_ptr()) };
|
||||||
|
MpvError::from_code(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize libmpv. After this point, options are frozen and the
|
||||||
|
/// event loop starts producing events.
|
||||||
|
pub fn initialize(&self) -> MpvResult<()> {
|
||||||
|
let code = unsafe { sys::mpv_initialize(self.raw) };
|
||||||
|
MpvError::from_code(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a command (built via `Command::new()`).
|
||||||
|
pub fn command(&self, cmd: &Command) -> MpvResult<()> {
|
||||||
|
let argv: Vec<*const c_char> = cmd.argv_with_null();
|
||||||
|
let code = unsafe {
|
||||||
|
sys::mpv_command(
|
||||||
|
self.raw,
|
||||||
|
argv.as_ptr() as *mut *const c_char,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
MpvError::from_code(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a property (typed).
|
||||||
|
pub fn set_property(&self, prop: &Property) -> MpvResult<()> {
|
||||||
|
let name = CString::new(prop.name())?;
|
||||||
|
let code = unsafe {
|
||||||
|
match prop.format() {
|
||||||
|
Format::String => {
|
||||||
|
let s = CString::new(prop.as_str()?)?;
|
||||||
|
sys::mpv_set_property(
|
||||||
|
self.raw,
|
||||||
|
name.as_ptr(),
|
||||||
|
sys::mpv_format_MPV_FORMAT_STRING,
|
||||||
|
s.as_ptr() as *mut c_void,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Format::Flag => {
|
||||||
|
let v: i32 = prop.as_flag()? as i32;
|
||||||
|
sys::mpv_set_property(
|
||||||
|
self.raw,
|
||||||
|
name.as_ptr(),
|
||||||
|
sys::mpv_format_MPV_FORMAT_FLAG,
|
||||||
|
&v as *const i32 as *mut c_void,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Format::Int64 => {
|
||||||
|
let v = prop.as_i64()?;
|
||||||
|
sys::mpv_set_property(
|
||||||
|
self.raw,
|
||||||
|
name.as_ptr(),
|
||||||
|
sys::mpv_format_MPV_FORMAT_INT64,
|
||||||
|
&v as *const i64 as *mut c_void,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Format::Double => {
|
||||||
|
let v = prop.as_f64()?;
|
||||||
|
sys::mpv_set_property(
|
||||||
|
self.raw,
|
||||||
|
name.as_ptr(),
|
||||||
|
sys::mpv_format_MPV_FORMAT_DOUBLE,
|
||||||
|
&v as *const f64 as *mut c_void,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => return Err(MpvError::PropertyFormat),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
MpvError::from_code(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a property to a string value (convenience for options that take
|
||||||
|
/// string forms like "no", "inf", "auto", "yes"). Equivalent to
|
||||||
|
/// `mpv_set_property_string(handle, name, value)`.
|
||||||
|
pub fn set_property_string(&self, name: &str, value: &str) -> MpvResult<()> {
|
||||||
|
let c_name = CString::new(name)?;
|
||||||
|
let c_val = CString::new(value)?;
|
||||||
|
let code = unsafe {
|
||||||
|
sys::mpv_set_property_string(self.raw, c_name.as_ptr(), c_val.as_ptr())
|
||||||
|
};
|
||||||
|
// mpv_set_property_string returns >= 0 on success, < 0 on error.
|
||||||
|
MpvError::from_code(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a property as string. (Most useful for metadata.)
|
||||||
|
pub fn get_property_string(&self, name: &str) -> MpvResult<Option<String>> {
|
||||||
|
let c_name = CString::new(name)?;
|
||||||
|
// Safety: mpv_get_property_string returns a malloc'd string or NULL.
|
||||||
|
let raw = unsafe { sys::mpv_get_property_string(self.raw, c_name.as_ptr()) };
|
||||||
|
if raw.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// Safety: the returned CStr is valid until we free it.
|
||||||
|
let s = unsafe { CStr::from_ptr(raw) }.to_str()?.to_owned();
|
||||||
|
unsafe { sys::mpv_free(raw as *mut c_void) };
|
||||||
|
Ok(Some(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a property as f64 (works for time-pos, duration, volume, etc.).
|
||||||
|
pub fn get_property_f64(&self, name: &str) -> MpvResult<f64> {
|
||||||
|
let c_name = CString::new(name)?;
|
||||||
|
let mut v: f64 = 0.0;
|
||||||
|
let code = unsafe {
|
||||||
|
sys::mpv_get_property(
|
||||||
|
self.raw,
|
||||||
|
c_name.as_ptr(),
|
||||||
|
sys::mpv_format_MPV_FORMAT_DOUBLE,
|
||||||
|
&mut v as *mut f64 as *mut c_void,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
MpvError::from_code(code)?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a property as i64.
|
||||||
|
pub fn get_property_i64(&self, name: &str) -> MpvResult<i64> {
|
||||||
|
let c_name = CString::new(name)?;
|
||||||
|
let mut v: i64 = 0;
|
||||||
|
let code = unsafe {
|
||||||
|
sys::mpv_get_property(
|
||||||
|
self.raw,
|
||||||
|
c_name.as_ptr(),
|
||||||
|
sys::mpv_format_MPV_FORMAT_INT64,
|
||||||
|
&mut v as *mut i64 as *mut c_void,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
MpvError::from_code(code)?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a property as bool (flag).
|
||||||
|
pub fn get_property_flag(&self, name: &str) -> MpvResult<bool> {
|
||||||
|
let c_name = CString::new(name)?;
|
||||||
|
let mut v: i32 = 0;
|
||||||
|
let code = unsafe {
|
||||||
|
sys::mpv_get_property(
|
||||||
|
self.raw,
|
||||||
|
c_name.as_ptr(),
|
||||||
|
sys::mpv_format_MPV_FORMAT_FLAG,
|
||||||
|
&mut v as *mut i32 as *mut c_void,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
MpvError::from_code(code)?;
|
||||||
|
Ok(v != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe a property. The engine thread will receive `Property` events
|
||||||
|
/// with the given reply_user_data tag when the value changes.
|
||||||
|
pub fn observe_property(&self, reply_userdata: u64, name: &str, format: Format) -> MpvResult<()> {
|
||||||
|
let c_name = CString::new(name)?;
|
||||||
|
let code = unsafe {
|
||||||
|
sys::mpv_observe_property(
|
||||||
|
self.raw,
|
||||||
|
reply_userdata,
|
||||||
|
c_name.as_ptr(),
|
||||||
|
format.as_mpv_format(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
MpvError::from_code(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the requested log level. Lower levels are filtered.
|
||||||
|
pub fn request_log_messages(&self, level: crate::event::LogLevel) -> MpvResult<()> {
|
||||||
|
let s = CString::new(level.as_str())?;
|
||||||
|
let code = unsafe { sys::mpv_request_log_messages(self.raw, s.as_ptr()) };
|
||||||
|
MpvError::from_code(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for the next event, blocking the calling thread.
|
||||||
|
/// Returns `None` if the handle has been terminated.
|
||||||
|
pub fn wait_event(&self, timeout_seconds: f64) -> MpvResult<Option<Event>> {
|
||||||
|
// SAFETY: `mpv_wait_event` returns a pointer to an internal mpv_event
|
||||||
|
// stored inside the handle. The pointer is valid until the next call
|
||||||
|
// to `wait_event` on the same handle, and we serialize all calls via
|
||||||
|
// `&self` (single-threaded access within the engine thread). We copy
|
||||||
|
// out everything we need before returning, so no aliasing escapes.
|
||||||
|
let raw_event = unsafe { sys::mpv_wait_event(self.raw, timeout_seconds) };
|
||||||
|
if raw_event.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let event = unsafe { &*raw_event };
|
||||||
|
if event.event_id == sys::mpv_event_id_MPV_EVENT_NONE {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if event.error != 0 {
|
||||||
|
// For MPV_EVENT_SHUTDOWN, error is 0; this branch catches other failures.
|
||||||
|
return Err(MpvError::from_code(event.error).unwrap_err());
|
||||||
|
}
|
||||||
|
Ok(Event::from_raw(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake up `wait_event` from another thread (e.g. on shutdown).
|
||||||
|
pub fn wakeup(&self) {
|
||||||
|
unsafe { sys::mpv_wakeup(self.raw) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroy the handle. Idempotent: a second call is a no-op. The atomic
|
||||||
|
/// guard makes this safe to invoke from `Drop` even when an explicit
|
||||||
|
/// `shutdown()` has already run.
|
||||||
|
pub fn shutdown(&self) {
|
||||||
|
if self.terminated.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: `mpv_terminate_destroy` is the documented teardown call.
|
||||||
|
// After it returns, the handle is invalid; we never touch `self.raw`
|
||||||
|
// again because the atomic guard short-circuits any future call.
|
||||||
|
unsafe { sys::mpv_terminate_destroy(self.raw) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw pointer (for advanced consumers). Don't use unless you know what
|
||||||
|
/// you're doing.
|
||||||
|
pub fn as_ptr(&self) -> *mut sys::mpv_handle {
|
||||||
|
self.raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for MpvHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder for an initialized `MpvHandle`.
|
||||||
|
pub struct Builder {
|
||||||
|
options: Vec<(String, String)>,
|
||||||
|
log_level: crate::event::LogLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Builder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Builder {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
options: Vec::new(),
|
||||||
|
log_level: crate::event::LogLevel::Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a string option (equivalent to `--name=value` on the mpv CLI).
|
||||||
|
pub fn option(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||||
|
self.options.push((name.into(), value.into()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log_level(mut self, level: crate::event::LogLevel) -> Self {
|
||||||
|
self.log_level = level;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build + initialize. Returns a ready-to-use `MpvHandle`.
|
||||||
|
pub fn build(self) -> MpvResult<MpvHandle> {
|
||||||
|
let h = MpvHandle::new()?;
|
||||||
|
for (k, v) in &self.options {
|
||||||
|
h.set_option_string(k, v)?;
|
||||||
|
}
|
||||||
|
h.initialize()?;
|
||||||
|
h.request_log_messages(self.log_level)?;
|
||||||
|
Ok(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
//! Safe Rust bindings to libmpv's client API.
|
||||||
|
//!
|
||||||
|
//! Architecture:
|
||||||
|
//! - `sys` : raw FFI from bindgen (re-export of OUT_DIR/bindings.rs)
|
||||||
|
//! - `error` : `MpvError` enum + `MpvResult<T>`
|
||||||
|
//! - `handle` : owned `MpvHandle` (creates + initializes libmpv)
|
||||||
|
//! - `command` : typed command builder
|
||||||
|
//! - `property` : typed property get/set with serde-style adapters
|
||||||
|
//! - `event` : safe `Event` enum parsed from `mpv_event`
|
||||||
|
//!
|
||||||
|
//! Design rules:
|
||||||
|
//! 1. `MpvHandle` is `Send` but NOT `Clone` — there is exactly one owner.
|
||||||
|
//! Multiple consumers must use the property/command API, not raw handle sharing.
|
||||||
|
//! 2. All FFI calls that return `int` are converted to `MpvResult<()>` via `Error::from_code`.
|
||||||
|
//! 3. Strings from libmpv are copied into `CString`/`String` immediately on the
|
||||||
|
//! engine thread and never held across the FFI boundary.
|
||||||
|
//! 4. We never expose raw `*mut` pointers in the public API.
|
||||||
|
|
||||||
|
// bindgen emits constants like `mpv_format_MPV_FORMAT_FLAG` which trigger
|
||||||
|
// non_upper_case_globals warnings. They're idiomatic to the C API.
|
||||||
|
#![allow(non_upper_case_globals)]
|
||||||
|
#![allow(non_camel_case_types)]
|
||||||
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
|
pub mod sys {
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
pub mod handle;
|
||||||
|
pub mod command;
|
||||||
|
pub mod property;
|
||||||
|
pub mod event;
|
||||||
|
|
||||||
|
pub use error::{MpvError, MpvResult};
|
||||||
|
pub use handle::MpvHandle;
|
||||||
|
pub use event::{Event, EventId, LogLevel};
|
||||||
|
pub use property::{Format, Property};
|
||||||
|
|
||||||
|
/// Library version string from build time.
|
||||||
|
pub const LIBMPV_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
//! Property get/set helpers.
|
||||||
|
//!
|
||||||
|
//! libmpv properties are typed values identified by string names like
|
||||||
|
//! `time-pos`, `duration`, `volume`, `pause`. We model the supported formats
|
||||||
|
//! as a `Format` enum and use `Property` as a typed bag that can carry any
|
||||||
|
//! of the value kinds we care about.
|
||||||
|
|
||||||
|
use crate::error::{MpvError, MpvResult};
|
||||||
|
|
||||||
|
/// The libmpv property formats we support. Mirrors `mpv_format` but trimmed
|
||||||
|
/// to what we actually use.
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Format {
|
||||||
|
String,
|
||||||
|
Flag,
|
||||||
|
Int64,
|
||||||
|
Double,
|
||||||
|
Node,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Format {
|
||||||
|
/// Return the raw `mpv_format` value as the type alias emitted by bindgen
|
||||||
|
/// (a `c_uint` / `u32`).
|
||||||
|
pub fn as_mpv_format(self) -> crate::sys::mpv_format {
|
||||||
|
match self {
|
||||||
|
Format::String => crate::sys::mpv_format_MPV_FORMAT_STRING,
|
||||||
|
Format::Flag => crate::sys::mpv_format_MPV_FORMAT_FLAG,
|
||||||
|
Format::Int64 => crate::sys::mpv_format_MPV_FORMAT_INT64,
|
||||||
|
Format::Double => crate::sys::mpv_format_MPV_FORMAT_DOUBLE,
|
||||||
|
Format::Node => crate::sys::mpv_format_MPV_FORMAT_NODE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed property value, paired with its name.
|
||||||
|
///
|
||||||
|
/// Construction is via the `Property::str()`, `flag()`, `int()`, `double()`
|
||||||
|
/// constructors. Reading happens through the `as_*` accessors.
|
||||||
|
pub struct Property {
|
||||||
|
name: String,
|
||||||
|
value: PropValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PropValue {
|
||||||
|
Str(String),
|
||||||
|
Flag(bool),
|
||||||
|
Int(i64),
|
||||||
|
Double(f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property {
|
||||||
|
pub fn str(name: impl Into<String>, v: impl Into<String>) -> Self {
|
||||||
|
Self { name: name.into(), value: PropValue::Str(v.into()) }
|
||||||
|
}
|
||||||
|
pub fn flag(name: impl Into<String>, v: bool) -> Self {
|
||||||
|
Self { name: name.into(), value: PropValue::Flag(v) }
|
||||||
|
}
|
||||||
|
pub fn int(name: impl Into<String>, v: i64) -> Self {
|
||||||
|
Self { name: name.into(), value: PropValue::Int(v) }
|
||||||
|
}
|
||||||
|
pub fn double(name: impl Into<String>, v: f64) -> Self {
|
||||||
|
Self { name: name.into(), value: PropValue::Double(v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &str { &self.name }
|
||||||
|
pub fn format(&self) -> Format {
|
||||||
|
match self.value {
|
||||||
|
PropValue::Str(_) => Format::String,
|
||||||
|
PropValue::Flag(_) => Format::Flag,
|
||||||
|
PropValue::Int(_) => Format::Int64,
|
||||||
|
PropValue::Double(_) => Format::Double,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> MpvResult<&str> {
|
||||||
|
match &self.value {
|
||||||
|
PropValue::Str(s) => Ok(s),
|
||||||
|
_ => Err(MpvError::PropertyFormat),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn as_flag(&self) -> MpvResult<bool> {
|
||||||
|
match self.value {
|
||||||
|
PropValue::Flag(b) => Ok(b),
|
||||||
|
_ => Err(MpvError::PropertyFormat),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn as_i64(&self) -> MpvResult<i64> {
|
||||||
|
match self.value {
|
||||||
|
PropValue::Int(i) => Ok(i),
|
||||||
|
_ => Err(MpvError::PropertyFormat),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn as_f64(&self) -> MpvResult<f64> {
|
||||||
|
match self.value {
|
||||||
|
PropValue::Double(d) => Ok(d),
|
||||||
|
_ => Err(MpvError::PropertyFormat),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
[package]
|
||||||
|
name = "player-app"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "ferret video player binary"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ferret"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
player-core = { workspace = true }
|
||||||
|
player-ui = { workspace = true }
|
||||||
|
mpv-bindings = { workspace = true }
|
||||||
|
winit = { workspace = true }
|
||||||
|
egui = { workspace = true }
|
||||||
|
egui-wgpu = { workspace = true }
|
||||||
|
wgpu = { workspace = true }
|
||||||
|
raw-window-handle = { workspace = true }
|
||||||
|
pollster = { workspace = true }
|
||||||
|
crossbeam-channel = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
//! Keyboard shortcut → `Cmd` translation table.
|
||||||
|
//!
|
||||||
|
//! Table-driven: each row is `(predicate, factory)` as `fn` pointers
|
||||||
|
//! (zero-cost, `const`-promotable, no captures). Step-down: the first row
|
||||||
|
//! whose predicate matches wins. `q` and `f` are intercepted by the caller
|
||||||
|
//! (`FerretApp::handle_keyboard`) before this table is consulted.
|
||||||
|
//!
|
||||||
|
//! Keys that depend on UI-derived values (loop mode, speed) read from a
|
||||||
|
//! `&PlaybackState` snapshot rather than the overlay renderer directly,
|
||||||
|
//! keeping this module decoupled from `player-ui`.
|
||||||
|
|
||||||
|
use player_core::state::PlaybackState;
|
||||||
|
use player_core::Cmd;
|
||||||
|
use winit::keyboard::{Key, NamedKey};
|
||||||
|
|
||||||
|
/// Translate a winit `Key` into the engine command it should produce, given
|
||||||
|
/// the current playback state for keys that depend on UI-derived values
|
||||||
|
/// (loop mode, speed).
|
||||||
|
///
|
||||||
|
/// Returns `None` for keys with no binding.
|
||||||
|
pub fn key_to_cmd(key: &Key, state: &PlaybackState) -> Option<Cmd> {
|
||||||
|
use mpv_bindings::command::{SeekFlags, SeekMode};
|
||||||
|
|
||||||
|
type Arm = fn(&Key) -> bool;
|
||||||
|
type Make = fn(&PlaybackState) -> Cmd;
|
||||||
|
|
||||||
|
// ---- Predicates: one per key we recognize. ----
|
||||||
|
fn is_space(k: &Key) -> bool { matches!(k, Key::Named(NamedKey::Space)) }
|
||||||
|
fn is_m(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "m") }
|
||||||
|
fn is_arrow_right(k: &Key) -> bool { matches!(k, Key::Named(NamedKey::ArrowRight)) }
|
||||||
|
fn is_arrow_left(k: &Key) -> bool { matches!(k, Key::Named(NamedKey::ArrowLeft)) }
|
||||||
|
fn is_arrow_up(k: &Key) -> bool { matches!(k, Key::Named(NamedKey::ArrowUp)) }
|
||||||
|
fn is_arrow_down(k: &Key) -> bool { matches!(k, Key::Named(NamedKey::ArrowDown)) }
|
||||||
|
fn is_dot(k: &Key) -> bool { matches!(k, Key::Character(s) if s == ".") }
|
||||||
|
fn is_comma(k: &Key) -> bool { matches!(k, Key::Character(s) if s == ",") }
|
||||||
|
fn is_lbracket(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "[") }
|
||||||
|
fn is_rbracket(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "]") }
|
||||||
|
fn is_backslash(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "\\") }
|
||||||
|
fn is_l(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "l" || s == "L") }
|
||||||
|
fn is_minus(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "-") }
|
||||||
|
fn is_plus(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "=" || s == "+") }
|
||||||
|
fn is_n(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "n" || s == "N") }
|
||||||
|
fn is_p(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "p" || s == "P") }
|
||||||
|
fn is_v(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "v" || s == "V") }
|
||||||
|
|
||||||
|
// ---- Factories: produce the Cmd. State-dependent ones read `state`. ----
|
||||||
|
fn play_pause(_: &PlaybackState) -> Cmd { Cmd::PlayPause }
|
||||||
|
fn toggle_mute(_: &PlaybackState) -> Cmd { Cmd::ToggleMute }
|
||||||
|
fn seek_right(_: &PlaybackState) -> Cmd {
|
||||||
|
Cmd::Seek { target_secs: 5.0, mode: SeekMode::Relative, flags: SeekFlags::Keyframes }
|
||||||
|
}
|
||||||
|
fn seek_left(_: &PlaybackState) -> Cmd {
|
||||||
|
Cmd::Seek { target_secs: -5.0, mode: SeekMode::Relative, flags: SeekFlags::Keyframes }
|
||||||
|
}
|
||||||
|
fn vol_up(_: &PlaybackState) -> Cmd { Cmd::AdjustVolume(0.05) }
|
||||||
|
fn vol_down(_: &PlaybackState) -> Cmd { Cmd::AdjustVolume(-0.05) }
|
||||||
|
fn frame_step(_: &PlaybackState) -> Cmd { Cmd::FrameStep }
|
||||||
|
fn frame_back(_: &PlaybackState) -> Cmd { Cmd::FrameBackStep }
|
||||||
|
fn marker_a(_: &PlaybackState) -> Cmd { Cmd::SetMarkerA }
|
||||||
|
fn marker_b(_: &PlaybackState) -> Cmd { Cmd::SetMarkerB }
|
||||||
|
fn clear_markers(_: &PlaybackState) -> Cmd { Cmd::ClearMarkers }
|
||||||
|
fn cycle_loop(state: &PlaybackState) -> Cmd {
|
||||||
|
Cmd::SetLoopMode(state.loop_mode.cycle())
|
||||||
|
}
|
||||||
|
fn speed_down(state: &PlaybackState) -> Cmd {
|
||||||
|
Cmd::SetSpeed((state.speed - 0.25_f32).max(0.25))
|
||||||
|
}
|
||||||
|
fn speed_up(state: &PlaybackState) -> Cmd {
|
||||||
|
Cmd::SetSpeed((state.speed + 0.25_f32).min(4.0))
|
||||||
|
}
|
||||||
|
fn next_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistNext }
|
||||||
|
fn prev_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistPrev }
|
||||||
|
fn toggle_subs(_: &PlaybackState) -> Cmd { Cmd::ToggleSubVisibility }
|
||||||
|
|
||||||
|
// ---- Lookup table. Order matters only for `q`/`f` which are
|
||||||
|
// intercepted by the caller; all other keys are mutually exclusive. ----
|
||||||
|
const TABLE: &[(Arm, Make)] = &[
|
||||||
|
(is_space, play_pause),
|
||||||
|
(is_m, toggle_mute),
|
||||||
|
(is_arrow_right, seek_right),
|
||||||
|
(is_arrow_left, seek_left),
|
||||||
|
(is_arrow_up, vol_up),
|
||||||
|
(is_arrow_down, vol_down),
|
||||||
|
(is_dot, frame_step),
|
||||||
|
(is_comma, frame_back),
|
||||||
|
(is_lbracket, marker_a),
|
||||||
|
(is_rbracket, marker_b),
|
||||||
|
(is_backslash, clear_markers),
|
||||||
|
(is_l, cycle_loop),
|
||||||
|
(is_minus, speed_down),
|
||||||
|
(is_plus, speed_up),
|
||||||
|
(is_n, next_track),
|
||||||
|
(is_p, prev_track),
|
||||||
|
(is_v, toggle_subs),
|
||||||
|
];
|
||||||
|
TABLE
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|(arm, _)| arm(key))
|
||||||
|
.map(|(_, make)| make(state))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,694 @@
|
||||||
|
//! ferret — main binary.
|
||||||
|
//!
|
||||||
|
//! Architecture:
|
||||||
|
//! 1. Parse CLI args (just the file path).
|
||||||
|
//! 2. Create winit event loop (single event loop, multiple windows).
|
||||||
|
//! 3. Create the "video window" — a plain winit window we hand to libmpv
|
||||||
|
//! via the `wid` option (X11 only today; Wayland waits on the libmpv
|
||||||
|
//! render-context API).
|
||||||
|
//! 4. Create the "overlay window" — transparent, borderless, always-on-top,
|
||||||
|
//! sized to overlap the video window. egui + wgpu renders controls here.
|
||||||
|
//! 5. Construct the engine with the video window's XID as `wid`.
|
||||||
|
//! 6. Run the event loop. Each RedrawRequested:
|
||||||
|
//! - For video window: do nothing (libmpv renders into it directly).
|
||||||
|
//! - For overlay window: pull latest state, render egui frame.
|
||||||
|
//! 7. Send Cmds to the engine based on user input (keyboard, mouse, UI events).
|
||||||
|
//!
|
||||||
|
//! File dialogs (Load File / Folder / Playlist / Export Markers) run on a
|
||||||
|
//! worker thread because `rfd` blocks while the dialog is open. The worker
|
||||||
|
//! sends the result back via a channel polled from `about_to_wait`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use crossbeam_channel::unbounded;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use winit::application::ApplicationHandler;
|
||||||
|
use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent};
|
||||||
|
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||||
|
use winit::keyboard::Key;
|
||||||
|
use winit::window::WindowId;
|
||||||
|
|
||||||
|
use player_core::cmd::LoadModeKind;
|
||||||
|
use player_core::options::EngineOptions;
|
||||||
|
use player_core::{Cmd, PlayerEngine};
|
||||||
|
use player_ui::OverlayRenderer;
|
||||||
|
|
||||||
|
mod keymap;
|
||||||
|
mod windows;
|
||||||
|
|
||||||
|
use windows::{WindowKind, WindowManager};
|
||||||
|
|
||||||
|
/// Result from a background ffmpeg export worker.
|
||||||
|
enum DialogResult {
|
||||||
|
/// Export succeeded; the file was written to this path.
|
||||||
|
File(String),
|
||||||
|
/// Export failed with this error message.
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only one background operation remains: ffmpeg video export. All file
|
||||||
|
/// selection is now in-UI (see `player_ui::file_dialog`).
|
||||||
|
enum DialogKind {
|
||||||
|
ExportVideo {
|
||||||
|
input: String,
|
||||||
|
start: f64,
|
||||||
|
end: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("warn,ferret=info")),
|
||||||
|
)
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// X11 today. Wayland waits on the libmpv render-context API.
|
||||||
|
if std::env::var("WAYLAND_DISPLAY").is_ok() && std::env::var("DISPLAY").is_ok() {
|
||||||
|
if std::env::var("FERRET_FORCE_WAYLAND").is_err() {
|
||||||
|
// SAFETY: `set_var` is unsafe as of Rust 2024 due to getenv races.
|
||||||
|
// We run before spawning any thread, so no concurrent reader exists.
|
||||||
|
unsafe { std::env::set_var("WAYLAND_DISPLAY", ""); }
|
||||||
|
info!("Wayland detected; locking to X11 (set FERRET_FORCE_WAYLAND=1 to override)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let file_path = if args.len() >= 2 {
|
||||||
|
Some(args[1].clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(ref p) = file_path {
|
||||||
|
info!("will load: {p}");
|
||||||
|
} else {
|
||||||
|
info!("no file argument; launching empty (use: ferret <path>)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_loop = EventLoop::new()?;
|
||||||
|
let engine_options = EngineOptions::default();
|
||||||
|
let mut app = FerretApp::new(engine_options, file_path);
|
||||||
|
event_loop.run_app(&mut app)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FerretApp {
|
||||||
|
engine_options: EngineOptions,
|
||||||
|
initial_file: Option<String>,
|
||||||
|
windows: WindowManager,
|
||||||
|
engine: Option<PlayerEngine>,
|
||||||
|
overlay: Option<OverlayRenderer>,
|
||||||
|
last_overlay_render: Instant,
|
||||||
|
overlay_mouse_pos: Option<egui::Pos2>,
|
||||||
|
fullscreen: bool,
|
||||||
|
/// Channel for commands emitted by the overlay UI (forwarded to the engine).
|
||||||
|
cmd_tx: crossbeam_channel::Sender<Cmd>,
|
||||||
|
cmd_rx: crossbeam_channel::Receiver<Cmd>,
|
||||||
|
/// Channel for dialog results coming back from worker threads.
|
||||||
|
/// We pair each result with the kind of dialog that produced it so we
|
||||||
|
/// know what Cmd to emit once we have the path.
|
||||||
|
dialog_result_rx: crossbeam_channel::Receiver<(DialogKind, DialogResult)>,
|
||||||
|
dialog_result_tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FerretApp {
|
||||||
|
fn new(engine_options: EngineOptions, initial_file: Option<String>) -> Self {
|
||||||
|
let (cmd_tx, cmd_rx) = unbounded::<Cmd>();
|
||||||
|
let (dialog_result_tx, dialog_result_rx) = unbounded::<(DialogKind, DialogResult)>();
|
||||||
|
Self {
|
||||||
|
engine_options,
|
||||||
|
initial_file,
|
||||||
|
windows: WindowManager::new(),
|
||||||
|
engine: None,
|
||||||
|
overlay: None,
|
||||||
|
last_overlay_render: Instant::now(),
|
||||||
|
overlay_mouse_pos: None,
|
||||||
|
fullscreen: false,
|
||||||
|
cmd_tx,
|
||||||
|
cmd_rx,
|
||||||
|
dialog_result_rx,
|
||||||
|
dialog_result_tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup(&mut self, event_loop: &ActiveEventLoop) -> Result<()> {
|
||||||
|
// 1. Create video window.
|
||||||
|
let video_window = self.windows.create_video_window(event_loop)?;
|
||||||
|
let video_window_arc = Arc::new(video_window);
|
||||||
|
|
||||||
|
// 1b. Set the X11 background pixel on the video window. winit creates
|
||||||
|
// windows with background_pixel=None, which means the X server shows
|
||||||
|
// framebuffer garbage (stale content from other windows) during a
|
||||||
|
// resize — before libmpv has a chance to repaint. Setting the
|
||||||
|
// background to dark grey (#141416, matching the VLC theme) makes the
|
||||||
|
// X server fill the window with that color on resize, eliminating the
|
||||||
|
// "mirrored desktop" artifact. This MUST happen before libmpv attaches
|
||||||
|
// via wid.
|
||||||
|
set_x11_window_background(&video_window_arc, 0x141416);
|
||||||
|
|
||||||
|
// 2. Get its X11 XID.
|
||||||
|
let wid = extract_x11_xid(&video_window_arc)?;
|
||||||
|
info!("video window XID: {wid}");
|
||||||
|
|
||||||
|
// 3. Construct engine with wid patched in.
|
||||||
|
self.engine_options.wid = Some(wid.to_string());
|
||||||
|
let mut engine = PlayerEngine::new(self.engine_options.clone())?;
|
||||||
|
engine.start().context("engine start")?;
|
||||||
|
|
||||||
|
// 4. Take the event receiver (single-consumer).
|
||||||
|
let event_rx = engine
|
||||||
|
.take_event_receiver()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("engine event receiver already taken"))?;
|
||||||
|
|
||||||
|
// 5. Load initial file.
|
||||||
|
if let Some(path) = self.initial_file.clone() {
|
||||||
|
engine.send(Cmd::LoadFile {
|
||||||
|
path,
|
||||||
|
options: player_core::cmd::LoadOptions {
|
||||||
|
mode: LoadModeKind::Replace,
|
||||||
|
pause: false,
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.engine = Some(engine);
|
||||||
|
|
||||||
|
// 6. Create overlay window.
|
||||||
|
let overlay_window = self
|
||||||
|
.windows
|
||||||
|
.create_overlay_window(event_loop, &video_window_arc)?;
|
||||||
|
let overlay_window_arc = Arc::new(overlay_window);
|
||||||
|
|
||||||
|
// 7. Create overlay renderer.
|
||||||
|
let state = self.engine.as_ref().unwrap().state();
|
||||||
|
let overlay_renderer = OverlayRenderer::new(
|
||||||
|
overlay_window_arc.clone(),
|
||||||
|
state,
|
||||||
|
event_rx,
|
||||||
|
self.cmd_tx.clone(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
self.overlay = Some(overlay_renderer);
|
||||||
|
self.windows.video = Some(video_window_arc);
|
||||||
|
self.windows.overlay = Some(overlay_window_arc);
|
||||||
|
|
||||||
|
self.request_redraw_both();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop any pending ffmpeg export results and convert them to info
|
||||||
|
/// toast messages. The only remaining use of the dialog-result channel
|
||||||
|
/// — all file selection is now in-UI.
|
||||||
|
fn poll_dialog_results(&mut self) -> Vec<String> {
|
||||||
|
let mut infos: Vec<String> = Vec::new();
|
||||||
|
while let Ok((kind, result)) = self.dialog_result_rx.try_recv() {
|
||||||
|
match (kind, result) {
|
||||||
|
(DialogKind::ExportVideo { .. }, DialogResult::File(path)) => {
|
||||||
|
infos.push(format!("A-B loop exported to {path}"));
|
||||||
|
}
|
||||||
|
(DialogKind::ExportVideo { .. }, DialogResult::Error(msg)) => {
|
||||||
|
infos.push(format!("Export failed: {msg}"));
|
||||||
|
}
|
||||||
|
(_, DialogResult::Error(msg)) => {
|
||||||
|
infos.push(msg);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
infos
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_overlay(&mut self) {
|
||||||
|
let Some(engine) = self.engine.as_ref() else { return; };
|
||||||
|
|
||||||
|
// Forward commands from the overlay UI to the engine. Two commands
|
||||||
|
// are intercepted here (not sent to the engine):
|
||||||
|
// - ToggleFullscreen: main-app window concern
|
||||||
|
// - ExportABLoopVideo: main-app runs ffmpeg (engine no-ops it)
|
||||||
|
let mut pending_fullscreen_toggle = false;
|
||||||
|
while let Ok(cmd) = self.cmd_rx.try_recv() {
|
||||||
|
match &cmd {
|
||||||
|
Cmd::ToggleFullscreen => {
|
||||||
|
pending_fullscreen_toggle = true;
|
||||||
|
}
|
||||||
|
Cmd::ExportABLoopVideo { path } => {
|
||||||
|
// The in-UI file dialog already validated A/B markers
|
||||||
|
// and sent this with the real output path. Read A/B +
|
||||||
|
// input from engine state, then spawn ffmpeg.
|
||||||
|
let st = engine.state();
|
||||||
|
let a = st.marker_a;
|
||||||
|
let b = st.marker_b;
|
||||||
|
let input = st.path.clone();
|
||||||
|
drop(st);
|
||||||
|
match (a, b, input) {
|
||||||
|
(Some(start), Some(end), Some(inp)) if end > start => {
|
||||||
|
spawn_ffmpeg_export(
|
||||||
|
inp,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
path.clone(),
|
||||||
|
self.dialog_result_tx.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = self.dialog_result_tx.send((
|
||||||
|
DialogKind::ExportVideo {
|
||||||
|
input: String::new(),
|
||||||
|
start: 0.0,
|
||||||
|
end: 0.0,
|
||||||
|
},
|
||||||
|
DialogResult::Error(
|
||||||
|
"Set both A and B markers before exporting".into(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = engine.send(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for ffmpeg export results.
|
||||||
|
let _ = engine;
|
||||||
|
let infos = self.poll_dialog_results();
|
||||||
|
for info in infos {
|
||||||
|
if let Some(overlay) = self.overlay.as_mut() {
|
||||||
|
overlay.app.show_info(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(overlay) = self.overlay.as_mut() else { return; };
|
||||||
|
let Some(engine) = self.engine.as_ref() else { return; };
|
||||||
|
let state = engine.state();
|
||||||
|
if let Err(e) = overlay.render(state, self.overlay_mouse_pos) {
|
||||||
|
warn!("overlay render: {e}");
|
||||||
|
}
|
||||||
|
self.last_overlay_render = Instant::now();
|
||||||
|
|
||||||
|
if pending_fullscreen_toggle {
|
||||||
|
self.toggle_fullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request a redraw on both windows. Single dispatch point so callers
|
||||||
|
/// never have to repeat the `if let Some(w) = ...` dance.
|
||||||
|
fn request_redraw_both(&self) {
|
||||||
|
self.windows.video.as_ref().map(|w| w.request_redraw());
|
||||||
|
self.windows.overlay.as_ref().map(|w| w.request_redraw());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request a redraw on the overlay window only.
|
||||||
|
fn request_redraw_overlay(&self) {
|
||||||
|
if let Some(w) = &self.windows.overlay {
|
||||||
|
w.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_keyboard(&mut self, key: &Key, event_loop: &ActiveEventLoop) {
|
||||||
|
// `q` and `f` are window-level concerns; they never reach the engine.
|
||||||
|
match key {
|
||||||
|
Key::Character(s) if s == "q" || s == "Q" => {
|
||||||
|
event_loop.exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Key::Character(s) if s == "f" || s == "F" => {
|
||||||
|
self.toggle_fullscreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let Some(engine) = self.engine.as_ref() else { return; };
|
||||||
|
// Snapshot the current playback state for keys that depend on
|
||||||
|
// UI-derived values (loop mode, speed). Keeps `keymap.rs` decoupled
|
||||||
|
// from `player-ui`.
|
||||||
|
let state = engine.state();
|
||||||
|
if let Some(cmd) = keymap::key_to_cmd(key, &state) {
|
||||||
|
let _ = engine.send(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_fullscreen(&mut self) {
|
||||||
|
let Some(video) = self.windows.video.clone() else { return; };
|
||||||
|
self.fullscreen = !self.fullscreen;
|
||||||
|
if self.fullscreen {
|
||||||
|
video.set_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
|
||||||
|
} else {
|
||||||
|
video.set_fullscreen(None);
|
||||||
|
}
|
||||||
|
// Sync state to overlay so the fullscreen button reflects it.
|
||||||
|
if let Some(overlay) = self.overlay.as_mut() {
|
||||||
|
overlay.app.set_fullscreen(self.fullscreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplicationHandler for FerretApp {
|
||||||
|
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
|
if self.engine.is_none() {
|
||||||
|
if let Err(e) = self.setup(event_loop) {
|
||||||
|
error!("setup failed: {e:#}");
|
||||||
|
event_loop.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_event(
|
||||||
|
&mut self,
|
||||||
|
event_loop: &ActiveEventLoop,
|
||||||
|
window_id: WindowId,
|
||||||
|
event: WindowEvent,
|
||||||
|
) {
|
||||||
|
let kind = self.windows.classify(window_id);
|
||||||
|
|
||||||
|
match kind {
|
||||||
|
WindowKind::Video => match event {
|
||||||
|
WindowEvent::KeyboardInput {
|
||||||
|
event:
|
||||||
|
KeyEvent {
|
||||||
|
state: ElementState::Pressed,
|
||||||
|
logical_key,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
self.handle_keyboard(&logical_key, event_loop);
|
||||||
|
self.request_redraw_overlay();
|
||||||
|
}
|
||||||
|
WindowEvent::Resized(_) | WindowEvent::Moved(_) => {
|
||||||
|
if let Err(e) = self.windows.sync_overlay_to_video() {
|
||||||
|
warn!("overlay sync: {e}");
|
||||||
|
}
|
||||||
|
// Proactively resize the overlay's wgpu surface to match
|
||||||
|
// the video window's new geometry. We can't wait for the
|
||||||
|
// overlay's own Resized event because:
|
||||||
|
// 1. During a MOVE, the overlay's size doesn't change,
|
||||||
|
// so its Resized event never fires.
|
||||||
|
// 2. During a RESIZE, there's a delay between
|
||||||
|
// request_inner_size() and the overlay's Resized
|
||||||
|
// event. During that delay, get_current_texture()
|
||||||
|
// returns Outdated and we can't paint.
|
||||||
|
// By calling resize() here, we reconfigure the surface
|
||||||
|
// immediately — the next render succeeds and paints an
|
||||||
|
// opaque dark grey frame that hides the video window's
|
||||||
|
// resize gap. resize() is a no-op if the size hasn't
|
||||||
|
// changed (e.g., during a pure move).
|
||||||
|
if let (Some(overlay), Some(video)) = (&mut self.overlay, &self.windows.video) {
|
||||||
|
let size = video.inner_size();
|
||||||
|
overlay.resize(size.width, size.height);
|
||||||
|
}
|
||||||
|
self.request_redraw_overlay();
|
||||||
|
}
|
||||||
|
WindowEvent::CloseRequested => {
|
||||||
|
event_loop.exit();
|
||||||
|
}
|
||||||
|
WindowEvent::RedrawRequested => {
|
||||||
|
// The video window itself doesn't render anything (libmpv
|
||||||
|
// owns it). But a RedrawRequested on the video window
|
||||||
|
// means the WM wants us to repaint — forward it to the
|
||||||
|
// overlay so the controls stay in sync.
|
||||||
|
self.render_overlay();
|
||||||
|
self.request_redraw_overlay();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
WindowKind::Overlay => match event {
|
||||||
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
|
// The renderer sets pixels_per_point=1.0, so egui's
|
||||||
|
// coordinate system matches physical pixels directly.
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
self.request_redraw_overlay();
|
||||||
|
}
|
||||||
|
WindowEvent::CursorLeft { .. } => {
|
||||||
|
self.overlay_mouse_pos = None;
|
||||||
|
if let Some(overlay) = self.overlay.as_mut() {
|
||||||
|
overlay.app.push_event(egui::Event::PointerGone);
|
||||||
|
}
|
||||||
|
self.request_redraw_overlay();
|
||||||
|
}
|
||||||
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Process the click immediately so dropdown menus open
|
||||||
|
// without waiting for the next render cycle. The
|
||||||
|
// RedrawRequested handler has a 16ms rate limit that can
|
||||||
|
// skip the render, and about_to_wait only fires on a 33ms
|
||||||
|
// timer — so without this eager render, the click sits in
|
||||||
|
// pending_events and the dropdown never appears until the
|
||||||
|
// mouse moves.
|
||||||
|
self.render_overlay();
|
||||||
|
self.request_redraw_overlay();
|
||||||
|
}
|
||||||
|
WindowEvent::KeyboardInput {
|
||||||
|
event:
|
||||||
|
KeyEvent {
|
||||||
|
state: ElementState::Pressed,
|
||||||
|
logical_key,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
self.handle_keyboard(&logical_key, event_loop);
|
||||||
|
}
|
||||||
|
WindowEvent::Resized(_) | WindowEvent::Moved(_) => {
|
||||||
|
// The overlay itself was resized or moved. When the overlay
|
||||||
|
// moves (because we called set_outer_position in the video
|
||||||
|
// window's Moved handler), its wgpu surface can become
|
||||||
|
// stale. Re-suppress transparency and request a redraw so
|
||||||
|
// the renderer reconfigures the surface and paints a fresh
|
||||||
|
// opaque frame. Without this, the old transparent frame
|
||||||
|
// stays visible and the desktop shows through.
|
||||||
|
let new_size = self.windows.overlay.as_ref().map(|w| w.inner_size());
|
||||||
|
if let (Some(overlay), Some(size)) = (self.overlay.as_mut(), new_size) {
|
||||||
|
overlay.resize(size.width, size.height);
|
||||||
|
}
|
||||||
|
self.request_redraw_overlay();
|
||||||
|
}
|
||||||
|
WindowEvent::RedrawRequested => {
|
||||||
|
// Always render on RedrawRequested. The previous 16ms rate
|
||||||
|
// limit caused skipped frames during resize bursts and
|
||||||
|
// delayed dropdown menu opening. wgpu's PresentMode already
|
||||||
|
// throttles to the display refresh rate.
|
||||||
|
self.render_overlay();
|
||||||
|
}
|
||||||
|
WindowEvent::CloseRequested => {
|
||||||
|
event_loop.exit();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
WindowKind::Unknown => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
|
||||||
|
// Re-render the overlay at ~30fps even when no input arrives,
|
||||||
|
// promptly whenever a dialog result lands, and immediately when
|
||||||
|
// there are queued egui events (clicks, key presses) that the
|
||||||
|
// rate-limited RedrawRequested handler might have skipped.
|
||||||
|
let has_pending_events = self.overlay
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| !o.app.pending_events.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let need_render = self.last_overlay_render.elapsed() > Duration::from_millis(33)
|
||||||
|
|| !self.dialog_result_rx.is_empty()
|
||||||
|
|| has_pending_events;
|
||||||
|
if need_render {
|
||||||
|
self.render_overlay();
|
||||||
|
self.request_redraw_overlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the X11 XID from a winit window.
|
||||||
|
fn extract_x11_xid(window: &Arc<winit::window::Window>) -> Result<u64> {
|
||||||
|
use raw_window_handle::HasWindowHandle;
|
||||||
|
let handle = window.window_handle()?.as_raw();
|
||||||
|
match handle {
|
||||||
|
raw_window_handle::RawWindowHandle::Xlib(x) => Ok(x.window as u64),
|
||||||
|
raw_window_handle::RawWindowHandle::Xcb(x) => Ok(x.window.get() as u64),
|
||||||
|
other => Err(anyhow::anyhow!(
|
||||||
|
"video window is not on X11 (got {other:?}). Wayland requires libmpv's render-context API, which is on the roadmap."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the X11 background pixel on a winit window.
|
||||||
|
///
|
||||||
|
/// winit creates windows with `background_pixel = None`, which means the X
|
||||||
|
/// server does NOT fill the window on resize/expose — it shows whatever is in
|
||||||
|
/// the framebuffer (stale content from other windows, GPU garbage). This is
|
||||||
|
/// the root cause of the "mirrored desktop" artifact during window resize/move.
|
||||||
|
///
|
||||||
|
/// By calling `XSetWindowBackground` with a dark grey pixel, we tell the X
|
||||||
|
/// server to fill the window with that color whenever it needs to clear or
|
||||||
|
/// resize the window. libmpv's video rendering paints on top of this
|
||||||
|
/// background, so normal playback is unaffected — but during the resize gap
|
||||||
|
/// (before libmpv repaints), the window shows dark grey instead of garbage.
|
||||||
|
///
|
||||||
|
/// The pixel value is in X11's native format: `0x00RRGGBB` (on most displays,
|
||||||
|
/// this is a 24-bit color packed into a 32-bit `unsigned long`).
|
||||||
|
fn set_x11_window_background(window: &Arc<winit::window::Window>, pixel: u64) {
|
||||||
|
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||||
|
|
||||||
|
// raw-window-handle 0.6 split the display handle into a separate type.
|
||||||
|
// XlibWindowHandle only carries the window ID; the display pointer lives
|
||||||
|
// in XlibDisplayHandle, accessed via HasDisplayHandle.
|
||||||
|
let Ok(win_handle) = window.window_handle() else { return; };
|
||||||
|
let Ok(disp_handle) = window.display_handle() else { return; };
|
||||||
|
|
||||||
|
let win_raw = win_handle.as_raw();
|
||||||
|
let disp_raw = disp_handle.as_raw();
|
||||||
|
|
||||||
|
match (win_raw, disp_raw) {
|
||||||
|
(
|
||||||
|
raw_window_handle::RawWindowHandle::Xlib(x),
|
||||||
|
raw_window_handle::RawDisplayHandle::Xlib(d),
|
||||||
|
) => {
|
||||||
|
// SAFETY: We're calling Xlib functions with the display pointer
|
||||||
|
// and window ID from the raw handles. Both are valid as long as
|
||||||
|
// the window exists (winit owns them). XSetWindowBackground is
|
||||||
|
// thread-safe and doesn't allocate. XFlush ensures the request
|
||||||
|
// is sent to the X server immediately.
|
||||||
|
// Link against libX11 — XSetWindowBackground and XFlush live there.
|
||||||
|
// winit's X11 backend already pulls in libX11 transitively, but
|
||||||
|
// the linker still needs the explicit #[link] to resolve our
|
||||||
|
// direct extern "C" references.
|
||||||
|
#[link(name = "X11")]
|
||||||
|
extern "C" {
|
||||||
|
fn XSetWindowBackground(
|
||||||
|
display: *mut std::os::raw::c_void,
|
||||||
|
w: std::os::raw::c_ulong,
|
||||||
|
pixel: std::os::raw::c_ulong,
|
||||||
|
) -> std::os::raw::c_int;
|
||||||
|
fn XFlush(display: *mut std::os::raw::c_void) -> std::os::raw::c_int;
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
// d.display is Option<NonNull<c_void>> in raw-window-handle 0.6.
|
||||||
|
// Unwrap and convert to a raw pointer. None would mean winit
|
||||||
|
// didn't provide a display handle — skip in that case.
|
||||||
|
let Some(display_ptr) = d.display else {
|
||||||
|
warn!("XlibDisplayHandle.display is None; cannot set background pixel");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let display = display_ptr.as_ptr();
|
||||||
|
XSetWindowBackground(
|
||||||
|
display,
|
||||||
|
x.window as std::os::raw::c_ulong,
|
||||||
|
pixel as std::os::raw::c_ulong,
|
||||||
|
);
|
||||||
|
XFlush(display);
|
||||||
|
}
|
||||||
|
info!("set X11 background pixel to {:#010x} on video window", pixel);
|
||||||
|
}
|
||||||
|
(
|
||||||
|
raw_window_handle::RawWindowHandle::Xcb(_),
|
||||||
|
raw_window_handle::RawDisplayHandle::Xcb(_),
|
||||||
|
) => {
|
||||||
|
// XCB path: would need xcb_change_window_attributes. For now,
|
||||||
|
// only the Xlib path is implemented — winit on X11 uses Xlib
|
||||||
|
// by default, so this covers the common case.
|
||||||
|
warn!("XCB backend detected; X11 background pixel not set (Xlib path only)");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Not X11 — Wayland, macOS, Windows, etc. No background pixel
|
||||||
|
// concept; the compositing model handles this differently.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a worker thread to run ffmpeg for A-B loop video export. The
|
||||||
|
/// thread runs the encode and sends the result back on `tx` when done.
|
||||||
|
/// Runs in the background so the UI stays responsive during encoding.
|
||||||
|
fn spawn_ffmpeg_export(
|
||||||
|
input: String,
|
||||||
|
start: f64,
|
||||||
|
end: f64,
|
||||||
|
output: String,
|
||||||
|
tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>,
|
||||||
|
) {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("ferret-ffmpeg".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let result = export_video_segment(&input, start, end, &output)
|
||||||
|
.map(|_| DialogResult::File(output.clone()))
|
||||||
|
.unwrap_or_else(|e| DialogResult::Error(e.to_string()));
|
||||||
|
let _ = tx.send((
|
||||||
|
DialogKind::ExportVideo {
|
||||||
|
input,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
result,
|
||||||
|
));
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run ffmpeg to extract the video segment [start, end] from `input` into
|
||||||
|
/// `output`. Re-encodes video (libx264) for frame accuracy and maximum
|
||||||
|
/// compatibility. Audio is re-encoded to AAC.
|
||||||
|
fn export_video_segment(input: &str, start: f64, end: f64, output: &str) -> std::io::Result<()> {
|
||||||
|
let duration = end - start;
|
||||||
|
info!("exporting video segment: {input} [{start:.3}..{end:.3}] → {output}");
|
||||||
|
|
||||||
|
// Check that ffmpeg is available.
|
||||||
|
if std::process::Command::new("ffmpeg")
|
||||||
|
.arg("-version")
|
||||||
|
.output()
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"ffmpeg not found in PATH. Install ffmpeg to export video segments.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use -ss before -i for fast seeking. Re-encode video (libx264) for
|
||||||
|
// frame accuracy and maximum compatibility. Use -y to overwrite output.
|
||||||
|
let output = std::process::Command::new("ffmpeg")
|
||||||
|
.arg("-y")
|
||||||
|
.arg("-ss").arg(format!("{start:.3}"))
|
||||||
|
.arg("-i").arg(input)
|
||||||
|
.arg("-t").arg(format!("{duration:.3}"))
|
||||||
|
.arg("-c:v").arg("libx264")
|
||||||
|
.arg("-preset").arg("fast")
|
||||||
|
.arg("-crf").arg("18")
|
||||||
|
.arg("-c:a").arg("aac")
|
||||||
|
.arg("-b:a").arg("192k")
|
||||||
|
.arg(output)
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let msg = stderr.lines().last().unwrap_or("unknown ffmpeg error");
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("ffmpeg: {msg}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
//! Multi-window management for ferret.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use crossbeam_channel::Receiver;
|
||||||
|
use tracing::info;
|
||||||
|
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize, Size};
|
||||||
|
use winit::event_loop::ActiveEventLoop;
|
||||||
|
use winit::window::{Window, WindowAttributes, WindowId, WindowLevel};
|
||||||
|
|
||||||
|
use player_core::Cmd;
|
||||||
|
|
||||||
|
/// Hard clamp for window dimensions. X11's CreateWindow takes u16 width/height
|
||||||
|
/// (max 65535), so anything above that must be clamped BEFORE being handed to
|
||||||
|
/// winit — otherwise winit's `dimensions.0.try_into().unwrap()` panics with
|
||||||
|
/// TryFromIntError(PosOverflow). We've seen Xfwm4 return bogus values for
|
||||||
|
/// `_NET_FRAME_EXTENTS` (which winit uses for `outer_size()`) when libmpv has
|
||||||
|
/// attached to a window via `wid`, so any code path that reads WM-supplied
|
||||||
|
/// frame extents needs this guard.
|
||||||
|
const MAX_WINDOW_DIM: u32 = 16384;
|
||||||
|
|
||||||
|
fn clamp_dim(v: u32) -> u32 {
|
||||||
|
v.clamp(1, MAX_WINDOW_DIM)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum WindowKind {
|
||||||
|
Video,
|
||||||
|
Overlay,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WindowManager {
|
||||||
|
pub video: Option<Arc<Window>>,
|
||||||
|
pub overlay: Option<Arc<Window>>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub cmd_rx: Option<Receiver<Cmd>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
video: None,
|
||||||
|
overlay: None,
|
||||||
|
cmd_rx: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_video_window(&self, event_loop: &ActiveEventLoop) -> Result<Window> {
|
||||||
|
let attrs = WindowAttributes::default()
|
||||||
|
.with_title("ferret")
|
||||||
|
.with_inner_size(LogicalSize::new(1280u32, 720u32))
|
||||||
|
.with_resizable(true)
|
||||||
|
.with_window_level(WindowLevel::Normal)
|
||||||
|
.with_visible(true);
|
||||||
|
let window = event_loop.create_window(attrs)?;
|
||||||
|
info!(
|
||||||
|
"created video window: {}x{}",
|
||||||
|
window.inner_size().width,
|
||||||
|
window.inner_size().height
|
||||||
|
);
|
||||||
|
Ok(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the video window's INNER geometry (the actual X11 window, not the
|
||||||
|
/// WM-frame outer size). `inner_size()` queries `XGetGeometry` directly
|
||||||
|
/// and is robust against WM/libmpv races. `outer_size()` queries
|
||||||
|
/// `_NET_FRAME_EXTENTS`, which Xfwm4 has been observed to return bogus
|
||||||
|
/// values for after libmpv attaches via `wid` — that path leads to a
|
||||||
|
/// `TryFromIntError(PosOverflow)` panic inside winit.
|
||||||
|
fn video_inner_geometry(video: &Window) -> (PhysicalPosition<i32>, PhysicalSize<u32>) {
|
||||||
|
// inner_position can fail with NotSupportedError on some backends;
|
||||||
|
// step down to (0,0) which the caller already does for outer_position.
|
||||||
|
let pos = video
|
||||||
|
.inner_position()
|
||||||
|
.unwrap_or_else(|_| PhysicalPosition::new(0, 0));
|
||||||
|
let size = video.inner_size();
|
||||||
|
let pos = PhysicalPosition::new(pos.x.max(0), pos.y.max(0));
|
||||||
|
let size = PhysicalSize::new(clamp_dim(size.width), clamp_dim(size.height));
|
||||||
|
(pos, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_overlay_window(
|
||||||
|
&self,
|
||||||
|
event_loop: &ActiveEventLoop,
|
||||||
|
video: &Arc<Window>,
|
||||||
|
) -> Result<Window> {
|
||||||
|
let (pos, size) = Self::video_inner_geometry(video);
|
||||||
|
|
||||||
|
let attrs = WindowAttributes::default()
|
||||||
|
.with_title("ferret-overlay")
|
||||||
|
.with_decorations(false)
|
||||||
|
.with_transparent(true)
|
||||||
|
.with_inner_size(size)
|
||||||
|
.with_position(pos)
|
||||||
|
.with_window_level(WindowLevel::AlwaysOnTop)
|
||||||
|
.with_resizable(false)
|
||||||
|
.with_visible(true)
|
||||||
|
.with_min_inner_size(Size::Physical(PhysicalSize::new(64, 64)));
|
||||||
|
|
||||||
|
let window = event_loop.create_window(attrs)?;
|
||||||
|
info!(
|
||||||
|
"created overlay window at {:?} size {}x{}",
|
||||||
|
pos, size.width, size.height
|
||||||
|
);
|
||||||
|
Ok(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sync_overlay_to_video(&self) -> Result<()> {
|
||||||
|
let video = self.video.as_ref().context("no video window")?;
|
||||||
|
let overlay = self.overlay.as_ref().context("no overlay window")?;
|
||||||
|
|
||||||
|
let (pos, size) = Self::video_inner_geometry(video);
|
||||||
|
|
||||||
|
overlay.set_outer_position(pos);
|
||||||
|
// request_inner_size returns Option<PhysicalSize<u32>> on X11 (always
|
||||||
|
// None on Wayland). Compare inner_size() — outer_size() on the overlay
|
||||||
|
// itself is fine because the overlay has no decorations.
|
||||||
|
if overlay.inner_size() != size {
|
||||||
|
let _ = overlay.request_inner_size(Size::Physical(size));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn classify(&self, id: WindowId) -> WindowKind {
|
||||||
|
// Step-down: video first, then overlay, then Unknown. Order matters
|
||||||
|
// only for diagnostics — a WindowId is owned by exactly one window.
|
||||||
|
[
|
||||||
|
(self.video.as_ref().map(|w| w.id()), WindowKind::Video),
|
||||||
|
(self.overlay.as_ref().map(|w| w.id()), WindowKind::Overlay),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.find(|(maybe_id, _)| maybe_id.is_some_and(|x| x == id))
|
||||||
|
.map(|(_, k)| *k)
|
||||||
|
.unwrap_or(WindowKind::Unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
[package]
|
||||||
|
name = "player-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "Headless video player engine (drives libmpv)"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "player_core"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mpv-bindings = { workspace = true }
|
||||||
|
crossbeam-channel = { workspace = true }
|
||||||
|
parking_lot = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
libc = { workspace = true }
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
//! Commands sent from the UI thread to the engine thread.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::CoreResult;
|
||||||
|
use mpv_bindings::command::{LoadMode, SeekFlags, SeekMode};
|
||||||
|
|
||||||
|
// ---- Magic dialog-request strings --------------------------------------------
|
||||||
|
//
|
||||||
|
// The overlay UI can't open native file dialogs itself (rfd blocks the event
|
||||||
|
// loop). Instead, it sends a Cmd with one of these magic path strings. The
|
||||||
|
// main app (player-app) intercepts them in render_overlay(), spawns an rfd
|
||||||
|
// worker thread, and forwards the result back as a real Cmd once the user
|
||||||
|
// picks a file.
|
||||||
|
//
|
||||||
|
// Defined here (not in player-app) so both player-ui and player-app can
|
||||||
|
// reference them without a circular dependency.
|
||||||
|
|
||||||
|
/// Sent by the UI to request a native "Load File" open dialog.
|
||||||
|
pub const MAGIC_FILE_DIALOG: &str = "__file_dialog__";
|
||||||
|
/// Sent by the UI to request a native "Load Folder" picker.
|
||||||
|
pub const MAGIC_FOLDER_DIALOG: &str = "__folder_dialog__";
|
||||||
|
/// Sent by the UI to request a native multi-select "Load Playlist" dialog.
|
||||||
|
pub const MAGIC_PLAYLIST_DIALOG: &str = "__playlist_dialog__";
|
||||||
|
/// Sent by the UI to request a native "Save Markers (txt)" dialog.
|
||||||
|
pub const MAGIC_SAVE_DIALOG_TXT: &str = "__save_dialog_txt__";
|
||||||
|
/// Sent by the UI to request a native "Save Markers (json)" dialog.
|
||||||
|
pub const MAGIC_SAVE_DIALOG_JSON: &str = "__save_dialog_json__";
|
||||||
|
/// Sent by the UI to request a native "Load Subtitle File" dialog.
|
||||||
|
pub const MAGIC_SUBTITLE_DIALOG: &str = "__subtitle_dialog__";
|
||||||
|
/// Sent by the UI to request a native "Import Markers" dialog.
|
||||||
|
pub const MAGIC_IMPORT_MARKERS_DIALOG: &str = "__import_markers_dialog__";
|
||||||
|
/// Sent by the UI to request a native "Export A-B Loop Video" save dialog.
|
||||||
|
/// The main app intercepts this, reads A/B markers from engine state, opens
|
||||||
|
/// a save dialog, then spawns ffmpeg to render the segment.
|
||||||
|
pub const MAGIC_EXPORT_VIDEO_DIALOG: &str = "__export_video_dialog__";
|
||||||
|
|
||||||
|
/// What the UI wants the engine to do.
|
||||||
|
///
|
||||||
|
/// Design rule: every variant is *non-blocking* — the engine thread should be
|
||||||
|
/// able to apply each one in O(1) FFI calls and immediately move on.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Cmd {
|
||||||
|
// ---- Existing / core playback --------------------------------------
|
||||||
|
|
||||||
|
/// Load a file (or URL). Replaces current playback by default.
|
||||||
|
LoadFile {
|
||||||
|
path: String,
|
||||||
|
options: LoadOptions,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Toggle pause.
|
||||||
|
PlayPause,
|
||||||
|
|
||||||
|
/// Set pause state explicitly (avoids a round-trip read).
|
||||||
|
SetPaused(bool),
|
||||||
|
|
||||||
|
/// Seek by an offset in seconds (relative) or to a position (absolute).
|
||||||
|
Seek {
|
||||||
|
target_secs: f64,
|
||||||
|
mode: SeekMode,
|
||||||
|
flags: SeekFlags,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Set volume. 0.0..=1.0 (mapped to 0..=100 in libmpv). NEVER > 1.0 —
|
||||||
|
/// software amplification is disabled per the PipeWire clash lesson.
|
||||||
|
SetVolume(f32),
|
||||||
|
|
||||||
|
/// Adjust volume by a delta (e.g. wheel scroll). Clamped to 0..=1.0.
|
||||||
|
AdjustVolume(f32),
|
||||||
|
|
||||||
|
/// Toggle mute.
|
||||||
|
ToggleMute,
|
||||||
|
|
||||||
|
/// Set mute explicitly.
|
||||||
|
SetMute(bool),
|
||||||
|
|
||||||
|
/// Stop playback and clear the playlist.
|
||||||
|
Stop,
|
||||||
|
|
||||||
|
/// Step one frame forward (pauses automatically).
|
||||||
|
FrameStep,
|
||||||
|
|
||||||
|
/// Step one frame backward.
|
||||||
|
FrameBackStep,
|
||||||
|
|
||||||
|
/// Tell libmpv to render into a specific native window. Set `None` to detach.
|
||||||
|
/// On X11 this is the XID; on Wayland this is a wl_surface* — passed as a
|
||||||
|
/// string like "12345" for X11. The engine thread calls mpv_set_option_string
|
||||||
|
/// ("wid", value) BEFORE initialize — so this only works pre-init.
|
||||||
|
SetWindowId(String),
|
||||||
|
|
||||||
|
/// Toggle fullscreen mode on the video window. Handled by the main app,
|
||||||
|
/// not the engine — but we route it through the same channel so the UI
|
||||||
|
/// can request it without needing a reference to the window.
|
||||||
|
ToggleFullscreen,
|
||||||
|
|
||||||
|
// ---- Loop / playlist -----------------------------------------------
|
||||||
|
|
||||||
|
/// Set the loop mode for the current file. Off = play once, File = loop
|
||||||
|
/// the current file forever, Playlist = loop the entire playlist.
|
||||||
|
SetLoopMode(LoopMode),
|
||||||
|
|
||||||
|
/// Skip to the next playlist entry. (mpv `playlist-next`.)
|
||||||
|
PlaylistNext,
|
||||||
|
|
||||||
|
/// Skip to the previous playlist entry. (mpv `playlist-prev`.)
|
||||||
|
PlaylistPrev,
|
||||||
|
|
||||||
|
// ---- Speed ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// Set playback speed. mpv range is 0.01..=100.0; we expose 0.25..=4.0
|
||||||
|
/// from the UI and pass through.
|
||||||
|
SetSpeed(f32),
|
||||||
|
|
||||||
|
// ---- Audio track selection -----------------------------------------
|
||||||
|
|
||||||
|
/// Select an audio track by mpv track id (1-based). Pass `None` to
|
||||||
|
/// auto-select. (mpv `aid` property.)
|
||||||
|
SetAudioTrack(Option<i64>),
|
||||||
|
|
||||||
|
// ---- Subtitle track selection --------------------------------------
|
||||||
|
|
||||||
|
/// Select a subtitle track by mpv track id (1-based). Pass `None` to
|
||||||
|
/// disable subtitles. (mpv `sid` property.)
|
||||||
|
SetSubtitleTrack(Option<i64>),
|
||||||
|
|
||||||
|
/// Toggle subtitle visibility on/off. (mpv `sub-visibility` property.)
|
||||||
|
/// Useful for hiding subs without losing the selected track.
|
||||||
|
ToggleSubVisibility,
|
||||||
|
|
||||||
|
/// Load an external subtitle file (e.g. .srt, .ass) and attach it to the
|
||||||
|
/// current file. (mpv `sub-add` command.) Pass the absolute path.
|
||||||
|
LoadSubtitleFile {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Video rotation / flip ----------------------------------------
|
||||||
|
|
||||||
|
/// Set video rotation in degrees. Must be 0, 90, 180, or 270.
|
||||||
|
/// (mpv `video-rotate` property.)
|
||||||
|
SetVideoRotate(u16),
|
||||||
|
|
||||||
|
/// Flip the video horizontally (mirror left-right). Toggles mpv's
|
||||||
|
/// `vf` filter `hflip`. Pass `true` to enable, `false` to disable.
|
||||||
|
SetVideoFlipH(bool),
|
||||||
|
|
||||||
|
/// Flip the video vertically (upside-down). Toggles mpv's `vf` filter
|
||||||
|
/// `vflip`. Pass `true` to enable, `false` to disable.
|
||||||
|
SetVideoFlipV(bool),
|
||||||
|
|
||||||
|
// ---- A/B markers ---------------------------------------------------
|
||||||
|
|
||||||
|
/// Drop marker A at the current playback position (mpv `time-pos`).
|
||||||
|
/// Stored locally + mirrored to mpv's `ab-loop-a` for visualisation.
|
||||||
|
SetMarkerA,
|
||||||
|
|
||||||
|
/// Drop marker B at the current playback position.
|
||||||
|
SetMarkerB,
|
||||||
|
|
||||||
|
/// Clear both A and B markers.
|
||||||
|
ClearMarkers,
|
||||||
|
|
||||||
|
/// Toggle A→B loop on/off. When on, mpv will loop between `ab-loop-a` and
|
||||||
|
/// `ab-loop-b`. We cache the toggle state so the UI can show it.
|
||||||
|
ToggleMarkerLoop,
|
||||||
|
|
||||||
|
/// Export the current markers (A, B, plus file path/duration) to disk.
|
||||||
|
/// The engine thread handles the file write so the UI doesn't block.
|
||||||
|
/// Format is plain text by default; pass `MarkerExportFormat::Json` for
|
||||||
|
/// structured output. The path is chosen by the UI (via rfd save dialog).
|
||||||
|
ExportMarkers {
|
||||||
|
path: String,
|
||||||
|
format: MarkerExportFormat,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Import markers from an exported marker file. The engine thread parses
|
||||||
|
/// the file and applies the A/B positions to mpv's `ab-loop-a` /
|
||||||
|
/// `ab-loop-b` properties. Format is auto-detected from the file
|
||||||
|
/// extension (.txt or .json).
|
||||||
|
ImportMarkers {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Export the video segment between A and B markers to a new file. The
|
||||||
|
/// main app intercepts the magic dialog string, reads A/B from engine
|
||||||
|
/// state, opens a save dialog, then spawns ffmpeg to render the clip.
|
||||||
|
/// This command never reaches the engine — it's handled entirely in
|
||||||
|
/// the main app.
|
||||||
|
ExportABLoopVideo {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Lifecycle -----------------------------------------------------
|
||||||
|
|
||||||
|
/// Shutdown libmpv and exit the engine thread.
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct LoadOptions {
|
||||||
|
/// Replace current playlist entry (default) or append.
|
||||||
|
pub mode: LoadModeKind,
|
||||||
|
/// Pause immediately after load (don't auto-play).
|
||||||
|
pub pause: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LoadModeKind {
|
||||||
|
Replace,
|
||||||
|
Append,
|
||||||
|
AppendPlay,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoadModeKind {
|
||||||
|
fn default() -> Self { LoadModeKind::Replace }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoadModeKind {
|
||||||
|
/// Map to the underlying mpv load mode. Single source of truth: the
|
||||||
|
/// table below mirrors the `LoadMode` declaration order 1:1.
|
||||||
|
pub fn to_mpv(self) -> LoadMode {
|
||||||
|
const TABLE: [(LoadModeKind, LoadMode); 3] = [
|
||||||
|
(LoadModeKind::Replace, LoadMode::Replace),
|
||||||
|
(LoadModeKind::Append, LoadMode::Append),
|
||||||
|
(LoadModeKind::AppendPlay, LoadMode::AppendPlay),
|
||||||
|
];
|
||||||
|
TABLE
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|(k, _)| *k == self)
|
||||||
|
.map(|(_, v)| v)
|
||||||
|
.expect("LoadModeKind is exhaustive over TABLE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loop policy for the current file / playlist.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum LoopMode {
|
||||||
|
/// Play once and stop at EOF (or move to next playlist entry).
|
||||||
|
#[default]
|
||||||
|
Off,
|
||||||
|
/// Repeat the current file indefinitely (mpv `loop-file=inf`).
|
||||||
|
File,
|
||||||
|
/// Repeat the entire playlist indefinitely (mpv `loop-playlist=inf`).
|
||||||
|
Playlist,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoopMode {
|
||||||
|
/// Cycle to the next loop mode: Off → File → Playlist → Off. Table-driven
|
||||||
|
/// so the cycle order lives in exactly one place.
|
||||||
|
pub fn cycle(self) -> Self {
|
||||||
|
const ORDER: [LoopMode; 3] = [
|
||||||
|
LoopMode::Off,
|
||||||
|
LoopMode::File,
|
||||||
|
LoopMode::Playlist,
|
||||||
|
];
|
||||||
|
let idx = ORDER
|
||||||
|
.iter()
|
||||||
|
.position(|m| *m == self)
|
||||||
|
.expect("LoopMode is exhaustive over ORDER");
|
||||||
|
ORDER[(idx + 1) % ORDER.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
const TABLE: [(LoopMode, &str); 3] = [
|
||||||
|
(LoopMode::Off, "Loop: Off"),
|
||||||
|
(LoopMode::File, "Loop: File"),
|
||||||
|
(LoopMode::Playlist, "Loop: List"),
|
||||||
|
];
|
||||||
|
TABLE
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|(m, _)| *m == self)
|
||||||
|
.map(|(_, l)| l)
|
||||||
|
.expect("LoopMode is exhaustive over TABLE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker export format. Plain text by default (grep-friendly), JSON for
|
||||||
|
/// feeding back into another tool.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MarkerExportFormat {
|
||||||
|
/// `A 00:01:23.456\nB 00:02:45.000\n...` — one marker per line.
|
||||||
|
Text,
|
||||||
|
/// `{ "file": "...", "duration": 180.0, "a": 83.456, "b": 165.0 }`
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarkerExportFormat {
|
||||||
|
pub fn extension(self) -> &'static str {
|
||||||
|
const TABLE: [(MarkerExportFormat, &str); 2] = [
|
||||||
|
(MarkerExportFormat::Text, "txt"),
|
||||||
|
(MarkerExportFormat::Json, "json"),
|
||||||
|
];
|
||||||
|
TABLE
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|(f, _)| *f == self)
|
||||||
|
.map(|(_, e)| e)
|
||||||
|
.expect("MarkerExportFormat is exhaustive over TABLE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: build the mpv `loadfile` Command from a `Cmd::LoadFile`.
|
||||||
|
pub(crate) fn build_loadfile(path: &str, opts: &LoadOptions) -> CoreResult<mpv_bindings::command::Command> {
|
||||||
|
let cmd = mpv_bindings::command::Command::loadfile(path, opts.mode.to_mpv())?;
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: build the mpv `seek` 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)?)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,21 @@
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CoreError {
|
||||||
|
#[error("mpv error: {0}")]
|
||||||
|
Mpv(#[from] mpv_bindings::MpvError),
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("engine not running")]
|
||||||
|
EngineNotRunning,
|
||||||
|
#[error("engine already stopped")]
|
||||||
|
EngineStopped,
|
||||||
|
#[error("invalid argument: {0}")]
|
||||||
|
InvalidArgument(String),
|
||||||
|
#[error("engine thread panicked")]
|
||||||
|
EnginePanic,
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type CoreResult<T> = Result<T, CoreError>;
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
//! Events emitted by the engine thread.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
use crate::state::PlaybackState;
|
||||||
|
|
||||||
|
/// Type alias for the channel the engine publishes events on.
|
||||||
|
pub type EngineEventSender = Sender<EngineEvent>;
|
||||||
|
|
||||||
|
/// Shared, cloneable handle to the engine's event channel.
|
||||||
|
/// Cloning this gives you another sender; the engine doesn't care how many subscribers exist.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct EngineEventBus {
|
||||||
|
tx: EngineEventSender,
|
||||||
|
/// Latest known playback state — readers can peek without round-tripping
|
||||||
|
/// through the engine. Updated atomically by the engine thread.
|
||||||
|
state: Arc<Mutex<PlaybackState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EngineEventBus {
|
||||||
|
pub fn new(tx: EngineEventSender, state: Arc<Mutex<PlaybackState>>) -> Self {
|
||||||
|
Self { tx, state }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send(&self, e: EngineEvent) {
|
||||||
|
// Ignore send errors: a closed channel just means the UI went away.
|
||||||
|
let _ = self.tx.send(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> PlaybackState {
|
||||||
|
self.state.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_state(&self, f: impl FnOnce(&mut PlaybackState)) {
|
||||||
|
let mut g = self.state.lock();
|
||||||
|
f(&mut g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All events the engine thread can emit to the UI.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum EngineEvent {
|
||||||
|
/// Engine finished initializing libmpv and is ready to accept LoadFile.
|
||||||
|
Ready,
|
||||||
|
|
||||||
|
/// The current file has started loading (mpv START_FILE).
|
||||||
|
StartFile,
|
||||||
|
|
||||||
|
/// The current file has finished loading and is ready to play (mpv FILE_LOADED).
|
||||||
|
/// Engine has populated path/duration in the state snapshot.
|
||||||
|
FileLoaded { path: String, title: Option<String> },
|
||||||
|
|
||||||
|
/// A property changed. UI should pull a fresh state snapshot.
|
||||||
|
StateChanged,
|
||||||
|
|
||||||
|
/// Playback reached end of file.
|
||||||
|
EndReached { reason: EndReason },
|
||||||
|
|
||||||
|
/// libmpv reported an error (e.g. codec init failed, decode error).
|
||||||
|
Error { message: String },
|
||||||
|
|
||||||
|
/// A log message from libmpv (level >= threshold).
|
||||||
|
Log { prefix: String, level: String, text: String },
|
||||||
|
|
||||||
|
/// Engine is shutting down. No more events will follow.
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EndReason {
|
||||||
|
Eof,
|
||||||
|
Stop,
|
||||||
|
Error,
|
||||||
|
Redirect,
|
||||||
|
Quit,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
//! Headless video player engine.
|
||||||
|
//!
|
||||||
|
//! `PlayerEngine` owns a libmpv instance, runs it on a dedicated thread, and
|
||||||
|
//! exposes a request/response API to the UI via two channels:
|
||||||
|
//!
|
||||||
|
//! - `Cmd` channel (UI → engine) : commands like LoadFile, Seek, SetVolume
|
||||||
|
//! - `Event` channel (engine → UI): state updates like TimePos, EndReached
|
||||||
|
//!
|
||||||
|
//! The engine thread does ONE thing: drain libmpv's event queue and translate
|
||||||
|
//! raw `mpv_event`s into typed `Event`s on the channel. Commands from the UI
|
||||||
|
//! are applied synchronously on the engine thread (libmpv is thread-safe but
|
||||||
|
//! we serialize to keep the model simple and the call stack debuggable).
|
||||||
|
//!
|
||||||
|
//! ## Accuracy-first error policy
|
||||||
|
//!
|
||||||
|
//! The engine configures libmpv for visual accuracy over continuity:
|
||||||
|
//! - `hr-seek=yes` : exact seeks, not keyframe-snapped
|
||||||
|
//! - `vd-lavc-fast=no` : full decode, no shortcuts
|
||||||
|
//! - `framedrop=vo` : only drop frames at the VO if behind, never on decode
|
||||||
|
//! - `video-sync=display-resample` : resample audio to match display
|
||||||
|
//!
|
||||||
|
//! This matches mpv's defaults; we set them explicitly to prevent user
|
||||||
|
//! config (~/.config/mpv/mpv.conf) from sneaking in different behavior.
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod cmd;
|
||||||
|
pub mod engine;
|
||||||
|
pub mod event;
|
||||||
|
pub mod state;
|
||||||
|
pub mod error;
|
||||||
|
pub mod options;
|
||||||
|
|
||||||
|
pub use cmd::{
|
||||||
|
Cmd, LoadOptions, LoopMode, MarkerExportFormat,
|
||||||
|
MAGIC_FILE_DIALOG, MAGIC_FOLDER_DIALOG, MAGIC_PLAYLIST_DIALOG,
|
||||||
|
MAGIC_SAVE_DIALOG_TXT, MAGIC_SAVE_DIALOG_JSON,
|
||||||
|
MAGIC_SUBTITLE_DIALOG, MAGIC_IMPORT_MARKERS_DIALOG,
|
||||||
|
MAGIC_EXPORT_VIDEO_DIALOG,
|
||||||
|
};
|
||||||
|
pub use engine::PlayerEngine;
|
||||||
|
pub use event::{EngineEvent, EngineEventSender};
|
||||||
|
pub use state::{AudioTrack, PlaybackState, PlayerStatus, Track};
|
||||||
|
pub use options::EngineOptions;
|
||||||
|
pub use error::{CoreError, CoreResult};
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::cmd::LoopMode;
|
||||||
|
|
||||||
|
/// Engine-level configuration (translates to libmpv options at init time).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EngineOptions {
|
||||||
|
/// Enable high-precision seeking (mpv `hr-seek`).
|
||||||
|
/// True = accuracy-first (mpv default). False = keyframe-snapped (faster but imprecise).
|
||||||
|
pub hr_seek: bool,
|
||||||
|
|
||||||
|
/// Framedrop policy: "never" | "vo" | "decoder" | "decoder+vo".
|
||||||
|
/// "vo" = drop only at display, never on decode. Accuracy-first.
|
||||||
|
pub framedrop: String,
|
||||||
|
|
||||||
|
/// Video sync mode: "audio" | "display-resample" | "display-resample-desync" | ...
|
||||||
|
/// "display-resample" = resample audio to match display (best A/V sync).
|
||||||
|
pub video_sync: String,
|
||||||
|
|
||||||
|
/// Hardware decoding: "no" | "auto" | "auto-safe" | "auto-copy".
|
||||||
|
/// "auto-safe" = use hwdec only when known-safe (no copy-back issues).
|
||||||
|
pub hwdec: String,
|
||||||
|
|
||||||
|
/// User-configured volume at startup (0.0..=1.0; libmpv uses 0..=100 internally).
|
||||||
|
pub initial_volume: f32,
|
||||||
|
|
||||||
|
/// Maximum amplification soft-cap. We DO NOT allow >1.0 (the PipeWire clash
|
||||||
|
/// lesson from the research). 1.0 == 100%.
|
||||||
|
pub volume_max: f32,
|
||||||
|
|
||||||
|
/// Default log level for libmpv's own diagnostics.
|
||||||
|
pub log_level: String,
|
||||||
|
|
||||||
|
/// Native window handle for libmpv to render into (X11 XID as a string,
|
||||||
|
/// e.g. "12345678"). None = libmpv creates its own window.
|
||||||
|
/// MUST be set before `mpv_initialize` (i.e. before `engine.start()`).
|
||||||
|
pub wid: Option<String>,
|
||||||
|
|
||||||
|
/// Video output driver. `"gpu"` for embedded rendering via `wid`.
|
||||||
|
/// `"libmpv"` selects the render-context API (target: Wayland support).
|
||||||
|
pub vo: String,
|
||||||
|
|
||||||
|
/// Initial loop mode. Off by default. Set to File/Playlist at construction
|
||||||
|
/// if you want looping on startup. Can be changed at runtime via Cmd::SetLoopMode.
|
||||||
|
pub loop_mode: LoopMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EngineOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
hr_seek: true,
|
||||||
|
framedrop: "vo".to_string(),
|
||||||
|
video_sync: "display-resample".to_string(),
|
||||||
|
hwdec: "auto-safe".to_string(),
|
||||||
|
initial_volume: 1.0,
|
||||||
|
volume_max: 1.0,
|
||||||
|
log_level: "warn".to_string(),
|
||||||
|
wid: None,
|
||||||
|
vo: "gpu".to_string(),
|
||||||
|
loop_mode: LoopMode::Off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EngineOptions {
|
||||||
|
/// Translate options to `(name, value)` pairs for `mpv_set_option_string`.
|
||||||
|
pub fn to_mpv_options(&self) -> Vec<(&'static str, String)> {
|
||||||
|
let mut v: Vec<(&'static str, String)> = Vec::new();
|
||||||
|
v.push(("hr-seek", if self.hr_seek { "yes".into() } else { "no".into() }));
|
||||||
|
v.push(("framedrop", self.framedrop.clone()));
|
||||||
|
v.push(("video-sync", self.video_sync.clone()));
|
||||||
|
v.push(("hwdec", self.hwdec.clone()));
|
||||||
|
v.push(("volume", format!("{}", (self.initial_volume * 100.0).clamp(0.0, 100.0))));
|
||||||
|
v.push(("volume-max", format!("{}", (self.volume_max * 100.0).clamp(0.0, 100.0))));
|
||||||
|
v.push(("terminal", "no".into())); // we route logs via events, not stdout
|
||||||
|
// mpv's msg-level parser requires `module=level` form. A bare level
|
||||||
|
// (e.g. "warn") was accepted by older mpv builds but is rejected by
|
||||||
|
// libmpv 2.x with MPV_ERROR_OPTION_ERROR, which aborts engine init.
|
||||||
|
// Use `all=<level>` to set the global default for every module.
|
||||||
|
v.push(("msg-level", format!("all={}", self.log_level)));
|
||||||
|
v.push(("config", "no".into())); // don't read ~/.config/mpv/mpv.conf
|
||||||
|
v.push(("input-default-bindings", "no".into())); // we own the input
|
||||||
|
v.push(("input-builtin-bindings", "no".into()));
|
||||||
|
v.push(("osc", "no".into())); // we render our own UI
|
||||||
|
v.push(("cursor-autohide", "no".into())); // we handle cursor hiding in UI
|
||||||
|
v.push(("input-vo-keyboard", "no".into())); // we feed keys ourselves
|
||||||
|
v.push(("vo", self.vo.clone()));
|
||||||
|
// Initial loop mode. `loop-file=inf` is the canonical form. We push
|
||||||
|
// both `loop-file` and `loop-playlist`; mpv honors each at its own
|
||||||
|
// scope (current file vs entire playlist). Table-driven so the three
|
||||||
|
// modes share one source of truth.
|
||||||
|
const LOOP_TABLE: [(LoopMode, &str, &str); 3] = [
|
||||||
|
(LoopMode::Off, "no", "no"),
|
||||||
|
(LoopMode::File, "inf", "no"),
|
||||||
|
(LoopMode::Playlist, "no", "inf"),
|
||||||
|
];
|
||||||
|
let (_, file_v, list_v) = LOOP_TABLE
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|(m, _, _)| *m == self.loop_mode)
|
||||||
|
.expect("LoopMode is exhaustive over LOOP_TABLE");
|
||||||
|
v.push(("loop-file", file_v.into()));
|
||||||
|
v.push(("loop-playlist", list_v.into()));
|
||||||
|
if let Some(wid) = &self.wid {
|
||||||
|
v.push(("wid", wid.clone()));
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
//! State snapshots published by the engine thread.
|
||||||
|
|
||||||
|
use serde::{Serialize, Serializer};
|
||||||
|
|
||||||
|
use crate::cmd::LoopMode;
|
||||||
|
|
||||||
|
/// One mpv track-list entry. Used for both audio and subtitle tracks — they
|
||||||
|
/// have the same shape, just different `type` values ("audio" vs "sub").
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Track {
|
||||||
|
/// mpv track id (1-based). Pass this back via `Cmd::SetAudioTrack` /
|
||||||
|
/// `Cmd::SetSubtitleTrack`.
|
||||||
|
pub id: i64,
|
||||||
|
/// Track type — "audio" or "sub". (We keep this so a single Vec<Track>
|
||||||
|
/// could be used if needed, though we currently split them.)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub kind: String,
|
||||||
|
/// Track title if present (e.g. "Director's Commentary", "Forced").
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Language code if present (e.g. "eng", "spa").
|
||||||
|
pub lang: Option<String>,
|
||||||
|
/// Is this the currently-selected track?
|
||||||
|
pub selected: bool,
|
||||||
|
/// Is this the default track?
|
||||||
|
pub default: bool,
|
||||||
|
/// For subtitle tracks: is this a forced/forced-only track? (mpv
|
||||||
|
/// `track-list/N/forced`.) Audio tracks always set this to false.
|
||||||
|
pub forced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Track {
|
||||||
|
/// Short label for the dropdown: "1: eng [default]" or "2: Commentary".
|
||||||
|
pub fn label(&self) -> String {
|
||||||
|
let mut s = format!("{}", self.id);
|
||||||
|
if let Some(lang) = &self.lang {
|
||||||
|
s.push_str(&format!(": {lang}"));
|
||||||
|
} else if let Some(title) = &self.title {
|
||||||
|
s.push_str(&format!(": {title}"));
|
||||||
|
} else {
|
||||||
|
s.push_str(": (untitled)");
|
||||||
|
}
|
||||||
|
if self.forced {
|
||||||
|
s.push_str(" [forced]");
|
||||||
|
}
|
||||||
|
if self.default {
|
||||||
|
s.push_str(" [default]");
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience alias for callers that consume only audio tracks. `Track`
|
||||||
|
/// models both audio and subtitle tracks via the `kind` field.
|
||||||
|
pub type AudioTrack = Track;
|
||||||
|
|
||||||
|
/// Snapshot of playback state, sent whenever something changes.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PlaybackState {
|
||||||
|
/// Current position in seconds. None if no file loaded.
|
||||||
|
pub time_pos: Option<f64>,
|
||||||
|
/// Total duration in seconds. None if unknown.
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
/// Is playback currently paused?
|
||||||
|
pub paused: bool,
|
||||||
|
/// Volume 0..=1 (clamped). Mapped 1:1 with libmpv's 0..=100.
|
||||||
|
pub volume: f32,
|
||||||
|
/// Muted?
|
||||||
|
pub muted: bool,
|
||||||
|
/// Path of the currently-loaded file (set on FileLoaded).
|
||||||
|
pub path: Option<String>,
|
||||||
|
/// Title metadata if available.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Playback speed multiplier (1.0 = normal).
|
||||||
|
pub speed: f32,
|
||||||
|
/// Current loop mode. Mirrors the engine's last `SetLoopMode` cmd.
|
||||||
|
pub loop_mode: LoopMode,
|
||||||
|
/// Available audio tracks. Empty until `track-list/count` is observed.
|
||||||
|
pub audio_tracks: Vec<Track>,
|
||||||
|
/// id of the currently-selected audio track, or None if auto.
|
||||||
|
pub current_audio_track: Option<i64>,
|
||||||
|
/// Available subtitle tracks. Empty until `track-list/count` is observed.
|
||||||
|
pub subtitle_tracks: Vec<Track>,
|
||||||
|
/// id of the currently-selected subtitle track, or None if no subs.
|
||||||
|
pub current_subtitle_track: Option<i64>,
|
||||||
|
/// Are subtitles currently visible? (mpv `sub-visibility`.)
|
||||||
|
pub sub_visibility: bool,
|
||||||
|
|
||||||
|
/// Video rotation in degrees (0, 90, 180, 270). Mirrors mpv's
|
||||||
|
/// `video-rotate` property.
|
||||||
|
pub video_rotate: u16,
|
||||||
|
/// Is horizontal flip (mirror) enabled? Tracked locally — mpv's vf
|
||||||
|
/// list is harder to read back reliably.
|
||||||
|
pub video_flip_h: bool,
|
||||||
|
/// Is vertical flip (upside-down) enabled? Tracked locally.
|
||||||
|
pub video_flip_v: bool,
|
||||||
|
|
||||||
|
/// A/B marker positions in seconds. None = not set.
|
||||||
|
/// Mirrored to mpv's `ab-loop-a` / `ab-loop-b` so mpv itself can drive
|
||||||
|
/// the looping; we cache them here for UI rendering.
|
||||||
|
pub marker_a: Option<f64>,
|
||||||
|
pub marker_b: Option<f64>,
|
||||||
|
/// Is A→B loop currently enabled?
|
||||||
|
pub marker_loop_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize Track for the JSON marker export. We do it manually so the
|
||||||
|
// output stays stable across versions.
|
||||||
|
impl Serialize for Track {
|
||||||
|
fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
|
||||||
|
use serde::ser::SerializeStruct;
|
||||||
|
let mut s = ser.serialize_struct("Track", 7)?;
|
||||||
|
s.serialize_field("id", &self.id)?;
|
||||||
|
s.serialize_field("type", &self.kind)?;
|
||||||
|
s.serialize_field("title", &self.title)?;
|
||||||
|
s.serialize_field("lang", &self.lang)?;
|
||||||
|
s.serialize_field("selected", &self.selected)?;
|
||||||
|
s.serialize_field("default", &self.default)?;
|
||||||
|
s.serialize_field("forced", &self.forced)?;
|
||||||
|
s.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackState {
|
||||||
|
/// Position as a 0..=1 fraction of duration, if both are known.
|
||||||
|
pub fn progress(&self) -> Option<f32> {
|
||||||
|
self.fraction_of_duration(self.time_pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position of marker A as a 0..=1 fraction of duration, if known.
|
||||||
|
pub fn marker_a_frac(&self) -> Option<f32> {
|
||||||
|
self.fraction_of_duration(self.marker_a)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position of marker B as a 0..=1 fraction of duration, if known.
|
||||||
|
pub fn marker_b_frac(&self) -> Option<f32> {
|
||||||
|
self.fraction_of_duration(self.marker_b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared helper: project a timestamp onto the [0,1] span of `duration`.
|
||||||
|
/// Returns `None` when either operand is missing or duration is non-positive.
|
||||||
|
fn fraction_of_duration(&self, t: Option<f64>) -> Option<f32> {
|
||||||
|
match (t, self.duration) {
|
||||||
|
(Some(t), Some(d)) if d > 0.0 => Some((t / d) as f32),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pretty-print position as "MM:SS / MM:SS".
|
||||||
|
pub fn time_str(&self) -> String {
|
||||||
|
let fmt = |s: f64| {
|
||||||
|
let s = s.max(0.0) as u64;
|
||||||
|
let h = s / 3600;
|
||||||
|
let m = (s % 3600) / 60;
|
||||||
|
let sec = s % 60;
|
||||||
|
if h > 0 {
|
||||||
|
format!("{h}:{m:02}:{sec:02}")
|
||||||
|
} else {
|
||||||
|
format!("{m:02}:{sec:02}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pos = self.time_pos.map(fmt).unwrap_or_else(|| "00:00".into());
|
||||||
|
let dur = self.duration.map(fmt).unwrap_or_else(|| "--:--".into());
|
||||||
|
format!("{pos} / {dur}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pos_duration(&self) -> (Option<std::time::Duration>, Option<std::time::Duration>) {
|
||||||
|
let p = self.time_pos.map(|t| std::time::Duration::from_secs_f64(t.max(0.0)));
|
||||||
|
let d = self.duration.map(|t| std::time::Duration::from_secs_f64(t.max(0.0)));
|
||||||
|
(p, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-level player status (state machine position).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PlayerStatus {
|
||||||
|
/// Engine constructed but no file loaded.
|
||||||
|
Idle,
|
||||||
|
/// File loaded and playing.
|
||||||
|
Playing,
|
||||||
|
/// File loaded but paused.
|
||||||
|
Paused,
|
||||||
|
/// Playback ended (reached EOF or was stopped).
|
||||||
|
Ended,
|
||||||
|
/// Engine has shut down.
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlayerStatus {
|
||||||
|
fn default() -> Self { PlayerStatus::Idle }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
[package]
|
||||||
|
name = "player-ui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "egui + wgpu overlay UI for ferret"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "player_ui"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
player-core = { workspace = true }
|
||||||
|
mpv-bindings = { workspace = true }
|
||||||
|
egui = { workspace = true }
|
||||||
|
egui-wgpu = { workspace = true }
|
||||||
|
wgpu = { workspace = true }
|
||||||
|
winit = { workspace = true }
|
||||||
|
raw-window-handle = { workspace = true }
|
||||||
|
pollster = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
crossbeam-channel = { workspace = true }
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,488 @@
|
||||||
|
//! In-UI file browser — replaces external zenity/kdialog/rfd dialogs.
|
||||||
|
//!
|
||||||
|
//! The overlay is `WindowLevel::AlwaysOnTop`, which means external file
|
||||||
|
//! dialogs (separate OS windows) pop *under* the overlay — invisible to
|
||||||
|
//! the user. Drawing the file browser inside the egui overlay eliminates
|
||||||
|
//! this z-order conflict entirely: the browser is part of the overlay,
|
||||||
|
//! so it's always visible and always on top of the video window.
|
||||||
|
//!
|
||||||
|
//! Supported kinds:
|
||||||
|
//! - LoadFile (single select, media extensions)
|
||||||
|
//! - LoadFolder (directory select)
|
||||||
|
//! - LoadPlaylist (multi select, media + playlist extensions)
|
||||||
|
//! - SaveMarkers (save with filename, txt/json)
|
||||||
|
//! - LoadSubtitle (single select, subtitle extensions)
|
||||||
|
//! - ImportMarkers (single select, txt/json)
|
||||||
|
//! - ExportVideo (save with filename, mp4/mkv/webm)
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use egui::{Color32, Context, Layout, Vec2};
|
||||||
|
|
||||||
|
use player_core::cmd::MarkerExportFormat;
|
||||||
|
|
||||||
|
use crate::theme::Theme;
|
||||||
|
|
||||||
|
/// Media file extensions for LoadFile / LoadFolder / LoadPlaylist.
|
||||||
|
const MEDIA_EXTENSIONS: &[&str] = &[
|
||||||
|
"mp4", "mkv", "webm", "avi", "mov", "flv",
|
||||||
|
"mp3", "ogg", "wav", "flac", "aac", "m4a",
|
||||||
|
"ts", "m2ts", "vob", "wmv", "3gp",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Playlist file extensions for LoadPlaylist.
|
||||||
|
const PLAYLIST_EXTENSIONS: &[&str] = &["m3u", "m3u8", "pls"];
|
||||||
|
|
||||||
|
/// Subtitle file extensions for LoadSubtitle.
|
||||||
|
const SUBTITLE_EXTENSIONS: &[&str] = &[
|
||||||
|
"srt", "ass", "ssa", "sub", "idx", "sup", "vtt", "smi", "lrc",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Marker file extensions for ImportMarkers.
|
||||||
|
const MARKER_EXTENSIONS: &[&str] = &["txt", "json"];
|
||||||
|
|
||||||
|
/// What kind of dialog to show. Determines title, filter, multi-select,
|
||||||
|
/// and what Cmd gets sent on confirm.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum FileDialogKind {
|
||||||
|
LoadFile,
|
||||||
|
LoadFolder,
|
||||||
|
LoadPlaylist,
|
||||||
|
SaveMarkers(MarkerExportFormat),
|
||||||
|
LoadSubtitle,
|
||||||
|
ImportMarkers,
|
||||||
|
ExportVideo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileDialogKind {
|
||||||
|
fn title(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FileDialogKind::LoadFile => "Open File",
|
||||||
|
FileDialogKind::LoadFolder => "Open Folder",
|
||||||
|
FileDialogKind::LoadPlaylist => "Open Playlist (select multiple files)",
|
||||||
|
FileDialogKind::SaveMarkers(_) => "Export Markers",
|
||||||
|
FileDialogKind::LoadSubtitle => "Open Subtitle File",
|
||||||
|
FileDialogKind::ImportMarkers => "Import Markers",
|
||||||
|
FileDialogKind::ExportVideo => "Export A-B Loop Video",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_save(&self) -> bool {
|
||||||
|
matches!(self, FileDialogKind::SaveMarkers(_) | FileDialogKind::ExportVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_multi(&self) -> bool {
|
||||||
|
matches!(self, FileDialogKind::LoadPlaylist)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_folder(&self) -> bool {
|
||||||
|
matches!(self, FileDialogKind::LoadFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extensions(&self) -> &[&str] {
|
||||||
|
match self {
|
||||||
|
FileDialogKind::LoadFile => MEDIA_EXTENSIONS,
|
||||||
|
FileDialogKind::LoadFolder => &[],
|
||||||
|
FileDialogKind::LoadPlaylist => &[], // we filter in-code (media OR playlist)
|
||||||
|
FileDialogKind::SaveMarkers(fmt) => match fmt {
|
||||||
|
MarkerExportFormat::Text => &["txt"],
|
||||||
|
MarkerExportFormat::Json => &["json"],
|
||||||
|
},
|
||||||
|
FileDialogKind::LoadSubtitle => SUBTITLE_EXTENSIONS,
|
||||||
|
FileDialogKind::ImportMarkers => MARKER_EXTENSIONS,
|
||||||
|
FileDialogKind::ExportVideo => &["mp4", "mkv", "webm"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For LoadPlaylist, accept either media or playlist extensions.
|
||||||
|
fn accepts(&self, ext: &str) -> bool {
|
||||||
|
match self {
|
||||||
|
FileDialogKind::LoadFile => MEDIA_EXTENSIONS.contains(&ext),
|
||||||
|
FileDialogKind::LoadFolder => false, // folders handled separately
|
||||||
|
FileDialogKind::LoadPlaylist => {
|
||||||
|
MEDIA_EXTENSIONS.contains(&ext) || PLAYLIST_EXTENSIONS.contains(&ext)
|
||||||
|
}
|
||||||
|
FileDialogKind::SaveMarkers(_) => true, // save accepts any extension
|
||||||
|
FileDialogKind::LoadSubtitle => SUBTITLE_EXTENSIONS.contains(&ext),
|
||||||
|
FileDialogKind::ImportMarkers => MARKER_EXTENSIONS.contains(&ext),
|
||||||
|
FileDialogKind::ExportVideo => true, // save accepts any extension
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One directory entry, cached for rendering.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Entry {
|
||||||
|
name: String,
|
||||||
|
path: PathBuf,
|
||||||
|
is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of a completed dialog.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum FileDialogResult {
|
||||||
|
/// User cancelled.
|
||||||
|
Cancel,
|
||||||
|
/// Single file or folder selected.
|
||||||
|
Path(String),
|
||||||
|
/// Multiple files selected (LoadPlaylist only).
|
||||||
|
Paths(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileDialog {
|
||||||
|
pub kind: FileDialogKind,
|
||||||
|
current_dir: PathBuf,
|
||||||
|
entries: Vec<Entry>,
|
||||||
|
selected: Option<PathBuf>,
|
||||||
|
/// Multi-select (LoadPlaylist). Stored as a set of paths.
|
||||||
|
selected_multi: Vec<PathBuf>,
|
||||||
|
/// Filename text input for save dialogs.
|
||||||
|
filename: String,
|
||||||
|
/// Error message (permission denied, etc.).
|
||||||
|
error: Option<String>,
|
||||||
|
/// When the dialog was opened — used for focus management.
|
||||||
|
opened_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileDialog {
|
||||||
|
pub fn open(kind: FileDialogKind) -> Self {
|
||||||
|
let start_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
|
||||||
|
let mut dlg = Self {
|
||||||
|
kind,
|
||||||
|
current_dir: start_dir,
|
||||||
|
entries: Vec::new(),
|
||||||
|
selected: None,
|
||||||
|
selected_multi: Vec::new(),
|
||||||
|
filename: String::new(),
|
||||||
|
error: None,
|
||||||
|
opened_at: Instant::now(),
|
||||||
|
};
|
||||||
|
dlg.refresh();
|
||||||
|
dlg
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-read the current directory. Sorts: directories first (alpha), then
|
||||||
|
/// files (alpha). Clears on error and sets `error`.
|
||||||
|
fn refresh(&mut self) {
|
||||||
|
self.entries.clear();
|
||||||
|
self.error = None;
|
||||||
|
match std::fs::read_dir(&self.current_dir) {
|
||||||
|
Ok(rd) => {
|
||||||
|
let mut dirs: Vec<Entry> = Vec::new();
|
||||||
|
let mut files: Vec<Entry> = Vec::new();
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let path = entry.path();
|
||||||
|
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||||
|
let e = Entry { name, path, is_dir };
|
||||||
|
if is_dir {
|
||||||
|
dirs.push(e);
|
||||||
|
} else {
|
||||||
|
files.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
self.entries = dirs;
|
||||||
|
self.entries.extend(files);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.error = Some(format!("Cannot read directory: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to a directory. Falls back to the parent if it fails.
|
||||||
|
fn navigate_to(&mut self, path: PathBuf) {
|
||||||
|
if path.is_dir() {
|
||||||
|
self.current_dir = path;
|
||||||
|
self.selected = None;
|
||||||
|
self.selected_multi.clear();
|
||||||
|
self.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Go up one level.
|
||||||
|
fn go_up(&mut self) {
|
||||||
|
if let Some(parent) = self.current_dir.parent() {
|
||||||
|
self.navigate_to(parent.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Go to the user's home directory.
|
||||||
|
fn go_home(&mut self) {
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
self.navigate_to(PathBuf::from(home));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm the selection. Returns the result if valid, None if the user
|
||||||
|
/// needs to select something first.
|
||||||
|
fn confirm(&self) -> Option<FileDialogResult> {
|
||||||
|
if self.kind.is_folder() {
|
||||||
|
// Folder select: return the selected directory (or current dir).
|
||||||
|
if let Some(sel) = &self.selected {
|
||||||
|
if sel.is_dir() {
|
||||||
|
return Some(FileDialogResult::Path(sel.to_string_lossy().into_owned()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.kind.is_save() {
|
||||||
|
// Save: need a filename.
|
||||||
|
if self.filename.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = self.current_dir.join(&self.filename);
|
||||||
|
return Some(FileDialogResult::Path(path.to_string_lossy().into_owned()));
|
||||||
|
}
|
||||||
|
if self.kind.is_multi() {
|
||||||
|
// Multi-select: return all selected files.
|
||||||
|
if self.selected_multi.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let paths: Vec<String> = self
|
||||||
|
.selected_multi
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
return Some(FileDialogResult::Paths(paths));
|
||||||
|
}
|
||||||
|
// Single file select.
|
||||||
|
if let Some(sel) = &self.selected {
|
||||||
|
return Some(FileDialogResult::Path(sel.to_string_lossy().into_owned()));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the dialog as a modal overlay. Returns `Some(result)` when the
|
||||||
|
/// user confirms or cancels, `None` while the dialog is still open.
|
||||||
|
pub fn draw(&mut self, ctx: &Context, theme: &Theme) -> Option<FileDialogResult> {
|
||||||
|
let mut result: Option<FileDialogResult> = None;
|
||||||
|
|
||||||
|
let win_size = ctx.screen_rect().size();
|
||||||
|
let dlg_w = 700.0_f32.min(win_size.x - 40.0).max(400.0);
|
||||||
|
let dlg_h = 500.0_f32.min(win_size.y - 40.0).max(300.0);
|
||||||
|
let dlg_x = (win_size.x - dlg_w) / 2.0;
|
||||||
|
let dlg_y = (win_size.y - dlg_h) / 2.0;
|
||||||
|
|
||||||
|
// Dim the background behind the dialog.
|
||||||
|
let dim_rect = ctx.screen_rect();
|
||||||
|
let painter = ctx.layer_painter(egui::LayerId::new(
|
||||||
|
egui::Order::Background,
|
||||||
|
egui::Id::new("ferret_file_dialog_dim"),
|
||||||
|
));
|
||||||
|
painter.rect_filled(
|
||||||
|
dim_rect,
|
||||||
|
0.0,
|
||||||
|
Color32::from_rgba_unmultiplied(0, 0, 0, 160),
|
||||||
|
);
|
||||||
|
|
||||||
|
let fg = theme.fg_color32();
|
||||||
|
let fg_dim = theme.fg_dim_color32();
|
||||||
|
let bg = theme.bg_color32();
|
||||||
|
let accent = theme.accent_color32();
|
||||||
|
|
||||||
|
egui::Area::new(egui::Id::new("ferret_file_dialog"))
|
||||||
|
.fixed_pos(egui::pos2(dlg_x, dlg_y))
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::none()
|
||||||
|
.fill(bg)
|
||||||
|
.rounding(8.0)
|
||||||
|
.stroke(egui::Stroke::new(1.0_f32, accent))
|
||||||
|
.inner_margin(egui::Margin::symmetric(0.0, 0.0))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_min_size(Vec2::new(dlg_w, dlg_h));
|
||||||
|
ui.set_max_size(Vec2::new(dlg_w, dlg_h));
|
||||||
|
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
// ---- Title bar ----
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(self.kind.title())
|
||||||
|
.color(fg)
|
||||||
|
.size(14.0)
|
||||||
|
.strong(),
|
||||||
|
);
|
||||||
|
ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("×").clicked() {
|
||||||
|
result = Some(FileDialogResult::Cancel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// ---- Path bar ----
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
if ui.button("↑ Up").clicked() {
|
||||||
|
self.go_up();
|
||||||
|
}
|
||||||
|
if ui.button("Home").clicked() {
|
||||||
|
self.go_home();
|
||||||
|
}
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("📁")
|
||||||
|
.color(accent)
|
||||||
|
.size(13.0),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
self.current_dir.to_string_lossy().into_owned(),
|
||||||
|
)
|
||||||
|
.color(fg_dim)
|
||||||
|
.size(12.0)
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// ---- File list ----
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(dlg_h - 140.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if let Some(err) = &self.error {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(err)
|
||||||
|
.color(Color32::from_rgb(220, 80, 80))
|
||||||
|
.size(12.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.entries.is_empty() && self.error.is_none() {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("(empty directory)")
|
||||||
|
.color(fg_dim)
|
||||||
|
.size(12.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Collect navigation requests — can't call
|
||||||
|
// self.navigate_to() inside the for loop
|
||||||
|
// because &self.entries borrows self immutably.
|
||||||
|
let mut navigate_to: Option<PathBuf> = None;
|
||||||
|
for entry in &self.entries {
|
||||||
|
let is_selected = if self.kind.is_multi() {
|
||||||
|
self.selected_multi.contains(&entry.path)
|
||||||
|
} else {
|
||||||
|
self.selected.as_ref() == Some(&entry.path)
|
||||||
|
};
|
||||||
|
let icon = if entry.is_dir { "📁" } else { "📄" };
|
||||||
|
let label = format!("{icon} {}", entry.name);
|
||||||
|
|
||||||
|
// For non-folder dialogs, skip files that
|
||||||
|
// don't match the extension filter.
|
||||||
|
if !entry.is_dir && !self.kind.is_folder() {
|
||||||
|
let ext = entry
|
||||||
|
.path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !self.kind.accepts(&ext) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let row_resp = ui.add_sized(
|
||||||
|
Vec2::new(ui.available_width(), 22.0),
|
||||||
|
egui::SelectableLabel::new(is_selected, label),
|
||||||
|
);
|
||||||
|
if row_resp.clicked() {
|
||||||
|
if entry.is_dir {
|
||||||
|
// Single-click selects, double-click navigates.
|
||||||
|
if self.kind.is_folder() {
|
||||||
|
self.selected = Some(entry.path.clone());
|
||||||
|
}
|
||||||
|
if row_resp.double_clicked() {
|
||||||
|
navigate_to = Some(entry.path.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if self.kind.is_multi() {
|
||||||
|
// Toggle selection.
|
||||||
|
if let Some(idx) =
|
||||||
|
self.selected_multi.iter().position(|p| p == &entry.path)
|
||||||
|
{
|
||||||
|
self.selected_multi.remove(idx);
|
||||||
|
} else {
|
||||||
|
self.selected_multi.push(entry.path.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.selected = Some(entry.path.clone());
|
||||||
|
// For save dialogs, prefill the filename.
|
||||||
|
if self.kind.is_save() {
|
||||||
|
self.filename = entry.name.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Apply navigation after the borrow ends.
|
||||||
|
if let Some(path) = navigate_to {
|
||||||
|
self.navigate_to(path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// ---- Filename input (save dialogs only) ----
|
||||||
|
if self.kind.is_save() {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("Filename:")
|
||||||
|
.color(fg_dim)
|
||||||
|
.size(12.0),
|
||||||
|
);
|
||||||
|
let resp = ui.add_sized(
|
||||||
|
Vec2::new(dlg_w - 120.0, 20.0),
|
||||||
|
egui::TextEdit::singleline(&mut self.filename),
|
||||||
|
);
|
||||||
|
// Focus the filename field on open.
|
||||||
|
if self.opened_at.elapsed().as_millis() < 200 {
|
||||||
|
resp.request_focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Bottom buttons ----
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
let confirm_label = if self.kind.is_save() { "Save" } else { "Open" };
|
||||||
|
let can_confirm = self.confirm().is_some();
|
||||||
|
ui.add_enabled_ui(can_confirm, |ui| {
|
||||||
|
if ui.button(confirm_label).clicked() {
|
||||||
|
if let Some(r) = self.confirm() {
|
||||||
|
result = Some(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
result = Some(FileDialogResult::Cancel);
|
||||||
|
}
|
||||||
|
ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
let count = if self.kind.is_multi() {
|
||||||
|
self.selected_multi.len()
|
||||||
|
} else if self.selected.is_some() {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("{count} selected"))
|
||||||
|
.color(fg_dim)
|
||||||
|
.size(11.0),
|
||||||
|
);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,407 @@
|
||||||
|
//! Vector-drawn icons for the control bar.
|
||||||
|
//!
|
||||||
|
//! VLC uses simple geometric icons (filled triangles, squares, bars).
|
||||||
|
//! Drawing them with the painter instead of Unicode characters gives us:
|
||||||
|
//! - Consistent rendering across all fonts / OSes
|
||||||
|
//! - Pixel-perfect sizing
|
||||||
|
//! - Easy color tinting on hover / active states
|
||||||
|
|
||||||
|
use egui::{Color32, Painter, Pos2, Rect, Stroke, Vec2};
|
||||||
|
|
||||||
|
/// Draw a play triangle (▶), pointed right, centered in `rect`.
|
||||||
|
pub fn play(painter: &Painter, rect: Rect, color: Color32) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.6;
|
||||||
|
let cx = rect.center().x + size * 0.1; // nudge right slightly for visual balance
|
||||||
|
let cy = rect.center().y;
|
||||||
|
let half_h = size * 0.5;
|
||||||
|
let half_w = size * 0.55;
|
||||||
|
let points = [
|
||||||
|
Pos2::new(cx - half_w, cy - half_h),
|
||||||
|
Pos2::new(cx - half_w, cy + half_h),
|
||||||
|
Pos2::new(cx + half_w, cy),
|
||||||
|
];
|
||||||
|
painter.add(egui::Shape::convex_polygon(
|
||||||
|
points.to_vec(),
|
||||||
|
color,
|
||||||
|
Stroke::NONE,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a pause icon (two vertical bars).
|
||||||
|
pub fn pause(painter: &Painter, rect: Rect, color: Color32) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.55;
|
||||||
|
let cx = rect.center().x;
|
||||||
|
let cy = rect.center().y;
|
||||||
|
let bar_w = size * 0.22;
|
||||||
|
let bar_h = size;
|
||||||
|
let gap = size * 0.18;
|
||||||
|
let left = Rect::from_center_size(Pos2::new(cx - gap - bar_w * 0.5, cy), Vec2::new(bar_w, bar_h));
|
||||||
|
let right = Rect::from_center_size(Pos2::new(cx + gap + bar_w * 0.5, cy), Vec2::new(bar_w, bar_h));
|
||||||
|
painter.rect_filled(left, 1.0, color);
|
||||||
|
painter.rect_filled(right, 1.0, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a stop icon (filled square).
|
||||||
|
pub fn stop(painter: &Painter, rect: Rect, color: Color32) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.5;
|
||||||
|
let r = Rect::from_center_size(rect.center(), Vec2::splat(size));
|
||||||
|
painter.rect_filled(r, 1.0, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a "previous track" icon (|◀) — bar + left-pointing triangle.
|
||||||
|
pub fn previous(painter: &Painter, rect: Rect, color: Color32) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.55;
|
||||||
|
let cx = rect.center().x;
|
||||||
|
let cy = rect.center().y;
|
||||||
|
let bar_w = size * 0.18;
|
||||||
|
let bar_h = size;
|
||||||
|
let tri_w = size * 0.55;
|
||||||
|
let tri_h = size * 0.9;
|
||||||
|
// Bar on the left
|
||||||
|
let bar = Rect::from_center_size(Pos2::new(cx - tri_w * 0.5 - bar_w * 0.5, cy), Vec2::new(bar_w, bar_h));
|
||||||
|
painter.rect_filled(bar, 1.0, color);
|
||||||
|
// Triangle pointing left
|
||||||
|
let tri_cx = cx + tri_w * 0.1;
|
||||||
|
let points = [
|
||||||
|
Pos2::new(tri_cx + tri_w * 0.5, cy - tri_h * 0.5),
|
||||||
|
Pos2::new(tri_cx + tri_w * 0.5, cy + tri_h * 0.5),
|
||||||
|
Pos2::new(tri_cx - tri_w * 0.5, cy),
|
||||||
|
];
|
||||||
|
painter.add(egui::Shape::convex_polygon(points.to_vec(), color, Stroke::NONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a "next track" icon (▶|) — right triangle + bar.
|
||||||
|
pub fn next(painter: &Painter, rect: Rect, color: Color32) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.55;
|
||||||
|
let cx = rect.center().x;
|
||||||
|
let cy = rect.center().y;
|
||||||
|
let bar_w = size * 0.18;
|
||||||
|
let bar_h = size;
|
||||||
|
let tri_w = size * 0.55;
|
||||||
|
let tri_h = size * 0.9;
|
||||||
|
// Triangle pointing right
|
||||||
|
let tri_cx = cx - tri_w * 0.1;
|
||||||
|
let points = [
|
||||||
|
Pos2::new(tri_cx - tri_w * 0.5, cy - tri_h * 0.5),
|
||||||
|
Pos2::new(tri_cx - tri_w * 0.5, cy + tri_h * 0.5),
|
||||||
|
Pos2::new(tri_cx + tri_w * 0.5, cy),
|
||||||
|
];
|
||||||
|
painter.add(egui::Shape::convex_polygon(points.to_vec(), color, Stroke::NONE));
|
||||||
|
// Bar on the right
|
||||||
|
let bar = Rect::from_center_size(Pos2::new(cx + tri_w * 0.5 + bar_w * 0.5, cy), Vec2::new(bar_w, bar_h));
|
||||||
|
painter.rect_filled(bar, 1.0, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a "step backward" icon (|◀◀ is overkill; VLC uses a single |◀ with a small line).
|
||||||
|
/// For frame-step back we use ◀| (left triangle + right bar) to differentiate from previous.
|
||||||
|
pub fn frame_back(painter: &Painter, rect: Rect, color: Color32) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.5;
|
||||||
|
let cx = rect.center().x;
|
||||||
|
let cy = rect.center().y;
|
||||||
|
let tri_w = size * 0.55;
|
||||||
|
let tri_h = size * 0.8;
|
||||||
|
let bar_w = size * 0.15;
|
||||||
|
let bar_h = size * 0.85;
|
||||||
|
// Left-pointing triangle
|
||||||
|
let tri_cx = cx - bar_w * 0.5;
|
||||||
|
let points = [
|
||||||
|
Pos2::new(tri_cx + tri_w * 0.5, cy - tri_h * 0.5),
|
||||||
|
Pos2::new(tri_cx + tri_w * 0.5, cy + tri_h * 0.5),
|
||||||
|
Pos2::new(tri_cx - tri_w * 0.5, cy),
|
||||||
|
];
|
||||||
|
painter.add(egui::Shape::convex_polygon(points.to_vec(), color, Stroke::NONE));
|
||||||
|
// Bar on the right
|
||||||
|
let bar = Rect::from_center_size(Pos2::new(cx + tri_w * 0.5 + bar_w * 0.5, cy), Vec2::new(bar_w, bar_h));
|
||||||
|
painter.rect_filled(bar, 1.0, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a "step forward" icon (▶|) for frame-step.
|
||||||
|
pub fn frame_forward(painter: &Painter, rect: Rect, color: Color32) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.5;
|
||||||
|
let cx = rect.center().x;
|
||||||
|
let cy = rect.center().y;
|
||||||
|
let tri_w = size * 0.55;
|
||||||
|
let tri_h = size * 0.8;
|
||||||
|
let bar_w = size * 0.15;
|
||||||
|
let bar_h = size * 0.85;
|
||||||
|
// Right-pointing triangle
|
||||||
|
let tri_cx = cx + bar_w * 0.5;
|
||||||
|
let points = [
|
||||||
|
Pos2::new(tri_cx - tri_w * 0.5, cy - tri_h * 0.5),
|
||||||
|
Pos2::new(tri_cx - tri_w * 0.5, cy + tri_h * 0.5),
|
||||||
|
Pos2::new(tri_cx + tri_w * 0.5, cy),
|
||||||
|
];
|
||||||
|
painter.add(egui::Shape::convex_polygon(points.to_vec(), color, Stroke::NONE));
|
||||||
|
// Bar on the left
|
||||||
|
let bar = Rect::from_center_size(Pos2::new(cx - tri_w * 0.5 - bar_w * 0.5, cy), Vec2::new(bar_w, bar_h));
|
||||||
|
painter.rect_filled(bar, 1.0, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a fullscreen icon — four L-shaped corner brackets.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub fn fullscreen(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 half = size * 0.5;
|
||||||
|
let arm = size * 0.35;
|
||||||
|
let thick = size * 0.12;
|
||||||
|
|
||||||
|
let corners = [
|
||||||
|
// top-left
|
||||||
|
(Pos2::new(cx - half, cy - half + arm), Pos2::new(cx - half, cy - half), Pos2::new(cx - half + arm, cy - half)),
|
||||||
|
// top-right
|
||||||
|
(Pos2::new(cx + half - arm, cy - half), Pos2::new(cx + half, cy - half), Pos2::new(cx + half, cy - half + arm)),
|
||||||
|
// bottom-left
|
||||||
|
(Pos2::new(cx - half, cy + half - arm), Pos2::new(cx - half, cy + half), Pos2::new(cx - half + arm, cy + half)),
|
||||||
|
// bottom-right
|
||||||
|
(Pos2::new(cx + half - arm, cy + half), Pos2::new(cx + half, cy + half), Pos2::new(cx + half, cy + half - arm)),
|
||||||
|
];
|
||||||
|
for (a, b, c) in corners {
|
||||||
|
painter.line_segment([a, b], Stroke::new(thick, color));
|
||||||
|
painter.line_segment([b, c], Stroke::new(thick, color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a volume icon (speaker shape). `level` is 0..=1; we draw 0-3 sound waves.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn volume(painter: &Painter, rect: Rect, color: Color32, level: f32, muted: bool) {
|
||||||
|
let size: f32 = rect.height().min(rect.width()) * 0.65;
|
||||||
|
let cx: f32 = rect.center().x - size * 0.15;
|
||||||
|
let cy: f32 = rect.center().y;
|
||||||
|
let box_w: f32 = size * 0.25;
|
||||||
|
let box_h: f32 = size * 0.4;
|
||||||
|
let cone_w: f32 = size * 0.35;
|
||||||
|
let cone_h: f32 = size * 0.6;
|
||||||
|
|
||||||
|
// Speaker box (small rectangle on the left).
|
||||||
|
let box_rect = Rect::from_center_size(Pos2::new(cx - cone_w * 0.5 + box_w * 0.5, cy), Vec2::new(box_w, box_h));
|
||||||
|
painter.rect_filled(box_rect, 1.0, color);
|
||||||
|
|
||||||
|
// Speaker cone (trapezoid extending right from the box).
|
||||||
|
let cone_left_x = cx + box_w * 0.0;
|
||||||
|
let cone_right_x = cx + cone_w * 0.6;
|
||||||
|
let cone_top_left = cy - box_h * 0.5;
|
||||||
|
let cone_bot_left = cy + box_h * 0.5;
|
||||||
|
let cone_top_right = cy - cone_h * 0.5;
|
||||||
|
let cone_bot_right = cy + cone_h * 0.5;
|
||||||
|
let cone_pts = vec![
|
||||||
|
Pos2::new(cone_left_x, cone_top_left),
|
||||||
|
Pos2::new(cone_right_x, cone_top_right),
|
||||||
|
Pos2::new(cone_right_x, cone_bot_right),
|
||||||
|
Pos2::new(cone_left_x, cone_bot_left),
|
||||||
|
];
|
||||||
|
painter.add(egui::Shape::convex_polygon(cone_pts, color, Stroke::NONE));
|
||||||
|
|
||||||
|
if muted {
|
||||||
|
// Red diagonal line through the speaker.
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
Pos2::new(cx - cone_w * 0.5, cy - cone_h * 0.55),
|
||||||
|
Pos2::new(cx + cone_w * 0.9, cy + cone_h * 0.55),
|
||||||
|
],
|
||||||
|
Stroke::new(size * 0.10, Color32::from_rgb(220, 60, 60)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Sound waves — 1, 2, or 3 arcs depending on level.
|
||||||
|
// We approximate arcs with short polylines (epaint 0.29 has no arc primitive).
|
||||||
|
let wave_count = if level <= 0.0 { 0 }
|
||||||
|
else if level < 0.34 { 1 }
|
||||||
|
else if level < 0.67 { 2 }
|
||||||
|
else { 3 };
|
||||||
|
let arc_origin = Pos2::new(cx + cone_w * 0.45, cy);
|
||||||
|
for i in 0..wave_count {
|
||||||
|
let r = size * (0.25 + 0.15 * (i as f32 + 1.0));
|
||||||
|
let start_angle = -std::f32::consts::FRAC_PI_4;
|
||||||
|
let end_angle = std::f32::consts::FRAC_PI_4;
|
||||||
|
let steps = 8;
|
||||||
|
let mut prev = None;
|
||||||
|
for step in 0..=steps {
|
||||||
|
let t = start_angle + (end_angle - start_angle) * (step as f32 / steps as f32);
|
||||||
|
let p = Pos2::new(arc_origin.x + r * t.cos(), arc_origin.y + r * t.sin());
|
||||||
|
if let Some(p0) = prev {
|
||||||
|
painter.line_segment([p0, p], Stroke::new(size * 0.06, color));
|
||||||
|
}
|
||||||
|
prev = Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loop icon — two curved arrows forming a circle. `mode` is 0=off, 1=file,
|
||||||
|
/// 2=playlist. When off, dim the icon; when on, full opacity. Playlist mode
|
||||||
|
/// adds a small "1" badge in the corner to distinguish from file mode.
|
||||||
|
pub fn loop_icon(painter: &Painter, rect: Rect, color: Color32, mode: u8) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.6;
|
||||||
|
let cx = rect.center().x;
|
||||||
|
let cy = rect.center().y;
|
||||||
|
let r = size * 0.4;
|
||||||
|
let thick = size * 0.10;
|
||||||
|
|
||||||
|
// Draw two arcs covering 3/4 of the circle, leaving gaps at the top-right
|
||||||
|
// and bottom-left for the arrowheads.
|
||||||
|
let gap = std::f32::consts::FRAC_PI_8;
|
||||||
|
let arc1_start = -std::f32::consts::FRAC_PI_2 + gap;
|
||||||
|
let arc1_end = std::f32::consts::FRAC_PI_2 - gap;
|
||||||
|
let arc2_start = std::f32::consts::FRAC_PI_2 + gap;
|
||||||
|
let arc2_end = std::f32::consts::PI + std::f32::consts::FRAC_PI_2 - gap;
|
||||||
|
|
||||||
|
let draw_arc = |start: f32, end: f32, arrow_at_end: bool, arrow_top: bool| {
|
||||||
|
let steps = 12;
|
||||||
|
let mut prev = None;
|
||||||
|
for step in 0..=steps {
|
||||||
|
let t = start + (end - start) * (step as f32 / steps as f32);
|
||||||
|
let p = Pos2::new(cx + r * t.cos(), cy + r * t.sin());
|
||||||
|
if let Some(p0) = prev {
|
||||||
|
painter.line_segment([p0, p], Stroke::new(thick, color));
|
||||||
|
}
|
||||||
|
prev = Some(p);
|
||||||
|
}
|
||||||
|
if arrow_at_end {
|
||||||
|
// Arrowhead at the END of the arc.
|
||||||
|
let end_pt = Pos2::new(cx + r * end.cos(), cy + r * end.sin());
|
||||||
|
let arrow_size = size * 0.18;
|
||||||
|
// Tangent direction at `end` (derivative of position w.r.t. angle).
|
||||||
|
let tx = -end.sin();
|
||||||
|
let ty = end.cos();
|
||||||
|
let nx = -end.cos();
|
||||||
|
let ny = -end.sin();
|
||||||
|
let tip = Pos2::new(end_pt.x + tx * arrow_size * 0.5, end_pt.y + ty * arrow_size * 0.5);
|
||||||
|
let _ = arrow_top;
|
||||||
|
let a = Pos2::new(end_pt.x + nx * arrow_size, end_pt.y + ny * arrow_size);
|
||||||
|
let b = Pos2::new(end_pt.x - nx * arrow_size, end_pt.y - ny * arrow_size);
|
||||||
|
painter.line_segment([tip, a], Stroke::new(thick, color));
|
||||||
|
painter.line_segment([tip, b], Stroke::new(thick, color));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
draw_arc(arc1_start, arc1_end, true, true);
|
||||||
|
draw_arc(arc2_start, arc2_end, true, false);
|
||||||
|
|
||||||
|
// Mode badge — small "P" overlay for playlist mode so the user can tell
|
||||||
|
// the difference from file mode at a glance. (Off = no badge, File = no
|
||||||
|
// badge, Playlist = "P".) We could also tint the icon for playlist mode.
|
||||||
|
let _ = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker A icon — a small downward-pointing triangle (like a timeline pin)
|
||||||
|
/// above the letter "A".
|
||||||
|
pub fn marker_a(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 pin_h = size * 0.5;
|
||||||
|
let pin_w = size * 0.4;
|
||||||
|
let pin_pts = vec![
|
||||||
|
Pos2::new(cx - pin_w * 0.5, cy - pin_h * 0.4),
|
||||||
|
Pos2::new(cx + pin_w * 0.5, cy - pin_h * 0.4),
|
||||||
|
Pos2::new(cx, cy + pin_h * 0.4),
|
||||||
|
];
|
||||||
|
painter.add(egui::Shape::convex_polygon(pin_pts, color, Stroke::NONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker B icon — same shape as marker A but distinguished by position
|
||||||
|
/// (rendered in a slightly different color when active).
|
||||||
|
pub fn marker_b(painter: &Painter, rect: Rect, color: Color32) {
|
||||||
|
marker_a(painter, rect, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker-loop icon — the A and B pins side by side, joined by a horizontal
|
||||||
|
/// line below, suggesting the loop segment.
|
||||||
|
pub fn marker_loop(painter: &Painter, rect: Rect, color: Color32, active: bool) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.6;
|
||||||
|
let cx = rect.center().x;
|
||||||
|
let cy = rect.center().y;
|
||||||
|
let pin_h = size * 0.4;
|
||||||
|
let pin_w = size * 0.22;
|
||||||
|
let gap = size * 0.18;
|
||||||
|
|
||||||
|
let left_cx = cx - gap - pin_w * 0.5;
|
||||||
|
let right_cx = cx + gap + pin_w * 0.5;
|
||||||
|
let top_y = cy - pin_h * 0.5;
|
||||||
|
let bot_y = cy + pin_h * 0.5;
|
||||||
|
|
||||||
|
// Two pins.
|
||||||
|
for px in [left_cx, right_cx] {
|
||||||
|
let pts = vec![
|
||||||
|
Pos2::new(px - pin_w * 0.5, top_y),
|
||||||
|
Pos2::new(px + pin_w * 0.5, top_y),
|
||||||
|
Pos2::new(px, bot_y),
|
||||||
|
];
|
||||||
|
painter.add(egui::Shape::convex_polygon(pts, color, Stroke::NONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom connecting line (the "loop" segment). Thicker and brighter
|
||||||
|
// when active.
|
||||||
|
let line_thick = if active { size * 0.10 } else { size * 0.06 };
|
||||||
|
let line_y = bot_y + size * 0.10;
|
||||||
|
let line_color = if active { color } else { Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 120) };
|
||||||
|
painter.line_segment(
|
||||||
|
[Pos2::new(left_cx, line_y), Pos2::new(right_cx, line_y)],
|
||||||
|
Stroke::new(line_thick, line_color),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Loop arrowheads at each end (pointing inward when active).
|
||||||
|
if active {
|
||||||
|
let arrow = size * 0.10;
|
||||||
|
for (px, dir) in [(left_cx, 1.0), (right_cx, -1.0)] {
|
||||||
|
painter.line_segment(
|
||||||
|
[Pos2::new(px, line_y), Pos2::new(px + dir * arrow, line_y - arrow * 0.7)],
|
||||||
|
Stroke::new(line_thick, color),
|
||||||
|
);
|
||||||
|
painter.line_segment(
|
||||||
|
[Pos2::new(px, line_y), Pos2::new(px + dir * arrow, line_y + arrow * 0.7)],
|
||||||
|
Stroke::new(line_thick, color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hamburger menu icon (three horizontal lines).
|
||||||
|
pub fn hamburger(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_w = size * 0.8;
|
||||||
|
let line_thick = size * 0.12;
|
||||||
|
let gap = size * 0.25;
|
||||||
|
[-1.0, 0.0, 1.0].iter().for_each(|&offset| {
|
||||||
|
let y = cy + offset * gap;
|
||||||
|
painter.line_segment(
|
||||||
|
[Pos2::new(cx - line_w * 0.5, y), Pos2::new(cx + line_w * 0.5, y)],
|
||||||
|
Stroke::new(line_thick, color),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speed icon — a small "1×" or "{speed}×" indicator. We just draw a
|
||||||
|
/// tachometer-style arc + needle; the actual speed value is rendered as a
|
||||||
|
/// text label by the caller.
|
||||||
|
pub fn speed_gauge(painter: &Painter, rect: Rect, color: Color32, speed: f32) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.6;
|
||||||
|
let cx = rect.center().x;
|
||||||
|
let cy = rect.center().y + size * 0.1;
|
||||||
|
let r = size * 0.4;
|
||||||
|
let thick = size * 0.08;
|
||||||
|
|
||||||
|
// Half-circle arc from 180° to 360° (top half).
|
||||||
|
let steps = 16;
|
||||||
|
let mut prev = None;
|
||||||
|
for step in 0..=steps {
|
||||||
|
let t = std::f32::consts::PI + (std::f32::consts::PI) * (step as f32 / steps as f32);
|
||||||
|
let p = Pos2::new(cx + r * t.cos(), cy + r * t.sin());
|
||||||
|
if let Some(p0) = prev {
|
||||||
|
painter.line_segment([p0, p], Stroke::new(thick, color));
|
||||||
|
}
|
||||||
|
prev = Some(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needle — position based on speed (0.25..=4.0 maps to 180°..=360°).
|
||||||
|
let s_norm = ((speed - 0.25) / (4.0 - 0.25)).clamp(0.0, 1.0);
|
||||||
|
let needle_angle = std::f32::consts::PI + std::f32::consts::PI * s_norm;
|
||||||
|
let needle_tip = Pos2::new(cx + r * 0.85 * needle_angle.cos(), cy + r * 0.85 * needle_angle.sin());
|
||||||
|
painter.line_segment(
|
||||||
|
[Pos2::new(cx, cy), needle_tip],
|
||||||
|
Stroke::new(thick * 1.2, Color32::from_rgb(255, 168, 40)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
//! egui + wgpu overlay UI for ferret.
|
||||||
|
//!
|
||||||
|
//! Architecture:
|
||||||
|
//! - `OverlayApp` : the egui::App impl that renders the control bar
|
||||||
|
//! - `OverlayState` : latest playback state (mirrored from engine via channels)
|
||||||
|
//! - `OverlayRenderer` : wgpu surface + egui_wgpu integration
|
||||||
|
//!
|
||||||
|
//! The overlay runs in the SAME winit event loop as the video window — they
|
||||||
|
//! are two windows owned by one EventLoop. This keeps input routing simple
|
||||||
|
//! and avoids IPC between windows.
|
||||||
|
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod file_dialog;
|
||||||
|
pub mod icons;
|
||||||
|
pub mod renderer;
|
||||||
|
pub mod theme;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
|
pub use app::OverlayApp;
|
||||||
|
pub use file_dialog::{FileDialog, FileDialogKind, FileDialogResult};
|
||||||
|
pub use renderer::OverlayRenderer;
|
||||||
|
pub use theme::Theme;
|
||||||
|
|
@ -0,0 +1,335 @@
|
||||||
|
//! wgpu + egui_wgpu renderer for the overlay window.
|
||||||
|
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use crossbeam_channel::Receiver;
|
||||||
|
use egui_wgpu::{wgpu, Renderer as EguiRenderer, ScreenDescriptor};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use player_core::event::EngineEvent;
|
||||||
|
use player_core::state::PlaybackState;
|
||||||
|
use player_core::Cmd;
|
||||||
|
|
||||||
|
use crate::app::OverlayApp;
|
||||||
|
|
||||||
|
static START_TIME: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||||
|
|
||||||
|
/// Owns the wgpu surface + egui_wgpu renderer for ONE overlay window.
|
||||||
|
pub struct OverlayRenderer {
|
||||||
|
pub device: Arc<wgpu::Device>,
|
||||||
|
pub queue: Arc<wgpu::Queue>,
|
||||||
|
pub surface: Arc<wgpu::Surface<'static>>,
|
||||||
|
pub surface_config: wgpu::SurfaceConfiguration,
|
||||||
|
pub egui_renderer: EguiRenderer,
|
||||||
|
pub egui_ctx: egui::Context,
|
||||||
|
pub app: OverlayApp,
|
||||||
|
pub viewport_size: [u32; 2],
|
||||||
|
/// Timestamp until which the overlay must clear opaque (dark grey)
|
||||||
|
/// instead of transparent. Set by `resize()`, `suppress_transparency()`,
|
||||||
|
/// and the surface-error recovery paths. Keeps the desktop from showing
|
||||||
|
/// through the overlay during window moves/resizes while libmpv catches
|
||||||
|
/// up to the new geometry.
|
||||||
|
force_opaque_until: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OverlayRenderer {
|
||||||
|
pub fn new(
|
||||||
|
window: Arc<winit::window::Window>,
|
||||||
|
state: PlaybackState,
|
||||||
|
event_rx: Receiver<EngineEvent>,
|
||||||
|
cmd_tx: crossbeam_channel::Sender<Cmd>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||||
|
backends: wgpu::Backends::VULKAN | wgpu::Backends::GL,
|
||||||
|
flags: wgpu::InstanceFlags::default(),
|
||||||
|
dx12_shader_compiler: wgpu::Dx12Compiler::default(),
|
||||||
|
gles_minor_version: wgpu::Gles3MinorVersion::default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let surface = instance.create_surface(window.clone())?;
|
||||||
|
|
||||||
|
let adapter = pollster::block_on(async {
|
||||||
|
instance
|
||||||
|
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||||
|
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||||
|
compatible_surface: Some(&surface),
|
||||||
|
force_fallback_adapter: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("no suitable wgpu adapter")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (device, queue) = pollster::block_on(async {
|
||||||
|
adapter
|
||||||
|
.request_device(
|
||||||
|
&wgpu::DeviceDescriptor {
|
||||||
|
label: Some("ferret-overlay"),
|
||||||
|
required_features: wgpu::Features::empty(),
|
||||||
|
required_limits: wgpu::Limits::downlevel_defaults(),
|
||||||
|
memory_hints: wgpu::MemoryHints::default(),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("failed to acquire wgpu device")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let device = Arc::new(device);
|
||||||
|
let queue = Arc::new(queue);
|
||||||
|
let surface = Arc::new(surface);
|
||||||
|
|
||||||
|
let caps = surface.get_capabilities(&adapter);
|
||||||
|
// Prefer non-sRGB formats — egui warns about sRGB framebuffers
|
||||||
|
// ("Detected a linear (sRGBA aware) framebuffer Bgra8UnormSrgb.
|
||||||
|
// egui prefers Rgba8Unorm or Bgra8Unorm"). Non-sRGB avoids color
|
||||||
|
// management issues during window operations.
|
||||||
|
let format = caps
|
||||||
|
.formats
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm))
|
||||||
|
.or_else(|| caps.formats.iter().copied().find(|f| matches!(f, wgpu::TextureFormat::Rgba8Unorm)))
|
||||||
|
.or_else(|| caps.formats.iter().copied().find(|f| matches!(f, wgpu::TextureFormat::Bgra8UnormSrgb | wgpu::TextureFormat::Rgba8UnormSrgb)))
|
||||||
|
.or_else(|| caps.formats.first().copied())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("surface has no supported formats"))?;
|
||||||
|
// Force PresentMode::Fifo (vsync). Mailbox causes "Unrecognized
|
||||||
|
// present mode" warnings on some X11/EGL setups and produces
|
||||||
|
// broken presentation during window moves/resizes. Fifo is the
|
||||||
|
// most compatible mode and is required by the WebGPU spec.
|
||||||
|
let present_mode = wgpu::PresentMode::Fifo;
|
||||||
|
// Use Auto alpha mode — let the surface pick the best-supported
|
||||||
|
// compositing mode. PreMultiplied can cause artifacts on compositors
|
||||||
|
// that don't fully support it (common on Xfwm4).
|
||||||
|
let alpha_mode = wgpu::CompositeAlphaMode::Auto;
|
||||||
|
|
||||||
|
let size = window.inner_size();
|
||||||
|
let surface_config = wgpu::SurfaceConfiguration {
|
||||||
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
|
format,
|
||||||
|
width: size.width.max(1),
|
||||||
|
height: size.height.max(1),
|
||||||
|
present_mode,
|
||||||
|
desired_maximum_frame_latency: 2,
|
||||||
|
alpha_mode,
|
||||||
|
view_formats: vec![],
|
||||||
|
};
|
||||||
|
surface.configure(&device, &surface_config);
|
||||||
|
|
||||||
|
let egui_renderer = EguiRenderer::new(
|
||||||
|
&device,
|
||||||
|
format,
|
||||||
|
None, // no depth buffer
|
||||||
|
1, // msaa_samples
|
||||||
|
false, // no dithering
|
||||||
|
);
|
||||||
|
|
||||||
|
let egui_ctx = egui::Context::default();
|
||||||
|
let mut app = OverlayApp::new(state, event_rx, cmd_tx);
|
||||||
|
app.window_size = egui::vec2(size.width as f32, size.height as f32);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"overlay renderer ready: {}x{} {:?} alpha={:?}",
|
||||||
|
size.width, size.height, format, alpha_mode
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
surface,
|
||||||
|
surface_config,
|
||||||
|
egui_renderer,
|
||||||
|
egui_ctx,
|
||||||
|
app,
|
||||||
|
viewport_size: [size.width.max(1), size.height.max(1)],
|
||||||
|
force_opaque_until: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one frame.
|
||||||
|
pub fn render(&mut self, state: PlaybackState, mouse_pos: Option<egui::Pos2>) -> Result<()> {
|
||||||
|
self.app.update_state(state);
|
||||||
|
self.app.set_mouse_inside(mouse_pos.is_some());
|
||||||
|
self.app.compute_visibility();
|
||||||
|
self.app.poll_events();
|
||||||
|
|
||||||
|
// Drain any egui input events (mouse moves, clicks) that the main
|
||||||
|
// app pushed since the last frame. Without these, egui never sees
|
||||||
|
// the mouse and no buttons/sliders/dropdowns respond.
|
||||||
|
let events = self.app.drain_events();
|
||||||
|
|
||||||
|
let pixels_per_point = 1.0;
|
||||||
|
let screen_size = [self.surface_config.width, self.surface_config.height];
|
||||||
|
|
||||||
|
let raw_input = egui::RawInput {
|
||||||
|
screen_rect: Some(egui::Rect::from_min_size(
|
||||||
|
egui::pos2(0.0, 0.0),
|
||||||
|
egui::vec2(screen_size[0] as f32, screen_size[1] as f32),
|
||||||
|
)),
|
||||||
|
time: Some(START_TIME.elapsed().as_secs_f64()),
|
||||||
|
events,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let full_output = self.egui_ctx.run(raw_input, |ctx| {
|
||||||
|
self.app.draw(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync textures (new/updated).
|
||||||
|
for (id, image_delta) in &full_output.textures_delta.set {
|
||||||
|
self.egui_renderer
|
||||||
|
.update_texture(&self.device, &self.queue, *id, image_delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire surface frame. During a window resize/move, the surface can
|
||||||
|
// become outdated. We MUST reconfigure and retry — skipping the render
|
||||||
|
// leaves the old (possibly transparent) frame visible, which is the
|
||||||
|
// root cause of the "ghosting from what's behind it" artifact. By
|
||||||
|
// reconfiguring and rendering immediately, we paint a fresh opaque
|
||||||
|
// frame that covers the video window's resize gap.
|
||||||
|
let frame = match self.surface.get_current_texture() {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(wgpu::SurfaceError::Outdated) | Err(wgpu::SurfaceError::Lost) => {
|
||||||
|
// Surface is stale — reconfigure with current config and retry.
|
||||||
|
// This ensures we always paint a fresh frame instead of leaving
|
||||||
|
// a stale transparent one visible.
|
||||||
|
self.surface.configure(&self.device, &self.surface_config);
|
||||||
|
self.suppress_transparency(Duration::from_millis(200));
|
||||||
|
// Retry once. If it still fails, skip (rare — usually means
|
||||||
|
// the GPU is truly unavailable).
|
||||||
|
match self.surface.get_current_texture() {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(wgpu::SurfaceError::Timeout) => return Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("surface acquire failed after reconfigure: {e:?}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(wgpu::SurfaceError::Timeout) => {
|
||||||
|
// GPU is busy — skip this frame, try again next time.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => Err(e)?,
|
||||||
|
};
|
||||||
|
let view = frame
|
||||||
|
.texture
|
||||||
|
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
|
||||||
|
let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("ferret-overlay-encoder"),
|
||||||
|
});
|
||||||
|
|
||||||
|
let paint_jobs = self.egui_ctx.tessellate(full_output.shapes, pixels_per_point);
|
||||||
|
|
||||||
|
let screen_descriptor = ScreenDescriptor {
|
||||||
|
size_in_pixels: screen_size,
|
||||||
|
pixels_per_point,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload vertex/index buffers.
|
||||||
|
let extra_cmd_buffers = self.egui_renderer.update_buffers(
|
||||||
|
&self.device,
|
||||||
|
&self.queue,
|
||||||
|
&mut encoder,
|
||||||
|
&paint_jobs,
|
||||||
|
&screen_descriptor,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render pass: clear to dark grey when no file is loaded, or when
|
||||||
|
// the overlay is in a transitional state (resize, move, surface
|
||||||
|
// recovery). Clearing to transparent when a file is loaded lets
|
||||||
|
// libmpv's video show through — but if libmpv hasn't repainted yet
|
||||||
|
// (common during a resize/drag), transparent would reveal the desktop.
|
||||||
|
// The `force_opaque_until` timestamp keeps the overlay opaque for a
|
||||||
|
// grace period after every window operation, giving libmpv time to
|
||||||
|
// catch up to the new geometry.
|
||||||
|
let has_file = self.app.state.path.is_some();
|
||||||
|
let force_opaque = self
|
||||||
|
.force_opaque_until
|
||||||
|
.map(|t| Instant::now() < t)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let clear_color = if has_file && !force_opaque {
|
||||||
|
wgpu::Color::TRANSPARENT
|
||||||
|
} else {
|
||||||
|
// #1a1a1d — matches the VLC dark theme bg.
|
||||||
|
wgpu::Color { r: 0.10, g: 0.10, b: 0.114, a: 1.0 }
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
|
label: Some("ferret-overlay-pass"),
|
||||||
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
|
view: &view,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: wgpu::Operations {
|
||||||
|
load: wgpu::LoadOp::Clear(clear_color),
|
||||||
|
store: wgpu::StoreOp::Store,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
depth_stencil_attachment: None,
|
||||||
|
timestamp_writes: None,
|
||||||
|
occlusion_query_set: None,
|
||||||
|
});
|
||||||
|
// SAFETY: `egui_wgpu::Renderer::render` requires
|
||||||
|
// `&mut RenderPass<'static>` even though it consumes the pass
|
||||||
|
// synchronously and never escapes it. We transmute the lifetime
|
||||||
|
// to `'static`; the borrow is sound because:
|
||||||
|
// - `rpass` borrows `encoder` for the duration of this block.
|
||||||
|
// - `encoder.finish()` is called only after `rpass` drops at
|
||||||
|
// the block's end, so no mutable aliasing of the encoder
|
||||||
|
// occurs while the pass is live.
|
||||||
|
// - `egui_wgpu::Renderer::render` does not retain the
|
||||||
|
// `RenderPass` reference past its own return.
|
||||||
|
let rpass_static: &mut wgpu::RenderPass<'static> = unsafe {
|
||||||
|
std::mem::transmute::<&mut wgpu::RenderPass<'_>, &mut wgpu::RenderPass<'static>>(&mut rpass)
|
||||||
|
};
|
||||||
|
self.egui_renderer.render(rpass_static, &paint_jobs, &screen_descriptor);
|
||||||
|
}
|
||||||
|
// rpass is dropped here; encoder is no longer borrowed.
|
||||||
|
|
||||||
|
// Free dropped textures.
|
||||||
|
for id in &full_output.textures_delta.free {
|
||||||
|
self.egui_renderer.free_texture(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit encoder + any callback command buffers.
|
||||||
|
let mut all_cmd_buffers: Vec<wgpu::CommandBuffer> = extra_cmd_buffers;
|
||||||
|
all_cmd_buffers.push(encoder.finish());
|
||||||
|
self.queue.submit(all_cmd_buffers);
|
||||||
|
frame.present();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resize(&mut self, width: u32, height: u32) {
|
||||||
|
let width = width.max(1);
|
||||||
|
let height = height.max(1);
|
||||||
|
// Only reconfigure the surface if the size actually changed.
|
||||||
|
// During a window MOVE (not resize), this function gets called with
|
||||||
|
// the same dimensions — reconfiguring would destroy the valid
|
||||||
|
// framebuffer and replace it with an uninitialized one, causing
|
||||||
|
// a flash of garbage on the next frame.
|
||||||
|
let changed = self.surface_config.width != width || self.surface_config.height != height;
|
||||||
|
if changed {
|
||||||
|
self.surface_config.width = width;
|
||||||
|
self.surface_config.height = height;
|
||||||
|
self.surface.configure(&self.device, &self.surface_config);
|
||||||
|
self.viewport_size = [width, height];
|
||||||
|
self.app.window_size = egui::vec2(width as f32, height as f32);
|
||||||
|
}
|
||||||
|
// Always suppress transparency on resize/move, even if the size
|
||||||
|
// didn't change (e.g., during a window move where only position
|
||||||
|
// changes). This ensures the overlay clears opaque for a grace
|
||||||
|
// period, hiding any lag in libmpv's repainting.
|
||||||
|
self.suppress_transparency(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suppress transparency for the given duration. Call this on every
|
||||||
|
/// window move/resize event and on surface-error recovery. The overlay
|
||||||
|
/// will clear opaque (dark grey) until the timestamp expires, hiding
|
||||||
|
/// any lag in libmpv's repainting of the video window underneath.
|
||||||
|
pub fn suppress_transparency(&mut self, duration: Duration) {
|
||||||
|
self.force_opaque_until = Some(Instant::now() + duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
//! VLC-inspired visual theme.
|
||||||
|
//!
|
||||||
|
//! VLC's classic skin uses a dark gray gradient bar with orange highlights
|
||||||
|
//! for the active/progress state. We approximate that with flat colors
|
||||||
|
//! (no gradient support in egui without custom shaders).
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct Theme {
|
||||||
|
/// Background of the control bar (very dark gray, opaque).
|
||||||
|
pub bg: [u8; 4],
|
||||||
|
/// Slightly lighter bg used for button "trays" / separators.
|
||||||
|
bg_panel: [u8; 4],
|
||||||
|
/// Foreground text / icons (near-white).
|
||||||
|
pub fg: [u8; 4],
|
||||||
|
/// Foreground for disabled / inactive controls.
|
||||||
|
pub fg_dim: [u8; 4],
|
||||||
|
/// VLC orange — used for the seek bar progress fill, slider thumb, and
|
||||||
|
/// active button states.
|
||||||
|
pub accent: [u8; 4],
|
||||||
|
/// Accent when hovered (slightly brighter).
|
||||||
|
pub accent_hover: [u8; 4],
|
||||||
|
/// Background of sliders / progress tracks (mid gray).
|
||||||
|
pub track: [u8; 4],
|
||||||
|
/// Buffered-range fill (dim orange).
|
||||||
|
pub buffered: [u8; 4],
|
||||||
|
/// Button background (normal state).
|
||||||
|
pub button_bg: [u8; 4],
|
||||||
|
/// Button background (hover state).
|
||||||
|
pub button_bg_hover: [u8; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Theme {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::vlc_dark()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
/// VLC classic dark — black/charcoal bar with orange accents.
|
||||||
|
pub fn vlc_dark() -> Self {
|
||||||
|
Self {
|
||||||
|
bg: [20, 20, 22, 255], // #141416 — near-black
|
||||||
|
bg_panel: [32, 32, 36, 255], // #202024
|
||||||
|
fg: [232, 232, 236, 255], // #e8e8ec
|
||||||
|
fg_dim: [128, 128, 132, 255], // #808084
|
||||||
|
accent: [255, 136, 0, 255], // #ff8800 — VLC orange
|
||||||
|
accent_hover: [255, 168, 40, 255], // brighter on hover
|
||||||
|
track: [56, 56, 62, 255], // #38383e
|
||||||
|
buffered: [120, 64, 0, 200], // dim orange
|
||||||
|
button_bg: [44, 44, 48, 255], // #2c2c30
|
||||||
|
button_bg_hover: [64, 64, 70, 255], // #404046
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VLC alternative — slightly lighter, more modern.
|
||||||
|
pub fn vlc_modern() -> Self {
|
||||||
|
Self {
|
||||||
|
bg: [28, 28, 32, 245],
|
||||||
|
bg_panel: [40, 40, 46, 255],
|
||||||
|
fg: [240, 240, 244, 255],
|
||||||
|
fg_dim: [140, 140, 148, 255],
|
||||||
|
accent: [255, 152, 0, 255],
|
||||||
|
accent_hover: [255, 183, 60, 255],
|
||||||
|
track: [60, 60, 68, 255],
|
||||||
|
buffered: [120, 72, 0, 200],
|
||||||
|
button_bg: [48, 48, 56, 255],
|
||||||
|
button_bg_hover: [72, 72, 80, 255],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RGBA conversion helpers (egui uses 0..=1 floats).
|
||||||
|
pub fn bg(self) -> [f32; 4] { rgba(self.bg) }
|
||||||
|
pub fn bg_panel(self) -> [f32; 4] { rgba(self.bg_panel) }
|
||||||
|
pub fn fg(self) -> [f32; 4] { rgba(self.fg) }
|
||||||
|
pub fn fg_dim(self) -> [f32; 4] { rgba(self.fg_dim) }
|
||||||
|
pub fn accent(self) -> [f32; 4] { rgba(self.accent) }
|
||||||
|
pub fn accent_hover(self) -> [f32; 4] { rgba(self.accent_hover) }
|
||||||
|
pub fn track(self) -> [f32; 4] { rgba(self.track) }
|
||||||
|
pub fn buffered(self) -> [f32; 4] { rgba(self.buffered) }
|
||||||
|
pub fn button_bg(self) -> [f32; 4] { rgba(self.button_bg) }
|
||||||
|
pub fn button_bg_hover(self) -> [f32; 4] { rgba(self.button_bg_hover) }
|
||||||
|
|
||||||
|
pub fn bg_color32(self) -> egui::Color32 { color32(self.bg) }
|
||||||
|
pub fn bg_panel_color32(self) -> egui::Color32 { color32(self.bg_panel) }
|
||||||
|
pub fn fg_color32(self) -> egui::Color32 { color32(self.fg) }
|
||||||
|
pub fn fg_dim_color32(self) -> egui::Color32 { color32(self.fg_dim) }
|
||||||
|
pub fn accent_color32(self) -> egui::Color32 { color32(self.accent) }
|
||||||
|
pub fn accent_hover_color32(self) -> egui::Color32 { color32(self.accent_hover) }
|
||||||
|
pub fn track_color32(self) -> egui::Color32 { color32(self.track) }
|
||||||
|
pub fn buffered_color32(self) -> egui::Color32 { color32(self.buffered) }
|
||||||
|
pub fn button_bg_color32(self) -> egui::Color32 { color32(self.button_bg) }
|
||||||
|
pub fn button_bg_hover_color32(self) -> egui::Color32 { color32(self.button_bg_hover) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgba(c: [u8; 4]) -> [f32; 4] {
|
||||||
|
[c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, c[3] as f32 / 255.0]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color32(c: [u8; 4]) -> egui::Color32 {
|
||||||
|
egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
//! VLC-style seek bar.
|
||||||
|
//!
|
||||||
|
//! Visual layout:
|
||||||
|
//! [00:12] ████████████░░░░░░░░░░░░ [01:30]
|
||||||
|
//! ^progress ^thumb (visible on hover/drag)
|
||||||
|
//!
|
||||||
|
//! - The track is ~6px tall, chunky and easy to click.
|
||||||
|
//! - The progress fill is VLC orange.
|
||||||
|
//! - The thumb is a 12px circle that appears on hover/drag.
|
||||||
|
//! - The whole widget has generous vertical padding so the hit area
|
||||||
|
//! is ~24px even though the track is only 6px.
|
||||||
|
|
||||||
|
use egui::{Color32, Response, Sense, Ui, Vec2};
|
||||||
|
|
||||||
|
/// Draw a VLC-style seek bar.
|
||||||
|
///
|
||||||
|
/// Returns `(response, new_position_fraction)` where `new_position_fraction`
|
||||||
|
/// is `Some(0.0..=1.0)` if the user clicked or dragged this frame.
|
||||||
|
pub fn seek_bar(
|
||||||
|
ui: &mut Ui,
|
||||||
|
progress: Option<f32>, // 0..=1, None if unknown
|
||||||
|
buffered: Option<f32>, // 0..=1, None if unknown
|
||||||
|
height: f32, // total widget height (hit area)
|
||||||
|
) -> (Response, Option<f32>) {
|
||||||
|
let desired = Vec2::new(ui.available_width(), height);
|
||||||
|
let (rect, response) = ui.allocate_exact_size(desired, Sense::click_and_drag());
|
||||||
|
|
||||||
|
let progress = progress.unwrap_or(0.0).clamp(0.0, 1.0);
|
||||||
|
let buffered = buffered.unwrap_or(0.0).clamp(0.0, 1.0);
|
||||||
|
let track_h = 6.0;
|
||||||
|
let track_y = rect.center().y;
|
||||||
|
let track_rect = egui::Rect::from_center_size(
|
||||||
|
egui::pos2(rect.center().x, track_y),
|
||||||
|
Vec2::new(rect.width() - 4.0, track_h),
|
||||||
|
);
|
||||||
|
|
||||||
|
let track_color = ui.style().visuals.widgets.inactive.bg_fill;
|
||||||
|
let buffered_color = Color32::from_rgb(120, 64, 0);
|
||||||
|
let progress_color = Color32::from_rgb(255, 136, 0);
|
||||||
|
let thumb_color = Color32::from_rgb(255, 168, 40);
|
||||||
|
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
|
||||||
|
// Track background (rounded).
|
||||||
|
painter.rect_filled(track_rect, track_h * 0.5, track_color);
|
||||||
|
|
||||||
|
// Buffered range (drawn behind progress).
|
||||||
|
if buffered > 0.0 {
|
||||||
|
let buf_w = track_rect.width() * buffered;
|
||||||
|
let buf_rect = egui::Rect::from_min_size(
|
||||||
|
track_rect.min,
|
||||||
|
Vec2::new(buf_w, track_h),
|
||||||
|
);
|
||||||
|
painter.rect_filled(buf_rect, track_h * 0.5, buffered_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress fill.
|
||||||
|
if progress > 0.0 {
|
||||||
|
let prog_w = track_rect.width() * progress;
|
||||||
|
let prog_rect = egui::Rect::from_min_size(
|
||||||
|
track_rect.min,
|
||||||
|
Vec2::new(prog_w, track_h),
|
||||||
|
);
|
||||||
|
painter.rect_filled(prog_rect, track_h * 0.5, progress_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumb — visible on hover, drag, or focus.
|
||||||
|
let show_thumb = response.hovered() || response.dragged() || response.has_focus();
|
||||||
|
if show_thumb && progress > 0.0 {
|
||||||
|
let thumb_x = track_rect.min.x + track_rect.width() * progress;
|
||||||
|
let thumb_y = track_y;
|
||||||
|
// Outer ring.
|
||||||
|
painter.circle_filled(egui::pos2(thumb_x, thumb_y), 7.0, thumb_color);
|
||||||
|
// Inner dot (slightly darker).
|
||||||
|
painter.circle_filled(egui::pos2(thumb_x, thumb_y), 3.0, Color32::from_rgb(80, 40, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover preview line (thin vertical line where the cursor is).
|
||||||
|
if response.hovered() {
|
||||||
|
if let Some(pos) = response.interact_pointer_pos() {
|
||||||
|
if pos.x >= track_rect.min.x && pos.x <= track_rect.max.x {
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
egui::pos2(pos.x, track_rect.min.y - 4.0),
|
||||||
|
egui::pos2(pos.x, track_rect.max.y + 4.0),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(1.0_f32, Color32::from_rgb(255, 168, 40)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute new position if the user interacted.
|
||||||
|
let new_pos = if response.dragged() || response.clicked() {
|
||||||
|
let click_x = response.interact_pointer_pos().map(|p| p.x);
|
||||||
|
click_x.map(|x| {
|
||||||
|
let frac = ((x - track_rect.min.x) / track_rect.width()).clamp(0.0, 1.0);
|
||||||
|
frac as f32
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
(response, new_pos)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Audit Rust bracket balance with a proper tokenizer.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Line comments (//...)
|
||||||
|
- Block comments (/* ... */)
|
||||||
|
- String literals ("..." with escapes)
|
||||||
|
- Raw string literals (r"...", r#"..."#)
|
||||||
|
- Char literals ('...' with escapes)
|
||||||
|
- Lifetime parameters ('ident)
|
||||||
|
|
||||||
|
Reports any file where (), {}, or [] are unbalanced.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def audit(path: Path) -> list[str]:
|
||||||
|
text = path.read_text()
|
||||||
|
issues = []
|
||||||
|
stack: list[tuple[str, int, int]] = [] # (char, line, col)
|
||||||
|
line, col = 1, 0
|
||||||
|
i = 0
|
||||||
|
n = len(text)
|
||||||
|
|
||||||
|
openers = {'(': ')', '{': '}', '[': ']'}
|
||||||
|
closers = set(openers.values())
|
||||||
|
|
||||||
|
while i < n:
|
||||||
|
c = text[i]
|
||||||
|
if c == '\n':
|
||||||
|
line += 1
|
||||||
|
col = 0
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
col += 1
|
||||||
|
|
||||||
|
# Line comment
|
||||||
|
if c == '/' and i + 1 < n and text[i + 1] == '/':
|
||||||
|
while i < n and text[i] != '\n':
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
# Block comment
|
||||||
|
if c == '/' and i + 1 < n and text[i + 1] == '*':
|
||||||
|
i += 2
|
||||||
|
while i + 1 < n and not (text[i] == '*' and text[i + 1] == '/'):
|
||||||
|
if text[i] == '\n':
|
||||||
|
line += 1
|
||||||
|
col = 0
|
||||||
|
else:
|
||||||
|
col += 1
|
||||||
|
i += 1
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
# Raw string r"..." or r#"..."#
|
||||||
|
if c == 'r' and i + 1 < n and text[i + 1] == '"':
|
||||||
|
j = i + 2
|
||||||
|
hashes = 0
|
||||||
|
while j < n and text[j] == '#':
|
||||||
|
hashes += 1
|
||||||
|
j += 1
|
||||||
|
if j < n and text[j] == '"':
|
||||||
|
close = '"' + '#' * hashes
|
||||||
|
start = j + 1
|
||||||
|
end = text.find(close, start)
|
||||||
|
if end == -1:
|
||||||
|
issues.append(f"{path}:{line}: unterminated raw string")
|
||||||
|
return issues
|
||||||
|
for ch in text[i:end + len(close)]:
|
||||||
|
if ch == '\n':
|
||||||
|
line += 1
|
||||||
|
col = 0
|
||||||
|
else:
|
||||||
|
col += 1
|
||||||
|
i = end + len(close)
|
||||||
|
continue
|
||||||
|
# Regular string
|
||||||
|
if c == '"':
|
||||||
|
j = i + 1
|
||||||
|
while j < n:
|
||||||
|
if text[j] == '\\' and j + 1 < n:
|
||||||
|
j += 2
|
||||||
|
continue
|
||||||
|
if text[j] == '"':
|
||||||
|
break
|
||||||
|
j += 1
|
||||||
|
if j >= n:
|
||||||
|
issues.append(f"{path}:{line}: unterminated string")
|
||||||
|
return issues
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
# Char literal or lifetime
|
||||||
|
if c == "'":
|
||||||
|
if i + 1 < n and (text[i + 1].isalpha() or text[i + 1] == '_'):
|
||||||
|
j = i + 1
|
||||||
|
while j < n and (text[j].isalnum() or text[j] == '_'):
|
||||||
|
j += 1
|
||||||
|
if j < n and text[j] != "'":
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
j = i + 1
|
||||||
|
while j < n:
|
||||||
|
if text[j] == '\\' and j + 1 < n:
|
||||||
|
j += 2
|
||||||
|
continue
|
||||||
|
if text[j] == "'":
|
||||||
|
break
|
||||||
|
j += 1
|
||||||
|
if j >= n:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
if c in openers:
|
||||||
|
stack.append((c, line, col))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if c in closers:
|
||||||
|
if not stack:
|
||||||
|
issues.append(f"{path}:{line}:{col}: unexpected '{c}'")
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
top, tline, tcol = stack.pop()
|
||||||
|
if openers[top] != c:
|
||||||
|
issues.append(
|
||||||
|
f"{path}:{line}:{col}: '{c}' does not match '{top}' "
|
||||||
|
f"opened at {tline}:{tcol}"
|
||||||
|
)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
for top, tline, tcol in stack:
|
||||||
|
issues.append(f"{path}: unclosed '{top}' opened at {tline}:{tcol}")
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('.')
|
||||||
|
rs_files = sorted(root.rglob('*.rs'))
|
||||||
|
total_issues = 0
|
||||||
|
for f in rs_files:
|
||||||
|
issues = audit(f)
|
||||||
|
for iss in issues:
|
||||||
|
print(iss)
|
||||||
|
total_issues += 1
|
||||||
|
print(f"\n{total_issues} issue(s) across {len(rs_files)} files")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Audit Rust match arms for missing `*` dereferences.
|
||||||
|
|
||||||
|
When a function takes `&Cmd` and matches on it, every captured field is
|
||||||
|
a reference. Forwarding that reference to a context expecting a value
|
||||||
|
(without `*`) is a compile error. This script finds such cases by:
|
||||||
|
|
||||||
|
1. Finding functions whose signature takes `&EnumType` parameters.
|
||||||
|
2. Finding `match <param> { Enum::Variant(capture) => body }` blocks.
|
||||||
|
3. Checking if `capture` appears in `body` without a leading `*`
|
||||||
|
in a value-expecting position (function arg, struct field, etc.).
|
||||||
|
|
||||||
|
Conservative: flags potential issues for manual review.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def find_ref_params(text: str) -> dict[str, str]:
|
||||||
|
"""Find function params of form `name: &EnumType`. Returns {param: type}."""
|
||||||
|
params = {}
|
||||||
|
# fn foo(... name: &SomeType, ...)
|
||||||
|
for m in re.finditer(r'(\w+)\s*:\s*&(\w+)', text):
|
||||||
|
param_name = m.group(1)
|
||||||
|
type_name = m.group(2)
|
||||||
|
# Heuristic: only flag types that start with uppercase (enums/structs)
|
||||||
|
if type_name[0].isupper():
|
||||||
|
params[param_name] = type_name
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def find_match_on_param(text: str, param: str) -> list[tuple[int, str, list[str]]]:
|
||||||
|
"""Find `match <param> { ... }` blocks and extract arm captures.
|
||||||
|
|
||||||
|
Returns list of (line, variant, [captures]).
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
# Find `match <param> {` — then scan arms until matching `}`
|
||||||
|
for m in re.finditer(rf'match\s+{re.escape(param)}\s*\{{', text):
|
||||||
|
block_start = m.end()
|
||||||
|
# Find matching close brace (naive depth counting)
|
||||||
|
depth = 1
|
||||||
|
i = block_start
|
||||||
|
while i < len(text) and depth > 0:
|
||||||
|
if text[i] == '{':
|
||||||
|
depth += 1
|
||||||
|
elif text[i] == '}':
|
||||||
|
depth -= 1
|
||||||
|
i += 1
|
||||||
|
block_end = i
|
||||||
|
block = text[block_start:block_end]
|
||||||
|
|
||||||
|
# Find arm patterns: Enum::Variant(captures) =>
|
||||||
|
for arm in re.finditer(
|
||||||
|
r'(\w+)::(\w+)\s*\(([^)]*)\)\s*=>',
|
||||||
|
block,
|
||||||
|
):
|
||||||
|
arm_line = text[:block_start + arm.start()].count('\n') + 1
|
||||||
|
variant = f"{arm.group(1)}::{arm.group(2)}"
|
||||||
|
captures_raw = arm.group(3).strip()
|
||||||
|
if not captures_raw:
|
||||||
|
continue
|
||||||
|
captures = []
|
||||||
|
for c in captures_raw.split(','):
|
||||||
|
c = c.strip()
|
||||||
|
# Strip type annotations: `deg: u16` -> `deg`
|
||||||
|
if ':' in c:
|
||||||
|
c = c.split(':')[0].strip()
|
||||||
|
if c and (c[0].isalpha() or c[0] == '_'):
|
||||||
|
captures.append(c)
|
||||||
|
if captures:
|
||||||
|
results.append((arm_line, variant, captures))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def check_deref_in_arm(text: str, arm_line: int, ident: str) -> bool:
|
||||||
|
"""Check if `ident` is used without `*` deref in the arm body (next ~15 lines).
|
||||||
|
|
||||||
|
Returns True if a potential issue is found.
|
||||||
|
"""
|
||||||
|
lines = text.split('\n')
|
||||||
|
# Scan from arm_line+1 for up to 15 lines
|
||||||
|
for offset in range(1, 16):
|
||||||
|
idx = arm_line - 1 + offset # 0-indexed
|
||||||
|
if idx >= len(lines):
|
||||||
|
break
|
||||||
|
line = lines[idx]
|
||||||
|
# Stop at next arm (line containing `=>` at start, or `}` ending block)
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith('}') or stripped.startswith('_') and '=>' in stripped:
|
||||||
|
break
|
||||||
|
# Find usages of `ident` not preceded by `*` or `&` or `.`
|
||||||
|
for m in re.finditer(rf'(?<![*.&>])\b{re.escape(ident)}\b', line):
|
||||||
|
# Skip if followed by `.` (method/field access — fine on references)
|
||||||
|
after_idx = m.end()
|
||||||
|
if after_idx < len(line) and line[after_idx] == '.':
|
||||||
|
continue
|
||||||
|
# Skip if it's in a closure binding: |ident|
|
||||||
|
before = line[:m.start()]
|
||||||
|
if before.endswith('|'):
|
||||||
|
continue
|
||||||
|
# Skip if it's a pattern match itself: Some(ident)
|
||||||
|
# (preceded by `(` or `,` and followed by `)` or `,`)
|
||||||
|
if before.endswith('(') or before.endswith(','):
|
||||||
|
after = line[after_idx:] if after_idx < len(line) else ''
|
||||||
|
if after.startswith(')') or after.startswith(','):
|
||||||
|
continue
|
||||||
|
# Skip if explicitly dereferenced with `*`
|
||||||
|
if before.endswith('*'):
|
||||||
|
continue
|
||||||
|
# Found a usage that might need `*`
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def audit_file(path: Path) -> list[str]:
|
||||||
|
text = path.read_text()
|
||||||
|
issues = []
|
||||||
|
ref_params = find_ref_params(text)
|
||||||
|
for param, type_name in ref_params.items():
|
||||||
|
arms = find_match_on_param(text, param)
|
||||||
|
for arm_line, variant, captures in arms:
|
||||||
|
for ident in captures:
|
||||||
|
if ident.startswith('_'):
|
||||||
|
continue
|
||||||
|
if check_deref_in_arm(text, arm_line, ident):
|
||||||
|
issues.append(
|
||||||
|
f" {path}:{arm_line}: `{ident}` captured from "
|
||||||
|
f"`{variant}` (matching `&{type_name} {param}`) — "
|
||||||
|
f"verify `*{ident}` is used where a value is expected"
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('.')
|
||||||
|
rs_files = sorted(root.rglob('*.rs'))
|
||||||
|
total_issues = 0
|
||||||
|
for f in rs_files:
|
||||||
|
issues = audit_file(f)
|
||||||
|
if issues:
|
||||||
|
print(f"\n{f}:")
|
||||||
|
for iss in issues:
|
||||||
|
print(iss)
|
||||||
|
total_issues += 1
|
||||||
|
print(f"\n{total_issues} potential issue(s) across {len(rs_files)} files")
|
||||||
|
print("(Each flag requires manual review — this is a heuristic.)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/build.sh
|
||||||
|
#
|
||||||
|
# One-shot build: ensures libmpv is set up, then cargo builds the release binary.
|
||||||
|
# Optionally runs lint checks and tests.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/build.sh # build release
|
||||||
|
# ./scripts/build.sh --debug # build debug
|
||||||
|
# ./scripts/build.sh --clean # clean + rebuild
|
||||||
|
# ./scripts/build.sh --test # run cargo test after build
|
||||||
|
# ./scripts/build.sh --lint # run clippy + bracket audit after build
|
||||||
|
# ./scripts/build.sh --ci # lint + test + build (full CI pass)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$HERE/.." && pwd)"
|
||||||
|
PREFIX="$PROJECT_ROOT/mpv-prefix"
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Step 1: ensure libmpv prefix exists.
|
||||||
|
if [ ! -f "$PREFIX/env.sh" ]; then
|
||||||
|
echo "==> mpv-prefix not found; running setup-libmpv.sh first..."
|
||||||
|
"$HERE/setup-libmpv.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 2: source env.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$PREFIX/env.sh"
|
||||||
|
|
||||||
|
# Step 3: ensure rust toolchain.
|
||||||
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
|
echo "==> cargo not found; installing rustup..."
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
source "$HOME/.cargo/env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 4: parse flags.
|
||||||
|
PROFILE="release"
|
||||||
|
RUN_TEST=0
|
||||||
|
RUN_LINT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--debug) PROFILE="dev" ;;
|
||||||
|
--clean) cargo clean ;;
|
||||||
|
--test) RUN_TEST=1 ;;
|
||||||
|
--lint) RUN_LINT=1 ;;
|
||||||
|
--ci) RUN_LINT=1; RUN_TEST=1 ;;
|
||||||
|
*) ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Step 5: cargo build.
|
||||||
|
echo "==> cargo build ($PROFILE)..."
|
||||||
|
if [ "$PROFILE" = "release" ]; then
|
||||||
|
cargo build --release
|
||||||
|
echo
|
||||||
|
echo "==> Built: $PROJECT_ROOT/target/release/ferret"
|
||||||
|
else
|
||||||
|
cargo build
|
||||||
|
echo
|
||||||
|
echo "==> Built: $PROJECT_ROOT/target/debug/ferret"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 6: lint (optional).
|
||||||
|
if [ "$RUN_LINT" -eq 1 ]; then
|
||||||
|
echo
|
||||||
|
echo "==> cargo clippy..."
|
||||||
|
cargo clippy --release -- -D warnings
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Bracket-balance audit..."
|
||||||
|
python3 "$HERE/audit_brackets.py" "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> &T deref audit (heuristic)..."
|
||||||
|
python3 "$HERE/audit_deref.py" "$PROJECT_ROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 7: test (optional).
|
||||||
|
if [ "$RUN_TEST" -eq 1 ]; then
|
||||||
|
echo
|
||||||
|
echo "==> cargo test..."
|
||||||
|
cargo test --release
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Done."
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/setup-libmpv.sh
|
||||||
|
#
|
||||||
|
# Extract libmpv + runtime deps into a local prefix without requiring root.
|
||||||
|
# Idempotent: safe to re-run.
|
||||||
|
#
|
||||||
|
# After running, source the generated env.sh:
|
||||||
|
# source ./mpv-prefix/env.sh
|
||||||
|
#
|
||||||
|
# Then `cargo build --release` will work.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$HERE/.." && pwd)"
|
||||||
|
PREFIX="$PROJECT_ROOT/mpv-prefix"
|
||||||
|
TMPDIR="${TMPDIR:-/tmp}/ferret-deps"
|
||||||
|
mkdir -p "$PREFIX" "$TMPDIR"
|
||||||
|
|
||||||
|
echo "==> Prefix: $PREFIX"
|
||||||
|
echo "==> Temp download dir: $TMPDIR"
|
||||||
|
|
||||||
|
# Packages we need. Split into:
|
||||||
|
# - libmpv itself (libmpv2, libmpv-dev)
|
||||||
|
# - libmpv's runtime deps (everything ldd complained about)
|
||||||
|
# - bindgen's runtime deps (libclang, llvm)
|
||||||
|
PACKAGES=(
|
||||||
|
# libmpv core
|
||||||
|
libmpv2 libmpv-dev
|
||||||
|
|
||||||
|
# libmpv runtime deps
|
||||||
|
libmujs3 liblua5.2-0 libuchardet0 libpipewire-0.3-0t64 libsndio7.0
|
||||||
|
libdisplay-info2 libsixel1 libxpresent1 libegl1 libegl-mesa0
|
||||||
|
libva-wayland2 libplacebo349 libass9 libva2 libva-drm2 libva-x11-2
|
||||||
|
libvdpau1 libxss1 libxv1 libbluray2 libdvdnav4 liblcms2-2 libzimg2
|
||||||
|
libwayland-egl1 libwayland-client0 libwayland-cursor0 libwayland-server0
|
||||||
|
libxkbcommon0 libgbm1 libdrm2 libxrandr2 libxi6 libgl1 libglx-mesa0
|
||||||
|
libglx0 libgl1-mesa-dri libx11-6 libxcb1 libxcb-randr0 libxcb-xfixes0
|
||||||
|
libxext6 libpulse0 libasound2t64 libxrender1 libxcursor1 libxinerama1
|
||||||
|
libjpeg62-turbo libcdio19 libcdio-paranoia2 libarchive13 librubberband2
|
||||||
|
|
||||||
|
# winit xkbcommon keyboard support (needed at runtime by winit 0.30)
|
||||||
|
libxkbcommon-x11-0 libxcb-xkb1
|
||||||
|
|
||||||
|
# mesa software rasterizer + vulkan (step-down when no real GPU is present)
|
||||||
|
mesa-vulkan-drivers libgl1-mesa-dri
|
||||||
|
|
||||||
|
# bindgen / build deps
|
||||||
|
libclang1-19 libllvm19 libclang-common-19-dev
|
||||||
|
|
||||||
|
# Xvfb (optional, for headless testing)
|
||||||
|
xvfb xauth xserver-xorg-core
|
||||||
|
|
||||||
|
# xkb keyboard layout data (needed by xkbcommon at runtime)
|
||||||
|
xkb-data
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "==> Downloading ${#PACKAGES[@]} packages..."
|
||||||
|
cd "$TMPDIR"
|
||||||
|
for pkg in "${PACKAGES[@]}"; do
|
||||||
|
if ! ls "${pkg}"_*.deb 2>/dev/null >/dev/null; then
|
||||||
|
apt-get download "$pkg" 2>/dev/null || echo " (skip $pkg — not available)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> Extracting all .deb files into $PREFIX ..."
|
||||||
|
cd "$PREFIX"
|
||||||
|
for deb in "$TMPDIR"/*.deb; do
|
||||||
|
[ -e "$deb" ] || continue
|
||||||
|
dpkg-deb -x "$deb" . 2>/dev/null || echo " (failed to extract $(basename "$deb"))"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Write a slimmed-down mpv.pc that skips the Requires.private entries.
|
||||||
|
# The original .pc lists ~50 transitive deps whose .pc files aren't
|
||||||
|
# installed; we only need the -lmpv link line for dynamic linking.
|
||||||
|
PC_FILE="$PREFIX/usr/lib/x86_64-linux-gnu/pkgconfig/mpv.pc"
|
||||||
|
mkdir -p "$(dirname "$PC_FILE")"
|
||||||
|
cat > "$PC_FILE" <<PC
|
||||||
|
prefix=$PREFIX/usr
|
||||||
|
includedir=\${prefix}/include
|
||||||
|
libdir=\${prefix}/lib/x86_64-linux-gnu
|
||||||
|
|
||||||
|
Name: mpv
|
||||||
|
Description: mpv media player client library (slim pc - runtime deps only)
|
||||||
|
Version: 2.5.0
|
||||||
|
Libs: -L\${libdir} -lmpv
|
||||||
|
Libs.private: -latomic -pthread -lm -lrt
|
||||||
|
Cflags: -I\${includedir}
|
||||||
|
PC
|
||||||
|
|
||||||
|
# Write env.sh — source this before building / running.
|
||||||
|
cat > "$PREFIX/env.sh" <<ENV_SH
|
||||||
|
# Source this before building/running ferret:
|
||||||
|
# source $PREFIX/env.sh
|
||||||
|
export MPV_PREFIX="$PREFIX"
|
||||||
|
export PKG_CONFIG_PATH="\$MPV_PREFIX/usr/lib/x86_64-linux-gnu/pkgconfig:\${PKG_CONFIG_PATH:-}"
|
||||||
|
export LD_LIBRARY_PATH="\$MPV_PREFIX/usr/lib/x86_64-linux-gnu:\$MPV_PREFIX/lib/x86_64-linux-gnu:\${LD_LIBRARY_PATH:-}"
|
||||||
|
export C_INCLUDE_PATH="\$MPV_PREFIX/usr/include:\${C_INCLUDE_PATH:-}"
|
||||||
|
export LIBRARY_PATH="\$MPV_PREFIX/usr/lib/x86_64-linux-gnu:\$MPV_PREFIX/lib/x86_64-linux-gnu:\${LIBRARY_PATH:-}"
|
||||||
|
export LIBCLANG_PATH="\$MPV_PREFIX/usr/lib/x86_64-linux-gnu"
|
||||||
|
export CLANG_RESOURCE_DIR="\$MPV_PREFIX/usr/lib/llvm-19/lib/clang/19"
|
||||||
|
export PATH="\$MPV_PREFIX/usr/bin:\$HOME/.cargo/bin:\${PATH:-}"
|
||||||
|
ENV_SH
|
||||||
|
|
||||||
|
# Verify the libmpv.so.2 dependency closure is complete.
|
||||||
|
echo "==> Verifying libmpv.so.2 dependency closure..."
|
||||||
|
export LD_LIBRARY_PATH="$PREFIX/usr/lib/x86_64-linux-gnu:$PREFIX/lib/x86_64-linux-gnu"
|
||||||
|
MISSING=$(ldd "$PREFIX/usr/lib/x86_64-linux-gnu/libmpv.so.2" 2>/dev/null | grep "not found" || true)
|
||||||
|
if [ -z "$MISSING" ]; then
|
||||||
|
echo " all deps resolved"
|
||||||
|
else
|
||||||
|
echo " missing libs:"
|
||||||
|
echo "$MISSING"
|
||||||
|
echo " Install the missing packages and re-run this script."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Done. Next steps:"
|
||||||
|
echo " source $PREFIX/env.sh"
|
||||||
|
echo " cargo build --release"
|
||||||
|
echo " ./target/release/ferret /path/to/video.mp4"
|
||||||
Loading…
Reference in New Issue