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:
Jeremy Anderson 2026-07-29 23:46:01 -04:00
commit cf2224c964
41 changed files with 12153 additions and 0 deletions

24
.gitignore vendored Executable file
View File

@ -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

503
BLOG.md Executable file
View File

@ -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.1v1.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

168
CONTRIBUTING.md Executable file
View File

@ -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.

2784
Cargo.lock generated Executable file

File diff suppressed because it is too large Load Diff

63
Cargo.toml Executable file
View File

@ -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

358
LICENSE Executable file
View File

@ -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.

241
QUICKSTART.md Executable file
View File

@ -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.

591
README.md Executable file
View File

@ -0,0 +1,591 @@
# ferret
**A modern, accuracy-first video player for Linux, written in Rust.**
[![License: GPL-2.0](https://img.shields.io/badge/license-GPL--2.0-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.0.0-orange.svg)](#)
[![Rust](https://img.shields.io/badge/rust-1.75%2B-orange.svg)](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.

32
clippy.toml Executable file
View File

@ -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

22
crates/mpv-bindings/Cargo.toml Executable file
View File

@ -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"

83
crates/mpv-bindings/build.rs Executable file
View File

@ -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");
}

View File

@ -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 }

View File

@ -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;

212
crates/mpv-bindings/src/event.rs Executable file
View File

@ -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,
}
}

310
crates/mpv-bindings/src/handle.rs Executable file
View File

@ -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)
}
}

41
crates/mpv-bindings/src/lib.rs Executable file
View File

@ -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");

View File

@ -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),
}
}
}

27
crates/player-app/Cargo.toml Executable file
View File

@ -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 }

101
crates/player-app/src/keymap.rs Executable file
View File

@ -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))
}

694
crates/player-app/src/main.rs Executable file
View File

@ -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(())
}

138
crates/player-app/src/windows.rs Executable file
View File

@ -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)
}
}

23
crates/player-core/Cargo.toml Executable file
View File

@ -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 }

314
crates/player-core/src/cmd.rs Executable file
View File

@ -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)?)
}

1184
crates/player-core/src/engine.rs Executable file

File diff suppressed because it is too large Load Diff

21
crates/player-core/src/error.rs Executable file
View File

@ -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>;

80
crates/player-core/src/event.rs Executable file
View File

@ -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,
}

45
crates/player-core/src/lib.rs Executable file
View File

@ -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};

109
crates/player-core/src/options.rs Executable file
View File

@ -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
}
}

190
crates/player-core/src/state.rs Executable file
View File

@ -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 }
}

25
crates/player-ui/Cargo.toml Executable file
View File

@ -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 }

1490
crates/player-ui/src/app.rs Executable file

File diff suppressed because it is too large Load Diff

View File

@ -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
}
}

407
crates/player-ui/src/icons.rs Executable file
View File

@ -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)),
);
}

24
crates/player-ui/src/lib.rs Executable file
View File

@ -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;

335
crates/player-ui/src/renderer.rs Executable file
View File

@ -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);
}
}

101
crates/player-ui/src/theme.rs Executable file
View File

@ -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])
}

107
crates/player-ui/src/widgets.rs Executable file
View File

@ -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)
}

151
scripts/audit_brackets.py Executable file
View File

@ -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()

152
scripts/audit_deref.py Executable file
View File

@ -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()

90
scripts/build.sh Executable file
View File

@ -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."

122
scripts/setup-libmpv.sh Executable file
View File

@ -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"