commit cf2224c9646a3b420a5b2826a5bde856216f33ca Author: Jeremy Anderson Date: Wed Jul 29 23:46:01 2026 -0400 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. 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"