504 lines
20 KiB
Markdown
Executable File
504 lines
20 KiB
Markdown
Executable File
# ferret Development Blog
|
||
|
||
A narrative history of ferret's development, design decisions, and the bugs
|
||
that shaped the codebase. This is not a changelog (see git log for that) —
|
||
it's the story behind the code.
|
||
|
||
**Author:** Jeremy Anderson
|
||
**Website:** http://git.dcos.net/dcosnet/ferret
|
||
|
||
---
|
||
|
||
## Why I built ferret
|
||
|
||
I watch a lot of video. Documentaries, lectures, archival footage. And I'm
|
||
picky about playback quality — I notice frame drops, audio drift, and
|
||
smearing on corrupt frames. VLC was fine until around 2018, when it started
|
||
stuttering on my hardware. mpv was better, but its UI is minimalist to a
|
||
fault — I wanted something between VLC's cluttered chrome and mpv's bare
|
||
keyboard-driven interface.
|
||
|
||
The SMPlayer approach (separate frontend process talking to mpv) always
|
||
appealed to me, but SMPlayer itself is a Qt app that feels dated. I wanted
|
||
something modern, written in a memory-safe language, with the accuracy-first
|
||
philosophy of mpv baked in from the start.
|
||
|
||
Rust + egui + libmpv seemed like the right combination. ferret is the result.
|
||
|
||
---
|
||
|
||
## v0.1 — The MVP (the option-error bug)
|
||
|
||
The first version was a four-crate workspace:
|
||
|
||
- `mpv-bindings` — bindgen FFI to libmpv
|
||
- `player-core` — headless engine with command/event channels
|
||
- `player-ui` — egui + wgpu overlay renderer
|
||
- `player-app` — binary, multi-window winit event loop
|
||
|
||
The architecture was sound: libmpv owns decode + VO + AO, the engine runs on
|
||
its own thread, the UI renders in a transparent always-on-top overlay
|
||
window. Threading model was thread-per-subsystem with crossbeam channels —
|
||
no async, just message passing.
|
||
|
||
### The msg-level bug
|
||
|
||
The very first run crashed with `libmpv init: mpv: option error`. The
|
||
overlay window appeared with a red error toast and a GNOME "Force Quit?"
|
||
dialog — the whole app hung.
|
||
|
||
It took a C probe to find the culprit: `msg-level=warn` is rejected by
|
||
libmpv 2.x with `MPV_ERROR_OPTION_ERROR`. The `msg-level` parser requires
|
||
`module=level` form — a bare level string like `warn` was accepted by older
|
||
mpv builds but is now an error. The fix was `msg-level=all=warn`.
|
||
|
||
**Lesson:** Always validate libmpv option strings against the actual library
|
||
version. The mpv docs are a reference, not a contract — behavior changes
|
||
between major versions.
|
||
|
||
### The 1-second hang
|
||
|
||
There was a secondary bug hiding behind the option error. `engine.start()`
|
||
polled a shared `Mutex<Option<Arc<MpvHandle>>>` for up to 1 second waiting
|
||
for the engine thread to publish its handle. When libmpv init failed, the
|
||
engine thread exited without publishing, so the main thread blocked
|
||
pointlessly for 1 second while the window was already non-responsive — that's
|
||
what triggered GNOME's "Force Quit?" dialog.
|
||
|
||
The fix was a one-shot channel: the engine thread sends `Ok(handle)` or
|
||
`Err(message)` the moment init completes (success or failure). `start()`
|
||
returns as soon as it gets the message — never longer than the libmpv init
|
||
time.
|
||
|
||
---
|
||
|
||
## v0.2 — The winit panic (the outer_size bug)
|
||
|
||
With the option error fixed, the next run panicked inside winit:
|
||
|
||
```
|
||
thread 'main' panicked at winit-0.30.13/src/platform_impl/linux/x11/window.rs:318:41:
|
||
called `Result::unwrap()` on an `Err` value: TryFromIntError(PosOverflow)
|
||
```
|
||
|
||
Line 318 was `dimensions.1.try_into().unwrap()` — winit converting the
|
||
window height from `u32` to `u16` (X11's `CreateWindow` protocol uses
|
||
`u16` width/height). `PosOverflow` meant the value was bigger than 65535.
|
||
|
||
The value came from `video.outer_size()`, which queries the window
|
||
manager's `_NET_FRAME_EXTENTS` property. After libmpv attached to the
|
||
video window via `wid`, Xfwm4 started returning garbage extents. The bogus
|
||
extents got `saturating_add`-ed to the inner size, capping at `u32::MAX`,
|
||
which then overflowed the `u32 → u16` cast.
|
||
|
||
### The fix
|
||
|
||
Two changes:
|
||
|
||
1. **Use `inner_size()` / `inner_position()` instead of `outer_size()` /
|
||
`outer_position()`.** These query `XGetGeometry` / `XTranslateCoordinates`
|
||
directly, never the WM-supplied frame extents. They're robust against
|
||
the libmpv/WM race.
|
||
|
||
2. **Hard clamp dimensions to 16384.** Even if a bogus value sneaks through,
|
||
the clamp prevents the overflow panic.
|
||
|
||
**Lesson:** WM-supplied properties are unreliable. libmpv attaching to a
|
||
window changes its properties in ways the WM doesn't always track correctly.
|
||
Prefer X11 core protocol queries over EWMH hints when the WM might be
|
||
confused.
|
||
|
||
---
|
||
|
||
## v0.3 — Feature build-out (loop, speed, A/B markers, subtitles)
|
||
|
||
With playback stable, I built out the feature set. The design principle
|
||
was: every feature should work via both UI (menu/button) and keyboard, and
|
||
the UI should never block the engine.
|
||
|
||
### Loop modes
|
||
|
||
mpv has two separate properties: `loop-file` (repeat current file) and
|
||
`loop-playlist` (repeat entire playlist). I modeled this as a `LoopMode`
|
||
enum (`Off` / `File` / `Playlist`) with a `cycle()` method — click the
|
||
loop button to cycle through the three states.
|
||
|
||
### Speed control
|
||
|
||
Speed was straightforward — `mpv speed` property, observed so the UI
|
||
stays in sync. I added both preset buttons (0.5×, 1×, 1.5×, 2×) and a
|
||
fine-grained slider (0.25×–4×). The slider uses a local `speed_drag` value
|
||
during dragging to avoid fighting with the engine's property-change echo.
|
||
|
||
### A/B markers
|
||
|
||
This is where I learned that mpv's `ab-loop` is not a property — it's a
|
||
**command**. `mpv_set_property_string("ab-loop", "yes")` returns
|
||
`MPV_ERROR_PROPERTY_NOT_FOUND`. The actual semantics:
|
||
|
||
- `ab-loop-a` and `ab-loop-b` are properties (set to a timestamp or `"no"`)
|
||
- When both are set to non-`"no"` values, mpv loops between them automatically
|
||
- There's no separate "enable" flag
|
||
|
||
So `ToggleMarkerLoop` became: if turning on, require both markers set; if
|
||
turning off, clear both markers (which actually stops the loop). The
|
||
`marker_loop_enabled` flag is purely for UI display.
|
||
|
||
### Audio + subtitle tracks
|
||
|
||
I refactored `AudioTrack` into a unified `Track` struct (with `kind: "audio"
|
||
| "sub"` and a `forced` flag for subtitle tracks). The engine's
|
||
`refresh_audio_tracks` function (now conceptually "refresh all tracks")
|
||
enumerates `track-list/N/*` for each track N, splits them into audio and
|
||
subtitle vectors, and publishes both in `PlaybackState`.
|
||
|
||
For subtitle visibility, I observed `sub-visibility` (a flag property) so
|
||
the "Show Subtitles" checkmark stays in sync. Selecting a track via
|
||
`SetSubtitleTrack` also forces visibility on — selecting a track you can't
|
||
see is a UX dead end.
|
||
|
||
---
|
||
|
||
## v0.4 — The menu that wouldn't lay out horizontally
|
||
|
||
This was the most embarrassing bug in the project. I added a File menu using
|
||
`egui::menu::menu_button`, and it worked — but the menu buttons stacked
|
||
vertically instead of horizontally. I wanted a horizontal menu bar
|
||
(File | Playback | Audio | Subtitles across the top), not a vertical stack.
|
||
|
||
### First attempt (wrong)
|
||
|
||
I wrapped everything in `ui.vertical()`, which made it... still vertical.
|
||
|
||
### Second attempt (wrong)
|
||
|
||
I removed the `ui.vertical()` wrapper. Still vertical. The problem wasn't
|
||
the wrapper — it was that `egui::Area::show()` gives you a `ui` with
|
||
`Layout::TopDown` (vertical) by default. Every `ui.menu_button()` call
|
||
stacked on top of the previous one.
|
||
|
||
### Final fix
|
||
|
||
Two changes:
|
||
|
||
1. **Use `egui::Frame` for the background.** `Frame::none().fill(...).show(
|
||
ui, |ui| { ... })` auto-sizes to its content and paints the fill behind
|
||
the widgets. No manual painting needed.
|
||
|
||
2. **Wrap menu buttons in `ui.horizontal()`.** This overrides the Area's
|
||
default vertical layout and lays out buttons left-to-right.
|
||
|
||
**Lesson:** egui's layout defaults are not always what you want. `Area` is
|
||
vertical by default; if you want horizontal, you must explicitly ask for it.
|
||
And don't try to paint backgrounds manually before widgets exist — use
|
||
`Frame`, which handles sizing for you.
|
||
|
||
---
|
||
|
||
## v0.5 — Overlay covering file dialogs
|
||
|
||
After the menu layout was fixed, the user reported that file dialogs were
|
||
covered by the overlay window. The overlay was created with
|
||
`WindowLevel::AlwaysOnTop`, which meant it stayed above everything —
|
||
including the zenity file picker.
|
||
|
||
### The half-fix (v0.5)
|
||
|
||
I added a `dialogs_open` counter and an `update_overlay_window_level()`
|
||
method that drops the overlay to `WindowLevel::Normal` while any dialog is
|
||
open, then restores `AlwaysOnTop` when all dialogs close.
|
||
|
||
But I only called `update_overlay_window_level()` when dialogs *close*
|
||
(inside `poll_dialog_results`). I forgot to call it when dialogs *open*. So
|
||
the overlay stayed `AlwaysOnTop` the entire time a dialog was open.
|
||
|
||
### The full fix (v0.6)
|
||
|
||
Added the call right before spawning the worker threads. The order is
|
||
critical — `update_overlay_window_level()` must run BEFORE
|
||
`spawn_dialog_worker()` because the dialog opens immediately in the worker
|
||
thread. Now the overlay drops to `Normal` before the zenity process starts,
|
||
so the picker appears on top.
|
||
|
||
---
|
||
|
||
## v0.6 — Polish and documentation
|
||
|
||
This version focused on:
|
||
|
||
### Dark grey background when no video loaded
|
||
|
||
v0.5 cleared the overlay to transparent. With no video loaded, this meant
|
||
the overlay showed the desktop through the window — confusing and ugly.
|
||
v0.6 makes the clear color conditional: `#1a1a1d` (matching the VLC dark
|
||
theme bg) when `state.path.is_none()`, transparent when a file is loaded so
|
||
libmpv's video shows through.
|
||
|
||
### A-B loop video export
|
||
|
||
The original "Export Markers" feature exported marker *metadata* (A/B
|
||
timestamps to a .txt or .json file). The user clarified: they wanted to
|
||
export the actual *video segment* between A and B. Completely different
|
||
feature.
|
||
|
||
I added "Export A-B Loop Video...", which reads A/B markers + file path
|
||
from engine state, opens a save dialog, then spawns ffmpeg to render the
|
||
clip. ffmpeg must be in PATH. The command re-encodes video (libx264, CRF 18)
|
||
for frame accuracy and maximum compatibility.
|
||
|
||
### Documentation
|
||
|
||
v0.6 adds proper documentation: README.md, QUICKSTART.md, and this BLOG.md.
|
||
|
||
---
|
||
|
||
## v1.0 — Production readiness
|
||
|
||
The jump to v1.0 was driven by a quality assurance pass and several rounds
|
||
of bug fixing. Here's what happened:
|
||
|
||
### The QA audit
|
||
|
||
A senior QA team audited the codebase against PEP 868, POSIX, SEI CERT, and
|
||
MISRA coding standards. The main findings:
|
||
|
||
1. **Nested-if / branch-heavy code** → refactored to `const TABLE` lookups
|
||
(log-level parser, loop-mode projector, video-rotate clamp, EndFileReason
|
||
mapper, keyboard dispatcher).
|
||
2. **`for`/`while` loops** → replaced with iterators (`try_iter`,
|
||
`filter_map().partition()`, `[-1.0, 0.0, 1.0].iter().for_each()`).
|
||
3. **Dead code** → deleted `input.rs` (fully shadowed by `main.rs`'s
|
||
keyboard handler), `_silence_warn` hack, `let _ = rect; // suppress unused`.
|
||
4. **Decisive-language cleanup** → purged "restored/brought back/falls
|
||
back/kept for compatibility/non-breaking/previously" from all code
|
||
comments and docs. Everything rewritten as decisive present-tense
|
||
design statements.
|
||
5. **Step-down logic** → the file dialog backends (zenity/kdialog/rfd)
|
||
refactored into `or_else` chains following the Unix step-down philosophy.
|
||
|
||
### Compile errors along the way
|
||
|
||
Three compile errors surfaced during the QA refactor, each teaching a lesson:
|
||
|
||
1. **`&u16` vs `u16`** — when matching on `&Cmd`, every captured field is a
|
||
reference. `SetVideoRotate(deg)` binds `deg` as `&u16`, so `.then_some(deg)`
|
||
produces `Option<&u16>`, and `.unwrap_or(0)` fails. Fixed with `*deg`.
|
||
|
||
2. **Stray paren** — a `)` left at the end of a format string in
|
||
`extract_x11_xid`. The bracket-balance audit (`scripts/audit_brackets.py`)
|
||
now catches this in milliseconds.
|
||
|
||
3. **`wgpu::SurfaceError::Suboptimal`** — doesn't exist in wgpu 22 (folded
|
||
into `Outdated`). The error recovery path was updated.
|
||
|
||
### Complete hotkey coverage
|
||
|
||
Every keyboard shortcut now has a menu entry and/or a control-bar button.
|
||
Menu items display their shortcut in parentheses (e.g. `Play / Pause (Space)`).
|
||
The keyboard dispatch table was extracted to `keymap.rs`, decoupled from
|
||
`player-ui` by taking `&PlaybackState` instead of `&Option<OverlayRenderer>`.
|
||
|
||
---
|
||
|
||
## v1.0.1–v1.0.4 — The resize/move "mirrored desktop" saga
|
||
|
||
After v1.0, the user reported that dragging or resizing the window caused
|
||
the entire UI to corrupt with "mirrored desktop" artifacts — the window
|
||
showed stale framebuffer content from behind it.
|
||
|
||
This took four rounds to fix completely. Each round revealed a deeper root
|
||
cause.
|
||
|
||
### Round 1: Boolean flag (v1.0.1)
|
||
|
||
First attempt: a `surface_just_reconfigured` boolean flag that forced the
|
||
overlay to clear opaque for one frame after every surface reconfiguration.
|
||
|
||
**Why it failed:** during a window *move* (not resize), the overlay's size
|
||
doesn't change, so `resize()` never fires and the flag never gets set. The
|
||
overlay stays transparent throughout the drag.
|
||
|
||
### Round 2: Timestamp grace period (v1.0.2)
|
||
|
||
Second attempt: replaced the boolean with `force_opaque_until: Option<Instant>`
|
||
— a timestamp set to `now + 150ms` on every `Moved` or `Resized` event.
|
||
|
||
**Why it failed:** the render was being skipped (surface `Outdated`), so the
|
||
old transparent frame stayed visible even with the grace period set.
|
||
|
||
### Round 3: X11 background pixel (v1.0.3)
|
||
|
||
Third attempt — I found the actual root cause. winit creates windows with
|
||
`background_pixel = None`. This means the X server does NOT fill the window
|
||
on resize — it shows whatever is in the framebuffer (stale content, GPU
|
||
garbage). That's the "mirror."
|
||
|
||
**Fix:** `XSetWindowBackground` via FFI, setting the video window's
|
||
background to `#141416` (dark grey, matching the VLC theme). Called in
|
||
`setup()` before libmpv attaches via `wid`. Now during a resize, the X
|
||
server fills the video window with dark grey instead of garbage.
|
||
|
||
Also fixed: overlay set to `WindowLevel::AlwaysOnTop` (it had regressed to
|
||
`Normal`), and `resize()` made a no-op when the size hasn't changed (to
|
||
avoid destroying the framebuffer on every `Moved` event).
|
||
|
||
### Round 4: wgpu presentation fixes (v1.0.4)
|
||
|
||
The runtime logs revealed the final piece:
|
||
|
||
```
|
||
WARN Unrecognized present mode 1000361000
|
||
WARN EGL says it can present to the window but not natively
|
||
WARN Detected a linear (sRGBA aware) framebuffer Bgra8UnormSrgb
|
||
```
|
||
|
||
**Fixes:**
|
||
1. **Reconfigure + retry on `Outdated`** (revert Round 2's skip-on-Outdated).
|
||
The render path now ALWAYS produces a fresh frame — no stale transparent
|
||
frames left visible.
|
||
2. **Force `PresentMode::Fifo`** — Mailbox was broken on this X11/EGL setup.
|
||
3. **Force `CompositeAlphaMode::Auto`** — PreMultiplied caused compositing
|
||
artifacts on Xfwm4.
|
||
4. **Prefer non-sRGB surface format** — egui warned about sRGB framebuffers.
|
||
5. **Handle the overlay's own `Moved` event** — when the overlay moves
|
||
(because we called `set_outer_position()`), its surface can become stale.
|
||
|
||
**The lesson:** all previous fixes managed the overlay's transparency
|
||
*timing* but assumed the render would actually *succeed*. When the surface
|
||
was `Outdated`, the render was skipped and the old transparent frame stayed.
|
||
The fix was to always reconfigure + retry, ensuring a fresh frame is always
|
||
painted.
|
||
|
||
---
|
||
|
||
## v1.0.5 — In-UI file browser (eliminating the pop-under)
|
||
|
||
The final issue: the overlay's `AlwaysOnTop` setting meant external file
|
||
dialogs (zenity/kdialog/rfd) popped *under* the overlay — invisible to the
|
||
user. Dropping to `Normal` during dialogs was a half-fix that reintroduced
|
||
the resize/move bugs.
|
||
|
||
**The fix:** replace all external file dialogs with an in-UI file browser
|
||
drawn inside the egui overlay. This eliminates the z-order conflict entirely
|
||
— the browser IS part of the overlay, so it's always visible and always on
|
||
top of the video window.
|
||
|
||
### Implementation
|
||
|
||
New module `crates/player-ui/src/file_dialog.rs` — a complete file browser:
|
||
- Directory listing with sorted entries (dirs first, then files)
|
||
- Extension filtering per dialog kind (media, subtitle, marker, video export)
|
||
- Single-select, multi-select (Load Playlist), and save modes (filename input)
|
||
- Path bar with Up / Home navigation
|
||
- Modal overlay: dims the background, blocks interaction with controls behind it
|
||
- Seven dialog kinds: LoadFile, LoadFolder, LoadPlaylist, SaveMarkers,
|
||
LoadSubtitle, ImportMarkers, ExportVideo
|
||
|
||
Menu buttons now open the in-UI dialog directly instead of sending magic
|
||
Cmd strings. The `dialogs.rs` module (zenity/kdialog/rfd) was deleted
|
||
entirely, and the `rfd` dependency was removed from `Cargo.toml`.
|
||
|
||
### The borrow-after-move bug
|
||
|
||
First compile of the in-UI browser had a classic Rust ownership bug: I
|
||
cleared `self.file_dialog = None` before calling
|
||
`handle_file_dialog_result()`, but that function tried to read the kind from
|
||
`self.file_dialog.as_ref()` — which was already `None`. No file ever loaded.
|
||
|
||
**Fix:** capture `dialog.kind.clone()` before clearing the dialog, and pass
|
||
it as a parameter to `handle_file_dialog_result(res, kind)`.
|
||
|
||
### The channel deadlock
|
||
|
||
The `LoadFolder` and `LoadPlaylist` handlers sent dozens of `Cmd::LoadFile`
|
||
commands in a tight loop using blocking `send()`. The engine's command
|
||
channel is bounded at 64 slots — if the engine was busy loading the first
|
||
file, the channel filled up and `send()` blocked, freezing the UI.
|
||
|
||
**Fix:** switched to `try_send()` (non-blocking). If the channel is full,
|
||
remaining files are skipped. The info toast shows `"Loaded 3/47 files"`.
|
||
|
||
---
|
||
|
||
## Design decisions worth recording
|
||
|
||
### Why thread-per-subsystem, not async?
|
||
|
||
The engine owns libmpv on a dedicated thread. Commands flow UI → engine via
|
||
a `crossbeam` channel; events flow engine → UI via another channel + shared
|
||
`Arc<Mutex<PlaybackState>>`. No async runtime, no futures, no tokio.
|
||
|
||
Reasons:
|
||
|
||
1. **libmpv is not async-friendly.** It's a C library with a blocking event
|
||
loop. Wrapping it in async would add complexity without benefit.
|
||
2. **Debuggability.** A thread-per-subsystem model has a simple call stack
|
||
— you can `gdb attach` and see exactly what each thread is doing. Async
|
||
stacks are spread across executors and harder to reason about.
|
||
3. **Backpressure.** Bounded channels give natural backpressure. If the UI
|
||
can't keep up with events, the engine blocks on send rather than dropping
|
||
or queuing unboundedly.
|
||
|
||
### Why a separate overlay window?
|
||
|
||
The overlay is a transparent, borderless, `AlwaysOnTop` window sized to
|
||
exactly cover the video window. egui + wgpu renders controls into it. The
|
||
video window is handed to libmpv via `wid` — libmpv renders directly into
|
||
it using Vulkan/OpenGL.
|
||
|
||
The two-window approach has one significant downside: mouse events don't
|
||
pass through the overlay to the video window. Clicking outside the control
|
||
bar does nothing (doesn't toggle pause). This is a known limitation;
|
||
planned fix is the libmpv render-context API (single-window compositing).
|
||
|
||
### Why in-UI file dialogs instead of zenity/kdialog/rfd?
|
||
|
||
The overlay is `AlwaysOnTop`, which means external file dialogs (separate
|
||
OS windows) pop under the overlay — invisible to the user. Drawing the file
|
||
browser inside the egui overlay eliminates this z-order conflict entirely.
|
||
|
||
The downside is that the in-UI browser doesn't have all the features of a
|
||
native file dialog (bookmarks, recent files, search). But it's always
|
||
visible, always on top of the video window, and has no external dependencies.
|
||
|
||
### Why GPL-2.0?
|
||
|
||
ferret links against libmpv, which is LGPL-2.1+ (or GPL-2+ at your option).
|
||
Dynamic linking keeps ferret's license compatible with either. I chose
|
||
GPL-2.0-or-later because:
|
||
|
||
1. **It's a media player.** The media player ecosystem has a tradition of
|
||
copyleft (VLC is GPL-2.0+, mpv is GPL-2.0+).
|
||
2. **It prevents proprietary forks.** If someone builds on ferret, they have
|
||
to share their changes.
|
||
3. **"Or later" clause** allows future compatibility with GPL-3.0 if needed.
|
||
|
||
### Why hard-cap volume at 100%?
|
||
|
||
VLC's software amplification (going above 100%) conflicts with PipeWire's
|
||
logarithmic volume curves. The result is audio that clips or distorts at
|
||
high volumes. By hard-capping at 100% and mapping the slider 1:1 to the
|
||
system sink, ferret avoids the clash entirely. If you need louder, turn up
|
||
your system volume — that's what it's there for.
|
||
|
||
### Why force `PresentMode::Fifo`?
|
||
|
||
During the resize/move debugging, the runtime logs showed "Unrecognized
|
||
present mode 1000361000" — wgpu was trying to use Mailbox, which the
|
||
X11/EGL driver doesn't support. This caused broken presentation during
|
||
window operations. `Fifo` (vsync) is the WebGPU-required mode and works
|
||
everywhere. The slight latency cost is worth the stability.
|
||
|
||
---
|
||
|
||
## What's next
|
||
|
||
The roadmap is in the README. The big one is Wayland support via
|
||
`mpv_render_context`, which would eliminate the X11 `wid` dependency and
|
||
unblock macOS/Windows. After that: config file, playlist UI, MPRIS.
|
||
|
||
ferret is a hobby project — I work on it when I have time. Patches are
|
||
welcome at <http://git.dcos.net/dcosnet/ferret>.
|
||
|
||
— Jeremy Anderson, 2026
|