From cf2224c9646a3b420a5b2826a5bde856216f33ca Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Wed, 29 Jul 2026 23:46:01 -0400 Subject: [PATCH] =?UTF-8?q?ferret=20is=20a=20desktop=20video=20player=20bu?= =?UTF-8?q?ilt=20on=20libmpv,=20with=20a=20custom=20egui=20overlay=20UI=20?= =?UTF-8?q?rendered=20in=20a=20transparent=20always-on-top=20window.=20The?= =?UTF-8?q?=20engine=20runs=20on=20its=20own=20thread;=20the=20UI=20never?= =?UTF-8?q?=20blocks=20playback.=20File=20dialogs=20are=20drawn=20inside?= =?UTF-8?q?=20the=20overlay=20=E2=80=94=20no=20external=20dialog=20process?= =?UTF-8?q?es,=20no=20z-order=20conflicts.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 24 + BLOG.md | 503 +++++ CONTRIBUTING.md | 168 ++ Cargo.lock | 2784 +++++++++++++++++++++++++++ Cargo.toml | 63 + LICENSE | 358 ++++ QUICKSTART.md | 241 +++ README.md | 591 ++++++ clippy.toml | 32 + crates/mpv-bindings/Cargo.toml | 22 + crates/mpv-bindings/build.rs | 83 + crates/mpv-bindings/src/command.rs | 108 ++ crates/mpv-bindings/src/error.rs | 96 + crates/mpv-bindings/src/event.rs | 212 ++ crates/mpv-bindings/src/handle.rs | 310 +++ crates/mpv-bindings/src/lib.rs | 41 + crates/mpv-bindings/src/property.rs | 99 + crates/player-app/Cargo.toml | 27 + crates/player-app/src/keymap.rs | 101 + crates/player-app/src/main.rs | 694 +++++++ crates/player-app/src/windows.rs | 138 ++ crates/player-core/Cargo.toml | 23 + crates/player-core/src/cmd.rs | 314 +++ crates/player-core/src/engine.rs | 1184 ++++++++++++ crates/player-core/src/error.rs | 21 + crates/player-core/src/event.rs | 80 + crates/player-core/src/lib.rs | 45 + crates/player-core/src/options.rs | 109 ++ crates/player-core/src/state.rs | 190 ++ crates/player-ui/Cargo.toml | 25 + crates/player-ui/src/app.rs | 1490 ++++++++++++++ crates/player-ui/src/file_dialog.rs | 488 +++++ crates/player-ui/src/icons.rs | 407 ++++ crates/player-ui/src/lib.rs | 24 + crates/player-ui/src/renderer.rs | 335 ++++ crates/player-ui/src/theme.rs | 101 + crates/player-ui/src/widgets.rs | 107 + scripts/audit_brackets.py | 151 ++ scripts/audit_deref.py | 152 ++ scripts/build.sh | 90 + scripts/setup-libmpv.sh | 122 ++ 41 files changed, 12153 insertions(+) create mode 100755 .gitignore create mode 100755 BLOG.md create mode 100755 CONTRIBUTING.md create mode 100755 Cargo.lock create mode 100755 Cargo.toml create mode 100755 LICENSE create mode 100755 QUICKSTART.md create mode 100755 README.md create mode 100755 clippy.toml create mode 100755 crates/mpv-bindings/Cargo.toml create mode 100755 crates/mpv-bindings/build.rs create mode 100755 crates/mpv-bindings/src/command.rs create mode 100755 crates/mpv-bindings/src/error.rs create mode 100755 crates/mpv-bindings/src/event.rs create mode 100755 crates/mpv-bindings/src/handle.rs create mode 100755 crates/mpv-bindings/src/lib.rs create mode 100755 crates/mpv-bindings/src/property.rs create mode 100755 crates/player-app/Cargo.toml create mode 100755 crates/player-app/src/keymap.rs create mode 100755 crates/player-app/src/main.rs create mode 100755 crates/player-app/src/windows.rs create mode 100755 crates/player-core/Cargo.toml create mode 100755 crates/player-core/src/cmd.rs create mode 100755 crates/player-core/src/engine.rs create mode 100755 crates/player-core/src/error.rs create mode 100755 crates/player-core/src/event.rs create mode 100755 crates/player-core/src/lib.rs create mode 100755 crates/player-core/src/options.rs create mode 100755 crates/player-core/src/state.rs create mode 100755 crates/player-ui/Cargo.toml create mode 100755 crates/player-ui/src/app.rs create mode 100644 crates/player-ui/src/file_dialog.rs create mode 100755 crates/player-ui/src/icons.rs create mode 100755 crates/player-ui/src/lib.rs create mode 100755 crates/player-ui/src/renderer.rs create mode 100755 crates/player-ui/src/theme.rs create mode 100755 crates/player-ui/src/widgets.rs create mode 100755 scripts/audit_brackets.py create mode 100755 scripts/audit_deref.py create mode 100755 scripts/build.sh create mode 100755 scripts/setup-libmpv.sh diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..17bf2de --- /dev/null +++ b/.gitignore @@ -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 diff --git a/BLOG.md b/BLOG.md new file mode 100755 index 0000000..c8b99da --- /dev/null +++ b/BLOG.md @@ -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>>` 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`. + +--- + +## 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` +— 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>`. 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 . + +— Jeremy Anderson, 2026 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100755 index 0000000..1286714 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,168 @@ +# Contributing to ferret + +Patches are welcome at . + +## 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`; the entry point +is a flat chain. + +```rust +// PREFERRED: step-down chain +pub fn pick_file(title: &str, filters: &[(&str, &[&str])]) -> Option { + 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 { + 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_: 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_, 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. diff --git a/Cargo.lock b/Cargo.lock new file mode 100755 index 0000000..8a8bef1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2784 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.19", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0481a0e032742109b1133a095184ee93d88f3dc9e0d28a5d033dc77a073f44f" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c54ff287cfc0a34f38a6b832ea1bd8e448a330b3e40a50859e6488bee07f22" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "d3d12" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdbd1f579714e3c809ebd822c81ef148b1ceaeb3d535352afc73fd0c4c6a0017" +dependencies = [ + "bitflags 2.13.1", + "libloading", + "winapi", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775cfde491852059e386c4e1deb4aef381c617dc364184c6f6afee99b87c402b" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" +dependencies = [ + "ahash", + "emath", + "epaint", + "nohash-hasher", +] + +[[package]] +name = "egui-wgpu" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00fd5d06d8405397e64a928fa0ef3934b3c30273ea7603e3dc4627b1f7a1a82" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "thiserror 1.0.69", + "type-map", + "web-time", + "wgpu", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "emath" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fe0049ce51d0fb414d029e668dd72eb30bc2b739bf34296ed97bd33df544f3" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "epaint" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "nohash-hasher", + "parking_lot", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483440db0b7993cf77a20314f08311dbe95675092405518c0677aa08c151a3ea" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd4240fc91d3433d5e5b0fc5b67672d771850dc19bbee03c1381e19322803d7" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.13.1", + "com", + "libc", + "libloading", + "thiserror 1.0.69", + "widestring", + "winapi", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mpv-bindings" +version = "1.0.0" +dependencies = [ + "bindgen", + "cc", + "libc", + "pkg-config", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "naga" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg_aliases 0.1.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2", + "dispatch", + "libc", + "objc2", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "player-app" +version = "1.0.0" +dependencies = [ + "anyhow", + "crossbeam-channel", + "egui", + "egui-wgpu", + "mpv-bindings", + "player-core", + "player-ui", + "pollster", + "raw-window-handle", + "tracing", + "tracing-subscriber", + "wgpu", + "winit", +] + +[[package]] +name = "player-core" +version = "1.0.0" +dependencies = [ + "anyhow", + "crossbeam-channel", + "libc", + "mpv-bindings", + "parking_lot", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "player-ui" +version = "1.0.0" +dependencies = [ + "anyhow", + "crossbeam-channel", + "egui", + "egui-wgpu", + "mpv-bindings", + "player-core", + "pollster", + "raw-window-handle", + "tracing", + "wgpu", + "winit", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433" +dependencies = [ + "arrayvec", + "cfg_aliases 0.1.1", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.13.1", + "cfg_aliases 0.1.1", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9d91f0e2c4b51434dfa6db77846f2793149d8e73f800fa2e41f52b8eac3c5d" +dependencies = [ + "bitflags 2.13.1", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases 0.2.2", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100755 index 0000000..66024e0 --- /dev/null +++ b/Cargo.toml @@ -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 "] +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 diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..668d437 --- /dev/null +++ b/LICENSE @@ -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. + + + Copyright (C) + + 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. + + , 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. diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100755 index 0000000..d5e6cb9 --- /dev/null +++ b/QUICKSTART.md @@ -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 + +--- + +## 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. diff --git a/README.md b/README.md new file mode 100755 index 0000000..1da5431 --- /dev/null +++ b/README.md @@ -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::(64)` — bounded for natural backpressure. +- **Event channel** (engine → UI): `bounded::(256)` — single consumer. +- **State snapshot**: `Arc>` (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 \ + -i \ + -t \ + -c:v libx264 -preset fast -crf 18 \ + -c:a aac -b:a 192k \ + +``` + +`-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=`, +`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 . + +--- + +## 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. diff --git a/clippy.toml b/clippy.toml new file mode 100755 index 0000000..e8817ea --- /dev/null +++ b/clippy.toml @@ -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 diff --git a/crates/mpv-bindings/Cargo.toml b/crates/mpv-bindings/Cargo.toml new file mode 100755 index 0000000..7abc41b --- /dev/null +++ b/crates/mpv-bindings/Cargo.toml @@ -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" diff --git a/crates/mpv-bindings/build.rs b/crates/mpv-bindings/build.rs new file mode 100755 index 0000000..01f2b91 --- /dev/null +++ b/crates/mpv-bindings/build.rs @@ -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"); +} diff --git a/crates/mpv-bindings/src/command.rs b/crates/mpv-bindings/src/command.rs new file mode 100755 index 0000000..c6aca00 --- /dev/null +++ b/crates/mpv-bindings/src/command.rs @@ -0,0 +1,108 @@ +//! Typed command builder for libmpv. +//! +//! libmpv commands take an argv-style array of strings, terminated by NULL. +//! Example: `["loadfile", "/path/to/video.mp4", "replace"]` + +use std::ffi::CString; +use std::os::raw::c_char; +use std::ptr; + +use crate::error::MpvResult; + +/// A command to be sent to libmpv. +pub struct Command { + // We hold the CStrings so the pointers stay valid for the duration of the + // mpv_command call. + args: Vec, +} + +impl Command { + pub fn new() -> Self { + Self { args: Vec::new() } + } + + /// Add a string argument. + pub fn arg(mut self, s: impl Into) -> MpvResult { + 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 [replace|append]` + pub fn loadfile(path: impl Into, mode: LoadMode) -> MpvResult { + Command::new() + .arg("loadfile")? + .arg(path)? + .arg(match mode { + LoadMode::Replace => "replace", + LoadMode::Append => "append", + LoadMode::AppendPlay => "append-play", + }) + } + + /// `seek [relative|absolute|relative-percent|absolute-percent] [default|exact|keyframes]` + pub fn seek(target_secs: f64, mode: SeekMode, flags: SeekFlags) -> MpvResult { + 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 } diff --git a/crates/mpv-bindings/src/error.rs b/crates/mpv-bindings/src/error.rs new file mode 100755 index 0000000..dc08026 --- /dev/null +++ b/crates/mpv-bindings/src/error.rs @@ -0,0 +1,96 @@ +//! Error type for libmpv FFI calls. + +use thiserror::Error; + +/// A libmpv error code, mapped from `mpv_error` integers. +/// +/// See: +#[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 = Result; + +use crate::sys; diff --git a/crates/mpv-bindings/src/event.rs b/crates/mpv-bindings/src/event.rs new file mode 100755 index 0000000..0db928b --- /dev/null +++ b/crates/mpv-bindings/src/event.rs @@ -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 }, + /// 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 { + 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, + } +} diff --git a/crates/mpv-bindings/src/handle.rs b/crates/mpv-bindings/src/handle.rs new file mode 100755 index 0000000..7139006 --- /dev/null +++ b/crates/mpv-bindings/src/handle.rs @@ -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 { + // 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 `--=` + /// 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> { + 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 { + 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 { + 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 { + 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> { + // 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, value: impl Into) -> 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 { + 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) + } +} diff --git a/crates/mpv-bindings/src/lib.rs b/crates/mpv-bindings/src/lib.rs new file mode 100755 index 0000000..7610933 --- /dev/null +++ b/crates/mpv-bindings/src/lib.rs @@ -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` +//! - `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"); diff --git a/crates/mpv-bindings/src/property.rs b/crates/mpv-bindings/src/property.rs new file mode 100755 index 0000000..b3fb2ae --- /dev/null +++ b/crates/mpv-bindings/src/property.rs @@ -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, v: impl Into) -> Self { + Self { name: name.into(), value: PropValue::Str(v.into()) } + } + pub fn flag(name: impl Into, v: bool) -> Self { + Self { name: name.into(), value: PropValue::Flag(v) } + } + pub fn int(name: impl Into, v: i64) -> Self { + Self { name: name.into(), value: PropValue::Int(v) } + } + pub fn double(name: impl Into, 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 { + match self.value { + PropValue::Flag(b) => Ok(b), + _ => Err(MpvError::PropertyFormat), + } + } + pub fn as_i64(&self) -> MpvResult { + match self.value { + PropValue::Int(i) => Ok(i), + _ => Err(MpvError::PropertyFormat), + } + } + pub fn as_f64(&self) -> MpvResult { + match self.value { + PropValue::Double(d) => Ok(d), + _ => Err(MpvError::PropertyFormat), + } + } +} diff --git a/crates/player-app/Cargo.toml b/crates/player-app/Cargo.toml new file mode 100755 index 0000000..bf16bad --- /dev/null +++ b/crates/player-app/Cargo.toml @@ -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 } diff --git a/crates/player-app/src/keymap.rs b/crates/player-app/src/keymap.rs new file mode 100755 index 0000000..4e40465 --- /dev/null +++ b/crates/player-app/src/keymap.rs @@ -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 { + 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)) +} diff --git a/crates/player-app/src/main.rs b/crates/player-app/src/main.rs new file mode 100755 index 0000000..1378a51 --- /dev/null +++ b/crates/player-app/src/main.rs @@ -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 = 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 )"); + } + + 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, + windows: WindowManager, + engine: Option, + overlay: Option, + last_overlay_render: Instant, + overlay_mouse_pos: Option, + fullscreen: bool, + /// Channel for commands emitted by the overlay UI (forwarded to the engine). + cmd_tx: crossbeam_channel::Sender, + cmd_rx: crossbeam_channel::Receiver, + /// 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) -> Self { + let (cmd_tx, cmd_rx) = unbounded::(); + 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 { + let mut infos: Vec = 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) -> Result { + 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, 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> 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(()) +} + diff --git a/crates/player-app/src/windows.rs b/crates/player-app/src/windows.rs new file mode 100755 index 0000000..3774411 --- /dev/null +++ b/crates/player-app/src/windows.rs @@ -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>, + pub overlay: Option>, + #[allow(dead_code)] + pub cmd_rx: Option>, +} + +impl WindowManager { + pub fn new() -> Self { + Self { + video: None, + overlay: None, + cmd_rx: None, + } + } + + pub fn create_video_window(&self, event_loop: &ActiveEventLoop) -> Result { + 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, PhysicalSize) { + // 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, + ) -> Result { + 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> 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) + } +} diff --git a/crates/player-core/Cargo.toml b/crates/player-core/Cargo.toml new file mode 100755 index 0000000..614ed9a --- /dev/null +++ b/crates/player-core/Cargo.toml @@ -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 } diff --git a/crates/player-core/src/cmd.rs b/crates/player-core/src/cmd.rs new file mode 100755 index 0000000..b4b00e9 --- /dev/null +++ b/crates/player-core/src/cmd.rs @@ -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), + + // ---- Subtitle track selection -------------------------------------- + + /// Select a subtitle track by mpv track id (1-based). Pass `None` to + /// disable subtitles. (mpv `sid` property.) + SetSubtitleTrack(Option), + + /// 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 { + 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 { + Ok(mpv_bindings::command::Command::seek(target, mode, flags)?) +} diff --git a/crates/player-core/src/engine.rs b/crates/player-core/src/engine.rs new file mode 100755 index 0000000..0eb208f --- /dev/null +++ b/crates/player-core/src/engine.rs @@ -0,0 +1,1184 @@ +//! The PlayerEngine — owns a libmpv instance on a dedicated thread. +//! +//! Architecture: +//! +//! UI thread ──[Cmd]──▶ Engine thread ──[mpv_*()]──▶ libmpv +//! │ +//! └──[EngineEvent]──▶ UI thread +//! +//! The engine thread runs a single loop: +//! 1. Poll the Cmd channel (non-blocking) — apply any new commands. +//! 2. Call `mpv_wait_event(timeout=0.05)` — drain any pending libmpv events. +//! 3. Translate events to `EngineEvent`s and publish to the bus. +//! 4. Sleep briefly if nothing happened (to avoid busy-looping). +//! +//! The engine owns the `MpvHandle`. When the loop exits, the handle is dropped +//! and libmpv is torn down. + +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use crossbeam_channel::{bounded, Receiver, Sender}; +use parking_lot::Mutex; +use tracing::{debug, error, info, warn}; + +use mpv_bindings::event::{Event as MpvEvent, EventId, LogLevel}; +use mpv_bindings::handle::Builder as MpvBuilder; +use mpv_bindings::property::{Format, Property}; +use mpv_bindings::MpvHandle; + +use crate::cmd::{build_loadfile, build_seek, Cmd, LoopMode, MarkerExportFormat}; +use crate::error::{CoreError, CoreResult}; +use crate::event::{EngineEvent, EngineEventBus, EngineEventSender, EndReason}; +use crate::options::EngineOptions; +use crate::state::{PlaybackState, Track}; + +/// Tags for observed properties. We use these to look up which property +/// changed when we receive a PropertyChange event. +const PROP_TIME_POS: EventId = 1; +const PROP_DURATION: EventId = 2; +const PROP_PAUSE: EventId = 3; +const PROP_VOLUME: EventId = 4; +const PROP_MUTE: EventId = 5; +const PROP_PATH: EventId = 6; +const PROP_MEDIA_TITLE: EventId = 7; +const PROP_SPEED: EventId = 8; +const PROP_EOF_REACHED: EventId = 9; +const PROP_TRACK_LIST_COUNT: EventId = 10; +const PROP_AB_LOOP_A: EventId = 11; +const PROP_AB_LOOP_B: EventId = 12; +const PROP_AID: EventId = 13; +const PROP_SID: EventId = 14; +const PROP_SUB_VISIBILITY: EventId = 15; + +/// The engine. Construct with `PlayerEngine::new()`, then `start()`, then +/// issue commands via `send()`. Consume events via `take_event_receiver()`. +pub struct PlayerEngine { + /// Options captured at construction. The engine thread reads these once. + options: EngineOptions, + /// Channel for commands from UI to engine. + cmd_tx: Sender, + /// Receiver, consumed by `start()`. + cmd_rx: Option>, + /// Sender for events from engine to UI. The engine thread writes to this. + event_tx: EngineEventSender, + /// Receiver, handed out once via `take_event_receiver()`. + event_rx: Option>, + /// Latest state snapshot, shared with subscribers. + state: Arc>, + /// The engine thread handle (None until start()). + thread: Option>, + /// Wakeup handle so the engine can break out of mpv_wait_event on shutdown. + /// We hold an Arc separately so the main thread can call wakeup(). + handle: Option>, +} + +impl PlayerEngine { + /// Construct a new engine. Does NOT start the thread — call `start()` next. + pub fn new(options: EngineOptions) -> CoreResult { + let (cmd_tx, cmd_rx) = bounded::(64); + let (event_tx, event_rx) = bounded::(256); + let state = Arc::new(Mutex::new(PlaybackState::default())); + Ok(Self { + options, + cmd_tx, + cmd_rx: Some(cmd_rx), + event_tx, + event_rx: Some(event_rx), + state, + thread: None, + handle: None, + }) + } + + /// Spawn the engine thread. Returns `Ok` once the engine is ready, or + /// `Err` if libmpv failed to initialize. Bounds the caller's wait to the + /// libmpv init time — never longer — and surfaces any init failure as an + /// `Err`. + /// + /// Uses a one-shot channel for the init handshake: the engine thread + /// publishes `Ok(handle)` or `Err(message)` exactly once when its init + /// work completes, so `start()` blocks for exactly as long as libmpv + /// takes to come up. + pub fn start(&mut self) -> CoreResult<()> { + if self.thread.is_some() { + return Err(CoreError::EngineNotRunning); + } + let cmd_rx = self.cmd_rx.take().ok_or(CoreError::EngineNotRunning)?; + let options = self.options.clone(); + let event_tx = self.event_tx.clone(); + let state = self.state.clone(); + + let (init_tx, init_rx) = bounded::, String>>(1); + + let thread = thread::Builder::new() + .name("ferret-engine".into()) + .spawn(move || { + engine_main(options, cmd_rx, event_tx, state, init_tx); + }) + .map_err(|e| CoreError::Io(std::io::Error::new(e.kind(), e.to_string())))?; + + let init_result = init_rx + .recv() + .map_err(|_| CoreError::EngineStopped)?; + + match init_result { + Ok(handle) => { + self.handle = Some(handle); + self.thread = Some(thread); + Ok(()) + } + Err(msg) => { + // Reap the engine thread to release the JoinHandle. The error + // event has already been published on the event channel. + let _ = thread.join(); + Err(CoreError::Other(msg)) + } + } + } + + /// Send a command to the engine. Non-blocking; errors only if the channel + /// is full or the engine has exited. + pub fn send(&self, cmd: Cmd) -> CoreResult<()> { + self.cmd_tx.send(cmd).map_err(|_| CoreError::EngineStopped) + } + + /// Take the event receiver (one-shot; only one consumer is supported). + pub fn take_event_receiver(&mut self) -> Option> { + self.event_rx.take() + } + + /// Get a clone of the event sender. Single-receiver contract: with + /// crossbeam bounded channels, the sole consumer obtains the receiver via + /// `take_event_receiver()`. This accessor exposes the sender for internal + /// tooling that needs to publish into the same channel. + #[deprecated(note = "use take_event_receiver() instead")] + pub fn subscribe(&self) -> EngineEventSender { + self.event_tx.clone() + } + + /// Get a clone of the event bus (sender + shared state snapshot). + pub fn event_bus(&self) -> EngineEventBus { + EngineEventBus::new(self.event_tx.clone(), self.state.clone()) + } + + /// Snapshot the current playback state. + pub fn state(&self) -> PlaybackState { + self.state.lock().clone() + } + + /// Wake up the engine's `mpv_wait_event` call (used for shutdown). + pub fn wakeup(&self) { + if let Some(h) = &self.handle { + h.wakeup(); + } + } + + /// Shutdown the engine and wait for the thread to exit. + pub fn shutdown(&mut self) -> CoreResult<()> { + if let Some(h) = &self.handle { + h.wakeup(); + } + let _ = self.cmd_tx.send(Cmd::Shutdown); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + self.handle = None; + Ok(()) + } +} + +impl Drop for PlayerEngine { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} + +// ---- Engine main loop ---- + +fn engine_main( + options: EngineOptions, + cmd_rx: Receiver, + event_tx: EngineEventSender, + state: Arc>, + init_tx: Sender, String>>, +) { + let bus = EngineEventBus::new(event_tx.clone(), state.clone()); + + // Build the libmpv instance. + let mut builder = MpvBuilder::new().log_level(parse_log_level(&options.log_level)); + for (k, v) in options.to_mpv_options() { + builder = builder.option(k, v); + } + let mpv = match builder.build() { + Ok(h) => h, + Err(e) => { + let msg = format!("libmpv init: {e}"); + error!("{msg}"); + bus.send(EngineEvent::Error { message: msg.clone() }); + bus.send(EngineEvent::Shutdown); + let _ = init_tx.send(Err(msg)); + return; + } + }; + + // Observe properties we care about. + let observed = [ + (PROP_TIME_POS, "time-pos", Format::Double), + (PROP_DURATION, "duration", Format::Double), + (PROP_PAUSE, "pause", Format::Flag), + (PROP_VOLUME, "volume", Format::Double), + (PROP_MUTE, "mute", Format::Flag), + (PROP_PATH, "path", Format::String), + (PROP_MEDIA_TITLE, "media-title", Format::String), + (PROP_SPEED, "speed", Format::Double), + (PROP_EOF_REACHED, "eof-reached", Format::Flag), + // Track list size — fires when a file loads / unloads, prompting us + // to enumerate audio tracks via `track-list/N/...` properties. + (PROP_TRACK_LIST_COUNT, "track-list/count", Format::Int64), + // A/B loop markers — observed so we keep state in sync if mpv itself + // changes them (e.g. via a future scripting feature). + (PROP_AB_LOOP_A, "ab-loop-a", Format::Double), + (PROP_AB_LOOP_B, "ab-loop-b", Format::Double), + // Current audio track id (mpv `aid`). Can be "auto" or a number; + // observe as String so we can handle "auto" cleanly. + (PROP_AID, "aid", Format::String), + // Current subtitle track id (mpv `sid`). Same semantics as `aid`. + (PROP_SID, "sid", Format::String), + // Subtitle visibility — when false, subtitles are hidden even if a + // track is selected. (mpv `sub-visibility`.) + (PROP_SUB_VISIBILITY, "sub-visibility", Format::Flag), + ]; + for (tag, name, fmt) in observed { + if let Err(e) = mpv.observe_property(tag, name, fmt) { + warn!("observe_property({name}) failed: {e}"); + } + } + + // Initialize state from options (loop mode, etc). + bus.update_state(|s| { + s.loop_mode = options.loop_mode; + }); + + // Publish the handle to the caller (start()) and signal readiness. + let mpv_arc = Arc::new(mpv); + let _ = init_tx.send(Ok(mpv_arc.clone())); + + info!("ferret engine ready"); + bus.send(EngineEvent::Ready); + + // Main loop. + loop { + // 1. Drain pending commands (non-blocking). The first Shutdown wins + // and tears the engine down; everything else is dispatched in order. + for cmd in cmd_rx.try_iter() { + if matches!(cmd, Cmd::Shutdown) { + info!("engine received shutdown"); + bus.send(EngineEvent::Shutdown); + return; + } + if let Err(e) = apply_cmd(&mpv_arc, &bus, &cmd) { + warn!("cmd apply failed: {cmd:?} -> {e}"); + bus.send(EngineEvent::Error { message: format!("{e}") }); + } + } + + // 2. Wait for next mpv event with a short timeout. This keeps the + // loop responsive to commands even when no events arrive. + match mpv_arc.wait_event(0.05) { + Ok(Some(event)) => handle_mpv_event(&event, &bus, &mpv_arc), + Ok(None) => { + // No event this round. + } + Err(mpv_bindings::MpvError::Terminated) => { + info!("libmpv terminated"); + bus.send(EngineEvent::Shutdown); + return; + } + Err(e) => { + warn!("mpv_wait_event error: {e:?}"); + thread::sleep(Duration::from_millis(50)); + } + } + } +} + +fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()> { + match cmd { + Cmd::LoadFile { path, options } => { + let mpv_cmd = build_loadfile(path, options)?; + mpv.command(&mpv_cmd)?; + if options.pause { + mpv.set_property(&Property::flag("pause", true))?; + } + // Update state immediately so UI shows "loading". + bus.update_state(|s| { + s.path = Some(path.clone()); + s.paused = options.pause; + s.time_pos = None; + s.duration = None; + }); + bus.send(EngineEvent::StateChanged); + Ok(()) + } + Cmd::PlayPause => { + let now_paused = mpv.get_property_flag("pause").unwrap_or(false); + mpv.set_property(&Property::flag("pause", !now_paused))?; + Ok(()) + } + Cmd::SetPaused(p) => { + mpv.set_property(&Property::flag("pause", *p))?; + Ok(()) + } + Cmd::Seek { target_secs, mode, flags } => { + let mpv_cmd = build_seek(*target_secs, *mode, *flags)?; + mpv.command(&mpv_cmd)?; + Ok(()) + } + Cmd::SetVolume(v) => { + let clamped = v.clamp(0.0, 1.0); + mpv.set_property(&Property::double("volume", (clamped * 100.0) as f64))?; + if clamped > 0.0 && mpv.get_property_flag("mute").unwrap_or(false) { + mpv.set_property(&Property::flag("mute", false))?; + } + Ok(()) + } + Cmd::AdjustVolume(delta) => { + let cur = mpv.get_property_f64("volume").unwrap_or(0.0) / 100.0; + let new_v = (cur + (*delta as f64)).clamp(0.0, 1.0); + mpv.set_property(&Property::double("volume", (new_v * 100.0) as f64))?; + Ok(()) + } + Cmd::ToggleMute => { + let m = mpv.get_property_flag("mute").unwrap_or(false); + mpv.set_property(&Property::flag("mute", !m))?; + Ok(()) + } + Cmd::SetMute(m) => { + mpv.set_property(&Property::flag("mute", *m))?; + Ok(()) + } + Cmd::Stop => { + mpv.command(&mpv_bindings::command::Command::stop())?; + Ok(()) + } + Cmd::FrameStep => { + mpv.set_property(&Property::flag("pause", true))?; + mpv.command(&mpv_bindings::command::Command::frame_step())?; + Ok(()) + } + Cmd::FrameBackStep => { + mpv.set_property(&Property::flag("pause", true))?; + mpv.command(&mpv_bindings::command::Command::frame_back_step())?; + Ok(()) + } + Cmd::SetWindowId(_) => { + warn!("SetWindowId ignored (must be set before engine start)"); + Ok(()) + } + Cmd::ToggleFullscreen => { + // This is a window-level concern, not an engine concern. The main + // app should intercept this command before it reaches the engine. + // If we get here, log and no-op. + debug!("ToggleFullscreen reached engine — main app should intercept"); + Ok(()) + } + + // ---- Loop / playlist -------------------------------------------- + + Cmd::SetLoopMode(mode) => { + apply_loop_mode(mpv, *mode)?; + bus.update_state(|s| s.loop_mode = *mode); + bus.send(EngineEvent::StateChanged); + Ok(()) + } + Cmd::PlaylistNext => { + let cmd = mpv_bindings::command::Command::new() + .arg("playlist-next")? + .arg("weak")?; + mpv.command(&cmd)?; + Ok(()) + } + Cmd::PlaylistPrev => { + let cmd = mpv_bindings::command::Command::new() + .arg("playlist-prev")? + .arg("weak")?; + mpv.command(&cmd)?; + Ok(()) + } + + // ---- Speed ------------------------------------------------------ + + Cmd::SetSpeed(s) => { + let v = (*s as f64).clamp(0.01, 100.0); + mpv.set_property(&Property::double("speed", v))?; + Ok(()) + } + + // ---- Audio track selection ------------------------------------- + + Cmd::SetAudioTrack(opt_id) => { + match opt_id { + Some(id) => { + mpv.set_property(&Property::int("aid", *id))?; + } + None => { + // "auto" lets mpv pick the default track. + mpv.set_property_string("aid", "auto")?; + } + } + Ok(()) + } + + // ---- Subtitle track selection ----------------------------------- + + Cmd::SetSubtitleTrack(opt_id) => { + match opt_id { + Some(id) => { + mpv.set_property(&Property::int("sid", *id))?; + // Selecting a track should also make it visible. + mpv.set_property(&Property::flag("sub-visibility", true))?; + } + None => { + // `sid=no` disables subtitles entirely. + mpv.set_property_string("sid", "no")?; + } + } + Ok(()) + } + Cmd::ToggleSubVisibility => { + let now = mpv.get_property_flag("sub-visibility").unwrap_or(false); + mpv.set_property(&Property::flag("sub-visibility", !now))?; + Ok(()) + } + Cmd::LoadSubtitleFile { path } => { + // mpv `sub-add [select|auto|cached] [title] [lang]`. + // We use "select" so the loaded sub becomes active immediately. + let cmd = mpv_bindings::command::Command::new() + .arg("sub-add")? + .arg(path)? + .arg("select")?; + mpv.command(&cmd)?; + // Refresh track list so the new subtitle appears in the dropdown. + refresh_audio_tracks(mpv, bus); + Ok(()) + } + + // ---- Video rotation / flip ------------------------------------ + + Cmd::SetVideoRotate(deg) => { + // mpv's `video-rotate` accepts 0/90/180/270. Anything else snaps to 0. + // `deg` is `&u16` here (we match on `&Cmd`); deref before forwarding + // so the result is `u16`, not `&u16`. + const ALLOWED: &[u16] = &[0, 90, 180, 270]; + let deg: u16 = ALLOWED.contains(deg).then_some(*deg).unwrap_or(0); + mpv.set_property_string("video-rotate", °.to_string())?; + bus.update_state(|s| s.video_rotate = deg); + bus.send(EngineEvent::StateChanged); + Ok(()) + } + Cmd::SetVideoFlipH(enable) => { + // Toggle the `hflip` video filter. mpv's vf list is comma-separated; + // we add or remove `hflip` from it. + apply_vf_toggle(mpv, "hflip", *enable)?; + bus.update_state(|s| s.video_flip_h = *enable); + bus.send(EngineEvent::StateChanged); + Ok(()) + } + Cmd::SetVideoFlipV(enable) => { + apply_vf_toggle(mpv, "vflip", *enable)?; + bus.update_state(|s| s.video_flip_v = *enable); + bus.send(EngineEvent::StateChanged); + Ok(()) + } + + // ---- A/B markers ----------------------------------------------- + + Cmd::SetMarkerA => { + let pos = mpv.get_property_f64("time-pos").ok(); + if let Some(t) = pos { + mpv.set_property(&Property::double("ab-loop-a", t))?; + bus.update_state(|s| s.marker_a = Some(t)); + bus.send(EngineEvent::StateChanged); + info!("marker A set at {t:.3}s"); + } else { + warn!("SetMarkerA: no time-pos available (no file loaded?)"); + } + Ok(()) + } + Cmd::SetMarkerB => { + let pos = mpv.get_property_f64("time-pos").ok(); + if let Some(t) = pos { + mpv.set_property(&Property::double("ab-loop-b", t))?; + bus.update_state(|s| s.marker_b = Some(t)); + bus.send(EngineEvent::StateChanged); + info!("marker B set at {t:.3}s"); + } else { + warn!("SetMarkerB: no time-pos available (no file loaded?)"); + } + Ok(()) + } + Cmd::ClearMarkers => { + // mpv uses "no" to disable ab-loop-a/b. Setting to 0 doesn't work + // — we must use the string form. + mpv.set_property_string("ab-loop-a", "no")?; + mpv.set_property_string("ab-loop-b", "no")?; + bus.update_state(|s| { + s.marker_a = None; + s.marker_b = None; + s.marker_loop_enabled = false; + }); + bus.send(EngineEvent::StateChanged); + info!("markers cleared"); + Ok(()) + } + Cmd::ToggleMarkerLoop => { + // mpv has no separate "ab-loop enable" property — when both + // ab-loop-a and ab-loop-b are set to non-"no" values, looping + // happens automatically. So our toggle is: + // * If we're "off" → require both markers set; if so, mark as + // "on" (looping is already active because the markers are set). + // If either marker is missing, surface an error. + // * If we're "on" → mark as "off" but keep the markers (so the + // user can re-enable without resetting them). We achieve this + // by temporarily clearing the markers... but that loses them. + // + // Cleaner approach: when toggling OFF, we DON'T clear markers — + // we just clear `marker_loop_enabled` in our state. mpv will keep + // looping, so to actually STOP the loop we have to also clear + // ab-loop-a/b. So toggle OFF = clear markers. + // + // Net behavior: toggle ON = require both markers, set flag. + // toggle OFF = clear markers + flag. + let now_enabled = { + let st = bus.snapshot(); + !st.marker_loop_enabled + }; + if now_enabled { + let a = mpv.get_property_string("ab-loop-a").ok().flatten(); + let b = mpv.get_property_string("ab-loop-b").ok().flatten(); + let a_set = a.as_deref().map(|s| s != "no").unwrap_or(false); + let b_set = b.as_deref().map(|s| s != "no").unwrap_or(false); + if !a_set || !b_set { + bus.send(EngineEvent::Error { + message: "Need both A and B markers before enabling A→B loop".into(), + }); + return Ok(()); + } + // Markers are set — mpv is already looping. Just update our flag. + bus.update_state(|s| s.marker_loop_enabled = true); + } else { + // Toggle OFF — clear the markers to actually stop the loop. + mpv.set_property_string("ab-loop-a", "no")?; + mpv.set_property_string("ab-loop-b", "no")?; + bus.update_state(|s| { + s.marker_a = None; + s.marker_b = None; + s.marker_loop_enabled = false; + }); + } + bus.send(EngineEvent::StateChanged); + info!("marker loop {}", if now_enabled { "ON" } else { "OFF" }); + Ok(()) + } + Cmd::ExportMarkers { path, format } => { + let st = bus.snapshot(); + match export_markers_to_file(path, *format, &st) { + Ok(()) => { + info!("markers exported to {path}"); + Ok(()) + } + Err(e) => { + let msg = format!("marker export failed: {e}"); + warn!("{msg}"); + bus.send(EngineEvent::Error { message: msg.clone() }); + Err(CoreError::Other(msg)) + } + } + } + Cmd::ImportMarkers { path } => { + match import_markers_from_file(path) { + Ok((a, b)) => { + // Apply A marker if present. + if let Some(t) = a { + mpv.set_property(&Property::double("ab-loop-a", t))?; + bus.update_state(|s| s.marker_a = Some(t)); + } else { + mpv.set_property_string("ab-loop-a", "no")?; + bus.update_state(|s| s.marker_a = None); + } + // Apply B marker if present. + if let Some(t) = b { + mpv.set_property(&Property::double("ab-loop-b", t))?; + bus.update_state(|s| s.marker_b = Some(t)); + } else { + mpv.set_property_string("ab-loop-b", "no")?; + bus.update_state(|s| s.marker_b = None); + } + bus.update_state(|s| s.marker_loop_enabled = a.is_some() && b.is_some()); + bus.send(EngineEvent::StateChanged); + info!("markers imported from {path} (A={a:?}, B={b:?})"); + Ok(()) + } + Err(e) => { + let msg = format!("marker import failed: {e}"); + warn!("{msg}"); + bus.send(EngineEvent::Error { message: msg.clone() }); + Err(CoreError::Other(msg)) + } + } + } + + Cmd::ExportABLoopVideo { .. } => { + // This command is intercepted by the main app (which reads A/B + // markers from state and spawns ffmpeg). If it reaches the engine, + // something went wrong — log and no-op. + warn!("ExportABLoopVideo reached engine — should be intercepted by main app"); + Ok(()) + } + Cmd::Shutdown => unreachable!("handled by caller"), + } +} + +fn handle_mpv_event(event: &MpvEvent, bus: &EngineEventBus, mpv: &MpvHandle) { + match event { + MpvEvent::StartFile => { + debug!("mpv: start-file"); + bus.send(EngineEvent::StartFile); + } + MpvEvent::FileLoaded => { + debug!("mpv: file-loaded"); + // Pull metadata immediately so the UI has it. + let path = mpv.get_property_string("path").ok().flatten().unwrap_or_default(); + let title = mpv.get_property_string("media-title").ok().flatten(); + let duration = mpv.get_property_f64("duration").ok(); + let time_pos = mpv.get_property_f64("time-pos").ok(); + bus.update_state(|s| { + s.path = Some(path.clone()); + s.title = title.clone(); + s.duration = duration; + s.time_pos = time_pos; + s.paused = false; // assume playing unless we hear otherwise + // Clear A/B markers — they belong to the previous file. + s.marker_a = None; + s.marker_b = None; + s.marker_loop_enabled = false; + }); + // Refresh audio tracks — track-list/count may not have fired yet. + refresh_audio_tracks(mpv, bus); + bus.send(EngineEvent::FileLoaded { path, title }); + } + MpvEvent::EndFile { reason, error } => { + info!("mpv: end-file reason={reason:?} error={error:?}"); + // Table-driven enum projection. `EndFileReason` is `#[repr(u8)]`, + // so the discriminant indexes 1:1 into END_REASON_MAP. + let r = END_REASON_MAP[(*reason) as usize]; + if let Some(code) = *error { + let msg = unsafe_libmpv_error_string(code); + bus.send(EngineEvent::Error { message: format!("end-file: {msg}") }); + } + bus.update_state(|s| { + s.time_pos = None; + }); + bus.send(EngineEvent::EndReached { reason: r }); + } + MpvEvent::PropertyChange { reply_userdata, name, value } => { + let (changed, want_track_refresh) = + apply_property_change(bus, *reply_userdata, name, value); + if want_track_refresh { + refresh_audio_tracks(mpv, bus); + } + if changed { + bus.send(EngineEvent::StateChanged); + } + } + MpvEvent::LogMessage { prefix, level, text } => { + debug!("mpv log [{prefix}/{level}]: {}", text.trim_end()); + bus.send(EngineEvent::Log { + prefix: prefix.clone(), + level: level.clone(), + text: text.clone(), + }); + } + MpvEvent::Shutdown => { + info!("mpv: shutdown"); + bus.send(EngineEvent::Shutdown); + } + MpvEvent::Hook { id, name } => { + debug!("mpv: hook {name} ({id}) — auto-continuing"); + // We don't use hooks yet, but if one fires we must continue it + // via `hook-ack `. Constructed as a one-arg command. + if let Ok(cmd) = mpv_bindings::command::Command::new() + .arg("hook-ack") + .and_then(|c| c.arg(format!("{id}"))) + { + let _ = mpv.command(&cmd); + } + } + MpvEvent::Other { event_id } => { + debug!("mpv: unhandled event_id={event_id}"); + } + } +} + +fn apply_property_change( + bus: &EngineEventBus, + tag: EventId, + name: &str, + value: &mpv_bindings::event::PropertyValue, +) -> (bool, bool) { + use mpv_bindings::event::PropertyValue as V; + let mut changed = true; + let mut want_track_refresh = false; + bus.update_state(|s| { + match (tag, value) { + (PROP_TIME_POS, V::Double(d)) => s.time_pos = Some(*d), + (PROP_DURATION, V::Double(d)) => s.duration = Some(*d), + (PROP_PAUSE, V::Flag(b)) => s.paused = *b, + (PROP_VOLUME, V::Double(d)) => s.volume = (*d as f32 / 100.0).clamp(0.0, 1.0), + (PROP_MUTE, V::Flag(b)) => s.muted = *b, + (PROP_PATH, V::String(s2)) => s.path = Some(s2.clone()), + (PROP_MEDIA_TITLE, V::String(s2)) => s.title = Some(s2.clone()), + (PROP_SPEED, V::Double(d)) => s.speed = *d as f32, + (PROP_EOF_REACHED, V::Flag(_)) => { + // eof-reached fires before end-file; let end-file drive the state change. + changed = false; + } + // Track list size changed — re-enumerate audio tracks. We don't + // update state here; refresh_audio_tracks() does that. + (PROP_TRACK_LIST_COUNT, V::Int64(_)) => { + want_track_refresh = true; + changed = false; + } + (PROP_AB_LOOP_A, V::Double(d)) => s.marker_a = Some(*d), + (PROP_AB_LOOP_A, V::String(st)) => { + // mpv returns "no" when ab-loop-a is unset, or a number string. + s.marker_a = if st == "no" { None } else { st.parse::().ok() }; + } + (PROP_AB_LOOP_B, V::Double(d)) => s.marker_b = Some(*d), + (PROP_AB_LOOP_B, V::String(st)) => { + s.marker_b = if st == "no" { None } else { st.parse::().ok() }; + } + (PROP_AID, V::String(st)) => { + // "auto" or a number. None means auto. + s.current_audio_track = st.parse::().ok(); + } + (PROP_AID, V::Int64(id)) => s.current_audio_track = Some(*id), + (PROP_SID, V::String(st)) => { + // "no" means no subtitle track selected; otherwise a number. + s.current_subtitle_track = if st == "no" { None } else { st.parse::().ok() }; + } + (PROP_SID, V::Int64(id)) => s.current_subtitle_track = Some(*id), + (PROP_SUB_VISIBILITY, V::Flag(b)) => s.sub_visibility = *b, + // Properties can become None when the file unloads. + (_, V::None) => { + // Likely time-pos or duration going to None on end-of-file. + match tag { + PROP_TIME_POS => s.time_pos = None, + PROP_DURATION => s.duration = None, + PROP_AB_LOOP_A => s.marker_a = None, + PROP_AB_LOOP_B => s.marker_b = None, + PROP_AID => s.current_audio_track = None, + PROP_SID => s.current_subtitle_track = None, + _ => changed = false, + } + } + _ => { + changed = false; + } + } + }); + let _ = name; + (changed, want_track_refresh) +} + +/// Enumerate every track (audio + sub) by walking `track-list/N/*` +/// properties. +/// +/// mpv exposes the track list as a series of indexed properties. Without +/// the node format (not yet wrapped in `mpv-bindings::property`), we read +/// each field individually. Audio and subtitle tracks are refreshed +/// together because they share the same `track-list/N` namespace. +fn refresh_audio_tracks(mpv: &MpvHandle, bus: &EngineEventBus) { + let Some(count) = mpv.get_property_i64("track-list/count").ok() else { + return; + }; + if count <= 0 { + bus.update_state(|s| { + s.audio_tracks.clear(); + s.subtitle_tracks.clear(); + s.current_audio_track = None; + s.current_subtitle_track = None; + }); + bus.send(EngineEvent::StateChanged); + return; + } + + let (audio, subs): (Vec, Vec) = (0..count) + .filter_map(|i| read_track(mpv, i)) + .partition(|t| t.kind == "audio"); + + let cur_audio = audio.iter().find(|t| t.selected).map(|t| t.id); + let cur_sub = subs.iter().find(|t| t.selected).map(|t| t.id); + bus.update_state(|s| { + s.audio_tracks = audio; + s.subtitle_tracks = subs; + s.current_audio_track = cur_audio; + s.current_subtitle_track = cur_sub; + }); + bus.send(EngineEvent::StateChanged); +} + +/// Read one `track-list/N` entry into a `Track`. Returns `None` for video +/// tracks and unreadable entries — audio/sub tracks only. +fn read_track(mpv: &MpvHandle, i: i64) -> Option { + let kind = mpv + .get_property_string(&format!("track-list/{i}/type")) + .ok() + .flatten() + .unwrap_or_default(); + if kind != "audio" && kind != "sub" { + return None; + } + Some(Track { + id: mpv.get_property_i64(&format!("track-list/{i}/id")).unwrap_or(0), + kind, + title: mpv.get_property_string(&format!("track-list/{i}/title")).ok().flatten(), + lang: mpv.get_property_string(&format!("track-list/{i}/lang")).ok().flatten(), + selected: mpv.get_property_flag(&format!("track-list/{i}/selected")).unwrap_or(false), + default: mpv.get_property_flag(&format!("track-list/{i}/default")).unwrap_or(false), + forced: mpv.get_property_flag(&format!("track-list/{i}/forced")).unwrap_or(false), + }) +} + +/// Toggle a video filter on or off by name. mpv's `vf` property is a +/// comma-separated list of filter strings (e.g. "hflip,vflip"). We read the +/// current list, add or remove the named filter, and write it back. +/// +/// This is a simple string-based approach — it doesn't handle filter +/// parameters (e.g. `rotate=90`), only bare filter names like `hflip`, +/// `vflip`, `flip`, `mirror`. For rotation we use the `video-rotate` +/// property instead. +fn apply_vf_toggle(mpv: &MpvHandle, filter: &str, enable: bool) -> CoreResult<()> { + let current = mpv + .get_property_string("vf") + .ok() + .flatten() + .unwrap_or_default(); + // mpv returns "" when no filters are set, or a comma-separated list. + let mut filters: Vec<&str> = if current.is_empty() { + Vec::new() + } else { + current.split(',').collect() + }; + let already_present = filters.iter().any(|f| { + // Match the filter name (before any '=' if params present). + f.split('=').next().unwrap_or(f) == filter + }); + if enable && !already_present { + filters.push(filter); + } else if !enable && already_present { + filters.retain(|f| f.split('=').next().unwrap_or(f) != filter); + } else { + // No change needed. + return Ok(()); + } + let new_vf = filters.join(","); + mpv.set_property_string("vf", &new_vf)?; + Ok(()) +} + +/// Translate a `LoopMode` into the corresponding mpv property sets. +/// +/// Table-driven: each row is `(file_value, playlist_value)`. `loop-file` and +/// `loop-playlist` are independent mpv properties; only one of them carries +/// `"inf"` at a time per our `LoopMode` enum. +fn apply_loop_mode(mpv: &MpvHandle, mode: LoopMode) -> CoreResult<()> { + const TABLE: [(LoopMode, &str, &str); 3] = [ + (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 the TABLE rows"); + mpv.set_property_string("loop-file", file_v)?; + mpv.set_property_string("loop-playlist", list_v)?; + Ok(()) +} + +/// Write A/B markers to a file. Plain text or JSON depending on `format`. +fn export_markers_to_file( + path: &str, + format: MarkerExportFormat, + state: &PlaybackState, +) -> std::io::Result<()> { + use std::fs; + use std::io::Write; + + let content = match format { + MarkerExportFormat::Text => { + let mut s = String::new(); + if let Some(p) = &state.path { + s.push_str(&format!("# file: {}\n", p)); + } + if let Some(d) = state.duration { + s.push_str(&format!("# duration: {:.3}s\n", d)); + } + s.push_str(&format!("# speed: {:.2}x\n", state.speed)); + s.push('\n'); + match state.marker_a { + Some(t) => s.push_str(&format!("A {}\n", format_hms(t))), + None => s.push_str("A -\n"), + } + match state.marker_b { + Some(t) => s.push_str(&format!("B {}\n", format_hms(t))), + None => s.push_str("B -\n"), + } + if state.marker_loop_enabled { + s.push_str("LOOP on\n"); + } else { + s.push_str("LOOP off\n"); + } + s + } + MarkerExportFormat::Json => { + // Hand-rolled JSON to keep the output stable and avoid pulling + // in a JSON serializer just for this one feature. + let mut s = String::new(); + s.push('{'); + s.push_str(&format!( + "\"file\":{},", + json_string(state.path.as_deref().unwrap_or("")) + )); + s.push_str(&format!( + "\"duration\":{},", + json_num(state.duration.unwrap_or(0.0)) + )); + s.push_str(&format!("\"speed\":{},", json_num(state.speed as f64))); + s.push_str(&format!( + "\"a\":{},", + state.marker_a.map(json_num).unwrap_or_else(|| "null".into()) + )); + s.push_str(&format!( + "\"b\":{},", + state.marker_b.map(json_num).unwrap_or_else(|| "null".into()) + )); + s.push_str(&format!( + "\"loop\":{}", + if state.marker_loop_enabled { "true" } else { "false" } + )); + s.push('}'); + s.push('\n'); + s + } + }; + + // Create parent dirs if needed (e.g. ~/.config/ferret/markers/x.txt). + if let Some(parent) = std::path::Path::new(path).parent() { + if !parent.as_os_str().is_empty() { + let _ = fs::create_dir_all(parent); + } + } + let mut f = fs::File::create(path)?; + f.write_all(content.as_bytes())?; + Ok(()) +} + +/// Read a marker file (.txt or .json) and extract the A and B marker +/// positions in seconds. Returns `(Option, Option)` — `None` for +/// either means the marker was absent or invalid. +/// +/// Format detection steps down by extension: `.json` tries the JSON parser, +/// and on any parse failure steps down to the plain-text parser. All other +/// extensions go straight to the text parser. +fn import_markers_from_file(path: &str) -> std::io::Result<(Option, Option)> { + use std::fs; + let content = fs::read_to_string(path)?; + if path.ends_with(".json") { + if let Ok((a, b)) = parse_markers_json(&content) { + return Ok((a, b)); + } + } + Ok(parse_markers_text(&content)) +} + +/// Parse the plain-text marker format written by `export_markers_to_file`: +/// +/// ```text +/// # file: /path/to/video.mp4 +/// # duration: 180.000s +/// # speed: 1.00x +/// +/// A 00:01:23.456 +/// B 00:02:45.000 +/// LOOP on +/// ``` +/// +/// Lines starting with `#` are comments. `A`/`B` lines carry an +/// `HH:MM:SS.mmm` (or `MM:SS` or plain seconds) timestamp. Missing or +/// `-` means the marker is absent. +fn parse_markers_text(content: &str) -> (Option, Option) { + let mut a: Option = None; + let mut b: Option = None; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + // Split on first whitespace: "A 00:01:23.456" → ("A", "00:01:23.456"). + let mut parts = line.splitn(2, char::is_whitespace); + let key = parts.next().unwrap_or(""); + let val = parts.next().unwrap_or("").trim(); + match key { + "A" => a = parse_timestamp(val), + "B" => b = parse_timestamp(val), + _ => {} + } + } + (a, b) +} + +/// Parse the JSON marker format written by `export_markers_to_file`. Only +/// extracts the `a` and `b` fields; ignores everything else. +fn parse_markers_json(content: &str) -> std::io::Result<(Option, Option)> { + // Hand-rolled JSON parsing — we only need to find "a" and "b" keys with + // numeric or null values. This avoids pulling in a JSON parser dep just + // for this one feature. + // + // We look for `"a": ` and `"b": ` (or `null`). + let a = find_json_number(content, "\"a\":"); + let b = find_json_number(content, "\"b\":"); + Ok((a, b)) +} + +/// Find `"": ` in a JSON string and return the value as a number. +/// Returns None if the key is absent or the value is `null`. +fn find_json_number(content: &str, key: &str) -> Option { + let idx = content.find(key)?; + let after = &content[idx + key.len()..]; + let after = after.trim_start(); + if after.starts_with("null") { + return None; + } + // Take chars until we hit a comma, brace, or whitespace. + let num_str: String = after + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+' || *c == 'e' || *c == 'E') + .collect(); + num_str.parse::().ok() +} + +/// Parse a timestamp in `HH:MM:SS.mmm`, `MM:SS`, or plain-seconds form. +/// Returns None if the string is `-` or unparseable. +fn parse_timestamp(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() || s == "-" { + return None; + } + // Plain seconds (e.g. "83.456"). + if let Ok(v) = s.parse::() { + return Some(v); + } + // HH:MM:SS.mmm or MM:SS — weights table-driven by segment count. + let parts: Vec<&str> = s.split(':').collect(); + let weights: &[f64] = match parts.len() { + 3 => &[3600.0, 60.0, 1.0], + 2 => &[60.0, 1.0], + _ => return None, + }; + parts + .iter() + .zip(weights.iter()) + .map(|(p, w)| p.parse::().ok().map(|v| v * w)) + .collect::>>() + .map(|terms| terms.iter().sum()) +} + +fn json_string(s: &str) -> String { + // Minimal JSON string escaping — enough for paths and titles. + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn json_num(f: f64) -> String { + if f.is_finite() { + format!("{:.3}", f) + } else { + "null".into() + } +} + +/// Format seconds as "HH:MM:SS.mmm" — always 3-digit milliseconds for +/// frame-accurate comparison across exports. +fn format_hms(secs: f64) -> String { + let total_ms = (secs.max(0.0) * 1000.0).round() as u64; + let h = total_ms / 3_600_000; + let m = (total_ms % 3_600_000) / 60_000; + let s = (total_ms % 60_000) / 1000; + let ms = total_ms % 1000; + format!("{h:02}:{m:02}:{s:02}.{ms:03}") +} + +fn parse_log_level(s: &str) -> LogLevel { + const TABLE: &[(&[&str], LogLevel)] = &[ + (&["no", "quiet"], LogLevel::Quiet), + (&["fatal"], LogLevel::Fatal), + (&["error"], LogLevel::Error), + (&["warn"], LogLevel::Warn), + (&["info"], LogLevel::Info), + (&["status"], LogLevel::Status), + (&["v", "verbose"], LogLevel::Verbose), + (&["debug"], LogLevel::Debug), + (&["trace"], LogLevel::Trace), + ]; + let needle = s.to_ascii_lowercase(); + TABLE + .iter() + .find(|(keys, _)| keys.iter().any(|k| *k == needle)) + .map(|(_, lvl)| *lvl) + .unwrap_or(LogLevel::Warn) +} + +/// Index-mapped projection from `mpv_bindings::event::EndFileReason` to our +/// `EndReason`. `EndFileReason` carries `#[repr(u8)]` and declares variants in +/// the same order as the rows below, so the discriminant indexes 1:1. +const END_REASON_MAP: [EndReason; 6] = [ + EndReason::Eof, // EndFileReason::Eof = 0 + EndReason::Stop, // EndFileReason::Stop = 1 + EndReason::Quit, // EndFileReason::Quit = 2 + EndReason::Error, // EndFileReason::Error = 3 + EndReason::Redirect, // EndFileReason::Redirect = 4 + EndReason::Unknown, // EndFileReason::Unknown = 5 +]; + +/// Convert a libmpv error code to a human-readable string via libmpv itself. +/// Steps down to a numeric string when libmpv declines to describe the code. +/// +/// Goes through the bindgen-generated `sys::mpv_error_string` rather than a +/// hand-rolled extern declaration, so the FFI surface stays in one place. +fn unsafe_libmpv_error_string(code: i32) -> String { + unsafe { + let p = mpv_bindings::sys::mpv_error_string(code); + if p.is_null() { + return format!("mpv error {code}"); + } + std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned() + } +} diff --git a/crates/player-core/src/error.rs b/crates/player-core/src/error.rs new file mode 100755 index 0000000..5d73765 --- /dev/null +++ b/crates/player-core/src/error.rs @@ -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 = Result; diff --git a/crates/player-core/src/event.rs b/crates/player-core/src/event.rs new file mode 100755 index 0000000..b3e6f6b --- /dev/null +++ b/crates/player-core/src/event.rs @@ -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; + +/// 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>, +} + +impl EngineEventBus { + pub fn new(tx: EngineEventSender, state: Arc>) -> 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 }, + + /// 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, +} diff --git a/crates/player-core/src/lib.rs b/crates/player-core/src/lib.rs new file mode 100755 index 0000000..dd2aeaa --- /dev/null +++ b/crates/player-core/src/lib.rs @@ -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}; diff --git a/crates/player-core/src/options.rs b/crates/player-core/src/options.rs new file mode 100755 index 0000000..341ac11 --- /dev/null +++ b/crates/player-core/src/options.rs @@ -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, + + /// 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=` 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 + } +} diff --git a/crates/player-core/src/state.rs b/crates/player-core/src/state.rs new file mode 100755 index 0000000..24db206 --- /dev/null +++ b/crates/player-core/src/state.rs @@ -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 + /// 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, + /// Language code if present (e.g. "eng", "spa"). + pub lang: Option, + /// 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, + /// Total duration in seconds. None if unknown. + pub duration: Option, + /// 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, + /// Title metadata if available. + pub title: Option, + /// 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, + /// id of the currently-selected audio track, or None if auto. + pub current_audio_track: Option, + /// Available subtitle tracks. Empty until `track-list/count` is observed. + pub subtitle_tracks: Vec, + /// id of the currently-selected subtitle track, or None if no subs. + pub current_subtitle_track: Option, + /// 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, + pub marker_b: Option, + /// 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(&self, ser: S) -> Result { + 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 { + 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 { + 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 { + 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) -> Option { + 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, Option) { + 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 } +} diff --git a/crates/player-ui/Cargo.toml b/crates/player-ui/Cargo.toml new file mode 100755 index 0000000..ce8b0a6 --- /dev/null +++ b/crates/player-ui/Cargo.toml @@ -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 } diff --git a/crates/player-ui/src/app.rs b/crates/player-ui/src/app.rs new file mode 100755 index 0000000..d1778a8 --- /dev/null +++ b/crates/player-ui/src/app.rs @@ -0,0 +1,1490 @@ +//! The overlay UI logic — VLC-styled. +//! +//! Layout (bottom of screen): +//! ┌──────────────────────────────────────────────────────────────────┐ +//! │ [≡] │ +//! │ 00:12 ████████●░░░░░░░░░░░░░░░░░░ 01:30 ← seek bar w/ time │ +//! │ ┌────┬────┬────┬────┬────┐ ┌─────────┐ ┌────────────┐ │ +//! │ │ ▶ │ ■ │ ⏮ │ ⏭ │ ↙ │ 00:12 │ 🔊━━━━○ │ │ ⛶ fullscr │ │ +//! │ └────┴────┴────┴────┴────┘ └─────────┘ └────────────┘ │ +//! │ ┌────┬────┬────┬────┬────┬────┬────┐ │ +//! │ │ A │ B │ AB │ ⟳ │ ½× │ 1× │ 2× │ Audio: [eng ▾] │ +//! │ └────┴────┴────┴────┴────┴────┴────┘ │ +//! └──────────────────────────────────────────────────────────────────┘ +//! +//! - Top-left ≡ hamburger opens the File menu (Load File / Folder / Playlist, +//! Export Markers, Quit). +//! - Seek bar with A/B marker pins rendered on top. +//! - Bottom row 1: transport | time | volume + fullscreen. +//! - Bottom row 2: A/B markers, AB-loop toggle, loop-mode toggle, speed +//! presets + slider, audio track dropdown. + +use std::time::Instant; + +use crossbeam_channel::Receiver; +use egui::{Color32, Context, Layout, Ui, Vec2}; + +use player_core::event::EngineEvent; +use player_core::state::PlaybackState; +use player_core::{Cmd, LoopMode}; + +use crate::icons; +use crate::theme::Theme; +use crate::widgets::seek_bar; + +pub struct OverlayApp { + pub state: PlaybackState, + pub event_rx: Receiver, + pub cmd_tx: crossbeam_channel::Sender, + pub theme: Theme, + pub last_mouse_move: Instant, + pub auto_hide_secs: f64, + pub visible: bool, + pub seeking: bool, + pub seek_drag_pos: f64, + pub window_size: Vec2, + pub error_msg: Option, + pub error_expiry: Option, + /// Is the cursor currently inside the overlay window? + pub mouse_inside: bool, + /// Are we in fullscreen mode? (Mirrored from main app.) + pub fullscreen: bool, + + /// Sticky info toast (e.g. "Markers exported to ..."). + pub info_msg: Option, + pub info_expiry: Option, + + /// Local speed slider value while dragging — avoids fighting with the + /// engine's property-change echo. Reset to None when not dragging. + pub speed_drag: Option, + + /// Buffer of egui input events (mouse moved, clicked, etc.) accumulated + /// since the last frame. The renderer drains this into RawInput.events + /// at the start of each render. Without this, egui never sees mouse + /// events and no buttons/sliders/dropdowns respond to clicks. + pub pending_events: Vec, + + /// Is the About panel visible? Toggled by the Help → About menu item. + /// When true, a persistent panel renders in the overlay showing ferret + /// version, author, website, and license info. + pub about_visible: bool, + + /// Active in-UI file dialog (if any). When `Some`, the dialog renders as + /// a modal overlay covering the controls. Replaces external zenity/kdialog + /// /rfd dialogs which popped under the overlay's AlwaysOnTop window. + pub file_dialog: Option, +} + +impl OverlayApp { + pub fn new( + state: PlaybackState, + event_rx: Receiver, + cmd_tx: crossbeam_channel::Sender, + ) -> Self { + Self { + state, + event_rx, + cmd_tx, + theme: Theme::vlc_dark(), + last_mouse_move: Instant::now(), + auto_hide_secs: 3.0, + visible: true, + seeking: false, + seek_drag_pos: 0.0, + window_size: Vec2::new(1280.0, 720.0), + error_msg: None, + error_expiry: None, + mouse_inside: false, + fullscreen: false, + info_msg: None, + info_expiry: None, + speed_drag: None, + pending_events: Vec::new(), + about_visible: false, + file_dialog: None, + } + } + + /// Push an egui input event (mouse move, click, etc.) into the buffer. + /// Called by the main app's window event handler. The renderer drains + /// these into RawInput at the start of each frame. + pub fn push_event(&mut self, event: egui::Event) { + self.pending_events.push(event); + } + + /// Drain all pending egui events. Called by the renderer before building + /// RawInput for the next frame. + pub fn drain_events(&mut self) -> Vec { + std::mem::take(&mut self.pending_events) + } + + pub fn poll_events(&mut self) { + while let Ok(ev) = self.event_rx.try_recv() { + match ev { + EngineEvent::Ready | EngineEvent::StartFile => {} + EngineEvent::FileLoaded { path, title } => { + self.state.path = Some(path); + self.state.title = title; + } + EngineEvent::StateChanged => {} + EngineEvent::EndReached { reason: _ } => { + self.state.paused = true; + self.state.time_pos = None; + } + EngineEvent::Error { message } => { + self.error_msg = Some(message); + self.error_expiry = Some(Instant::now() + std::time::Duration::from_secs(5)); + self.visible = true; + self.last_mouse_move = Instant::now(); + } + EngineEvent::Log { .. } | EngineEvent::Shutdown => {} + } + } + if let Some(exp) = self.error_expiry { + if Instant::now() > exp { + self.error_msg = None; + self.error_expiry = None; + } + } + if let Some(exp) = self.info_expiry { + if Instant::now() > exp { + self.info_msg = None; + self.info_expiry = None; + } + } + } + + pub fn update_state(&mut self, s: PlaybackState) { + if !self.seeking { + self.state = s; + } else { + // Preserve time_pos so the seek thumb doesn't snap back while dragging. + let old_pos = self.state.time_pos; + self.state = s; + self.state.time_pos = old_pos; + } + } + + pub fn note_mouse_activity(&mut self) { + self.last_mouse_move = Instant::now(); + self.visible = true; + } + + pub fn set_mouse_inside(&mut self, inside: bool) { + self.mouse_inside = inside; + if inside { + self.note_mouse_activity(); + } + } + + pub fn set_fullscreen(&mut self, fs: bool) { + self.fullscreen = fs; + } + + pub fn compute_visibility(&mut self) -> bool { + if self.mouse_inside || self.error_msg.is_some() || self.info_msg.is_some() { + self.visible = true; + self.last_mouse_move = Instant::now(); + } else if self.last_mouse_move.elapsed().as_secs_f64() > self.auto_hide_secs { + self.visible = false; + } + self.visible + } + + fn send(&self, cmd: Cmd) { + let _ = self.cmd_tx.send(cmd); + } + + /// Show a transient info toast (e.g. "Markers exported to ..."). + pub fn show_info(&mut self, msg: impl Into) { + self.info_msg = Some(msg.into()); + self.info_expiry = Some(Instant::now() + std::time::Duration::from_secs(4)); + self.visible = true; + self.last_mouse_move = Instant::now(); + } + + pub fn draw(&mut self, ctx: &Context) { + ctx.request_repaint_after(std::time::Duration::from_millis(33)); + + // The File menu is ALWAYS visible — it must be reachable even when the + // rest of the overlay has auto-hidden. This is the primary way to open + // files, so it can't disappear after 3 seconds of mouse inactivity. + // But if a file dialog is open, skip the menu — the modal handles all + // interaction. + if self.file_dialog.is_none() { + self.draw_file_menu(ctx); + } + + // If a file dialog is open, render it as a modal and process its + // result. This replaces all external (zenity/kdialog/rfd) dialogs. + if let Some(dialog) = &mut self.file_dialog { + // Capture the kind BEFORE we clear the dialog — handle_file_dialog_result + // needs it to know which Cmd to send. If we clear first, the kind is lost. + let kind = dialog.kind.clone(); + let result = dialog.draw(ctx, &self.theme); + if let Some(res) = result { + self.file_dialog = None; + self.handle_file_dialog_result(res, kind); + } + return; + } + + let visible = self.visible; + if !visible { + return; + } + + let win_w = self.window_size.x; + let win_h = self.window_size.y; + + // ----- Title strip (top center, only when there's a file loaded) ----- + if let Some(title) = self.state.title.clone().or(self.state.path.clone()) { + let title_short = if title.len() > 80 { + format!("{}…", &title[..77]) + } else { + title.clone() + }; + let title_w = (title_short.len() as f32 * 7.5).min(win_w - 80.0).max(200.0); + egui::Area::new(egui::Id::new("ferret_title_strip")) + .fixed_pos(egui::pos2((win_w - title_w) / 2.0, 12.0)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let bg = self.theme.bg_color32(); + let bg = egui::Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 200); + egui::Frame::none() + .fill(bg) + .rounding(6.0) + .inner_margin(egui::Margin::symmetric(12.0, 6.0)) + .show(ui, |ui| { + ui.set_min_width(title_w); + ui.label( + egui::RichText::new(title_short) + .color(self.theme.fg_color32()) + .size(12.0), + ); + }); + }); + } + + // ----- Bottom control bar ----- + let bar_h = 118.0; + let bar_rect = egui::Rect::from_min_size( + egui::pos2(0.0, win_h - bar_h), + Vec2::new(win_w, bar_h), + ); + + egui::Area::new(egui::Id::new("ferret_overlay")) + .fixed_pos(bar_rect.min) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + let painter = ui.painter(); + painter.rect_filled(bar_rect, 0.0, self.theme.bg_color32()); + + let top_line = egui::Rect::from_min_size( + bar_rect.min, + Vec2::new(bar_rect.width(), 1.0), + ); + painter.rect_filled(top_line, 0.0, self.theme.accent_color32()); + + let inner = bar_rect.shrink2(Vec2::new(16.0, 8.0)); + ui.allocate_new_ui(egui::UiBuilder::new().max_rect(inner), |ui| { + self.draw_controls(ui); + }); + }); + + // ----- Error toast (top center) ----- + if let Some(msg) = self.error_msg.clone() { + let toast_w: f32 = 600.0_f32.min(win_w - 40.0); + egui::Area::new(egui::Id::new("ferret_error_toast")) + .fixed_pos(egui::pos2((win_w - toast_w) / 2.0, 48.0)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + egui::Frame::popup(ui.style()) + .fill(egui::Color32::from_rgb(120, 30, 30)) + .rounding(6.0) + .show(ui, |ui| { + ui.set_min_width(toast_w); + ui.label( + egui::RichText::new(msg) + .color(egui::Color32::WHITE) + .size(13.0), + ); + }); + }); + } + + // ----- Info toast (below error toast if any) ----- + if let Some(msg) = self.info_msg.clone() { + let toast_w: f32 = 500.0_f32.min(win_w - 40.0); + let y = if self.error_msg.is_some() { 88.0 } else { 48.0 }; + egui::Area::new(egui::Id::new("ferret_info_toast")) + .fixed_pos(egui::pos2((win_w - toast_w) / 2.0, y)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + egui::Frame::popup(ui.style()) + .fill(egui::Color32::from_rgb(20, 60, 30)) + .rounding(6.0) + .show(ui, |ui| { + ui.set_min_width(toast_w); + ui.label( + egui::RichText::new(msg) + .color(egui::Color32::WHITE) + .size(12.0), + ); + }); + }); + } + } + + /// Draw the File menu bar at the top-left corner. ALWAYS visible — not + /// subject to auto-hide. Uses egui's built-in `menu_button` which handles + /// popup open/close, click-outside-to-dismiss, and Escape-to-close + /// automatically. + fn draw_file_menu(&mut self, ctx: &Context) { + // Collect commands to send after the UI closure (can't borrow self + // for send() while the closure also borrows self for theme/state). + let mut pending_cmds: Vec = Vec::new(); + let mut pending_about_toggle = false; + // Collect file-dialog-open requests — can't open the dialog inside + // the closure because it borrows self for the theme snapshot. + let mut pending_dialog: Option = None; + + // Snapshot the theme colors we need so the closure doesn't borrow self. + let fg = self.theme.fg_color32(); + let fg_dim = self.theme.fg_dim_color32(); + let bg = self.theme.bg_color32(); + let loop_mode = self.state.loop_mode; + let speed = self.state.speed; + let has_audio = !self.state.audio_tracks.is_empty(); + let sub_vis = self.state.sub_visibility; + let video_rotate = self.state.video_rotate; + let video_flip_h = self.state.video_flip_h; + let video_flip_v = self.state.video_flip_v; + let about_visible = self.about_visible; + + egui::Area::new(egui::Id::new("ferret_menu_bar")) + .fixed_pos(egui::pos2(4.0, 4.0)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + // Use a Frame for the background — it auto-sizes to the content + // and paints the fill behind the widgets. (Painting manually + // before the widgets doesn't work because ui.max_rect() is + // empty at that point.) + egui::Frame::none() + .fill(egui::Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 220)) + .rounding(4.0) + .inner_margin(egui::Margin::symmetric(6.0, 3.0)) + .show(ui, |ui| { + // Horizontal layout: menus sit side by side, left to right. + ui.horizontal(|ui| { + ui.spacing_mut().button_padding = egui::vec2(8.0, 4.0); + ui.spacing_mut().item_spacing.x = 2.0; + + // ---- File menu ---- + ui.menu_button( + egui::RichText::new("File").color(fg).size(13.0), + |ui| { + ui.set_min_width(200.0); + ui.style_mut().visuals.override_text_color = Some(fg); + + ui.label( + egui::RichText::new("Open") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + + if ui.button("Load File...").clicked() { + pending_dialog = Some(crate::file_dialog::FileDialogKind::LoadFile); + ui.close_menu(); + } + if ui.button("Load Folder...").clicked() { + pending_dialog = Some(crate::file_dialog::FileDialogKind::LoadFolder); + ui.close_menu(); + } + if ui.button("Load Playlist...").clicked() { + pending_dialog = Some(crate::file_dialog::FileDialogKind::LoadPlaylist); + ui.close_menu(); + } + + ui.separator(); + + ui.label( + egui::RichText::new("Markers") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + + if ui.button("Set A Marker ([)").clicked() { + pending_cmds.push(Cmd::SetMarkerA); + ui.close_menu(); + } + if ui.button("Set B Marker (])").clicked() { + pending_cmds.push(Cmd::SetMarkerB); + ui.close_menu(); + } + if ui.button("Clear Markers (\\)").clicked() { + pending_cmds.push(Cmd::ClearMarkers); + ui.close_menu(); + } + if ui.button("Toggle A-B Loop").clicked() { + pending_cmds.push(Cmd::ToggleMarkerLoop); + ui.close_menu(); + } + if ui.button("Export A-B Loop Video...").clicked() { + pending_dialog = Some(crate::file_dialog::FileDialogKind::ExportVideo); + ui.close_menu(); + } + if ui.button("Import Markers...").clicked() { + pending_dialog = Some(crate::file_dialog::FileDialogKind::ImportMarkers); + ui.close_menu(); + } + + ui.separator(); + + ui.label( + egui::RichText::new("Window") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + if ui.button("Toggle Fullscreen (F)").clicked() { + pending_cmds.push(Cmd::ToggleFullscreen); + ui.close_menu(); + } + + ui.separator(); + + if ui.button("Quit (Q)").clicked() { + pending_cmds.push(Cmd::Shutdown); + ui.close_menu(); + } + }, + ); + + // ---- Playback menu ---- + ui.menu_button( + egui::RichText::new("Playback").color(fg).size(13.0), + |ui| { + ui.set_min_width(220.0); + ui.style_mut().visuals.override_text_color = Some(fg); + + if ui.button("Play / Pause (Space)").clicked() { + pending_cmds.push(Cmd::PlayPause); + ui.close_menu(); + } + if ui.button("Stop").clicked() { + pending_cmds.push(Cmd::Stop); + ui.close_menu(); + } + + ui.separator(); + + ui.label( + egui::RichText::new("Seek") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + if ui.button("Forward 5s (→)").clicked() { + pending_cmds.push(Cmd::Seek { + target_secs: 5.0, + mode: mpv_bindings::command::SeekMode::Relative, + flags: mpv_bindings::command::SeekFlags::Keyframes, + }); + ui.close_menu(); + } + if ui.button("Backward 5s (←)").clicked() { + pending_cmds.push(Cmd::Seek { + target_secs: -5.0, + mode: mpv_bindings::command::SeekMode::Relative, + flags: mpv_bindings::command::SeekFlags::Keyframes, + }); + ui.close_menu(); + } + if ui.button("Frame Step Forward (.)").clicked() { + pending_cmds.push(Cmd::FrameStep); + ui.close_menu(); + } + if ui.button("Frame Step Backward (,)").clicked() { + pending_cmds.push(Cmd::FrameBackStep); + ui.close_menu(); + } + + ui.separator(); + + ui.label( + egui::RichText::new("Playlist") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + if ui.button("Next (N)").clicked() { + pending_cmds.push(Cmd::PlaylistNext); + ui.close_menu(); + } + if ui.button("Previous (P)").clicked() { + pending_cmds.push(Cmd::PlaylistPrev); + ui.close_menu(); + } + + ui.separator(); + + ui.label( + egui::RichText::new("Volume") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + if ui.button("Volume Up 5% (↑)").clicked() { + pending_cmds.push(Cmd::AdjustVolume(0.05)); + ui.close_menu(); + } + if ui.button("Volume Down 5% (↓)").clicked() { + pending_cmds.push(Cmd::AdjustVolume(-0.05)); + ui.close_menu(); + } + if ui.button("Toggle Mute (M)").clicked() { + pending_cmds.push(Cmd::ToggleMute); + ui.close_menu(); + } + + ui.separator(); + + ui.label( + egui::RichText::new("Loop") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + let modes = [ + ("Off", LoopMode::Off), + ("Loop File", LoopMode::File), + ("Loop Playlist", LoopMode::Playlist), + ]; + for (label, mode) in modes { + let checked = loop_mode == mode; + if ui.selectable_label(checked, label).clicked() { + pending_cmds.push(Cmd::SetLoopMode(mode)); + ui.close_menu(); + } + } + if ui.button("Cycle Loop Mode (L)").clicked() { + pending_cmds.push(Cmd::SetLoopMode(loop_mode.cycle())); + ui.close_menu(); + } + + ui.separator(); + + ui.label( + egui::RichText::new("Speed") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + if ui.button("Speed Up +0.25× (=)").clicked() { + pending_cmds.push(Cmd::SetSpeed((speed + 0.25).min(4.0))); + ui.close_menu(); + } + if ui.button("Speed Down -0.25× (-)").clicked() { + pending_cmds.push(Cmd::SetSpeed((speed - 0.25).max(0.25))); + ui.close_menu(); + } + ui.separator(); + for &s in &[0.25_f32, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0] { + let checked = (speed - s).abs() < 0.01; + if ui.selectable_label(checked, format!("{:.2}x", s)).clicked() { + pending_cmds.push(Cmd::SetSpeed(s)); + ui.close_menu(); + } + } + }, + ); + + // ---- Audio menu (only if tracks are available) ---- + if has_audio { + let tracks = self.state.audio_tracks.clone(); + let current = self.state.current_audio_track; + ui.menu_button( + egui::RichText::new("Audio").color(fg).size(13.0), + |ui| { + ui.set_min_width(180.0); + ui.style_mut().visuals.override_text_color = Some(fg); + + let auto_checked = current.is_none(); + if ui.selectable_label(auto_checked, "Auto").clicked() { + pending_cmds.push(Cmd::SetAudioTrack(None)); + ui.close_menu(); + } + ui.separator(); + for track in &tracks { + let checked = current == Some(track.id); + if ui.selectable_label(checked, track.label()).clicked() { + pending_cmds.push(Cmd::SetAudioTrack(Some(track.id))); + ui.close_menu(); + } + } + }, + ); + } + + // ---- Subtitles menu ---- + { + let tracks = self.state.subtitle_tracks.clone(); + let current = self.state.current_subtitle_track; + ui.menu_button( + egui::RichText::new("Subtitles").color(fg).size(13.0), + |ui| { + ui.set_min_width(200.0); + ui.style_mut().visuals.override_text_color = Some(fg); + + if ui.button("Load Subtitle File...").clicked() { + pending_dialog = Some(crate::file_dialog::FileDialogKind::LoadSubtitle); + ui.close_menu(); + } + ui.separator(); + + if ui.selectable_label(sub_vis, "Show Subtitles (V)").clicked() { + pending_cmds.push(Cmd::ToggleSubVisibility); + ui.close_menu(); + } + ui.separator(); + + let none_checked = current.is_none(); + if ui.selectable_label(none_checked, "None").clicked() { + pending_cmds.push(Cmd::SetSubtitleTrack(None)); + ui.close_menu(); + } + if !tracks.is_empty() { + ui.separator(); + for track in &tracks { + let checked = current == Some(track.id); + if ui.selectable_label(checked, track.label()).clicked() { + pending_cmds.push(Cmd::SetSubtitleTrack(Some(track.id))); + ui.close_menu(); + } + } + } else { + ui.label( + egui::RichText::new("(no embedded subtitle tracks)") + .color(fg_dim).size(10.0), + ); + } + }, + ); + } + + // ---- Video menu (rotate + flip) ---- + ui.menu_button( + egui::RichText::new("Video").color(fg).size(13.0), + |ui| { + ui.set_min_width(180.0); + ui.style_mut().visuals.override_text_color = Some(fg); + + ui.label( + egui::RichText::new("Rotate") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + for ° in &[0u16, 90, 180, 270] { + let checked = video_rotate == deg; + let label = if deg == 0 { "0° (normal)".to_string() } else { format!("{}°", deg) }; + if ui.selectable_label(checked, label).clicked() { + pending_cmds.push(Cmd::SetVideoRotate(deg)); + ui.close_menu(); + } + } + + ui.separator(); + + ui.label( + egui::RichText::new("Flip") + .color(fg_dim).size(10.0).strong(), + ); + ui.add_space(2.0); + if ui.selectable_label(video_flip_h, "Flip Horizontal (mirror)").clicked() { + pending_cmds.push(Cmd::SetVideoFlipH(!video_flip_h)); + ui.close_menu(); + } + if ui.selectable_label(video_flip_v, "Flip Vertical (upside-down)").clicked() { + pending_cmds.push(Cmd::SetVideoFlipV(!video_flip_v)); + ui.close_menu(); + } + }, + ); + + // ---- Help menu ---- + ui.menu_button( + egui::RichText::new("Help").color(fg).size(13.0), + |ui| { + ui.set_min_width(200.0); + ui.style_mut().visuals.override_text_color = Some(fg); + + if ui.selectable_label(about_visible, "About ferret").clicked() { + pending_about_toggle = true; + ui.close_menu(); + } + ui.separator(); + ui.label( + egui::RichText::new("ferret 1.0.0") + .color(fg_dim).size(10.0), + ); + ui.label( + egui::RichText::new("GPL-2.0-or-later") + .color(fg_dim).size(10.0), + ); + }, + ); + + // ---- Status line ---- + ui.add_space(8.0); + let status = if self.state.paused { "paused" } else { "playing" }; + let speed_str = if (speed - 1.0).abs() < 0.01 { + String::new() + } else { + format!(" {:.2}x", speed) + }; + let loop_str = match loop_mode { + LoopMode::Off => String::new(), + LoopMode::File => " loop:file".into(), + LoopMode::Playlist => " loop:list".into(), + }; + ui.label( + egui::RichText::new(format!("{status}{speed_str}{loop_str}")) + .color(fg_dim).size(10.0), + ); + }); + }); + }); + + // Send any commands that were collected during the UI pass. + for cmd in pending_cmds { + self.send(cmd); + } + // Apply the About toggle after the closure (can't mutate self inside). + if pending_about_toggle { + self.about_visible = !self.about_visible; + } + // Open the in-UI file dialog if a menu item requested one. + if let Some(kind) = pending_dialog { + // For ExportVideo, pre-check that A/B markers are set. + if matches!(kind, crate::file_dialog::FileDialogKind::ExportVideo) { + let (a, b) = (self.state.marker_a, self.state.marker_b); + match (a, b) { + (Some(start), Some(end)) if end > start => { + self.file_dialog = Some(crate::file_dialog::FileDialog::open(kind)); + } + _ => { + self.show_info("Set both A and B markers before exporting"); + } + } + } else { + self.file_dialog = Some(crate::file_dialog::FileDialog::open(kind)); + } + } + // Draw the About panel if visible. + if self.about_visible { + self.draw_about_panel(ctx); + } + } + + /// Handle the result of a completed file dialog. Sends the appropriate + /// `Cmd` (or Cmds) to the engine via `cmd_tx`. The `kind` is passed in + /// because the dialog has already been cleared from `self.file_dialog` + /// by the time this is called. + fn handle_file_dialog_result( + &mut self, + result: crate::file_dialog::FileDialogResult, + kind: crate::file_dialog::FileDialogKind, + ) { + use crate::file_dialog::{FileDialogKind, FileDialogResult}; + use player_core::cmd::{LoadModeKind, LoadOptions}; + + match result { + FileDialogResult::Cancel => {} + FileDialogResult::Path(path) => match kind { + FileDialogKind::LoadFile => { + let _ = self.cmd_tx.send(Cmd::LoadFile { + path: path.clone(), + options: LoadOptions { mode: LoadModeKind::Replace, pause: false }, + }); + self.show_info(format!("Loading: {path}")); + } + FileDialogKind::LoadFolder => { + // Enumerate media files in the folder, load as playlist. + if let Some(paths) = collect_folder_as_playlist(&path) { + if paths.is_empty() { + self.show_info("No video files found in folder"); + } else { + // Use try_send (non-blocking) — if the engine's + // command channel is full (64 slots), skip the + // remaining files rather than blocking the UI thread. + let mut sent = 0usize; + for (i, p) in paths.iter().enumerate() { + let mode = if i == 0 { + LoadModeKind::Replace + } else { + LoadModeKind::AppendPlay + }; + if self.cmd_tx.try_send(Cmd::LoadFile { + path: p.clone(), + options: LoadOptions { mode, pause: false }, + }).is_ok() { + sent += 1; + } + } + self.show_info(format!("Loaded {}/{} files from folder", sent, paths.len())); + } + } else { + self.show_info(format!("Could not read folder: {path}")); + } + } + FileDialogKind::SaveMarkers(fmt) => { + let _ = self.cmd_tx.send(Cmd::ExportMarkers { path: path.clone(), format: fmt }); + self.show_info(format!("Markers exported to {path}")); + } + FileDialogKind::LoadSubtitle => { + let _ = self.cmd_tx.send(Cmd::LoadSubtitleFile { path }); + self.show_info("Subtitle file loaded"); + } + FileDialogKind::ImportMarkers => { + let _ = self.cmd_tx.send(Cmd::ImportMarkers { path: path.clone() }); + self.show_info(format!("Markers imported from {path}")); + } + FileDialogKind::ExportVideo => { + // Send the real export command with the chosen path. + // Main app intercepts all ExportABLoopVideo Cmds and runs ffmpeg. + let _ = self.cmd_tx.send(Cmd::ExportABLoopVideo { path }); + } + FileDialogKind::LoadPlaylist => { + // Single file from a multi-select dialog (shouldn't happen, + // but handle gracefully). + let _ = self.cmd_tx.send(Cmd::LoadFile { + path, + options: LoadOptions { mode: LoadModeKind::Replace, pause: false }, + }); + } + }, + FileDialogResult::Paths(paths) => match kind { + FileDialogKind::LoadPlaylist => { + if paths.is_empty() { + self.show_info("No files selected"); + } else { + // Use try_send (non-blocking) to avoid UI deadlock + // if the engine's command channel is full. + let mut sent = 0usize; + for (i, p) in paths.iter().enumerate() { + let mode = if i == 0 { + LoadModeKind::Replace + } else { + LoadModeKind::AppendPlay + }; + if self.cmd_tx.try_send(Cmd::LoadFile { + path: p.clone(), + options: LoadOptions { mode, pause: false }, + }).is_ok() { + sent += 1; + } + } + self.show_info(format!("Loaded {}/{} files", sent, paths.len())); + } + } + _ => {} + }, + } + } + + /// Draw a persistent About panel in the overlay. This is NOT a popup — + /// it stays visible until the user toggles it off via Help → About. + /// Positioned at top-right so it doesn't overlap the menu bar. + fn draw_about_panel(&mut self, ctx: &Context) { + let win_w = self.window_size.x; + let panel_w = 320.0_f32.min(win_w - 20.0); + let panel_x = win_w - panel_w - 10.0; + let panel_y = 40.0; + + let fg = self.theme.fg_color32(); + let fg_dim = self.theme.fg_dim_color32(); + let bg = self.theme.bg_color32(); + let accent = self.theme.accent_color32(); + + egui::Area::new(egui::Id::new("ferret_about_panel")) + .fixed_pos(egui::pos2(panel_x, panel_y)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + egui::Frame::none() + .fill(egui::Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 240)) + .rounding(6.0) + .inner_margin(egui::Margin::symmetric(14.0, 10.0)) + .stroke(egui::Stroke::new(1.0_f32, accent)) + .show(ui, |ui| { + ui.set_min_width(panel_w - 28.0); + ui.set_max_width(panel_w - 28.0); + + // Title + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("ferret") + .color(accent) + .size(20.0) + .strong(), + ); + ui.label( + egui::RichText::new("1.0.0") + .color(fg_dim) + .size(13.0), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("×").clicked() { + self.about_visible = false; + } + }); + }); + + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); + + // Description + ui.label( + egui::RichText::new("A modern, accuracy-first video player for Linux.") + .color(fg).size(12.0), + ); + + ui.add_space(6.0); + + // Author + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Author:").color(fg_dim).size(11.0)); + ui.label(egui::RichText::new("Jeremy Anderson").color(fg).size(11.0)); + }); + + // Website + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Website:").color(fg_dim).size(11.0)); + ui.label( + egui::RichText::new("http://git.dcos.net/dcosnet/ferret") + .color(accent).size(11.0), + ); + }); + + // License + ui.horizontal(|ui| { + ui.label(egui::RichText::new("License:").color(fg_dim).size(11.0)); + ui.label(egui::RichText::new("GPL-2.0-or-later").color(fg).size(11.0)); + }); + + ui.add_space(6.0); + ui.separator(); + ui.add_space(4.0); + + // Tech credits + ui.label( + egui::RichText::new("Built with Rust, libmpv, egui, wgpu, and winit.") + .color(fg_dim).size(10.0), + ); + ui.label( + egui::RichText::new("Copyright © 2026 Jeremy Anderson.") + .color(fg_dim).size(10.0), + ); + }); + }); + } + + fn draw_controls(&mut self, ui: &mut Ui) { + ui.vertical(|ui| { + // ===== Row 1: Seek bar with time labels on either side ===== + ui.horizontal(|ui| { + let cur_time = self + .state + .time_pos + .map(|t| format_time(t)) + .unwrap_or_else(|| "00:00".to_string()); + ui.label( + egui::RichText::new(cur_time) + .color(self.theme.fg_color32()) + .size(11.0) + .monospace(), + ); + ui.add_space(8.0); + + let progress = self.state.progress(); + let (response, new_pos) = seek_bar(ui, progress, None, 18.0); + + // Draw A/B marker pins on top of the seek bar. + if let Some(dur) = self.state.duration { + if dur > 0.0 { + let bar_rect = response.rect; + let track_y = bar_rect.center().y; + let track_w = bar_rect.width() - 4.0; + let track_min_x = bar_rect.min.x + 2.0; + let painter = ui.painter_at(bar_rect); + if let Some(a) = self.state.marker_a { + let x = track_min_x + (a / dur).clamp(0.0, 1.0) as f32 * track_w; + draw_marker_pin(&painter, egui::pos2(x, track_y - 8.0), Color32::from_rgb(255, 100, 100)); + } + if let Some(b) = self.state.marker_b { + let x = track_min_x + (b / dur).clamp(0.0, 1.0) as f32 * track_w; + draw_marker_pin(&painter, egui::pos2(x, track_y - 8.0), Color32::from_rgb(100, 180, 255)); + } + } + } + + if response.hovered() || response.dragged() { + self.note_mouse_activity(); + } + if let Some(frac) = new_pos { + self.seeking = response.dragged(); + if let Some(dur) = self.state.duration { + self.seek_drag_pos = dur * (frac as f64); + self.state.time_pos = Some(self.seek_drag_pos); + if !response.dragged() { + self.send(Cmd::Seek { + target_secs: self.seek_drag_pos, + mode: mpv_bindings::command::SeekMode::Absolute, + flags: mpv_bindings::command::SeekFlags::Exact, + }); + self.seeking = false; + } + } + } else if !response.dragged() { + self.seeking = false; + } + + ui.add_space(8.0); + let dur_str = self + .state + .duration + .map(|t| format_time(t)) + .unwrap_or_else(|| "--:--".to_string()); + ui.label( + egui::RichText::new(dur_str) + .color(self.theme.fg_color32()) + .size(11.0) + .monospace(), + ); + }); + + ui.add_space(4.0); + + // ===== Row 2: Transport buttons | time | volume + fullscreen ===== + ui.horizontal(|ui| { + let btn_size = Vec2::new(32.0, 24.0); + + let paused = self.state.paused; + let muted = self.state.muted; + let volume = self.state.volume; + + // Play / Pause + let play_resp = self.icon_button(ui, "btn_play", btn_size, move |p, r, c| { + if paused { icons::play(p, r, c) } else { icons::pause(p, r, c) } + }); + if play_resp.clicked() { self.send(Cmd::PlayPause); } + + // Stop + let stop_resp = self.icon_button(ui, "btn_stop", btn_size, |p, r, c| icons::stop(p, r, c)); + if stop_resp.clicked() { self.send(Cmd::Stop); } + + // Previous (playlist) + let prev_resp = self.icon_button(ui, "btn_prev", btn_size, |p, r, c| icons::previous(p, r, c)); + if prev_resp.clicked() { self.send(Cmd::PlaylistPrev); } + + // Next (playlist) + let next_resp = self.icon_button(ui, "btn_next", btn_size, |p, r, c| icons::next(p, r, c)); + if next_resp.clicked() { self.send(Cmd::PlaylistNext); } + + ui.add_space(6.0); + + // Frame step back / forward + let fb_resp = self.icon_button(ui, "btn_frame_back", btn_size, |p, r, c| icons::frame_back(p, r, c)); + if fb_resp.clicked() { self.send(Cmd::FrameBackStep); } + let ff_resp = self.icon_button(ui, "btn_frame_fwd", btn_size, |p, r, c| icons::frame_forward(p, r, c)); + if ff_resp.clicked() { self.send(Cmd::FrameStep); } + + // Center: time display + ui.with_layout(Layout::centered_and_justified(egui::Direction::TopDown), |ui| { + let time_str = self.state.time_str(); + ui.label( + egui::RichText::new(time_str) + .color(self.theme.fg_color32()) + .size(12.0) + .monospace(), + ); + }); + + // Right: volume + fullscreen + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + let fs_resp = self.icon_button(ui, "btn_fullscreen", btn_size, |p, r, c| icons::fullscreen(p, r, c)); + if fs_resp.clicked() { self.send(Cmd::ToggleFullscreen); } + if fs_resp.hovered() { self.note_mouse_activity(); } + ui.add_space(8.0); + + let mut vol_pct = self.state.volume * 100.0; + let slider = egui::Slider::new(&mut vol_pct, 0.0..=100.0) + .show_value(false) + .fixed_decimals(0); + let slider_resp = ui.add_sized(Vec2::new(100.0, 18.0), slider); + if slider_resp.changed() { + self.send(Cmd::SetVolume(vol_pct / 100.0)); + self.state.volume = vol_pct / 100.0; + } + if slider_resp.hovered() { self.note_mouse_activity(); } + ui.add_space(4.0); + + let vol_resp = self.icon_button(ui, "btn_vol", btn_size, move |p, r, c| { + let level = if muted { 0.0 } else { volume }; + icons::volume(p, r, c, level, muted) + }); + if vol_resp.clicked() { self.send(Cmd::ToggleMute); } + }); + }); + + ui.add_space(4.0); + + // ===== Row 3: A/B markers | loop | speed presets | audio track ===== + ui.horizontal(|ui| { + let btn_size = Vec2::new(32.0, 24.0); + + // --- A marker button (with current position label) --- + let a_label = self.state.marker_a.map(format_time).unwrap_or_else(|| " A ".into()); + let a_resp = self.labeled_button(ui, "btn_marker_a", btn_size, &a_label, Color32::from_rgb(255, 100, 100)); + if a_resp.clicked() { self.send(Cmd::SetMarkerA); } + + // --- B marker button --- + let b_label = self.state.marker_b.map(format_time).unwrap_or_else(|| " B ".into()); + let b_resp = self.labeled_button(ui, "btn_marker_b", btn_size, &b_label, Color32::from_rgb(100, 180, 255)); + if b_resp.clicked() { self.send(Cmd::SetMarkerB); } + + // --- AB-loop toggle --- + let ab_active = self.state.marker_loop_enabled; + let ab_resp = self.icon_button_toggled(ui, "btn_ab_loop", btn_size, ab_active, |p, r, c| { + icons::marker_loop(p, r, c, ab_active) + }); + if ab_resp.clicked() { self.send(Cmd::ToggleMarkerLoop); } + + ui.add_space(8.0); + + // --- Loop mode toggle (off/file/playlist) --- + let loop_mode = self.state.loop_mode; + let loop_label = loop_mode.label(); + let loop_active = loop_mode != LoopMode::Off; + // Table-driven loop-mode → icon variant mapping. Indices + // mirror the contract documented on `icons::loop_icon`. + const LOOP_ICON_IDX: [(LoopMode, u8); 3] = [ + (LoopMode::Off, 0), + (LoopMode::File, 1), + (LoopMode::Playlist, 2), + ]; + let icon_idx = LOOP_ICON_IDX + .iter() + .copied() + .find(|(m, _)| *m == loop_mode) + .map(|(_, i)| i) + .expect("LoopMode is exhaustive over LOOP_ICON_IDX"); + let loop_resp = self.icon_button_toggled(ui, "btn_loop", btn_size, loop_active, |p, r, c| { + icons::loop_icon(p, r, c, icon_idx) + }); + if loop_resp.clicked() { + self.send(Cmd::SetLoopMode(loop_mode.cycle())); + } + // Tooltip showing current mode. + loop_resp.on_hover_text(loop_label); + + ui.add_space(8.0); + + // --- Speed presets: 0.5×, 1×, 1.5×, 2× --- + let presets = [0.5_f32, 1.0, 1.5, 2.0]; + let current_speed = self.speed_drag.unwrap_or(self.state.speed); + for &p in &presets { + let active = (current_speed - p).abs() < 0.01; + let label = format!("{p:.2}×"); + let resp = self.text_button(ui, &format!("btn_speed_{p}"), btn_size, &label, active); + if resp.clicked() { + self.send(Cmd::SetSpeed(p)); + self.speed_drag = Some(p); + } + } + + // Fine-grained speed slider (0.25×–4×). + let mut speed_val = self.speed_drag.unwrap_or(self.state.speed); + let slider = egui::Slider::new(&mut speed_val, 0.25..=4.0) + .show_value(false) + .fixed_decimals(2) + .clamping(egui::SliderClamping::Always); + let slider_resp = ui.add_sized(Vec2::new(110.0, 18.0), slider); + if slider_resp.drag_started() { + self.speed_drag = Some(speed_val); + } + if slider_resp.dragged() { + self.speed_drag = Some(speed_val); + } + if slider_resp.drag_stopped() { + if let Some(v) = self.speed_drag.take() { + self.send(Cmd::SetSpeed(v)); + } + } + if slider_resp.hovered() { self.note_mouse_activity(); } + + // Current speed label. + ui.label( + egui::RichText::new(format!("{:.2}×", current_speed)) + .color(self.theme.fg_color32()) + .size(11.0) + .monospace(), + ); + + ui.add_space(8.0); + + // --- Audio track dropdown --- + ui.label( + egui::RichText::new("Audio:") + .color(self.theme.fg_dim_color32()) + .size(11.0), + ); + egui::ComboBox::from_id_salt("ferret_audio_track") + .selected_text(self.current_audio_label()) + .width(140.0) + .show_ui(ui, |ui| { + // "Auto" option. + let auto_selected = self.state.current_audio_track.is_none(); + if ui.selectable_label(auto_selected, "auto").clicked() { + self.send(Cmd::SetAudioTrack(None)); + } + ui.separator(); + for track in &self.state.audio_tracks { + let label = track.label(); + let selected = self.state.current_audio_track == Some(track.id); + if ui.selectable_label(selected, label).clicked() { + self.send(Cmd::SetAudioTrack(Some(track.id))); + } + } + if self.state.audio_tracks.is_empty() { + ui.label( + egui::RichText::new("(no audio tracks)") + .color(self.theme.fg_dim_color32()) + .size(10.0), + ); + } + }); + }); + }); + } + + fn current_audio_label(&self) -> String { + match self.state.current_audio_track { + None => "auto".to_string(), + Some(id) => { + self.state + .audio_tracks + .iter() + .find(|t| t.id == id) + .map(|t| t.label()) + .unwrap_or_else(|| format!("track {id}")) + } + } + } + + /// Draw an icon button with hover state. `draw_fn` receives (painter, rect, color). + fn icon_button( + &mut self, + ui: &mut Ui, + _id: &str, + size: Vec2, + draw_fn: impl Fn(&egui::Painter, egui::Rect, egui::Color32), + ) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + if response.hovered() { + self.note_mouse_activity(); + } + if ui.is_rect_visible(rect) { + let painter = ui.painter_at(rect); + let bg = if response.hovered() || response.has_focus() { + self.theme.button_bg_hover_color32() + } else { + self.theme.button_bg_color32() + }; + painter.rect_filled(rect, 4.0, bg); + let icon_color = if response.hovered() { + self.theme.accent_hover_color32() + } else { + self.theme.fg_color32() + }; + let icon_rect = rect.shrink2(Vec2::splat(5.0)); + draw_fn(&painter, icon_rect, icon_color); + } + response + } + + /// Like `icon_button` but with a "toggled on" visual state — brighter bg + /// and accent icon color. + fn icon_button_toggled( + &mut self, + ui: &mut Ui, + _id: &str, + size: Vec2, + toggled: bool, + draw_fn: impl Fn(&egui::Painter, egui::Rect, egui::Color32), + ) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + if response.hovered() { + self.note_mouse_activity(); + } + if ui.is_rect_visible(rect) { + let painter = ui.painter_at(rect); + let bg = if toggled { + egui::Color32::from_rgba_unmultiplied( + self.theme.accent[0], + self.theme.accent[1], + self.theme.accent[2], + 60, + ) + } else if response.hovered() || response.has_focus() { + self.theme.button_bg_hover_color32() + } else { + self.theme.button_bg_color32() + }; + painter.rect_filled(rect, 4.0, bg); + let icon_color = if toggled { + self.theme.accent_color32() + } else if response.hovered() { + self.theme.accent_hover_color32() + } else { + self.theme.fg_color32() + }; + let icon_rect = rect.shrink2(Vec2::splat(5.0)); + draw_fn(&painter, icon_rect, icon_color); + } + response + } + + /// A small button with a text label (used for A/B markers + speed presets). + fn text_button( + &mut self, + ui: &mut Ui, + _id: &str, + size: Vec2, + label: &str, + active: bool, + ) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + if response.hovered() { + self.note_mouse_activity(); + } + if ui.is_rect_visible(rect) { + let painter = ui.painter_at(rect); + let bg = if active { + egui::Color32::from_rgba_unmultiplied( + self.theme.accent[0], + self.theme.accent[1], + self.theme.accent[2], + 60, + ) + } else if response.hovered() { + self.theme.button_bg_hover_color32() + } else { + self.theme.button_bg_color32() + }; + painter.rect_filled(rect, 4.0, bg); + let text_color = if active { + self.theme.accent_color32() + } else if response.hovered() { + self.theme.accent_hover_color32() + } else { + self.theme.fg_color32() + }; + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(11.0), + text_color, + ); + } + response + } + + /// A button that shows a small colored pin + a time label below it. + /// Used for A/B markers. + fn labeled_button( + &mut self, + ui: &mut Ui, + _id: &str, + size: Vec2, + label: &str, + pin_color: egui::Color32, + ) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + if response.hovered() { + self.note_mouse_activity(); + } + if ui.is_rect_visible(rect) { + let painter = ui.painter_at(rect); + let bg = if response.hovered() { + self.theme.button_bg_hover_color32() + } else { + self.theme.button_bg_color32() + }; + painter.rect_filled(rect, 4.0, bg); + // Pin (small triangle at top). + let pin_rect = egui::Rect::from_min_size( + egui::pos2(rect.center().x - 6.0, rect.min.y + 3.0), + Vec2::new(12.0, 8.0), + ); + let pin_pts = vec![ + egui::pos2(pin_rect.min.x, pin_rect.min.y), + egui::pos2(pin_rect.max.x, pin_rect.min.y), + egui::pos2(pin_rect.center().x, pin_rect.max.y), + ]; + painter.add(egui::Shape::convex_polygon(pin_pts, pin_color, egui::Stroke::NONE)); + // Label. + painter.text( + egui::pos2(rect.center().x, rect.max.y - 4.0), + egui::Align2::CENTER_BOTTOM, + label, + egui::FontId::proportional(10.0), + self.theme.fg_color32(), + ); + } + response + } +} + +/// Draw a small downward-pointing pin on the seek bar at (x, y). +fn draw_marker_pin(painter: &egui::Painter, pos: egui::Pos2, color: egui::Color32) { + let pts = vec![ + egui::pos2(pos.x - 4.0, pos.y), + egui::pos2(pos.x + 4.0, pos.y), + egui::pos2(pos.x, pos.y + 6.0), + ]; + painter.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE)); +} + +/// Format a duration in seconds as "HH:MM:SS" if >= 1 hour, else "MM:SS". +fn format_time(secs: f64) -> String { + let s = secs.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}") + } +} + +/// Enumerate a directory for video/audio files. Returns absolute paths +/// sorted alphabetically. Used by the in-UI "Load Folder" dialog to build +/// an implicit playlist. +fn collect_folder_as_playlist(dir: &str) -> Option> { + use std::fs; + let entries = fs::read_dir(dir).ok()?; + const EXTENSIONS: &[&str] = &[ + "mp4", "mkv", "webm", "avi", "mov", "flv", "mp3", "ogg", "wav", "flac", + "aac", "m4a", "ts", "m2ts", "vob", "wmv", "3gp", + ]; + let mut paths: Vec = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let p = e.path(); + let ext = p.extension()?.to_str()?.to_lowercase(); + EXTENSIONS + .contains(&ext.as_str()) + .then(|| p.to_string_lossy().into_owned()) + }) + .collect(); + paths.sort(); + Some(paths) +} diff --git a/crates/player-ui/src/file_dialog.rs b/crates/player-ui/src/file_dialog.rs new file mode 100644 index 0000000..1141e33 --- /dev/null +++ b/crates/player-ui/src/file_dialog.rs @@ -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), +} + +pub struct FileDialog { + pub kind: FileDialogKind, + current_dir: PathBuf, + entries: Vec, + selected: Option, + /// Multi-select (LoadPlaylist). Stored as a set of paths. + selected_multi: Vec, + /// Filename text input for save dialogs. + filename: String, + /// Error message (permission denied, etc.). + error: Option, + /// 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 = Vec::new(); + let mut files: Vec = 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 { + 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 = 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 { + let mut result: Option = 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 = 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 + } +} diff --git a/crates/player-ui/src/icons.rs b/crates/player-ui/src/icons.rs new file mode 100755 index 0000000..5f1addb --- /dev/null +++ b/crates/player-ui/src/icons.rs @@ -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)), + ); +} diff --git a/crates/player-ui/src/lib.rs b/crates/player-ui/src/lib.rs new file mode 100755 index 0000000..d5db06f --- /dev/null +++ b/crates/player-ui/src/lib.rs @@ -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; diff --git a/crates/player-ui/src/renderer.rs b/crates/player-ui/src/renderer.rs new file mode 100755 index 0000000..a4cee21 --- /dev/null +++ b/crates/player-ui/src/renderer.rs @@ -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 = LazyLock::new(Instant::now); + +/// Owns the wgpu surface + egui_wgpu renderer for ONE overlay window. +pub struct OverlayRenderer { + pub device: Arc, + pub queue: Arc, + pub surface: Arc>, + 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, +} + +impl OverlayRenderer { + pub fn new( + window: Arc, + state: PlaybackState, + event_rx: Receiver, + cmd_tx: crossbeam_channel::Sender, + ) -> Result { + 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) -> 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 = 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); + } +} diff --git a/crates/player-ui/src/theme.rs b/crates/player-ui/src/theme.rs new file mode 100755 index 0000000..487e464 --- /dev/null +++ b/crates/player-ui/src/theme.rs @@ -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]) +} diff --git a/crates/player-ui/src/widgets.rs b/crates/player-ui/src/widgets.rs new file mode 100755 index 0000000..7a1c70b --- /dev/null +++ b/crates/player-ui/src/widgets.rs @@ -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, // 0..=1, None if unknown + buffered: Option, // 0..=1, None if unknown + height: f32, // total widget height (hit area) +) -> (Response, Option) { + 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) +} diff --git a/scripts/audit_brackets.py b/scripts/audit_brackets.py new file mode 100755 index 0000000..0cc984e --- /dev/null +++ b/scripts/audit_brackets.py @@ -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() diff --git a/scripts/audit_deref.py b/scripts/audit_deref.py new file mode 100755 index 0000000..4e52771 --- /dev/null +++ b/scripts/audit_deref.py @@ -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 { 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 { ... }` blocks and extract arm captures. + + Returns list of (line, variant, [captures]). + """ + results = [] + # Find `match {` — 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() diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..889d990 --- /dev/null +++ b/scripts/build.sh @@ -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." diff --git a/scripts/setup-libmpv.sh b/scripts/setup-libmpv.sh new file mode 100755 index 0000000..d78ef5b --- /dev/null +++ b/scripts/setup-libmpv.sh @@ -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" < "$PREFIX/env.sh" < 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"