ferret/BLOG.md

504 lines
20 KiB
Markdown
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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