From 39b955b6e74105bb3e4a25e1606c8928bbfbaa74 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 10 Jun 2026 16:11:09 +0100 Subject: [PATCH] Init --- .gitignore | 1 + ARCHITECTURE.md | 240 ++ Cargo.lock | 4520 ++++++++++++++++++++++++++++++++ Cargo.toml | 28 + src/api/auth.rs | 227 ++ src/api/client.rs | 455 ++++ src/api/mod.rs | 3 + src/api/models.rs | 232 ++ src/app/action.rs | 74 + src/app/cmdline.rs | 153 ++ src/app/command.rs | 63 + src/app/event.rs | 65 + src/app/login.rs | 190 ++ src/app/mod.rs | 887 +++++++ src/app/sso.rs | 134 + src/app/state.rs | 407 +++ src/app/update.rs | 1028 ++++++++ src/art.rs | 87 + src/config/default_keymap.toml | 180 ++ src/config/keymap.rs | 484 ++++ src/config/logging.rs | 172 ++ src/config/mod.rs | 8 + src/main.rs | 111 + src/media.rs | 159 ++ src/player/mod.rs | 258 ++ src/ui/art.rs | 24 + src/ui/global.rs | 643 +++++ src/ui/login.rs | 176 ++ src/ui/mod.rs | 350 +++ src/ui/playlists.rs | 144 + src/ui/theme.rs | 23 + 31 files changed, 11526 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/api/auth.rs create mode 100644 src/api/client.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/models.rs create mode 100644 src/app/action.rs create mode 100644 src/app/cmdline.rs create mode 100644 src/app/command.rs create mode 100644 src/app/event.rs create mode 100644 src/app/login.rs create mode 100644 src/app/mod.rs create mode 100644 src/app/sso.rs create mode 100644 src/app/state.rs create mode 100644 src/app/update.rs create mode 100644 src/art.rs create mode 100644 src/config/default_keymap.toml create mode 100644 src/config/keymap.rs create mode 100644 src/config/logging.rs create mode 100644 src/config/mod.rs create mode 100644 src/main.rs create mode 100644 src/media.rs create mode 100644 src/player/mod.rs create mode 100644 src/ui/art.rs create mode 100644 src/ui/global.rs create mode 100644 src/ui/login.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/playlists.rs create mode 100644 src/ui/theme.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..e386d1b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,240 @@ +# furumi_cli — Architecture + +Cross-platform terminal client (cmus-style TUI) for the furumusic backend. +Targets: macOS, Linux (ALSA/Pulse/PipeWire), Windows (WASAPI, Windows Terminal). + +## 1. Technology choices + +### TUI: ratatui 0.30 + crossterm 0.29 + +Evaluated: **ratatui**, cursive, tui-realm, iocraft. + +- **ratatui 0.30.x** — the de-facto standard (gitui, yazi, spotify-player all use it). + 0.30 split the project into workspace crates (`ratatui-core`, `ratatui-widgets`, + `ratatui-crossterm`) with a stable core API. Stock widgets cover everything we + need: `Tabs`, `List`, `Table`, nested `Layout` for tile grids. +- cursive — maintenance mode since 2024, rejected. +- tui-realm — viable framework on top of ratatui (termusic uses it), but a + single-maintainer abstraction layer; we prefer plain ratatui with our own + thin component layer. +- iocraft — too young, optimized for inline CLI output rather than fullscreen apps. +- termion — Unix-only, eliminated (we need Windows). + +crossterm is the only backend that covers Windows. Caveats to handle: +- Enable kitty keyboard enhancement flags only when + `supports_keyboard_enhancement()` returns true; always pop flags on exit. +- Filter key events to `KeyEventKind::Press` (Windows and kitty-enhanced + terminals also deliver Repeat/Release — otherwise bindings double-fire). +- Restore the terminal on panic (panic hook) — a TUI that corrupts the shell + is the #1 reliability complaint. + +### Keybindings: crokey + TOML keymap + +- **crokey 1.4** — parses/formats key combos (`ctrl-a`, `g`), serde support, used + for the config file format. +- Keymap model copied from spotify-player: a `[[keymaps]]` TOML table mapping a + *key sequence* (space-separated chords, e.g. `"g g"`, `"C-c x"`) to a + `Command` enum, optionally parameterized (`{ SeekForward = { seconds = 10 } }`). +- A small chord state machine resolves sequences; bindings are layered: + built-in defaults ← user config (`~/.config/furumi/keymap.toml`). +- Bindings resolve per *input context* (Global, LibraryGrid, TrackList, + TextInput, Popup) so the same key can mean different things per view. + +### Audio: rodio 0.22 + stream-download, behind a backend trait + +Evaluated: **rodio**, kira, raw cpal+symphonia, gstreamer-rs, libmpv. + +- **rodio 0.22** (`Player` / `DeviceSinkBuilder` API — note the 0.21/0.22 renames; + most older tutorials are outdated). Symphonia is the default decoder; enable + the `aac`, `isomp4`, `alac` features for m4a support. Pure Rust → trivial + cross-compilation; cpal covers CoreAudio / ALSA / WASAPI. +- **stream-download 0.24** bridges HTTP to rodio: background download exposing + blocking `Read + Seek`, built on reqwest (shares our authenticated client, + auth headers included), seek into undownloaded regions via HTTP Range + (the backend's `/stream/{id}` supports Range), temp-file storage, retries. +- kira — game-audio oriented, no network story, rejected. +- gstreamer / libmpv — best playback quality but heavy system dependencies; + not acceptable as the only backend for a portable CLI. + +Playback lives behind a trait so backends can be added later (termusic ships +rodio + mpv + gstreamer this way): + +```rust +trait AudioBackend { + fn play(&mut self, source: TrackSource) -> Result<()>; + fn pause(&mut self); fn resume(&mut self); + fn seek(&mut self, pos: Duration) -> Result<()>; + fn set_volume(&mut self, v: f32); + fn position(&self) -> Duration; + fn events(&self) -> Receiver; // TrackEnded, Failed, ... +} +``` + +Gapless-ish playback: pre-open the `stream-download` source and decoder for the +next queue item and append it to the rodio `Player` before the current track +ends. (True gapless is impossible for AAC/M4A anyway — symphonia has no AAC +gapless trim.) + +### Async runtime: tokio + +Needed for: crossterm `EventStream`, reqwest, stream-download, device-sync +polling, debounced search. The audio decode thread is rodio's own; everything +else is async tasks talking over channels. + +## 2. Application architecture + +Elm-style (TEA) core with a component-per-view UI layer — the pattern from the +official ratatui component template and spotify-player. + +``` + ┌────────────────────────────────────────────┐ + │ main loop │ + │ recv Event -> keymap -> Action -> update() │ + │ tick -> draw(&state) │ + └───────▲──────────────────────────┬──────────┘ + Event (mpsc) │ │ Command (spawn task / send msg) + ┌─────────────────────┼──────────────┐ │ + │ terminal input (crossterm stream) │ ┌───────▼────────┐ + │ api task results │ │ side effects │ + │ player events (TrackEnded, ...) │ │ api::Client │ + │ device-sync poll results/commands │ │ player::Engine │ + │ tick (render + position updates) │ │ sync::Poller │ + └────────────────────────────────────┘ └────────────────┘ +``` + +Key rules: + +- **Single source of truth**: one `AppState` struct, mutated only in `update()`. + Views are pure render functions over `&AppState`. +- **No blocking in the UI loop.** All I/O (HTTP, audio open) happens in spawned + tasks that report back via the event channel. Every remote list is a + `Loadable { NotAsked, Loading, Loaded(T), Failed(Error) }` so views can + render spinners and errors honestly. +- **Input → Action indirection**: raw key events are translated by the keymap + into semantic `Action`s (`PlayPause`, `FocusNextTab`, `Select`, `Back`, + `SeekForward(10)`). Views never see raw keys; this is what makes bindings + configurable and the app testable. + +### Module layout (single crate now, splittable later) + +``` +src/ + main.rs // setup: terminal guard, tokio, channels, run loop + config/ // Config + keymap loading (figment or manual TOML merge) + api/ // typed client for /api/player/* + client.rs // reqwest wrapper: base_url, bearer auth, retries + auth.rs // password login, token store, auto-refresh (15min TTL) + models.rs // ArtistCard, Release, TrackItem, PlaylistCard, ... + player/ // playback engine + backend.rs // AudioBackend trait + rodio_backend.rs // rodio Player + stream-download sources + queue.rs // queue, shuffle, repeat_mode, next-track prefetch + sync/ // connected devices: heartbeat/poll loop, command handling + app/ // AppState, Action, Event, update() + ui/ // ratatui rendering + views/ // library_grid, artist, release, playlists, search, + // queue, devices, now_playing bar, popups + theme.rs +``` + +The `api`, `player`, and `app` layers do not import `ui` or ratatui. If a +shared core for furumi_macos/android ever makes sense, those modules extract +into workspace crates without surgery. + +## 3. UI model + +Persistent layout: a tab bar on top, the active view in the middle, a +now-playing/status bar at the bottom (track, position gauge, volume, shuffle/ +repeat, active device indicator). + +Tabs (each owns a navigation stack, like a browser per tab): + +1. **Library** — paginated grid of artist tiles (`GET /artists`). + `Enter` on a tile pushes **Artist view** (`GET /artists/{id}`: metadata, + top tracks, releases list). Selecting a release pushes **Release view** + (`GET /releases/{id}`: metadata + track list). `Esc`/`Backspace` pops. +2. **Search** — debounced `GET /search?q=` with artists/releases/tracks sections. +3. **Playlists** — own + saved playlists, likes ("Liked tracks" virtual playlist). +4. **Queue** — current play queue, reorder/remove. +5. **Devices** — connected devices list, pick active device, transfer playback. + +Navigation state is `Vec` per tab; a `Route` is an enum +(`ArtistGrid { page }`, `Artist { id }`, `Release { id }`, ...). Views cache +their loaded data in `AppState` keyed by route so Back is instant. + +Tile grid: computed from terminal width (`Layout` columns × rows), each tile a +bordered block with artist name (cover art rendering in-terminal is a later, +optional feature — e.g. ratatui-image with kitty/sixel detection, never a +hard dependency). + +## 4. Backend integration notes + +(Verified against the furumusic source; base path `/api/player`.) + +- **Auth**: `POST /api/auth/password` → access token (15 min) + refresh token + (60 days). Client stores tokens at `~/.config/furumi/credentials.json` + (0600) and refreshes proactively via `POST /api/auth/refresh`. All API calls + go through one client that retries once on 401 after refreshing. +- **Streaming**: `GET /api/player/stream/{track_id}` with `Accept-Ranges: + bytes` — exactly what stream-download needs for seek. Original files are + served untranscoded (mp3/flac/ogg/m4a/...), hence the symphonia feature set. +- **Playback state**: persisted server-side via `PUT /api/player/state` + (queue, position, shuffle, repeat, volume). We push throttled updates + (on track change + every ~10s while playing) and restore on startup. +- **History/scrobbling**: `POST /history` on track completion; + `POST /lastfm/now-playing` and `/lastfm/scrobble` if last.fm is connected. +- **Connected devices**: *polling, not websockets.* The sync task: + - sends `POST /devices/poll` every ~5s while the app runs (device TTL is + 30s; commands TTL 20s) with our stable `device_id` (generated once, + persisted) and current `playback_state`; + - applies returned commands (`transfer_state` → load queue/position and + start/stop locally; play/pause/seek commands when we are the active + device but controlled remotely); + - feeds the device list into the Devices tab. Activating another device = + `POST /devices/active`; we then stop local audio and become a remote + control (UI keeps working, actions are sent via `POST /devices/command`). +- **Jams** (collaborative sessions) exist in the API — out of scope for v1, + but the sync task's command-handling design must not preclude them. + +## 5. Reliability checklist + +- Terminal guard type + panic hook: raw mode/alternate screen/keyboard flags + always restored, even on panic. +- Every spawned task's failure becomes an `Event::TaskFailed` rendered as a + status-bar error — no silent hangs, no `unwrap` on I/O. +- Token refresh races guarded by a single-flight lock. +- Audio device disappearance (headphones unplugged) → backend emits + `PlayerEvent::Failed`, engine retries on default device, pauses on repeated + failure. +- Config/keymap parse errors are reported with line context and fall back to + defaults — a typo in keymap.toml must not brick the app. + +## 6. Suggested initial dependencies + +```toml +[dependencies] +ratatui = "0.30" +crossterm = "0.29" +crokey = "1.4" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +rodio = { version = "0.22", features = ["symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } # check exact feature names +stream-download = { version = "0.24", features = ["reqwest"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +thiserror = "2" +anyhow = "1" +directories = "6" # config/cache paths per-OS +tracing = "0.1" # file-based logging (never stdout — it's the UI) +tracing-subscriber = "0.3" +``` + +## 7. Build order (milestones) + +1. Skeleton: terminal guard, event loop, tab bar, status bar, keymap with defaults. +2. `api` crate-module: auth + artists/releases/tracks; Library grid → Artist → Release navigation. +3. Playback: rodio backend + stream-download, queue, now-playing bar, seek/volume. +4. Likes, playlists, search, history reporting. +5. Device sync: heartbeat/poll, transfer playback, remote-control mode. +6. Polish: server-side state restore, last.fm, config file, themes, optional cover art. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6886cbe --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4520 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[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.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[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" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "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 0.9.4", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crokey" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" +dependencies = [ + "crokey-proc_macros", + "crossterm", + "once_cell", + "serde", + "strict", +] + +[[package]] +name = "crokey-proc_macros" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" +dependencies = [ + "crossterm", + "proc-macro2", + "quote", + "strict", + "syn 2.0.117", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "furumi_cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "core-foundation 0.10.1", + "crokey", + "crossterm", + "directories", + "futures-util", + "image", + "open", + "ratatui", + "reqwest", + "rodio", + "serde", + "serde_json", + "souvlaki", + "stream-download", + "thiserror 2.0.18", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[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", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +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.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[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.18", + "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.117", +] + +[[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.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[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 = "mediatype" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "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.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[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-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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.117", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.0", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.0", + "objc2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[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.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[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 = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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.117", +] + +[[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.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "ratatui" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" +dependencies = [ + "bitflags 2.13.0", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "indoc", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +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 = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rodio" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" +dependencies = [ + "cpal", + "dasp_sample", + "num-rational", + "symphonia", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[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 = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +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 = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "souvlaki" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" +dependencies = [ + "base64", + "block", + "cocoa", + "core-graphics", + "dbus", + "dbus-crossroads", + "dispatch", + "objc", + "thiserror 1.0.69", + "windows 0.44.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stream-download" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45067938077d0ef5f48c490527742eb0c9cb5e26e1b80e3bcf57d3a83ef50554" +dependencies = [ + "bytes", + "educe", + "futures-util", + "mediatype", + "parking_lot", + "rangemap", + "reqwest", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "strict" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[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.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[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.117", +] + +[[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 = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[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-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[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 = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +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 = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[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.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..05eae58 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "furumi_cli" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.102" +crokey = "1.4.0" +crossterm = { version = "0.29.0", features = ["event-stream"] } +directories = "6.0.0" +futures-util = "0.3.32" +image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] } +open = "5.3.5" +ratatui = "0.30.1" +reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } +rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +souvlaki = "0.8.3" +stream-download = { version = "0.24.1", default-features = false, features = ["reqwest-rustls", "temp-storage"] } +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time"] } +toml = "1.1.2" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } + +[target.'cfg(target_os="macos")'.dependencies] +core-foundation = "0.10.1" diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..c95f072 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,227 @@ +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context as _, Result, bail}; +use serde::{Deserialize, Serialize}; + +use super::models::{TokensResponse, User}; + +/// Margin before access-token expiry at which we refresh proactively, +/// mirroring the Android/macOS clients. +pub const EXPIRY_SKEW_SECONDS: i64 = 60; + +/// Persisted session, same shape as the macOS client's AuthSession. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthSession { + pub server_base_url: String, + pub user: User, + pub access_token: String, + pub refresh_token: String, + pub token_type: String, + pub expires_at_epoch_seconds: i64, +} + +impl AuthSession { + pub fn new(server_base_url: String, user: User, tokens: TokensResponse) -> Self { + Self { + server_base_url, + user, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + token_type: tokens.token_type, + expires_at_epoch_seconds: now_epoch_seconds() + tokens.expires_in_seconds, + } + } + + pub fn apply_tokens(&mut self, tokens: TokensResponse) { + self.access_token = tokens.access_token; + self.refresh_token = tokens.refresh_token; + self.token_type = tokens.token_type; + self.expires_at_epoch_seconds = now_epoch_seconds() + tokens.expires_in_seconds; + } + + pub fn access_token_expired(&self) -> bool { + now_epoch_seconds() + EXPIRY_SKEW_SECONDS >= self.expires_at_epoch_seconds + } +} + +pub fn now_epoch_seconds() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +pub fn session_path() -> Option { + crate::config::project_dirs().map(|dirs| dirs.config_dir().join("credentials.json")) +} + +pub fn load_session() -> Option { + let path = session_path()?; + let text = fs::read_to_string(&path).ok()?; + match serde_json::from_str(&text) { + Ok(session) => Some(session), + Err(err) => { + tracing::warn!(path = %path.display(), %err, "ignoring unreadable credentials file"); + None + } + } +} + +pub fn save_session(session: &AuthSession) -> Result<()> { + let path = session_path().context("cannot determine config directory")?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; + } + let text = serde_json::to_string_pretty(session)?; + write_private(&path, &text).with_context(|| format!("writing {}", path.display())) +} + +pub fn delete_session() { + if let Some(path) = session_path() { + let _ = fs::remove_file(path); + } +} + +#[cfg(unix)] +fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(text.as_bytes()) +} + +#[cfg(not(unix))] +fn write_private(path: &PathBuf, text: &str) -> std::io::Result<()> { + fs::write(path, text) +} + +/// Same normalization rules as the Android client's ServerConfig: +/// add https:// when no scheme, require http(s) with a host, reject +/// credentials/query/fragment, lowercase the host, trim trailing slashes. +pub fn normalize_base_url(raw: &str) -> Result { + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + bail!("server URL is empty"); + } + let with_scheme = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("https://{trimmed}") + }; + let url = reqwest::Url::parse(&with_scheme).context("invalid server URL")?; + if !matches!(url.scheme(), "http" | "https") { + bail!("server URL must use http or https"); + } + let host = url.host_str().filter(|h| !h.is_empty()); + let Some(host) = host else { + bail!("server URL has no host"); + }; + if !url.username().is_empty() || url.password().is_some() { + bail!("server URL must not contain credentials"); + } + if url.query().is_some() || url.fragment().is_some() { + bail!("server URL must not contain a query or fragment"); + } + let mut normalized = format!("{}://{}", url.scheme(), host.to_ascii_lowercase()); + if let Some(port) = url.port() { + normalized.push_str(&format!(":{port}")); + } + let path = url.path().trim_end_matches('/'); + normalized.push_str(path); + Ok(normalized) +} + +/// Accepts what the user pastes after browser SSO: either the full +/// `furumi://auth/callback?code=furu_mx_...` link (copied from the +/// "Open Furumi" button) or the bare `furu_mx_...` code. +pub fn extract_sso_code(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + bail!("paste the link or code first"); + } + if input.starts_with("furu_mx_") { + return Ok(input.to_string()); + } + if let Ok(url) = reqwest::Url::parse(input) { + if let Some((_, error)) = url.query_pairs().find(|(k, _)| k == "error") { + bail!("SSO failed: {error}"); + } + if let Some((_, code)) = url.query_pairs().find(|(k, _)| k == "code") { + return Ok(code.into_owned()); + } + } + bail!("no furu_mx_ code found in the pasted text"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_adds_https_and_strips_slash() { + assert_eq!( + normalize_base_url(" Music.Hexor.cy/ ").unwrap(), + "https://music.hexor.cy" + ); + } + + #[test] + fn normalize_keeps_port_and_path() { + assert_eq!( + normalize_base_url("http://localhost:8000/furumi/").unwrap(), + "http://localhost:8000/furumi" + ); + } + + #[test] + fn normalize_rejects_bad_urls() { + assert!(normalize_base_url("").is_err()); + assert!(normalize_base_url("ftp://x").is_err()); + assert!(normalize_base_url("https://user:pw@host").is_err()); + assert!(normalize_base_url("https://host?x=1").is_err()); + } + + #[test] + fn sso_code_from_deep_link() { + let code = + extract_sso_code("furumi://auth/callback?code=furu_mx_abc123").unwrap(); + assert_eq!(code, "furu_mx_abc123"); + } + + #[test] + fn sso_code_bare() { + assert_eq!(extract_sso_code(" furu_mx_x ").unwrap(), "furu_mx_x"); + } + + #[test] + fn sso_error_is_reported() { + let err = extract_sso_code("furumi://auth/callback?error=provider_denied") + .unwrap_err() + .to_string(); + assert!(err.contains("provider_denied")); + } + + #[test] + fn expiry_uses_skew() { + let session = AuthSession { + server_base_url: "https://x".into(), + user: User { + id: 1, + name: "n".into(), + role: "user".into(), + }, + access_token: "a".into(), + refresh_token: "r".into(), + token_type: "Bearer".into(), + expires_at_epoch_seconds: now_epoch_seconds() + 30, + }; + assert!(session.access_token_expired()); + } +} diff --git a/src/api/client.rs b/src/api/client.rs new file mode 100644 index 0000000..e0bca07 --- /dev/null +++ b/src/api/client.rs @@ -0,0 +1,455 @@ +use serde::Serialize; +use serde::de::DeserializeOwned; +use tokio::sync::Mutex; + +use super::auth::{self, AuthSession}; +use super::models::{ + ApiErrorBody, ArtistDetail, ArtistsPage, LikesResponse, LoginResponse, MeResponse, + PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults, TokensResponse, TrackItem, +}; + +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("{0}")] + Server(String), + /// Refresh token rejected or expired — the user must sign in again. + #[error("session expired, please sign in again")] + SessionExpired, + #[error("network error: {0}")] + Network(#[from] reqwest::Error), + #[error("{0}")] + Other(#[from] anyhow::Error), +} + +pub fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .user_agent(format!( + "furumi-cli/{} ({})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS + )) + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("reqwest client config is static") +} + +pub fn device_name() -> String { + format!("furumi-cli ({})", std::env::consts::OS) +} + +#[derive(Serialize)] +struct PasswordLoginRequest<'a> { + username: &'a str, + password: &'a str, + device_name: String, +} + +#[derive(Serialize)] +struct SsoExchangeRequest<'a> { + code: &'a str, + device_name: String, +} + +#[derive(Serialize)] +struct RefreshRequest<'a> { + refresh_token: &'a str, +} + +#[derive(Serialize)] +struct LogoutRequest<'a> { + refresh_token: &'a str, +} + +pub async fn login_password( + http: &reqwest::Client, + base_url: &str, + username: &str, + password: &str, +) -> Result { + let response = http + .post(format!("{base_url}/api/auth/password")) + .json(&PasswordLoginRequest { + username, + password, + device_name: device_name(), + }) + .send() + .await?; + let login: LoginResponse = parse_response(response).await?; + Ok(AuthSession::new(base_url.to_string(), login.user, login.tokens)) +} + +pub async fn login_sso_exchange( + http: &reqwest::Client, + base_url: &str, + code: &str, +) -> Result { + let response = http + .post(format!("{base_url}/api/auth/sso/exchange")) + .json(&SsoExchangeRequest { + code, + device_name: device_name(), + }) + .send() + .await?; + let login: LoginResponse = parse_response(response).await?; + Ok(AuthSession::new(base_url.to_string(), login.user, login.tokens)) +} + +/// Browser entry point for SSO. redirect_uri is either our loopback +/// listener (`http://127.0.0.1:{port}/callback`) or the `furumi://` deep +/// link as a manual-paste fallback. +pub fn sso_start_url(base_url: &str, redirect_uri: &str) -> String { + let mut url = reqwest::Url::parse(&format!("{base_url}/auth/mobile/oidc/start")) + .expect("base_url is pre-validated"); + url.query_pairs_mut() + .append_pair("redirect_uri", redirect_uri); + url.to_string() +} + +async fn refresh_tokens( + http: &reqwest::Client, + base_url: &str, + refresh_token: &str, +) -> Result { + let response = http + .post(format!("{base_url}/api/auth/refresh")) + .json(&RefreshRequest { refresh_token }) + .send() + .await?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(ApiError::SessionExpired); + } + parse_response(response).await +} + +/// Mirrors the backend's PlaybackStateDto. +#[derive(Debug, Serialize)] +pub struct PlaybackStateBody { + pub current_track_id: Option, + pub position_ms: i32, + pub queue: Vec, + pub queue_position: i32, + pub shuffle: bool, + pub repeat_mode: String, + pub volume: f64, +} + +/// Percent-encode a query-string value. +fn url_encode(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char); + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +async fn parse_response(response: reqwest::Response) -> Result { + let status = response.status(); + if status.is_success() { + return Ok(response.json().await?); + } + let message = match response.json::().await { + Ok(body) => body.error, + Err(_) => format!("server returned {status}"), + }; + Err(ApiError::Server(message)) +} + +/// Authenticated API client. Owns the session; refreshes the access token +/// proactively (60s skew) and once more on 401, persisting rotated tokens. +/// The session mutex makes concurrent refreshes single-flight. +pub struct ApiClient { + http: reqwest::Client, + base_url: String, + session: Mutex, +} + +impl ApiClient { + pub fn new(http: reqwest::Client, session: AuthSession) -> Self { + Self { + http, + base_url: session.server_base_url.clone(), + session: Mutex::new(session), + } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub async fn me(&self) -> Result { + self.get_json("/api/player/me").await + } + + pub async fn artists(&self, page: i64, limit: i64) -> Result { + self.get_json(&format!("/api/player/artists?page={page}&limit={limit}")) + .await + } + + pub async fn artist(&self, id: i64) -> Result { + self.get_json(&format!("/api/player/artists/{id}")).await + } + + pub async fn release(&self, id: i64) -> Result { + self.get_json(&format!("/api/player/releases/{id}")).await + } + + pub async fn search(&self, query: &str, limit: i64) -> Result { + self.get_json(&format!( + "/api/player/search?q={}&limit={limit}", + url_encode(query) + )) + .await + } + + /// Open an audio stream for playback: background download backed by a + /// temp file, exposing blocking Read+Seek for the decoder; seeking into + /// not-yet-downloaded ranges uses HTTP Range requests. + /// + /// The download client carries the bearer token valid at start; on very + /// long tracks a Range request after token expiry (15 min) can fail — + /// acceptable for now, a refreshing middleware can replace this later. + pub async fn open_stream( + &self, + path: &str, + ) -> Result<(crate::player::TrackReader, Option), ApiError> { + use stream_download::Settings; + use stream_download::http::HttpStream; + use stream_download::source::SourceStream as _; + use stream_download::storage::temp::TempStorageProvider; + + let token = self.fresh_access_token().await?; + let mut headers = reqwest::header::HeaderMap::new(); + let value = format!("Bearer {token}") + .parse() + .map_err(|_| ApiError::Server("invalid token header".to_string()))?; + headers.insert(reqwest::header::AUTHORIZATION, value); + let client = reqwest::Client::builder() + .default_headers(headers) + .build() + .map_err(ApiError::Network)?; + + let url = format!("{}{path}", self.base_url) + .parse() + .map_err(|err| ApiError::Server(format!("bad stream url: {err}")))?; + let stream = HttpStream::new(client, url) + .await + .map_err(|err| ApiError::Server(format!("stream open failed: {err}")))?; + let byte_len = stream.content_length(); + let reader = stream_download::StreamDownload::from_stream( + stream, + TempStorageProvider::new(), + Settings::default(), + ) + .await + .map_err(|err| ApiError::Server(format!("stream start failed: {err}")))?; + Ok((reader, byte_len)) + } + + /// Raw bytes (cover art, artist images) from a server-relative path. + pub async fn get_bytes(&self, path: &str) -> Result, ApiError> { + let url = format!("{}{path}", self.base_url); + let response = self + .send_authed(&url, |client, url, token| client.get(url).bearer_auth(token)) + .await?; + let status = response.status(); + if !status.is_success() { + return Err(ApiError::Server(format!("server returned {status}"))); + } + Ok(response.bytes().await?.to_vec()) + } + + pub async fn playlists(&self) -> Result, ApiError> { + self.get_json("/api/player/playlists").await + } + + pub async fn playlist(&self, id: i64) -> Result { + self.get_json(&format!("/api/player/playlists/{id}")).await + } + + pub async fn likes(&self) -> Result, ApiError> { + let response: LikesResponse = self.get_json("/api/player/likes").await?; + Ok(response.track_ids) + } + + pub async fn toggle_like(&self, track_id: i64) -> Result { + #[derive(serde::Deserialize)] + struct Body { + liked: bool, + } + let body: Body = self + .post_json(&format!("/api/player/likes/toggle/{track_id}"), &()) + .await?; + Ok(body.liked) + } + + #[allow(dead_code, reason = "device-sync state restore needs id→track resolution")] + pub async fn tracks_by_ids(&self, track_ids: &[i64]) -> Result, ApiError> { + #[derive(Serialize)] + struct Body<'a> { + track_ids: &'a [i64], + } + self.post_json("/api/player/tracks-by-ids", &Body { track_ids }) + .await + } + + /// Report a finished/aborted listen to the play history. + pub async fn report_history( + &self, + track_id: i64, + started_at: Option, + listened_seconds: i32, + ) -> Result<(), ApiError> { + #[derive(Serialize)] + struct Body { + track_id: i64, + started_at: Option, + listened_seconds: i32, + } + let _: serde_json::Value = self + .post_json( + "/api/player/history", + &Body { + track_id, + started_at, + listened_seconds, + }, + ) + .await?; + Ok(()) + } + + /// Persist playback state server-side (used for cross-device restore). + pub async fn push_state(&self, state: &PlaybackStateBody) -> Result<(), ApiError> { + let _: serde_json::Value = self.put_json("/api/player/state", state).await?; + Ok(()) + } + + /// Revoke this device's session server-side. Best effort: local + /// credentials are deleted regardless of the outcome. + pub async fn logout(&self) -> Result { + let (access_token, refresh_token) = { + let session = self.session.lock().await; + (session.access_token.clone(), session.refresh_token.clone()) + }; + let response = self + .http + .post(format!("{}/api/auth/logout", self.base_url)) + .bearer_auth(access_token) + .json(&LogoutRequest { + refresh_token: &refresh_token, + }) + .send() + .await?; + + #[derive(serde::Deserialize)] + struct LogoutResponse { + revoked: bool, + } + let body: LogoutResponse = parse_response(response).await?; + Ok(body.revoked) + } + + pub async fn get_json(&self, path: &str) -> Result { + self.json_request::<(), T>(reqwest::Method::GET, path, None) + .await + } + + pub async fn post_json( + &self, + path: &str, + body: &B, + ) -> Result { + self.json_request(reqwest::Method::POST, path, Some(body)) + .await + } + + pub async fn put_json( + &self, + path: &str, + body: &B, + ) -> Result { + self.json_request(reqwest::Method::PUT, path, Some(body)) + .await + } + + async fn json_request( + &self, + method: reqwest::Method, + path: &str, + body: Option<&B>, + ) -> Result { + let url = format!("{}{path}", self.base_url); + let response = self + .send_authed(&url, |client, url, token| { + let mut request = client.request(method.clone(), url).bearer_auth(token); + if let Some(body) = body { + request = request.json(body); + } + request + }) + .await?; + parse_response(response).await + } + + /// Send a request with a fresh bearer token; on 401, refresh once and + /// retry. `build` is called per attempt because RequestBuilder is not + /// reusable after send. + async fn send_authed(&self, url: &str, build: F) -> Result + where + F: Fn(&reqwest::Client, &str, &str) -> reqwest::RequestBuilder, + { + let token = self.fresh_access_token().await?; + let response = build(&self.http, url, &token).send().await?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + let token = self.refresh_after_rejection(&token).await?; + return Ok(build(&self.http, url, &token).send().await?); + } + Ok(response) + } + + async fn fresh_access_token(&self) -> Result { + let mut session = self.session.lock().await; + if session.access_token_expired() { + self.refresh_locked(&mut session).await?; + } + Ok(session.access_token.clone()) + } + + /// A 401 with a token another task already rotated just retries with the + /// current token; otherwise this task performs the refresh itself. + async fn refresh_after_rejection(&self, rejected_token: &str) -> Result { + let mut session = self.session.lock().await; + if session.access_token != rejected_token { + return Ok(session.access_token.clone()); + } + self.refresh_locked(&mut session).await?; + Ok(session.access_token.clone()) + } + + async fn refresh_locked(&self, session: &mut AuthSession) -> Result<(), ApiError> { + let result = refresh_tokens(&self.http, &self.base_url, &session.refresh_token).await; + match result { + Ok(tokens) => { + session.apply_tokens(tokens); + if let Err(err) = auth::save_session(session) { + tracing::warn!(%err, "failed to persist rotated tokens"); + } + tracing::debug!("access token refreshed"); + Ok(()) + } + Err(ApiError::SessionExpired) => { + auth::delete_session(); + Err(ApiError::SessionExpired) + } + Err(err) => Err(err), + } + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..c44c82c --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,3 @@ +pub mod auth; +pub mod client; +pub mod models; diff --git a/src/api/models.rs b/src/api/models.rs new file mode 100644 index 0000000..7701117 --- /dev/null +++ b/src/api/models.rs @@ -0,0 +1,232 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: i64, + pub name: String, + pub role: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TokensResponse { + pub access_token: String, + pub refresh_token: String, + pub token_type: String, + pub expires_in_seconds: i64, +} + +#[derive(Debug, Deserialize)] +pub struct LoginResponse { + pub user: User, + pub tokens: TokensResponse, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code, reason = "rendered by the profile view in a later milestone")] +pub struct MeStats { + pub liked_tracks: i64, + pub playlists: i64, + pub plays: i64, + pub listened_minutes: i64, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code, reason = "rendered by the profile view in a later milestone")] +pub struct MeResponse { + pub id: i64, + pub name: String, + pub role: String, + pub stats: MeStats, +} + +#[derive(Debug, Deserialize)] +pub struct ApiErrorBody { + pub error: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ArtistCard { + #[allow(dead_code, reason = "opens the artist view in the next milestone")] + pub id: i64, + pub name: String, + /// Relative path like `/api/player/cover/{file_id}/medium`. + pub image_url: Option, + pub release_count: i64, + pub track_count: i64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ArtistRef { + #[allow(dead_code, reason = "navigation to artists from track rows later")] + pub id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TrackItem { + #[allow(dead_code, reason = "playback engine consumes this in milestone 3")] + pub id: i64, + pub title: String, + pub track_number: Option, + pub duration_seconds: f64, + pub artists: Vec, + pub featured_artists: Vec, + #[allow(dead_code, reason = "jump-to-release navigation later")] + pub release_id: i64, + pub release_title: String, + #[allow(dead_code, reason = "shown in queue/now-playing later")] + pub release_year: Option, + /// Server-relative path to `/api/player/stream/{id}`. + pub stream_url: String, + #[allow(dead_code, reason = "now-playing artwork in milestone 3")] + pub cover_url: Option, + pub audio_format: Option, + pub audio_bitrate: Option, + pub audio_sample_rate: Option, + pub file_size_bytes: Option, + #[allow(dead_code, reason = "popularity column later")] + pub lastfm_playcount: Option, +} + +impl TrackItem { + pub fn artist_line(&self) -> String { + let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect(); + if !self.featured_artists.is_empty() { + names.push("feat."); + names.extend(self.featured_artists.iter().map(|a| a.name.as_str())); + } + names.join(", ") + } + + pub fn duration_label(&self) -> String { + let total = self.duration_seconds.round() as i64; + format!("{}:{:02}", total / 60, total % 60) + } + + /// Compact "FLAC 929k 24.3MB" suffix for track rows. + pub fn tech_label_short(&self) -> String { + let mut parts = Vec::new(); + if let Some(format) = &self.audio_format { + parts.push(format.to_uppercase()); + } + if let Some(bitrate) = self.audio_bitrate { + parts.push(format!("{bitrate}k")); + } + if let Some(bytes) = self.file_size_bytes { + parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0)); + } + parts.join(" ") + } + + /// Full tech line for the status bar, including the sample rate. + pub fn tech_label_full(&self) -> String { + let mut parts = Vec::new(); + if let Some(format) = &self.audio_format { + parts.push(format.to_uppercase()); + } + if let Some(bitrate) = self.audio_bitrate { + parts.push(format!("{bitrate}kbps")); + } + if let Some(rate) = self.audio_sample_rate { + parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0)); + } + if let Some(bytes) = self.file_size_bytes { + parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0)); + } + parts.join(" · ") + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ReleaseCard { + pub id: i64, + pub title: String, + pub release_type: String, + pub year: Option, + pub cover_url: Option, + pub track_count: i64, +} + +#[derive(Debug, Deserialize)] +pub struct ArtistDetail { + #[allow(dead_code, reason = "cache key is held by the caller")] + pub id: i64, + pub name: String, + pub image_url: Option, + pub total_track_count: i64, + pub total_play_count: i64, + pub top_tracks: Vec, + pub releases: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UploaderSummary { + pub name: String, + #[allow(dead_code, reason = "per-uploader stats for a later detail popup")] + pub track_count: i64, +} + +#[derive(Debug, Deserialize)] +pub struct ReleaseDetail { + #[allow(dead_code, reason = "cache key is held by the caller")] + pub id: i64, + pub title: String, + pub release_type: String, + pub year: Option, + pub cover_url: Option, + pub artists: Vec, + pub tracks: Vec, + pub uploaders: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PlaylistCard { + pub id: i64, + pub title: String, + pub track_count: i64, + pub is_own: bool, + pub owner_name: Option, + pub is_public: bool, + #[allow(dead_code, reason = "save/unsave playlists later")] + pub is_saved: bool, + #[allow(dead_code, reason = "playlist kinds get distinct icons later")] + pub kind: String, +} + +#[derive(Debug, Deserialize)] +pub struct PlaylistDetail { + #[allow(dead_code, reason = "cache key is held by the caller")] + pub id: i64, + pub title: String, + #[allow(dead_code, reason = "shown in a detail header later")] + pub description: Option, + pub tracks: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct LikesResponse { + pub track_ids: Vec, +} + +#[derive(Debug, Default, Deserialize)] +pub struct SearchResults { + pub artists: Vec, + pub releases: Vec, + pub tracks: Vec, +} + +impl SearchResults { + pub fn len(&self) -> usize { + self.artists.len() + self.releases.len() + self.tracks.len() + } +} + +#[derive(Debug, Deserialize)] +pub struct ArtistsPage { + pub items: Vec, + pub total: i64, + pub page: i64, + #[allow(dead_code, reason = "part of the server pagination envelope")] + pub per_page: i64, + pub has_more: bool, +} diff --git a/src/app/action.rs b/src/app/action.rs new file mode 100644 index 0000000..6d15c62 --- /dev/null +++ b/src/app/action.rs @@ -0,0 +1,74 @@ +use serde::Deserialize; + +/// Semantic commands the user can trigger. Raw key events are translated into +/// these by the keymap; views and `update()` never see raw keys. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub enum Action { + Quit, + NextTab, + PrevTab, + GoToTab(usize), + MoveUp, + MoveDown, + MoveLeft, + MoveRight, + PageUp, + PageDown, + SelectFirst, + SelectLast, + Select, + Back, + PlayPause, + NextTrack, + PrevTrack, + SeekForward { seconds: u32 }, + SeekBackward { seconds: u32 }, + VolumeUp, + VolumeDown, + ToggleShuffle, + CycleRepeat, + ToggleLike, + QueueAddNext, + QueueAddLast, + ToggleHelp, + ToggleViewMode, + OpenCommandLine, + Logout, +} + +impl Action { + pub fn describe(&self) -> String { + match self { + Action::Quit => "Quit".into(), + Action::NextTab => "Next tab".into(), + Action::PrevTab => "Previous tab".into(), + Action::GoToTab(i) => format!("Go to tab {}", i + 1), + Action::MoveUp => "Move up".into(), + Action::MoveDown => "Move down".into(), + Action::MoveLeft => "Move left".into(), + Action::MoveRight => "Move right".into(), + Action::PageUp => "Page up".into(), + Action::PageDown => "Page down".into(), + Action::SelectFirst => "Jump to first item".into(), + Action::SelectLast => "Jump to last item".into(), + Action::Select => "Open / activate".into(), + Action::Back => "Go back / close".into(), + Action::PlayPause => "Play / pause".into(), + Action::NextTrack => "Next track".into(), + Action::PrevTrack => "Previous track".into(), + Action::SeekForward { seconds } => format!("Seek forward {seconds}s"), + Action::SeekBackward { seconds } => format!("Seek backward {seconds}s"), + Action::VolumeUp => "Volume up".into(), + Action::VolumeDown => "Volume down".into(), + Action::ToggleShuffle => "Toggle shuffle".into(), + Action::CycleRepeat => "Cycle repeat mode".into(), + Action::ToggleLike => "Like / unlike".into(), + Action::QueueAddNext => "Queue: add next".into(), + Action::QueueAddLast => "Queue: add to end".into(), + Action::ToggleHelp => "Show / hide keybindings".into(), + Action::ToggleViewMode => "Toggle tiles / table view".into(), + Action::OpenCommandLine => "Open command line (:/name searches)".into(), + Action::Logout => "Sign out".into(), + } + } +} diff --git a/src/app/cmdline.rs b/src/app/cmdline.rs new file mode 100644 index 0000000..ab20c1b --- /dev/null +++ b/src/app/cmdline.rs @@ -0,0 +1,153 @@ +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::api::client::ApiError; +use crate::app::Runtime; +use crate::app::command::{self, Command, Parsed}; +use crate::app::event::AppEvent; +use crate::app::state::{AppState, GlobalView, SearchState, Tab}; + +const SEARCH_DEBOUNCE: Duration = Duration::from_millis(180); +const SEARCH_LIMIT: i64 = 12; + +/// Keys go here instead of the keymap while the command line is open. +pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) { + match key.code { + KeyCode::Esc => cancel(state), + KeyCode::Enter => commit(state), + KeyCode::Backspace => { + if state.cmdline.input.pop().is_none() { + // Backspace on an empty line closes it, like vim. + cancel(state); + return; + } + after_change(state, runtime); + } + KeyCode::Char(c) if is_typing(key) => { + state.cmdline.input.push(c); + after_change(state, runtime); + } + _ => {} + } +} + +pub fn handle_paste(state: &mut AppState, runtime: &Runtime, pasted: &str) { + let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect(); + state.cmdline.input.push_str(&cleaned); + after_change(state, runtime); +} + +fn is_typing(key: KeyEvent) -> bool { + key.modifiers.difference(KeyModifiers::SHIFT).is_empty() +} + +/// Re-evaluate the input after every edit; live commands (search) take +/// effect immediately, while typing. +fn after_change(state: &mut AppState, runtime: &Runtime) { + match command::parse(&state.cmdline.input) { + Parsed::Command(command) if command::is_live(&command) => { + apply_live(state, runtime, command); + } + _ => retract_live(state), + } +} + +fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) { + match command { + Command::Search(query) => { + state.active_tab = Tab::Global; + if !matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) { + state.global.stack.push(GlobalView::Search { cursor: 0 }); + } + state.cmdline.live = true; + if state.search.query != query { + state.search.query = query; + set_view_cursor_zero(state); + schedule_search(state, runtime); + } + } + } +} + +fn set_view_cursor_zero(state: &mut AppState) { + if let Some(GlobalView::Search { cursor }) = state.global.stack.last_mut() { + *cursor = 0; + } +} + +/// Debounced, race-free search: every edit bumps the global sequence; the +/// spawned task only queries if it is still the latest after the debounce, +/// and the receiver drops responses that arrive out of date. +fn schedule_search(state: &mut AppState, runtime: &Runtime) { + let seq = runtime.search_seq.fetch_add(1, Ordering::SeqCst) + 1; + let query = state.search.query.clone(); + if query.is_empty() { + state.search.loading = false; + state.search.results = None; + return; + } + let Some(api) = runtime.api.clone() else { + return; + }; + state.search.loading = true; + let tx = runtime.event_tx.clone(); + let latest = Arc::clone(&runtime.search_seq); + tokio::spawn(async move { + tokio::time::sleep(SEARCH_DEBOUNCE).await; + if latest.load(Ordering::SeqCst) != seq { + return; + } + let event = match api.search(&query, SEARCH_LIMIT).await { + Ok(results) => AppEvent::SearchLoaded { + seq, + result: Ok(results), + }, + Err(ApiError::SessionExpired) => AppEvent::SessionExpired, + Err(err) => AppEvent::SearchLoaded { + seq, + result: Err(err.to_string()), + }, + }; + let _ = tx.send(event); + }); +} + +/// Enter: close the line. Live commands already took effect (their view +/// stays open); one-shot commands would execute here. +fn commit(state: &mut AppState) { + let parsed = command::parse(&state.cmdline.input); + close(state); + match parsed { + Parsed::Empty | Parsed::Command(_) => {} + Parsed::Unknown(name) => { + state.status_message = Some(format!("unknown command: {name}")); + } + } +} + +/// Esc: close the line and undo any live effect it had. +fn cancel(state: &mut AppState) { + retract_live(state); + close(state); +} + +fn close(state: &mut AppState) { + state.cmdline.active = false; + state.cmdline.input.clear(); + state.cmdline.live = false; +} + +/// Pop the live search view if this command-line session opened it. +fn retract_live(state: &mut AppState) { + if !state.cmdline.live { + return; + } + state.cmdline.live = false; + if matches!(state.global.stack.last(), Some(GlobalView::Search { .. })) { + state.global.stack.pop(); + state.search = SearchState::default(); + } +} diff --git a/src/app/command.rs b/src/app/command.rs new file mode 100644 index 0000000..71eb2af --- /dev/null +++ b/src/app/command.rs @@ -0,0 +1,63 @@ +//! Command line (`:`) command parsing. +//! +//! To add a command: +//! 1. Add a `Command` variant. +//! 2. Recognize it in `parse()` below. +//! 3. Handle it in `app::cmdline` — live commands (re-evaluated on every +//! keystroke, like search) in `apply_live`, one-shot commands in `commit`. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// `:/query` — realtime search over artists, releases and tracks. + Search(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Parsed { + /// Nothing typed yet. + Empty, + Command(Command), + Unknown(String), +} + +pub fn parse(input: &str) -> Parsed { + if input.is_empty() { + return Parsed::Empty; + } + if let Some(query) = input.strip_prefix('/') { + return Parsed::Command(Command::Search(query.trim().to_string())); + } + // Future word commands parse here, e.g. "volume 50" / "seek +30". + let name = input.split_whitespace().next().unwrap_or(input); + Parsed::Unknown(name.to_string()) +} + +/// Live commands take effect while typing; one-shot commands run on Enter. +pub fn is_live(command: &Command) -> bool { + matches!(command, Command::Search(_)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_search() { + assert_eq!( + parse("/daft punk"), + Parsed::Command(Command::Search("daft punk".to_string())) + ); + assert_eq!(parse("/"), Parsed::Command(Command::Search(String::new()))); + } + + #[test] + fn unknown_and_empty() { + assert_eq!(parse(""), Parsed::Empty); + assert_eq!(parse("volume 50"), Parsed::Unknown("volume".to_string())); + } + + #[test] + fn search_is_live() { + assert!(is_live(&Command::Search("x".into()))); + } +} diff --git a/src/app/event.rs b/src/app/event.rs new file mode 100644 index 0000000..b0cb0af --- /dev/null +++ b/src/app/event.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use crate::api::auth::AuthSession; +use crate::api::models::{ + ArtistDetail, ArtistsPage, PlaylistCard, PlaylistDetail, ReleaseDetail, SearchResults, + TrackItem, +}; +use crate::art::ArtImage; + +/// Events delivered to the main loop by background tasks (API fetches, the +/// playback engine, device sync). Tasks never touch AppState directly. +#[derive(Debug)] +pub enum AppEvent { + StatusMessage(String), + LoginSucceeded(Box), + LoginFailed(String), + /// Loopback listener received the browser SSO callback. + SsoCallback(Result), + /// Refresh token rejected — stored credentials were deleted. + SessionExpired, + /// A page of the Global artists list arrived (or failed). + ArtistsLoaded(Result), + ArtistViewLoaded { + id: i64, + result: Result, + }, + ReleaseViewLoaded { + id: i64, + result: Result, + }, + /// Live search results; `seq` drops responses that are already stale. + SearchLoaded { + seq: u64, + result: Result, + }, + /// Artwork fetched and decoded for the shared art cache. + ArtLoaded { + key: String, + art: Option>, + }, + Player(crate::player::PlayerEvent), + /// A command from the OS media keys. + Media(crate::media::MediaCommand), + /// Gapless prefetch could not open the stream; the normal track-switch + /// path takes over when the current track ends. + PrefetchFailed { + pos: usize, + }, + PlaylistsLoaded(Result, String>), + PlaylistViewLoaded { + id: i64, + result: Result, + }, + /// Liked track ids for the ♥ markers. + LikesLoaded(Result, String>), + LikeToggled { + track_id: i64, + liked: bool, + }, + /// A release fetched for queueing (a / shift-a on a release). + EnqueueTracks { + tracks: Vec, + next: bool, + }, +} diff --git a/src/app/login.rs b/src/app/login.rs new file mode 100644 index 0000000..1ac977b --- /dev/null +++ b/src/app/login.rs @@ -0,0 +1,190 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::api::{auth, client}; +use crate::app::Runtime; +use crate::app::event::AppEvent; +use crate::app::sso; +use crate::app::state::{AppState, LoginField, LoginForm, LoginMode}; + +pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + state.should_quit = true; + return; + } + let form = &mut state.login; + if form.busy { + return; + } + match form.mode { + LoginMode::Form => handle_form_key(form, runtime, key), + LoginMode::SsoPending => handle_sso_key(form, runtime, key), + } +} + +/// Bracketed paste goes into whichever text field is focused. +pub fn handle_paste(state: &mut AppState, pasted: &str) { + let form = &mut state.login; + if form.busy { + return; + } + let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect(); + if let Some(field) = focused_text(form) { + field.push_str(&cleaned); + } +} + +fn handle_form_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) { + match key.code { + KeyCode::Tab | KeyCode::Down => form.focus = form.focus.next(), + KeyCode::BackTab | KeyCode::Up => form.focus = form.focus.prev(), + KeyCode::Backspace => { + if let Some(field) = focused_text(form) { + field.pop(); + } + } + KeyCode::Enter => match form.focus { + LoginField::ServerUrl | LoginField::Username => form.focus = form.focus.next(), + LoginField::Password | LoginField::SignInButton => { + submit_password(form, runtime); + } + LoginField::SsoButton => start_sso(form, runtime), + }, + KeyCode::Char(c) if is_typing(key) => { + if let Some(field) = focused_text(form) { + field.push(c); + } + } + _ => {} + } +} + +fn handle_sso_key(form: &mut LoginForm, runtime: &mut Runtime, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + if let Some(listener) = runtime.sso.take() { + listener.abort(); + } + form.mode = LoginMode::Form; + form.sso_paste.clear(); + form.error = None; + } + KeyCode::Backspace => { + form.sso_paste.pop(); + } + KeyCode::Enter => submit_sso_code(form, runtime), + KeyCode::Char(c) if is_typing(key) => form.sso_paste.push(c), + _ => {} + } +} + +fn is_typing(key: KeyEvent) -> bool { + key.modifiers + .difference(KeyModifiers::SHIFT) + .is_empty() +} + +fn focused_text(form: &mut LoginForm) -> Option<&mut String> { + if form.mode == LoginMode::SsoPending { + return Some(&mut form.sso_paste); + } + match form.focus { + LoginField::ServerUrl => Some(&mut form.server_url), + LoginField::Username => Some(&mut form.username), + LoginField::Password => Some(&mut form.password), + LoginField::SignInButton | LoginField::SsoButton => None, + } +} + +fn submit_password(form: &mut LoginForm, runtime: &Runtime) { + form.error = None; + let base_url = match auth::normalize_base_url(&form.server_url) { + Ok(url) => url, + Err(err) => return form.error = Some(err.to_string()), + }; + let username = form.username.trim().to_string(); + if username.is_empty() { + return form.error = Some("enter a username".to_string()); + } + if form.password.is_empty() { + return form.error = Some("enter a password".to_string()); + } + form.server_url = base_url.clone(); + form.busy = true; + + let password = form.password.clone(); + let http = runtime.http.clone(); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = client::login_password(&http, &base_url, &username, &password).await; + let _ = tx.send(login_event(result)); + }); +} + +fn start_sso(form: &mut LoginForm, runtime: &mut Runtime) { + form.error = None; + let base_url = match auth::normalize_base_url(&form.server_url) { + Ok(url) => url, + Err(err) => return form.error = Some(err.to_string()), + }; + form.server_url = base_url.clone(); + form.sso_paste.clear(); + + // Preferred flow: loopback listener, the browser redirect finishes the + // login hands-free. Fallback: furumi:// deep link + manual code paste. + if let Some(listener) = runtime.sso.take() { + listener.abort(); + } + match sso::start(runtime.event_tx.clone()) { + Ok(listener) => { + let redirect = format!("http://127.0.0.1:{}/callback", listener.port); + form.sso_url = client::sso_start_url(&base_url, &redirect); + form.sso_port = Some(listener.port); + runtime.sso = Some(listener); + } + Err(err) => { + tracing::warn!(%err, "loopback listener unavailable, falling back to manual paste"); + form.sso_url = client::sso_start_url(&base_url, "furumi://auth/callback"); + form.sso_port = None; + } + } + + form.mode = LoginMode::SsoPending; + if let Err(err) = open::that_detached(&form.sso_url) { + tracing::warn!(%err, "failed to open browser for SSO"); + form.error = Some("couldn't open a browser — use the URL below".to_string()); + } +} + +fn submit_sso_code(form: &mut LoginForm, runtime: &Runtime) { + form.error = None; + let code = match auth::extract_sso_code(&form.sso_paste) { + Ok(code) => code, + Err(err) => return form.error = Some(err.to_string()), + }; + spawn_sso_exchange(form, runtime, code); +} + +/// Used by both the manual paste path and the loopback callback event. +pub fn spawn_sso_exchange(form: &mut LoginForm, runtime: &Runtime, code: String) { + let base_url = form.server_url.clone(); + form.busy = true; + + let http = runtime.http.clone(); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = client::login_sso_exchange(&http, &base_url, &code).await; + let _ = tx.send(login_event(result)); + }); +} + +fn login_event(result: Result) -> AppEvent { + match result { + Ok(session) => { + if let Err(err) = auth::save_session(&session) { + tracing::warn!(%err, "failed to persist credentials"); + } + AppEvent::LoginSucceeded(Box::new(session)) + } + Err(err) => AppEvent::LoginFailed(err.to_string()), + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..cab3c4f --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,887 @@ +pub mod action; +mod cmdline; +pub mod command; +pub mod event; +mod login; +mod sso; +pub mod state; +pub mod update; + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use crokey::KeyCombination; +use crossterm::event::{Event as TermEvent, EventStream, KeyEvent, KeyEventKind}; +use futures_util::StreamExt; +use ratatui::DefaultTerminal; +use tokio::sync::mpsc; +use tokio::time::MissedTickBehavior; + +use crate::api::auth; +use crate::api::client::{ApiClient, ApiError, http_client}; +use crate::config::keymap::{KeyResolution, Keymap}; +use crate::player; +use crate::ui; +use event::AppEvent; +use state::{AppState, Screen}; +use update::{Effect, update}; + +const TICK_INTERVAL: Duration = Duration::from_millis(250); + +/// Handles shared by background tasks; AppState stays pure UI data. +pub struct Runtime { + pub event_tx: mpsc::UnboundedSender, + pub http: reqwest::Client, + pub api: Option>, + pub sso: Option, + /// Caps concurrent artwork downloads so they never starve API calls. + pub art_semaphore: Arc, + /// Monotonic sequence for live search; stale responses are dropped. + pub search_seq: Arc, + pub player: player::Controller, + pub last_state_push: Option, + pub media_tx: std::sync::mpsc::Sender, + pub last_media_push: Option, +} + +pub async fn run( + mut terminal: DefaultTerminal, + mut keymap: Keymap, + startup_warning: Option, + event_tx: mpsc::UnboundedSender, + mut event_rx: mpsc::UnboundedReceiver, + media_tx: std::sync::mpsc::Sender, +) -> Result<()> { + let mut state = AppState { + status_message: startup_warning, + ..AppState::default() + }; + + let player_events = event_tx.clone(); + let mut runtime = Runtime { + event_tx, + http: http_client(), + api: None, + sso: None, + art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), + search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)), + player: player::spawn(move |event| { + let _ = player_events.send(AppEvent::Player(event)); + }), + last_state_push: None, + media_tx, + last_media_push: None, + }; + + match auth::load_session() { + Some(session) => { + state.user = Some(session.user.clone()); + let api = Arc::new(ApiClient::new(runtime.http.clone(), session)); + runtime.api = Some(Arc::clone(&api)); + spawn_session_check(&runtime, api); + } + None => state.screen = Screen::Login, + } + + let mut input = EventStream::new(); + let mut tick = tokio::time::interval(TICK_INTERVAL); + tick.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + terminal.draw(|frame| ui::draw(frame, &state, &keymap))?; + + tokio::select! { + maybe_event = input.next() => match maybe_event { + Some(Ok(event)) => handle_terminal_event(&mut state, &mut keymap, &mut runtime, event), + Some(Err(err)) => return Err(err.into()), + None => state.should_quit = true, + }, + Some(app_event) = event_rx.recv() => handle_app_event(&mut state, &mut runtime, app_event), + _ = tick.tick() => { + expire_quit_confirmation(&mut state); + if state.player.current.is_some() { + state.player.position_secs = runtime.player.shared.position().as_secs_f64(); + state.player.paused = runtime.player.shared.paused(); + } + maybe_prefetch_next(&mut state, &runtime); + maybe_push_state(&state, &mut runtime); + push_media_update(&state, &mut runtime, false); + } + } + + if state.should_quit { + return Ok(()); + } + maintenance(&mut state, &mut runtime); + } +} + +const ARTISTS_PAGE_SIZE: i64 = 48; +const ARTISTS_PREFETCH_MARGIN: usize = 24; + +/// Runs after every event: kicks off whatever background work the current +/// state needs — the first artists page, the next page when the selection +/// nears the end, and artwork for loaded artists. +fn maintenance(state: &mut AppState, runtime: &mut Runtime) { + let Some(api) = runtime.api.clone() else { + return; + }; + if state.screen != Screen::Main { + return; + } + + { + let global = &mut state.global; + let initial = global.artists.is_empty(); + let near_end = + !initial && global.selected + ARTISTS_PREFETCH_MARGIN >= global.artists.len(); + if global.has_more && !global.loading && global.error.is_none() && (initial || near_end) { + global.loading = true; + let page = global.next_page; + let api = Arc::clone(&api); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let event = match api.artists(page, ARTISTS_PAGE_SIZE).await { + Ok(page) => AppEvent::ArtistsLoaded(Ok(page)), + Err(ApiError::SessionExpired) => AppEvent::SessionExpired, + Err(err) => AppEvent::ArtistsLoaded(Err(err.to_string())), + }; + let _ = tx.send(event); + }); + } + } + + // Liked ids load once per session — markers are shown everywhere. + if !state.likes_loaded { + state.likes_loaded = true; + let api = Arc::clone(&api); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let event = match api.likes().await { + Ok(ids) => AppEvent::LikesLoaded(Ok(ids)), + Err(ApiError::SessionExpired) => AppEvent::SessionExpired, + Err(err) => AppEvent::LikesLoaded(Err(err.to_string())), + }; + let _ = tx.send(event); + }); + } + + // Playlists tab data. + if state.active_tab == state::Tab::Playlists { + if state.playlists.list.is_none() { + state.playlists.list = Some(state::Loadable::Loading); + let api = Arc::clone(&api); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let event = match api.playlists().await { + Ok(list) => AppEvent::PlaylistsLoaded(Ok(list)), + Err(ApiError::SessionExpired) => AppEvent::SessionExpired, + Err(err) => AppEvent::PlaylistsLoaded(Err(err.to_string())), + }; + let _ = tx.send(event); + }); + } + if let Some(opened) = state.playlists.opened { + let id = opened.id; + if let std::collections::hash_map::Entry::Vacant(entry) = + state.playlist_views.entry(id) + { + entry.insert(state::Loadable::Loading); + let api = Arc::clone(&api); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let event = match api.playlist(id).await { + Ok(detail) => AppEvent::PlaylistViewLoaded { + id, + result: Ok(detail), + }, + Err(ApiError::SessionExpired) => AppEvent::SessionExpired, + Err(err) => AppEvent::PlaylistViewLoaded { + id, + result: Err(err.to_string()), + }, + }; + let _ = tx.send(event); + }); + } + } + } + + // Drill-down views pushed on the stack fetch their data on first sight. + for view in state.global.stack.clone() { + match view { + state::GlobalView::Artist { id, .. } => { + if let std::collections::hash_map::Entry::Vacant(entry) = + state.artist_views.entry(id) + { + entry.insert(state::Loadable::Loading); + let api = Arc::clone(&api); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let event = match api.artist(id).await { + Ok(detail) => AppEvent::ArtistViewLoaded { + id, + result: Ok(detail), + }, + Err(ApiError::SessionExpired) => AppEvent::SessionExpired, + Err(err) => AppEvent::ArtistViewLoaded { + id, + result: Err(err.to_string()), + }, + }; + let _ = tx.send(event); + }); + } + } + state::GlobalView::Release { id, .. } => { + if let std::collections::hash_map::Entry::Vacant(entry) = + state.release_views.entry(id) + { + entry.insert(state::Loadable::Loading); + let api = Arc::clone(&api); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let event = match api.release(id).await { + Ok(detail) => AppEvent::ReleaseViewLoaded { + id, + result: Ok(detail), + }, + Err(ApiError::SessionExpired) => AppEvent::SessionExpired, + Err(err) => AppEvent::ReleaseViewLoaded { + id, + result: Err(err.to_string()), + }, + }; + let _ = tx.send(event); + }); + } + } + state::GlobalView::Search { .. } => {} + } + } + + // Artwork wanted by everything currently loaded, at its display size. + let mut wanted: Vec<(String, u16, u16)> = Vec::new(); + let tile = (state::ART_CELL_WIDTH, state::ART_CELL_HEIGHT); + let header = (state::ART_HEADER_WIDTH, state::ART_HEADER_HEIGHT); + for artist in &state.global.artists { + if let Some(url) = &artist.image_url { + wanted.push((url.clone(), tile.0, tile.1)); + } + } + for detail in state.artist_views.values() { + if let state::Loadable::Ready(detail) = detail { + if let Some(url) = &detail.image_url { + wanted.push((url.clone(), header.0, header.1)); + } + for release in &detail.releases { + if let Some(url) = &release.cover_url { + wanted.push((url.clone(), tile.0, tile.1)); + } + } + } + } + for detail in state.release_views.values() { + if let state::Loadable::Ready(detail) = detail { + if let Some(url) = &detail.cover_url { + wanted.push((url.clone(), header.0, header.1)); + } + } + } + for (url, width, height) in wanted { + let key = crate::art::cache_key(&url, width, height); + if state.art.contains_key(&key) { + continue; + } + state.art.insert(key.clone(), state::ArtState::Loading); + spawn_art_fetch(runtime, Arc::clone(&api), key, url, width, height); + } +} + +fn spawn_art_fetch( + runtime: &Runtime, + api: Arc, + key: String, + url: String, + width: u16, + height: u16, +) { + let tx = runtime.event_tx.clone(); + let semaphore = Arc::clone(&runtime.art_semaphore); + tokio::spawn(async move { + let Ok(_permit) = semaphore.acquire_owned().await else { + return; + }; + let art = match api.get_bytes(&url).await { + Ok(bytes) => tokio::task::spawn_blocking(move || { + crate::art::decode_to_cells(&bytes, width, height) + }) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r) + .map_err(|err| tracing::warn!(%err, url, "artwork decode failed")) + .ok() + .map(Arc::new), + Err(err) => { + tracing::warn!(%err, url, "artwork fetch failed"); + None + } + }; + let _ = tx.send(AppEvent::ArtLoaded { key, art }); + }); +} + +/// Execute a side effect requested by update(). +fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { + match effect { + Effect::PlayCurrent => { + play_current(state, runtime); + push_state_now(state, runtime); + push_media_metadata(state, runtime); + push_media_update(state, runtime, true); + } + Effect::TogglePause => { + runtime.player.toggle_pause(); + push_media_update(state, runtime, true); + } + Effect::StopPlayback => { + runtime.player.stop(); + push_media_update(state, runtime, true); + } + Effect::SeekBy(delta) => { + let target = (state.player.position_secs + delta as f64).max(0.0); + state.player.position_secs = target; + runtime.player.seek(std::time::Duration::from_secs_f64(target)); + } + Effect::SetVolume(volume) => runtime.player.set_volume(player::amplitude(volume)), + Effect::EnqueueRelease { id, next } => { + let Some(api) = runtime.api.clone() else { + return; + }; + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + match api.release(id).await { + Ok(detail) => { + let _ = tx.send(AppEvent::EnqueueTracks { + tracks: detail.tracks, + next, + }); + } + Err(err) => { + tracing::warn!(%err, release = id, "queueing a release failed"); + let _ = tx.send(AppEvent::StatusMessage(format!("queue failed: {err}"))); + } + } + }); + } + Effect::ToggleLike { track_id } => { + let Some(api) = runtime.api.clone() else { + return; + }; + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + match api.toggle_like(track_id).await { + Ok(liked) => { + let _ = tx.send(AppEvent::LikeToggled { track_id, liked }); + } + Err(err) => { + tracing::warn!(%err, track_id, "like toggle failed"); + let _ = tx.send(AppEvent::StatusMessage(format!("like failed: {err}"))); + } + } + }); + } + } +} + +/// Start streaming `queue[queue_pos]`: open the authenticated HTTP stream in +/// a background task and hand the reader to the audio thread. +fn play_current(state: &mut AppState, runtime: &Runtime) { + let Some(track) = state.player.queue.get(state.player.queue_pos).cloned() else { + return; + }; + let Some(api) = runtime.api.clone() else { + return; + }; + // The track that was playing until now was cut short by this switch. + if let Some(previous) = state.player.current.take() { + if state.player.playing { + report_history( + runtime, + previous.id, + state.player.track_started_at, + state.player.position_secs.round() as i32, + ); + } + } + state.player.current = Some(track.clone()); + state.player.playing = true; + state.player.paused = false; + state.player.position_secs = 0.0; + state.player.track_started_at = Some(auth::now_epoch_seconds()); + state.player.prefetched_pos = None; + state.status_message = Some(format!("▶ {} — {}", track.title, track.artist_line())); + + let controller = runtime.player.clone(); + let volume = player::amplitude(state.player.volume); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + match api.open_stream(&track.stream_url).await { + Ok((reader, byte_len)) => controller.play(reader, byte_len, volume), + Err(ApiError::SessionExpired) => { + let _ = tx.send(AppEvent::SessionExpired); + } + Err(err) => { + let _ = tx.send(AppEvent::StatusMessage(format!("playback failed: {err}"))); + } + } + }); +} + +/// Start streaming the next queue item ~30s before the current track ends +/// and append it in the audio thread, so rodio switches sources without a +/// device gap. +fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) { + const PREFETCH_MARGIN_SECS: f64 = 30.0; + let player = &state.player; + if !player.playing || player.paused || player.prefetched_pos.is_some() { + return; + } + let Some(track) = &player.current else { + return; + }; + if track.duration_seconds <= 0.0 + || track.duration_seconds - player.position_secs > PREFETCH_MARGIN_SECS + { + return; + } + let Some(next_pos) = update::peek_next_pos(player) else { + return; + }; + let Some(next) = player.queue.get(next_pos).cloned() else { + return; + }; + let Some(api) = runtime.api.clone() else { + return; + }; + state.player.prefetched_pos = Some(next_pos); + tracing::debug!(title = %next.title, "prefetching next track"); + let controller = runtime.player.clone(); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + match api.open_stream(&next.stream_url).await { + Ok((reader, byte_len)) => controller.enqueue(reader, byte_len), + Err(err) => { + tracing::warn!(%err, "prefetch failed; falling back to a normal switch"); + let _ = tx.send(AppEvent::PrefetchFailed { pos: next_pos }); + } + } + }); +} + +/// Persist playback state server-side: on track changes (called directly) +/// and every ~10s while something is playing (called from the tick). +fn maybe_push_state(state: &AppState, runtime: &mut Runtime) { + const PUSH_INTERVAL: Duration = Duration::from_secs(10); + if !state.player.playing { + return; + } + let due = runtime + .last_state_push + .is_none_or(|at| at.elapsed() >= PUSH_INTERVAL); + if due { + push_state_now(state, runtime); + } +} + +fn push_state_now(state: &AppState, runtime: &mut Runtime) { + let Some(api) = runtime.api.clone() else { + return; + }; + runtime.last_state_push = Some(std::time::Instant::now()); + let player = &state.player; + let body = crate::api::client::PlaybackStateBody { + current_track_id: player.current.as_ref().map(|t| t.id), + position_ms: (player.position_secs * 1000.0) as i32, + queue: player.queue.iter().map(|t| t.id).collect(), + queue_position: player.queue_pos as i32, + shuffle: player.shuffle, + repeat_mode: player.repeat.label().to_string(), + volume: f64::from(player.volume) / 100.0, + }; + tokio::spawn(async move { + if let Err(err) = api.push_state(&body).await { + tracing::warn!(%err, "state push failed"); + } + }); +} + +/// Fire-and-forget history report; listens shorter than 5s are noise. +fn report_history(runtime: &Runtime, track_id: i64, started_at: Option, listened: i32) { + if listened < 5 { + return; + } + let Some(api) = runtime.api.clone() else { + return; + }; + tokio::spawn(async move { + if let Err(err) = api.report_history(track_id, started_at, listened).await { + tracing::warn!(%err, "history report failed"); + } + }); +} + +/// Clear a timed-out quit confirmation and its status-bar hint. +fn expire_quit_confirmation(state: &mut AppState) { + if state + .quit_armed_until + .is_some_and(|deadline| std::time::Instant::now() > deadline) + { + state.quit_armed_until = None; + if state.status_message.as_deref() == Some(update::QUIT_CONFIRM_HINT) { + state.status_message = None; + } + } +} + +/// Validate the stored session in the background: a dead refresh token sends +/// the user back to the login screen instead of failing on first use. +fn spawn_session_check(runtime: &Runtime, api: Arc) { + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + match api.me().await { + Ok(me) => { + let _ = tx.send(AppEvent::StatusMessage(format!("signed in as {}", me.name))); + } + Err(ApiError::SessionExpired) => { + let _ = tx.send(AppEvent::SessionExpired); + } + Err(err) => { + tracing::warn!(%err, "session check failed"); + let _ = tx.send(AppEvent::StatusMessage(format!("server unreachable: {err}"))); + } + } + }); +} + +fn handle_terminal_event( + state: &mut AppState, + keymap: &mut Keymap, + runtime: &mut Runtime, + event: TermEvent, +) { + match event { + TermEvent::Key(key) => { + // Kitty-enhanced terminals and Windows also deliver Release + // events; acting on them would double-fire every binding. + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return; + } + match state.screen { + Screen::Login => login::handle_key(state, runtime, key), + Screen::Main if state.cmdline.active => cmdline::handle_key(state, runtime, key), + Screen::Main => handle_main_key(state, keymap, runtime, key), + } + } + TermEvent::Paste(pasted) => match state.screen { + Screen::Login => login::handle_paste(state, &pasted), + Screen::Main if state.cmdline.active => cmdline::handle_paste(state, runtime, &pasted), + Screen::Main => {} + }, + _ => {} + } +} + +fn handle_main_key(state: &mut AppState, keymap: &mut Keymap, runtime: &mut Runtime, key: KeyEvent) { + let combo = KeyCombination::from(key); + match keymap.resolve(combo, state.active_tab.key_context()) { + KeyResolution::Action(action) => { + state.pending_keys = None; + tracing::debug!(?action, "key resolved"); + // Logout needs the Runtime, which pure update() never touches. + if action == action::Action::Logout { + perform_logout(state, runtime); + } else if let Some(effect) = update(state, action) { + perform_effect(state, runtime, effect); + } + } + KeyResolution::Pending(keys) => state.pending_keys = Some(keys), + KeyResolution::Unmatched => state.pending_keys = None, + } +} + +/// Sign out: revoke the session server-side (best effort, in the background), +/// delete stored credentials, return to the login screen with the server +/// URL kept for convenience. +fn perform_logout(state: &mut AppState, runtime: &mut Runtime) { + let server_url = runtime.api.as_ref().map(|api| api.base_url().to_string()); + if let Some(api) = runtime.api.take() { + tokio::spawn(async move { + match api.logout().await { + Ok(revoked) => tracing::info!(revoked, "logged out"), + Err(err) => tracing::warn!(%err, "server-side logout failed"), + } + }); + } + auth::delete_session(); + runtime.player.stop(); + state.player = state::PlayerBar::default(); + state.user = None; + state.login = state::LoginForm::default(); + if let Some(url) = server_url { + state.login.server_url = url; + } + reset_library_state(state); + state.screen = Screen::Login; + state.status_message = None; +} + +/// Drop everything fetched from the previous account/server. +fn reset_library_state(state: &mut AppState) { + state.global = state::GlobalTab::default(); + state.artist_views.clear(); + state.release_views.clear(); + state.playlists = state::PlaylistsTab::default(); + state.playlist_views.clear(); + state.likes.clear(); + state.likes_loaded = false; + state.search = state::SearchState::default(); + state.cmdline = state::Cmdline::default(); + state.art.clear(); +} + +fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) { + match event { + AppEvent::StatusMessage(message) => state.status_message = Some(message), + AppEvent::LoginSucceeded(session) => { + if let Some(listener) = runtime.sso.take() { + listener.abort(); + } + state.status_message = Some(format!("signed in as {}", session.user.name)); + state.user = Some(session.user.clone()); + runtime.api = Some(Arc::new(ApiClient::new(runtime.http.clone(), *session))); + state.login = state::LoginForm::default(); + state.screen = Screen::Main; + } + AppEvent::LoginFailed(message) => { + state.login.busy = false; + state.login.error = Some(message); + } + AppEvent::SsoCallback(result) => { + runtime.sso = None; + if state.screen != Screen::Login + || state.login.mode != state::LoginMode::SsoPending + || state.login.busy + { + return; + } + match result { + Ok(code) => login::spawn_sso_exchange(&mut state.login, runtime, code), + Err(message) => state.login.error = Some(message), + } + } + AppEvent::SessionExpired => { + state.user = None; + state.login = state::LoginForm::default(); + if let Some(api) = runtime.api.take() { + state.login.server_url = api.base_url().to_string(); + } + state.login.error = Some("session expired — sign in again".to_string()); + runtime.player.stop(); + state.player = state::PlayerBar::default(); + reset_library_state(state); + state.screen = Screen::Login; + } + AppEvent::ArtistsLoaded(Ok(page)) => { + let global = &mut state.global; + global.loading = false; + global.total = page.total; + global.has_more = page.has_more; + global.next_page = page.page + 1; + global.artists.extend(page.items); + } + AppEvent::ArtistsLoaded(Err(message)) => { + state.global.loading = false; + state.global.error = Some(message.clone()); + state.status_message = Some(message); + } + AppEvent::ArtistViewLoaded { id, result } => { + let entry = match result { + Ok(detail) => state::Loadable::Ready(detail), + Err(message) => state::Loadable::Failed(message), + }; + state.artist_views.insert(id, entry); + } + AppEvent::ReleaseViewLoaded { id, result } => { + let entry = match result { + Ok(detail) => state::Loadable::Ready(detail), + Err(message) => state::Loadable::Failed(message), + }; + state.release_views.insert(id, entry); + } + AppEvent::SearchLoaded { seq, result } => { + if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) { + return; + } + state.search.loading = false; + match result { + Ok(results) => state.search.results = Some(results), + Err(message) => state.status_message = Some(message), + } + } + AppEvent::ArtLoaded { key, art } => { + let entry = match art { + Some(image) => state::ArtState::Ready(image), + None => state::ArtState::Failed, + }; + state.art.insert(key, entry); + } + AppEvent::Player(player::PlayerEvent::TrackFinished { has_next }) => { + // The finished track gets a full-duration history entry. + if let Some(finished) = state.player.current.clone() { + report_history( + runtime, + finished.id, + state.player.track_started_at, + finished.duration_seconds.round() as i32, + ); + } + if has_next { + // A prefetched source is already playing; just realign state. + let next_pos = state + .player + .prefetched_pos + .take() + .unwrap_or(state.player.queue_pos + 1); + state.player.queue_pos = next_pos.min(state.player.queue.len().saturating_sub(1)); + state.player.current = state.player.queue.get(state.player.queue_pos).cloned(); + state.player.position_secs = 0.0; + state.player.track_started_at = Some(auth::now_epoch_seconds()); + push_media_metadata(state, runtime); + push_media_update(state, runtime, true); + } else { + state.player.current = None; + state.player.prefetched_pos = None; + if let Some(effect) = update::advance_after_finish(state) { + perform_effect(state, runtime, effect); + } + } + push_state_now(state, runtime); + } + AppEvent::Player(player::PlayerEvent::Failed(message)) => { + state.player.playing = false; + state.player.paused = false; + state.status_message = Some(message); + } + AppEvent::PrefetchFailed { pos } => { + if state.player.prefetched_pos == Some(pos) { + state.player.prefetched_pos = None; + } + } + AppEvent::PlaylistsLoaded(result) => { + state.playlists.list = Some(match result { + Ok(list) => state::Loadable::Ready(list), + Err(message) => { + tracing::warn!(%message, "playlists load failed"); + state::Loadable::Failed(message) + } + }); + } + AppEvent::PlaylistViewLoaded { id, result } => { + let entry = match result { + Ok(detail) => state::Loadable::Ready(detail), + Err(message) => state::Loadable::Failed(message), + }; + state.playlist_views.insert(id, entry); + } + AppEvent::LikesLoaded(result) => match result { + Ok(ids) => { + state.likes = ids.into_iter().collect(); + } + Err(message) => tracing::warn!(%message, "likes load failed"), + }, + AppEvent::LikeToggled { track_id, liked } => { + if liked { + state.likes.insert(track_id); + } else { + state.likes.remove(&track_id); + } + // The virtual Likes playlist is stale now; refetch on next open. + state.playlist_views.remove(&state::LIKES_PLAYLIST_ID); + state.status_message = Some(if liked { + "♥ liked".to_string() + } else { + "like removed".to_string() + }); + } + AppEvent::EnqueueTracks { tracks, next } => { + let count = tracks.len(); + update::enqueue_tracks(state, tracks, next); + state.status_message = Some(if next { + format!("{count} tracks queued next") + } else { + format!("{count} tracks queued") + }); + } + AppEvent::Media(command) => { + use crate::media::MediaCommand; + tracing::debug!(?command, "media key"); + let action = match command { + MediaCommand::TogglePause | MediaCommand::Play | MediaCommand::Pause => { + action::Action::PlayPause + } + MediaCommand::Next => action::Action::NextTrack, + MediaCommand::Previous => action::Action::PrevTrack, + MediaCommand::Stop => { + state.player.playing = false; + state.player.paused = false; + state.player.current = None; + runtime.player.stop(); + push_media_update(state, runtime, true); + return; + } + }; + if let Some(effect) = update(state, action) { + perform_effect(state, runtime, effect); + } + } + } +} + +/// Mirror the playback state to the OS now-playing surface. `force` skips +/// the position throttle (track switches, pauses). +fn push_media_update(state: &AppState, runtime: &mut Runtime, force: bool) { + use crate::media::MediaUpdate; + const POSITION_INTERVAL: Duration = Duration::from_secs(2); + if !force + && runtime + .last_media_push + .is_some_and(|at| at.elapsed() < POSITION_INTERVAL) + { + return; + } + runtime.last_media_push = Some(std::time::Instant::now()); + let player = &state.player; + if !player.playing { + let _ = runtime.media_tx.send(MediaUpdate::Stopped); + return; + } + let _ = runtime.media_tx.send(MediaUpdate::Playback { + playing: player.playing, + paused: player.paused, + position_secs: player.position_secs, + }); +} + +fn push_media_metadata(state: &AppState, runtime: &Runtime) { + use crate::media::MediaUpdate; + if let Some(track) = &state.player.current { + let _ = runtime.media_tx.send(MediaUpdate::Metadata { + title: track.title.clone(), + artist: track.artist_line(), + album: track.release_title.clone(), + duration_secs: track.duration_seconds, + }); + } +} diff --git a/src/app/sso.rs b/src/app/sso.rs new file mode 100644 index 0000000..e11c152 --- /dev/null +++ b/src/app/sso.rs @@ -0,0 +1,134 @@ +use std::io; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::app::event::AppEvent; + +/// Loopback callback listener for browser SSO (RFC 8252 native-app flow). +/// The backend 303-redirects the browser to `http://127.0.0.1:{port}/callback` +/// with the exchange code; the listener delivers it as an AppEvent and exits. +pub struct SsoListener { + pub port: u16, + handle: tokio::task::JoinHandle<()>, +} + +impl SsoListener { + pub fn abort(&self) { + self.handle.abort(); + } +} + +pub fn start(tx: UnboundedSender) -> io::Result { + let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?; + listener.set_nonblocking(true)?; + let port = listener.local_addr()?.port(); + let handle = tokio::spawn(async move { + let result = match serve_one(listener).await { + Ok(result) => result, + Err(err) => Err(format!("callback listener failed: {err}")), + }; + let _ = tx.send(AppEvent::SsoCallback(result)); + }); + Ok(SsoListener { port, handle }) +} + +async fn serve_one(listener: std::net::TcpListener) -> io::Result> { + let listener = tokio::net::TcpListener::from_std(listener)?; + loop { + let (mut stream, _) = listener.accept().await?; + let request_line = read_request_line(&mut stream).await?; + let Some(result) = parse_request_line(&request_line) else { + // Stray request (favicon, prefetch) — keep waiting for the code. + let _ = stream.write_all(&response(404, "Not Found")).await; + continue; + }; + let page = match &result { + Ok(_) => "Sign-in complete. You can close this window and return to the terminal.", + Err(_) => "Sign-in failed. Return to the terminal to see the error.", + }; + let _ = stream.write_all(&response(200, page)).await; + let _ = stream.shutdown().await; + return Ok(result); + } +} + +async fn read_request_line(stream: &mut tokio::net::TcpStream) -> io::Result { + let mut buf = vec![0u8; 8192]; + let mut len = 0; + while len < buf.len() { + let n = stream.read(&mut buf[len..]).await?; + if n == 0 { + break; + } + len += n; + if buf[..len].windows(2).any(|w| w == b"\r\n") { + break; + } + } + let text = String::from_utf8_lossy(&buf[..len]); + Ok(text.lines().next().unwrap_or_default().to_string()) +} + +/// `GET /callback?code=furu_mx_... HTTP/1.1` → Ok(code) / Err(error). +/// Values are plain tokens (no percent-encoded characters expected). +fn parse_request_line(line: &str) -> Option> { + let path = line.split_whitespace().nth(1)?; + let query = path.split_once('?').map(|(_, q)| q).unwrap_or(""); + let mut code = None; + let mut error = None; + for pair in query.split('&') { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + match key { + "code" if !value.is_empty() => code = Some(value.to_string()), + "error" if !value.is_empty() => error = Some(value.to_string()), + _ => {} + } + } + if let Some(error) = error { + return Some(Err(format!("SSO failed: {error}"))); + } + code.map(Ok) +} + +fn response(status: u16, body: &str) -> Vec { + let reason = if status == 200 { "OK" } else { "Not Found" }; + let body = format!( + "furumi\ +

{body}

" + ); + format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_code_from_request_line() { + assert_eq!( + parse_request_line("GET /callback?code=furu_mx_abc HTTP/1.1"), + Some(Ok("furu_mx_abc".to_string())) + ); + } + + #[test] + fn parses_error_from_request_line() { + assert_eq!( + parse_request_line("GET /callback?error=provider_denied HTTP/1.1"), + Some(Err("SSO failed: provider_denied".to_string())) + ); + } + + #[test] + fn ignores_unrelated_requests() { + assert_eq!(parse_request_line("GET /favicon.ico HTTP/1.1"), None); + assert_eq!(parse_request_line("GET /callback HTTP/1.1"), None); + } +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 0000000..9bcc600 --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,407 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use crate::api::models::{ + ArtistCard, ArtistDetail, PlaylistCard, PlaylistDetail, ReleaseCard, ReleaseDetail, + SearchResults, TrackItem, User, +}; +use crate::art::ArtImage; +use crate::config::keymap::KeyContext; + +/// Remote data that a view renders: spinner, content, or error. +#[derive(Debug)] +pub enum Loadable { + Loading, + Ready(T), + Failed(String), +} + +/// Tile geometry for the Global artist grid (kept here so selection math in +/// update() and rendering in ui::global agree). Width × height in cells, +/// including the tile border; the art area inside is 18×8 cells = 18×16 px. +pub const TILE_WIDTH: u16 = 20; +pub const TILE_HEIGHT: u16 = 12; +pub const ART_CELL_WIDTH: u16 = 18; +pub const ART_CELL_HEIGHT: u16 = 8; +/// Header artwork (artist page, release page): 24×12 cells = 24×24 px. +pub const ART_HEADER_WIDTH: u16 = 24; +pub const ART_HEADER_HEIGHT: u16 = 12; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ViewMode { + #[default] + Tiles, + Table, +} + +impl ViewMode { + pub fn toggle(self) -> ViewMode { + match self { + ViewMode::Tiles => ViewMode::Table, + ViewMode::Table => ViewMode::Tiles, + } + } +} + +/// Artist image in the shared art cache. +#[derive(Debug, Clone)] +pub enum ArtState { + Loading, + Ready(Arc), + Failed, +} + +/// A drill-down view pushed on top of the Global artist grid. Cursors live +/// in the stack entry so going Back restores the previous position. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GlobalView { + /// Linear cursor over top tracks (0..tracks) then releases in display + /// order (tracks..tracks+releases). + Artist { id: i64, cursor: usize }, + Release { id: i64, cursor: usize }, + /// Linear cursor over search results: artists, then releases, then tracks. + Search { cursor: usize }, +} + +/// The Global tab: the whole server library of artists. +#[derive(Debug)] +pub struct GlobalTab { + pub artists: Vec, + pub total: i64, + pub has_more: bool, + pub next_page: i64, + pub loading: bool, + pub error: Option, + pub selected: usize, + pub view: ViewMode, + pub stack: Vec, +} + +impl Default for GlobalTab { + fn default() -> Self { + Self { + artists: Vec::new(), + total: 0, + has_more: true, + next_page: 1, + loading: false, + error: None, + selected: 0, + view: ViewMode::default(), + stack: Vec::new(), + } + } +} + +/// Releases of an artist in display order: grouped by type (albums, EPs, +/// singles, compilations, then anything else), keeping server order within a +/// group. Returns (group label, indices into the original slice). Cursor +/// positions use this flattened order, so update() and ui must both go +/// through here. +pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec)> { + const GROUPS: [(&str, &str); 4] = [ + ("album", "Albums"), + ("ep", "EPs"), + ("single", "Singles"), + ("compilation", "Compilations"), + ]; + let mut groups: Vec<(&'static str, Vec)> = Vec::new(); + for (kind, label) in GROUPS { + let indices: Vec = releases + .iter() + .enumerate() + .filter(|(_, r)| r.release_type.eq_ignore_ascii_case(kind)) + .map(|(i, _)| i) + .collect(); + if !indices.is_empty() { + groups.push((label, indices)); + } + } + let known: Vec = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect(); + let other: Vec = (0..releases.len()).filter(|i| !known.contains(i)).collect(); + if !other.is_empty() { + groups.push(("Other", other)); + } + groups +} + +/// Flattened display order of releases (concatenated groups). +pub fn release_display_order(releases: &[ReleaseCard]) -> Vec { + release_groups(releases) + .into_iter() + .flat_map(|(_, indices)| indices) + .collect() +} + +/// Visual tile-grid rows of the releases section: each group starts its own +/// rows, chunked by the column count. Values are display-order positions. +/// Vertical cursor movement must follow these rows to match the rendering. +pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec> { + let columns = columns.max(1); + let mut rows = Vec::new(); + let mut position = 0; + for (_, group) in release_groups(releases) { + for chunk in group.chunks(columns) { + rows.push((position..position + chunk.len()).collect()); + position += chunk.len(); + } + } + rows +} + +/// The virtual server-side Likes playlist id (`kind == "likes"`). +pub const LIKES_PLAYLIST_ID: i64 = -1; + +#[derive(Debug, Clone, Copy)] +pub struct OpenedPlaylist { + pub id: i64, + pub cursor: usize, +} + +/// The Playlists tab. The server list includes the virtual "Likes" +/// playlist (id = -1), rendered with a ♥ marker. +#[derive(Debug, Default)] +pub struct PlaylistsTab { + pub list: Option>>, + pub selected: usize, + pub opened: Option, +} + +/// Command line (`:`), vim-style. Lives on the Main screen status bar. +#[derive(Debug, Default)] +pub struct Cmdline { + pub active: bool, + pub input: String, + /// A live command (search) applied effects during this session; Esc + /// undoes them, Enter keeps them. + pub live: bool, +} + +/// Live search state driven by the `:/query` command. +#[derive(Debug, Default)] +pub struct SearchState { + pub query: String, + pub loading: bool, + pub results: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Screen { + #[default] + Main, + Login, +} + +/// SSO is the primary sign-in path, so it sits right under the server URL; +/// the password fields below are the rare fallback. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LoginField { + #[default] + ServerUrl, + SsoButton, + Username, + Password, + SignInButton, +} + +impl LoginField { + const ORDER: [LoginField; 5] = [ + LoginField::ServerUrl, + LoginField::SsoButton, + LoginField::Username, + LoginField::Password, + LoginField::SignInButton, + ]; + + pub fn next(self) -> LoginField { + let i = Self::ORDER.iter().position(|f| *f == self).unwrap(); + Self::ORDER[(i + 1) % Self::ORDER.len()] + } + + pub fn prev(self) -> LoginField { + let i = Self::ORDER.iter().position(|f| *f == self).unwrap(); + Self::ORDER[(i + Self::ORDER.len() - 1) % Self::ORDER.len()] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LoginMode { + /// Server / username / password fields plus the SSO button. + #[default] + Form, + /// Browser SSO started; waiting for the pasted callback link or code. + SsoPending, +} + +#[derive(Debug)] +pub struct LoginForm { + pub server_url: String, + pub username: String, + pub password: String, + pub sso_paste: String, + pub sso_url: String, + pub sso_port: Option, + pub focus: LoginField, + pub mode: LoginMode, + pub busy: bool, + pub error: Option, +} + +impl Default for LoginForm { + fn default() -> Self { + Self { + server_url: "https://music.hexor.cy".to_string(), + username: String::new(), + password: String::new(), + sso_paste: String::new(), + sso_url: String::new(), + sso_port: None, + focus: LoginField::default(), + mode: LoginMode::default(), + busy: false, + error: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Tab { + #[default] + Global, + Playlists, + Queue, + Devices, +} + +impl Tab { + pub const ALL: [Tab; 4] = [Tab::Global, Tab::Playlists, Tab::Queue, Tab::Devices]; + + pub fn title(self) -> &'static str { + match self { + Tab::Global => "Global", + Tab::Playlists => "Playlists", + Tab::Queue => "Queue", + Tab::Devices => "Devices", + } + } + + pub fn index(self) -> usize { + Self::ALL.iter().position(|t| *t == self).unwrap() + } + + pub fn from_index(index: usize) -> Option { + Self::ALL.get(index).copied() + } + + pub fn next(self) -> Tab { + Self::ALL[(self.index() + 1) % Self::ALL.len()] + } + + pub fn prev(self) -> Tab { + Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] + } + + pub fn key_context(self) -> KeyContext { + match self { + Tab::Global => KeyContext::Library, + Tab::Playlists => KeyContext::Playlists, + Tab::Queue => KeyContext::Queue, + Tab::Devices => KeyContext::Devices, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RepeatMode { + #[default] + Off, + One, + All, +} + +impl RepeatMode { + pub fn label(self) -> &'static str { + match self { + RepeatMode::Off => "off", + RepeatMode::One => "one", + RepeatMode::All => "all", + } + } + + pub fn next(self) -> RepeatMode { + match self { + RepeatMode::Off => RepeatMode::All, + RepeatMode::All => RepeatMode::One, + RepeatMode::One => RepeatMode::Off, + } + } +} + +/// Playback state mirrored for the UI: the queue, the loaded track and the +/// position polled from the audio thread on every tick. +#[derive(Debug)] +pub struct PlayerBar { + pub queue: Vec, + pub queue_pos: usize, + pub current: Option, + /// A track is loaded (playing or paused); false = stopped. + pub playing: bool, + pub paused: bool, + pub position_secs: f64, + /// Epoch seconds when the current track started (for history reports). + pub track_started_at: Option, + /// Queue index already enqueued in the audio thread for gapless play. + pub prefetched_pos: Option, + pub volume: u8, + pub shuffle: bool, + pub repeat: RepeatMode, +} + +impl Default for PlayerBar { + fn default() -> Self { + Self { + queue: Vec::new(), + queue_pos: 0, + current: None, + playing: false, + paused: false, + position_secs: 0.0, + track_started_at: None, + prefetched_pos: None, + volume: 80, + shuffle: false, + repeat: RepeatMode::Off, + } + } +} + +/// Single source of truth for the UI. Mutated only by `update()` and the +/// event handlers in the main loop; views render from `&AppState`. +#[derive(Debug, Default)] +pub struct AppState { + pub screen: Screen, + pub active_tab: Tab, + pub should_quit: bool, + /// Double-press quit confirmation: set by the first Quit press, expires + /// after a short window (any other action also cancels it). + pub quit_armed_until: Option, + pub help_visible: bool, + pub pending_keys: Option, + pub status_message: Option, + pub player: PlayerBar, + pub login: LoginForm, + pub user: Option, + pub global: GlobalTab, + pub artist_views: HashMap>, + pub release_views: HashMap>, + pub playlists: PlaylistsTab, + pub playlist_views: HashMap>, + /// Liked track ids, for the ♥ markers everywhere tracks are shown. + pub likes: std::collections::HashSet, + pub likes_loaded: bool, + pub cmdline: Cmdline, + pub search: SearchState, + /// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by + /// every view that shows artwork. + pub art: HashMap, +} diff --git a/src/app/update.rs b/src/app/update.rs new file mode 100644 index 0000000..b1abe28 --- /dev/null +++ b/src/app/update.rs @@ -0,0 +1,1028 @@ +use std::time::{Duration, Instant}; + +use super::action::Action; +use crate::api::models::TrackItem; + +use super::state::{ + AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, Tab, TILE_HEIGHT, TILE_WIDTH, + ViewMode, release_display_order, release_rows, +}; + +pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500); +pub const QUIT_CONFIRM_HINT: &str = "press quit again to exit"; + +/// Side effects requested by `update()`; executed by the app loop, which +/// owns the Runtime (audio controller, API client). Keeps update() pure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Effect { + /// (Re)start playback of `queue[queue_pos]`. + PlayCurrent, + TogglePause, + StopPlayback, + /// Seek relative to the current position, in seconds. + SeekBy(i64), + SetVolume(u8), + /// Fetch a release and append all its tracks to the queue. + EnqueueRelease { id: i64, next: bool }, + ToggleLike { track_id: i64 }, +} + +pub fn update(state: &mut AppState, action: Action) -> Option { + // Any action other than a second Quit press disarms the confirmation. + let quit_armed = state + .quit_armed_until + .take() + .is_some_and(|deadline| Instant::now() <= deadline); + state.status_message = None; + match action { + Action::Quit => { + if quit_armed { + state.should_quit = true; + } else { + state.quit_armed_until = Some(Instant::now() + QUIT_CONFIRM_WINDOW); + state.status_message = Some(QUIT_CONFIRM_HINT.to_string()); + } + None + } + Action::ToggleHelp => { + state.help_visible = !state.help_visible; + None + } + Action::Back if state.help_visible => { + state.help_visible = false; + None + } + Action::NextTab => { + switch_tab(state, state.active_tab.next()); + None + } + Action::PrevTab => { + switch_tab(state, state.active_tab.prev()); + None + } + Action::GoToTab(index) => { + if let Some(tab) = Tab::from_index(index) { + // Pressing the current tab's number again resets it to its + // top level (closes drill-down views). + if tab == state.active_tab { + reset_tab(state, tab); + } else { + switch_tab(state, tab); + } + } + None + } + Action::PlayPause => { + if state.player.current.is_some() { + state.player.paused = !state.player.paused; + Some(Effect::TogglePause) + } else if state.player.queue.is_empty() { + state.status_message = Some("nothing queued — open a track and press enter".into()); + None + } else { + Some(Effect::PlayCurrent) + } + } + Action::NextTrack => queue_step(state, 1), + Action::PrevTrack => queue_step(state, -1), + Action::SeekForward { seconds } => { + state.player.current.is_some().then_some(Effect::SeekBy(seconds as i64)) + } + Action::SeekBackward { seconds } => state + .player + .current + .is_some() + .then_some(Effect::SeekBy(-(seconds as i64))), + Action::VolumeUp => { + state.player.volume = (state.player.volume + 5).min(100); + Some(Effect::SetVolume(state.player.volume)) + } + Action::VolumeDown => { + state.player.volume = state.player.volume.saturating_sub(5); + Some(Effect::SetVolume(state.player.volume)) + } + Action::ToggleShuffle => { + state.player.shuffle = !state.player.shuffle; + None + } + Action::CycleRepeat => { + state.player.repeat = state.player.repeat.next(); + None + } + Action::MoveUp => { + move_selection(state, 0, -1); + None + } + Action::MoveDown => { + move_selection(state, 0, 1); + None + } + Action::MoveLeft => { + move_selection(state, -1, 0); + None + } + Action::MoveRight => { + move_selection(state, 1, 0); + None + } + Action::PageUp => { + move_selection(state, 0, -page_step(state)); + None + } + Action::PageDown => { + move_selection(state, 0, page_step(state)); + None + } + Action::SelectFirst => { + jump_selection(state, true); + None + } + Action::SelectLast => { + jump_selection(state, false); + None + } + Action::ToggleViewMode => { + if state.active_tab == Tab::Global { + state.global.view = state.global.view.toggle(); + } + None + } + Action::OpenCommandLine => { + state.cmdline.active = true; + state.cmdline.input.clear(); + None + } + Action::Select => select_current(state), + Action::Back => { + go_back(state); + None + } + Action::ToggleLike => { + let target = selected_track(state) + .map(|t| t.id) + .or(state.player.current.as_ref().map(|t| t.id)); + match target { + Some(track_id) => Some(Effect::ToggleLike { track_id }), + None => { + state.status_message = Some("no track selected".into()); + None + } + } + } + Action::QueueAddNext => queue_add(state, true), + Action::QueueAddLast => queue_add(state, false), + // Needs the Runtime, so it is intercepted in app::handle_main_key + // before reaching update(). + Action::Logout => None, + } +} + +/// The track under the cursor in whatever view is showing tracks. +pub fn selected_track(state: &AppState) -> Option { + match state.active_tab { + Tab::Global => match state.global.stack.last()? { + GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { + Loadable::Ready(detail) => detail.top_tracks.get(*cursor).cloned(), + _ => None, + }, + GlobalView::Release { id, cursor } => match state.release_views.get(id)? { + Loadable::Ready(detail) => detail.tracks.get(*cursor).cloned(), + _ => None, + }, + GlobalView::Search { cursor } => { + let results = state.search.results.as_ref()?; + let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?; + results.tracks.get(offset).cloned() + } + }, + Tab::Playlists => { + let opened = state.playlists.opened.as_ref()?; + playlist_tracks(state, opened.id)?.get(opened.cursor).cloned() + } + Tab::Queue => state + .player + .queue + .get(state.player.queue_pos) + .cloned(), + Tab::Devices => None, + } +} + +/// Tracks backing an opened playlist, if loaded. +pub fn playlist_tracks(state: &AppState, id: i64) -> Option<&Vec> { + match state.playlist_views.get(&id)? { + Loadable::Ready(detail) => Some(&detail.tracks), + _ => None, + } +} + +/// A *release* under the cursor (artist-view tile/row or a search release). +fn selected_release_id(state: &AppState) -> Option { + if state.active_tab != Tab::Global { + return None; + } + match state.global.stack.last()? { + GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { + Loadable::Ready(detail) => { + let position = cursor.checked_sub(detail.top_tracks.len())?; + let order = release_display_order(&detail.releases); + order.get(position).map(|&i| detail.releases[i].id) + } + _ => None, + }, + GlobalView::Search { cursor } => { + let results = state.search.results.as_ref()?; + let offset = cursor.checked_sub(results.artists.len())?; + results.releases.get(offset).map(|r| r.id) + } + GlobalView::Release { .. } => None, + } +} + +/// a / shift-a: queue the selection — a single track directly, a release via +/// an async fetch effect. +fn queue_add(state: &mut AppState, next: bool) -> Option { + if let Some(track) = selected_track(state) { + let title = track.title.clone(); + enqueue_tracks(state, vec![track], next); + state.status_message = Some(if next { + format!("queued next: {title}") + } else { + format!("queued: {title}") + }); + return None; + } + if let Some(id) = selected_release_id(state) { + return Some(Effect::EnqueueRelease { id, next }); + } + state.status_message = Some("nothing to queue here".into()); + None +} + +/// Insert tracks after the playing one (`next`) or at the end. Keeps the +/// gapless prefetch index pointing at the same track if items shift. +pub fn enqueue_tracks(state: &mut AppState, tracks: Vec, next: bool) { + let player = &mut state.player; + if tracks.is_empty() { + return; + } + let insert_at = if next && !player.queue.is_empty() { + (player.queue_pos + 1).min(player.queue.len()) + } else if next { + 0 + } else { + player.queue.len() + }; + let count = tracks.len(); + for (offset, track) in tracks.into_iter().enumerate() { + player.queue.insert(insert_at + offset, track); + } + if let Some(prefetched) = &mut player.prefetched_pos { + if insert_at <= *prefetched { + *prefetched += count; + } + } + if insert_at <= player.queue_pos && player.current.is_some() { + player.queue_pos += count; + } +} + +/// Manual queue navigation (n / p). +fn queue_step(state: &mut AppState, direction: isize) -> Option { + let player = &mut state.player; + if player.queue.is_empty() { + state.status_message = Some("queue is empty".into()); + return None; + } + let len = player.queue.len(); + if player.shuffle && direction > 0 { + player.queue_pos = pseudo_random(len); + return Some(Effect::PlayCurrent); + } + let next = player.queue_pos as isize + direction; + if next < 0 { + player.queue_pos = 0; + } else if next >= len as isize { + if player.repeat == super::state::RepeatMode::All { + player.queue_pos = 0; + } else { + state.status_message = Some("end of queue".into()); + return None; + } + } else { + player.queue_pos = next as usize; + } + Some(Effect::PlayCurrent) +} + +/// What plays after the current track, without mutating anything — used to +/// pick the gapless prefetch target. Mirrors `advance_after_finish`. +pub fn peek_next_pos(player: &super::state::PlayerBar) -> Option { + if player.queue.is_empty() { + return None; + } + match player.repeat { + super::state::RepeatMode::One => Some(player.queue_pos), + _ if player.shuffle => Some(pseudo_random(player.queue.len())), + repeat => { + if player.queue_pos + 1 < player.queue.len() { + Some(player.queue_pos + 1) + } else if repeat == super::state::RepeatMode::All { + Some(0) + } else { + None + } + } + } +} + +/// The current track finished: pick what plays next according to +/// repeat/shuffle, or stop at the end of the queue. +pub fn advance_after_finish(state: &mut AppState) -> Option { + let player = &mut state.player; + if player.queue.is_empty() { + player.playing = false; + player.current = None; + return None; + } + match player.repeat { + super::state::RepeatMode::One => Some(Effect::PlayCurrent), + _ if player.shuffle => { + player.queue_pos = pseudo_random(player.queue.len()); + Some(Effect::PlayCurrent) + } + repeat => { + if player.queue_pos + 1 < player.queue.len() { + player.queue_pos += 1; + Some(Effect::PlayCurrent) + } else if repeat == super::state::RepeatMode::All { + player.queue_pos = 0; + Some(Effect::PlayCurrent) + } else { + player.playing = false; + player.paused = false; + Some(Effect::StopPlayback) + } + } + } +} + +/// Shuffle pick without a rand dependency: clock-derived index. +fn pseudo_random(len: usize) -> usize { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + nanos as usize % len.max(1) +} + +/// Columns of the Global tile grid. Derived from the terminal width the same +/// way ui::global does (full width minus the surrounding block's borders), +/// so selection math and rendering agree. +pub fn grid_columns() -> usize { + let width = crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80); + usize::from((width.saturating_sub(2) / TILE_WIDTH).max(1)) +} + +/// Lines of content visible in the main area (terminal height minus the tab +/// bar, status bar and the view's borders). +fn viewport_lines() -> isize { + let height = crossterm::terminal::size().map(|(_, h)| h).unwrap_or(24); + height.saturating_sub(5).max(1) as isize +} + +/// One PageUp/PageDown step in MoveUp/MoveDown units for the current view: +/// move_selection() multiplies vertical steps by the column count in tile +/// zones, so tile views page by visible tile rows, line views by visible +/// lines. +fn page_step(state: &AppState) -> isize { + let lines = viewport_lines(); + let tile_rows = (lines / TILE_HEIGHT as isize).max(1); + match state.global.stack.last() { + None => match state.global.view { + ViewMode::Tiles => tile_rows, + ViewMode::Table => lines, + }, + Some(GlobalView::Artist { id, cursor }) => { + let in_tracks = match state.artist_views.get(id) { + Some(Loadable::Ready(detail)) => *cursor < detail.top_tracks.len(), + _ => true, + }; + if in_tracks || state.global.view == ViewMode::Table { + lines + } else { + tile_rows + } + } + Some(GlobalView::Release { .. }) | Some(GlobalView::Search { .. }) => lines, + } +} + +fn move_selection(state: &mut AppState, dx: isize, dy: isize) { + if state.active_tab == Tab::Playlists { + let len = playlists_view_len(state); + if len == 0 { + return; + } + let last = len as isize - 1; + match &mut state.playlists.opened { + Some(opened) => { + opened.cursor = (opened.cursor as isize + dy).clamp(0, last) as usize; + } + None => { + state.playlists.selected = + (state.playlists.selected as isize + dy).clamp(0, last) as usize; + } + } + return; + } + if state.active_tab != Tab::Global { + return not_yet(state, "Navigation in this view"); + } + match state.global.stack.last().copied() { + None => { + let global = &mut state.global; + if global.artists.is_empty() { + return; + } + let step = match global.view { + ViewMode::Tiles => dx + dy * grid_columns() as isize, + ViewMode::Table => dy, + }; + let last = global.artists.len() as isize - 1; + global.selected = (global.selected as isize + step).clamp(0, last) as usize; + } + Some(GlobalView::Artist { id, cursor }) => { + let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else { + return; + }; + let tracks = detail.top_tracks.len(); + let total = tracks + detail.releases.len(); + if total == 0 { + return; + } + let next = if cursor < tracks { + // Top-tracks zone: vertical only; stepping past the last + // track enters the releases zone (its first item). + (cursor as isize + dy).clamp(0, total as isize - 1) as usize + } else if state.global.view == ViewMode::Table { + let next = cursor as isize + dy; + if next < tracks as isize && dy < 0 && tracks > 0 { + tracks - 1 + } else { + next.clamp(0, total as isize - 1) as usize + } + } else { + // Tiles: move by visual rows (groups break rows), keeping + // the column, so Up lands on the tile directly above. + let rows = release_rows(&detail.releases, grid_columns()); + let position = cursor - tracks; + let (row, column) = rows + .iter() + .enumerate() + .find_map(|(r, items)| { + items.iter().position(|p| *p == position).map(|c| (r, c)) + }) + .unwrap_or((0, 0)); + if dx != 0 { + let last = detail.releases.len() as isize - 1; + tracks + (position as isize + dx).clamp(0, last) as usize + } else { + let target = row as isize + dy; + if target < 0 { + if tracks > 0 { + tracks - 1 + } else { + cursor + } + } else { + let items = &rows[(target as usize).min(rows.len() - 1)]; + tracks + items[column.min(items.len() - 1)] + } + } + }; + set_view_cursor(state, next); + } + Some(GlobalView::Release { id, cursor }) => { + let Some(Loadable::Ready(detail)) = state.release_views.get(&id) else { + return; + }; + let total = detail.tracks.len() as isize; + if total == 0 { + return; + } + let next = (cursor as isize + dy).clamp(0, total - 1); + set_view_cursor(state, next as usize); + } + Some(GlobalView::Search { cursor }) => { + let total = state.search.results.as_ref().map_or(0, |r| r.len()) as isize; + if total == 0 { + return; + } + let next = (cursor as isize + dy).clamp(0, total - 1); + set_view_cursor(state, next as usize); + } + } +} + +fn set_view_cursor(state: &mut AppState, value: usize) { + if let Some(view) = state.global.stack.last_mut() { + match view { + GlobalView::Artist { cursor, .. } + | GlobalView::Release { cursor, .. } + | GlobalView::Search { cursor } => *cursor = value, + } + } +} + +/// Items in the playlists tab's current view (list or opened playlist). +fn playlists_view_len(state: &AppState) -> usize { + match &state.playlists.opened { + Some(opened) => playlist_tracks(state, opened.id).map_or(0, Vec::len), + None => match &state.playlists.list { + Some(Loadable::Ready(list)) => list.len(), + _ => 0, + }, + } +} + +fn current_view_len(state: &AppState) -> usize { + if state.active_tab == Tab::Playlists { + return playlists_view_len(state); + } + match state.global.stack.last() { + None => state.global.artists.len(), + Some(GlobalView::Artist { id, .. }) => match state.artist_views.get(id) { + Some(Loadable::Ready(d)) => d.top_tracks.len() + d.releases.len(), + _ => 0, + }, + Some(GlobalView::Release { id, .. }) => match state.release_views.get(id) { + Some(Loadable::Ready(d)) => d.tracks.len(), + _ => 0, + }, + Some(GlobalView::Search { .. }) => state.search.results.as_ref().map_or(0, |r| r.len()), + } +} + +fn jump_selection(state: &mut AppState, first: bool) { + if state.active_tab != Tab::Global && state.active_tab != Tab::Playlists { + return not_yet(state, "Navigation in this view"); + } + let len = current_view_len(state); + if len == 0 { + return; + } + let target = if first { 0 } else { len - 1 }; + if state.active_tab == Tab::Playlists { + match &mut state.playlists.opened { + Some(opened) => opened.cursor = target, + None => state.playlists.selected = target, + } + } else if state.global.stack.is_empty() { + state.global.selected = target; + } else { + set_view_cursor(state, target); + } +} + +/// Enter on the Playlists tab: open a playlist from the list, or play the +/// selected track with the playlist as the queue. +fn select_playlist(state: &mut AppState) -> Option { + match state.playlists.opened { + Some(opened) => { + let tracks = playlist_tracks(state, opened.id)?.clone(); + if tracks.is_empty() { + return None; + } + state.player.queue = tracks; + state.player.queue_pos = opened.cursor.min(state.player.queue.len() - 1); + Some(Effect::PlayCurrent) + } + None => { + let id = match &state.playlists.list { + Some(Loadable::Ready(list)) => list.get(state.playlists.selected)?.id, + _ => return None, + }; + state.playlists.opened = Some(OpenedPlaylist { id, cursor: 0 }); + None + } + } +} + +/// Enter: drill down (grid → artist → release) or play the selected track +/// with its surrounding list as the queue. +fn select_current(state: &mut AppState) -> Option { + if state.active_tab == Tab::Playlists { + return select_playlist(state); + } + if state.active_tab != Tab::Global { + not_yet(state, "Navigation in this view"); + return None; + } + enum Outcome { + Push(GlobalView), + Play { tracks: Vec, start: usize }, + Nothing, + } + let outcome = match state.global.stack.last().copied() { + None => match state.global.artists.get(state.global.selected) { + Some(artist) => Outcome::Push(GlobalView::Artist { + id: artist.id, + cursor: 0, + }), + None => Outcome::Nothing, + }, + Some(GlobalView::Artist { id, cursor }) => match state.artist_views.get(&id) { + Some(Loadable::Ready(detail)) => { + let tracks = detail.top_tracks.len(); + if cursor < tracks { + Outcome::Play { + tracks: detail.top_tracks.clone(), + start: cursor, + } + } else { + let order = release_display_order(&detail.releases); + match order.get(cursor - tracks) { + Some(&original) => Outcome::Push(GlobalView::Release { + id: detail.releases[original].id, + cursor: 0, + }), + None => Outcome::Nothing, + } + } + } + _ => Outcome::Nothing, + }, + Some(GlobalView::Release { id, cursor }) => match state.release_views.get(&id) { + Some(Loadable::Ready(detail)) if !detail.tracks.is_empty() => Outcome::Play { + tracks: detail.tracks.clone(), + start: cursor.min(detail.tracks.len() - 1), + }, + _ => Outcome::Nothing, + }, + Some(GlobalView::Search { cursor }) => match &state.search.results { + Some(results) => { + let artists = results.artists.len(); + let releases = results.releases.len(); + if cursor < artists { + Outcome::Push(GlobalView::Artist { + id: results.artists[cursor].id, + cursor: 0, + }) + } else if cursor < artists + releases { + Outcome::Push(GlobalView::Release { + id: results.releases[cursor - artists].id, + cursor: 0, + }) + } else if results.tracks.get(cursor - artists - releases).is_some() { + Outcome::Play { + tracks: results.tracks.clone(), + start: cursor - artists - releases, + } + } else { + Outcome::Nothing + } + } + None => Outcome::Nothing, + }, + }; + match outcome { + Outcome::Push(view) => { + state.global.stack.push(view); + None + } + Outcome::Play { tracks, start } => { + state.player.queue = tracks; + state.player.queue_pos = start; + Some(Effect::PlayCurrent) + } + Outcome::Nothing => None, + } +} + +/// Esc/Backspace: pop the navigation stack; leaving a search view resets the +/// search so the next `:/` starts clean. +fn go_back(state: &mut AppState) { + match state.active_tab { + Tab::Playlists => { + state.playlists.opened = None; + } + Tab::Global => { + if let Some(popped) = state.global.stack.pop() { + if matches!(popped, GlobalView::Search { .. }) { + state.search = SearchState::default(); + } + } + } + _ => {} + } +} + +fn switch_tab(state: &mut AppState, tab: Tab) { + state.active_tab = tab; + state.help_visible = false; +} + +fn reset_tab(state: &mut AppState, tab: Tab) { + match tab { + Tab::Global => { + if state + .global + .stack + .iter() + .any(|v| matches!(v, GlobalView::Search { .. })) + { + state.search = SearchState::default(); + } + state.global.stack.clear(); + } + Tab::Playlists => state.playlists.opened = None, + Tab::Queue | Tab::Devices => {} + } +} + +fn not_yet(state: &mut AppState, what: &str) { + state.status_message = Some(format!("{what}: coming in a later milestone")); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::models::ArtistCard; + + fn with_artists(n: usize) -> AppState { + let mut state = AppState::default(); + state.global.artists = (0..n) + .map(|i| ArtistCard { + id: i as i64, + name: format!("artist {i}"), + image_url: None, + release_count: 1, + track_count: 2, + }) + .collect(); + state + } + + #[test] + fn quit_needs_double_press() { + let mut state = AppState::default(); + update(&mut state, Action::Quit); + assert!(!state.should_quit); + assert_eq!(state.status_message.as_deref(), Some(QUIT_CONFIRM_HINT)); + update(&mut state, Action::Quit); + assert!(state.should_quit); + } + + #[test] + fn other_action_disarms_quit() { + let mut state = AppState::default(); + update(&mut state, Action::Quit); + update(&mut state, Action::NextTab); + update(&mut state, Action::Quit); + assert!(!state.should_quit); + } + + #[test] + fn expired_quit_confirmation_rearms() { + let mut state = AppState::default(); + update(&mut state, Action::Quit); + state.quit_armed_until = Some(Instant::now() - Duration::from_secs(1)); + update(&mut state, Action::Quit); + assert!(!state.should_quit); + assert_eq!(state.status_message.as_deref(), Some(QUIT_CONFIRM_HINT)); + } + + #[test] + fn tab_cycling_wraps() { + let mut state = AppState::default(); + update(&mut state, Action::PrevTab); + assert_eq!(state.active_tab, Tab::Devices); + update(&mut state, Action::NextTab); + assert_eq!(state.active_tab, Tab::Global); + } + + #[test] + fn volume_clamps() { + let mut state = AppState::default(); + for _ in 0..30 { + update(&mut state, Action::VolumeUp); + } + assert_eq!(state.player.volume, 100); + for _ in 0..30 { + update(&mut state, Action::VolumeDown); + } + assert_eq!(state.player.volume, 0); + } + + #[test] + fn back_closes_help_first() { + let mut state = AppState::default(); + update(&mut state, Action::ToggleHelp); + assert!(state.help_visible); + update(&mut state, Action::Back); + assert!(!state.help_visible); + } + + #[test] + fn grid_movement_clamps_and_wraps_rows() { + let mut state = with_artists(10); + let cols = grid_columns(); + update(&mut state, Action::MoveDown); + assert_eq!(state.global.selected, cols.min(9)); + update(&mut state, Action::MoveUp); + assert_eq!(state.global.selected, 0); + update(&mut state, Action::MoveLeft); + assert_eq!(state.global.selected, 0); + update(&mut state, Action::MoveRight); + assert_eq!(state.global.selected, 1); + } + + #[test] + fn table_mode_moves_one_row() { + let mut state = with_artists(10); + state.global.view = ViewMode::Table; + update(&mut state, Action::MoveDown); + assert_eq!(state.global.selected, 1); + // Left/right are meaningless in the table. + update(&mut state, Action::MoveRight); + assert_eq!(state.global.selected, 1); + } + + #[test] + fn page_down_moves_a_page_and_clamps() { + let mut state = with_artists(200); + let cols = grid_columns() as isize; + let expected = (page_step(&state) * cols).min(199) as usize; + update(&mut state, Action::PageDown); + assert_eq!(state.global.selected, expected); + update(&mut state, Action::PageUp); + assert_eq!(state.global.selected, 0); + + state.global.view = ViewMode::Table; + update(&mut state, Action::PageDown); + assert_eq!(state.global.selected, page_step(&state).min(199) as usize); + } + + #[test] + fn jump_first_last() { + let mut state = with_artists(10); + update(&mut state, Action::SelectLast); + assert_eq!(state.global.selected, 9); + update(&mut state, Action::SelectFirst); + assert_eq!(state.global.selected, 0); + } + + #[test] + fn artist_tiles_move_by_visual_rows_across_groups() { + use crate::api::models::{ArtistDetail, ReleaseCard}; + + let release = |id: i64, kind: &str| ReleaseCard { + id, + title: format!("r{id}"), + release_type: kind.to_string(), + year: None, + cover_url: None, + track_count: 1, + }; + // columns = 3 in tests (no tty → 80 wide): albums rows [0,1,2],[3], + // compilations row [4,5]. + let detail = ArtistDetail { + id: 1, + name: "a".into(), + image_url: None, + total_track_count: 0, + total_play_count: 0, + top_tracks: vec![], + releases: vec![ + release(10, "album"), + release(11, "album"), + release(12, "album"), + release(13, "album"), + release(14, "compilation"), + release(15, "compilation"), + ], + }; + let mut state = AppState::default(); + state.artist_views.insert(1, Loadable::Ready(detail)); + state.global.stack.push(GlobalView::Artist { id: 1, cursor: 4 }); + + // Up from the first compilation lands on the album row directly + // above (position 3), not three flat items back. + update(&mut state, Action::MoveUp); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Artist { id: 1, cursor: 3 }) + ); + // And back down returns to the compilation row, same column. + update(&mut state, Action::MoveDown); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Artist { id: 1, cursor: 4 }) + ); + // Up from the second compilation clamps to the single tile above. + state.global.stack.pop(); + state.global.stack.push(GlobalView::Artist { id: 1, cursor: 5 }); + update(&mut state, Action::MoveUp); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Artist { id: 1, cursor: 3 }) + ); + } + + #[test] + fn select_opens_artist_and_back_returns() { + let mut state = with_artists(3); + state.global.selected = 2; + update(&mut state, Action::Select); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Artist { id: 2, cursor: 0 }) + ); + update(&mut state, Action::Back); + assert!(state.global.stack.is_empty()); + assert_eq!(state.global.selected, 2); + } + + #[test] + fn back_from_search_resets_search_state() { + let mut state = AppState::default(); + state.global.stack.push(GlobalView::Search { cursor: 0 }); + state.search.query = "abc".to_string(); + update(&mut state, Action::Back); + assert!(state.global.stack.is_empty()); + assert!(state.search.query.is_empty()); + } + + #[test] + fn queue_advances_and_respects_repeat() { + use crate::api::models::TrackItem; + use crate::app::state::RepeatMode; + + let track = |id: i64| TrackItem { + id, + title: format!("t{id}"), + track_number: None, + duration_seconds: 1.0, + artists: vec![], + featured_artists: vec![], + release_id: 1, + release_title: "r".into(), + release_year: None, + cover_url: None, + stream_url: format!("/api/player/stream/{id}"), + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + file_size_bytes: None, + lastfm_playcount: None, + }; + let mut state = AppState::default(); + state.player.queue = vec![track(1), track(2)]; + state.player.playing = true; + + // Track 1 finishes → play track 2. + assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent)); + assert_eq!(state.player.queue_pos, 1); + // Last track, repeat off → stop. + assert_eq!(advance_after_finish(&mut state), Some(Effect::StopPlayback)); + assert!(!state.player.playing); + // Repeat all wraps to the start. + state.player.playing = true; + state.player.repeat = RepeatMode::All; + assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent)); + assert_eq!(state.player.queue_pos, 0); + // Repeat one replays the same position. + state.player.repeat = RepeatMode::One; + assert_eq!(advance_after_finish(&mut state), Some(Effect::PlayCurrent)); + assert_eq!(state.player.queue_pos, 0); + } + + #[test] + fn same_tab_number_resets_to_root() { + let mut state = with_artists(3); + update(&mut state, Action::Select); + assert!(!state.global.stack.is_empty()); + update(&mut state, Action::GoToTab(0)); + assert!(state.global.stack.is_empty()); + + state.playlists.opened = Some(OpenedPlaylist { + id: crate::app::state::LIKES_PLAYLIST_ID, + cursor: 0, + }); + update(&mut state, Action::GoToTab(1)); + assert_eq!(state.active_tab, Tab::Playlists); + assert!(state.playlists.opened.is_some()); + update(&mut state, Action::GoToTab(1)); + assert!(state.playlists.opened.is_none()); + } + + #[test] + fn view_toggle() { + let mut state = AppState::default(); + update(&mut state, Action::ToggleViewMode); + assert_eq!(state.global.view, ViewMode::Table); + update(&mut state, Action::ToggleViewMode); + assert_eq!(state.global.view, ViewMode::Tiles); + } +} diff --git a/src/art.rs b/src/art.rs new file mode 100644 index 0000000..feffffe --- /dev/null +++ b/src/art.rs @@ -0,0 +1,87 @@ +//! Image → terminal-art conversion, used wherever the player shows pictures +//! (artist tiles, release covers, now-playing). +//! +//! The format is "half-block art": each terminal cell renders `▀` with the +//! foreground colored as the top pixel and the background as the bottom +//! pixel, giving 2 vertical pixels per cell. This needs no terminal image +//! protocol (sixel/kitty), so it works everywhere crossterm does, and it +//! looks far better than glyph-luminance ASCII at tile sizes. The UI layer +//! converts `ArtImage` cells into styled spans. + +use anyhow::{Context as _, Result}; + +/// Cache key for the shared artwork cache: the same image can be cached at +/// several cell sizes (grid tile vs page header). +pub fn cache_key(url: &str, width_cells: u16, height_cells: u16) -> String { + format!("{url}#{width_cells}x{height_cells}") +} + +/// Decoded, cell-sized art. `pixels` holds `width_cells * height_cells * 2` +/// RGB triples, row-major, two pixel rows per cell row (top, then bottom). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArtImage { + pub width_cells: u16, + pub height_cells: u16, + pixels: Vec<[u8; 3]>, +} + +impl ArtImage { + /// Top and bottom pixel of the cell at (column, cell row). + pub fn cell(&self, x: u16, y: u16) -> ([u8; 3], [u8; 3]) { + let width = self.width_cells as usize; + let top = (y as usize * 2) * width + x as usize; + let bottom = (y as usize * 2 + 1) * width + x as usize; + (self.pixels[top], self.pixels[bottom]) + } +} + +/// Decode image bytes (jpeg/png/webp/gif/bmp) and scale them to fill a +/// `width_cells` × `height_cells` terminal area, center-cropping overflow +/// like CSS object-fit: cover. +pub fn decode_to_cells(bytes: &[u8], width_cells: u16, height_cells: u16) -> Result { + let width_px = u32::from(width_cells.max(1)); + let height_px = u32::from(height_cells.max(1)) * 2; + let image = image::load_from_memory(bytes).context("unsupported or corrupt image")?; + let image = image + .resize_to_fill(width_px, height_px, image::imageops::FilterType::Triangle) + .into_rgb8(); + let pixels = image.pixels().map(|p| p.0).collect(); + Ok(ArtImage { + width_cells: width_cells.max(1), + height_cells: height_cells.max(1), + pixels, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png_2x2() -> Vec { + // red, green / blue, white + let mut img = image::RgbImage::new(2, 2); + img.put_pixel(0, 0, image::Rgb([255, 0, 0])); + img.put_pixel(1, 0, image::Rgb([0, 255, 0])); + img.put_pixel(0, 1, image::Rgb([0, 0, 255])); + img.put_pixel(1, 1, image::Rgb([255, 255, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(img) + .write_to(&mut out, image::ImageFormat::Png) + .unwrap(); + out.into_inner() + } + + #[test] + fn decodes_to_requested_cell_grid() { + let art = decode_to_cells(&png_2x2(), 2, 1).unwrap(); + assert_eq!((art.width_cells, art.height_cells), (2, 1)); + let (top, bottom) = art.cell(0, 0); + assert_eq!(top, [255, 0, 0]); + assert_eq!(bottom, [0, 0, 255]); + } + + #[test] + fn rejects_garbage() { + assert!(decode_to_cells(b"not an image", 4, 4).is_err()); + } +} diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml new file mode 100644 index 0000000..d0727bb --- /dev/null +++ b/src/config/default_keymap.toml @@ -0,0 +1,180 @@ +# Default keybindings for furumi-cli. +# +# To customize, copy entries into /furumi/keymap.toml +# (~/.config/furumi/keymap.toml on Linux/macOS). A user binding replaces the +# default binding with the same key sequence and context. +# +# key_sequence: space-separated chords, e.g. "g g" or "ctrl-a x". +# Modifiers: ctrl-, alt-, shift-, cmd-. Uppercase letters: "shift-g". +# command: an Action name, optionally with parameters: +# command = { SeekForward = { seconds = 30 } } +# context: optional view filter — global (default), library, search, +# playlists, queue, devices. + +[[keymaps]] +key_sequence = "q" +command = "Quit" + +[[keymaps]] +key_sequence = "ctrl-c" +command = "Quit" + +[[keymaps]] +key_sequence = "?" +command = "ToggleHelp" + +[[keymaps]] +key_sequence = "tab" +command = "NextTab" + +[[keymaps]] +key_sequence = "backtab" +command = "PrevTab" + +[[keymaps]] +key_sequence = "1" +command = { GoToTab = 0 } + +[[keymaps]] +key_sequence = "2" +command = { GoToTab = 1 } + +[[keymaps]] +key_sequence = "3" +command = { GoToTab = 2 } + +[[keymaps]] +key_sequence = "4" +command = { GoToTab = 3 } + +[[keymaps]] +key_sequence = "a" +command = "QueueAddNext" + +[[keymaps]] +key_sequence = "shift-a" +command = "QueueAddLast" + +[[keymaps]] +key_sequence = "j" +command = "MoveDown" + +[[keymaps]] +key_sequence = "down" +command = "MoveDown" + +[[keymaps]] +key_sequence = "k" +command = "MoveUp" + +[[keymaps]] +key_sequence = "up" +command = "MoveUp" + +[[keymaps]] +key_sequence = "h" +command = "MoveLeft" + +[[keymaps]] +key_sequence = "left" +command = "MoveLeft" + +[[keymaps]] +key_sequence = "l" +command = "MoveRight" + +[[keymaps]] +key_sequence = "right" +command = "MoveRight" + +[[keymaps]] +key_sequence = "pageup" +command = "PageUp" + +[[keymaps]] +key_sequence = "pagedown" +command = "PageDown" + +[[keymaps]] +key_sequence = "ctrl-u" +command = "PageUp" + +[[keymaps]] +key_sequence = "ctrl-d" +command = "PageDown" + +[[keymaps]] +key_sequence = "g g" +command = "SelectFirst" + +[[keymaps]] +key_sequence = "shift-g" +command = "SelectLast" + +[[keymaps]] +key_sequence = "enter" +command = "Select" + +[[keymaps]] +key_sequence = "esc" +command = "Back" + +[[keymaps]] +key_sequence = "backspace" +command = "Back" + +[[keymaps]] +key_sequence = "space" +command = "PlayPause" + +[[keymaps]] +key_sequence = "n" +command = "NextTrack" + +[[keymaps]] +key_sequence = "p" +command = "PrevTrack" + +[[keymaps]] +key_sequence = "." +command = { SeekForward = { seconds = 10 } } + +[[keymaps]] +key_sequence = "," +command = { SeekBackward = { seconds = 10 } } + +[[keymaps]] +key_sequence = "+" +command = "VolumeUp" + +[[keymaps]] +key_sequence = "=" +command = "VolumeUp" + +[[keymaps]] +key_sequence = "-" +command = "VolumeDown" + +[[keymaps]] +key_sequence = "s" +command = "ToggleShuffle" + +[[keymaps]] +key_sequence = "r" +command = "CycleRepeat" + +[[keymaps]] +key_sequence = "x" +command = "ToggleLike" + +[[keymaps]] +key_sequence = "shift-l" +command = "Logout" + +[[keymaps]] +key_sequence = "v" +command = "ToggleViewMode" + +[[keymaps]] +key_sequence = ":" +command = "OpenCommandLine" diff --git a/src/config/keymap.rs b/src/config/keymap.rs new file mode 100644 index 0000000..b2c76ef --- /dev/null +++ b/src/config/keymap.rs @@ -0,0 +1,484 @@ +use std::{fs, path::PathBuf, str::FromStr}; + +use anyhow::{Context as _, Result, bail}; +use crokey::{KeyCombination, KeyCombinationFormat, key}; +use crossterm::event::{KeyCode, KeyModifiers}; +use serde::Deserialize; + +use crate::app::action::Action; + +const DEFAULT_KEYMAP: &str = include_str!("default_keymap.toml"); + +/// Input context a binding applies to. `Global` bindings work everywhere; +/// view-specific bindings shadow global ones for the same key sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum KeyContext { + #[default] + Global, + Library, + Search, + Playlists, + Queue, + Devices, +} + +impl KeyContext { + pub fn label(self) -> &'static str { + match self { + KeyContext::Global => "global", + KeyContext::Library => "library", + KeyContext::Search => "search", + KeyContext::Playlists => "playlists", + KeyContext::Queue => "queue", + KeyContext::Devices => "devices", + } + } +} + +#[derive(Debug, Clone)] +pub struct Binding { + pub keys: Vec, + pub action: Action, + pub context: KeyContext, +} + +#[derive(Debug, Deserialize)] +struct RawBinding { + key_sequence: String, + command: Action, + #[serde(default)] + context: KeyContext, +} + +#[derive(Debug, Default, Deserialize)] +struct KeymapFile { + #[serde(default)] + keymaps: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum KeyResolution { + Action(Action), + /// The pressed keys are a prefix of a longer sequence; the formatted + /// pending chord is returned for display in the status bar. + Pending(String), + Unmatched, +} + +pub struct Keymap { + bindings: Vec, + pending: Vec, + format: KeyCombinationFormat, +} + +impl Keymap { + /// Load defaults merged with the user's keymap.toml. A broken user file + /// must not brick the app: it is ignored and reported as a warning. + pub fn load() -> (Self, Option) { + let mut bindings = + parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse"); + let mut warning = None; + if let Some(path) = user_keymap_path() { + if path.exists() { + match fs::read_to_string(&path) + .map_err(anyhow::Error::from) + .and_then(|text| parse_bindings(&text)) + { + Ok(user) => merge(&mut bindings, user), + Err(err) => { + warning = Some(format!( + "{} ignored: {err:#}; using default keybindings", + path.display() + )); + } + } + } + } + let keymap = Self { + bindings, + pending: Vec::new(), + format: KeyCombinationFormat::default(), + }; + (keymap, warning) + } + + /// Feed one key combination; returns an action, a pending-chord state, or + /// nothing. Esc clears a pending chord instead of resolving. + pub fn resolve(&mut self, key: KeyCombination, context: KeyContext) -> KeyResolution { + let key = self.localize(normalize(key)); + if key == key!(esc) && !self.pending.is_empty() { + self.pending.clear(); + return KeyResolution::Unmatched; + } + self.pending.push(key); + match self.lookup(context) { + Lookup::Exact(action) => { + self.pending.clear(); + KeyResolution::Action(action) + } + Lookup::Prefix => KeyResolution::Pending(self.format_pending()), + Lookup::Nothing => { + let retry = self.pending.len() > 1; + self.pending.clear(); + if retry { + // The aborted chord's last key may start a new sequence. + self.resolve(key, context) + } else { + KeyResolution::Unmatched + } + } + } + } + + /// All bindings as (keys, description, context) for the help view. + pub fn help_entries(&self) -> Vec<(String, String, KeyContext)> { + self.bindings + .iter() + .map(|b| (self.format_keys(&b.keys), b.action.describe(), b.context)) + .collect() + } + + fn lookup(&self, context: KeyContext) -> Lookup { + let mut exact_ctx: Option<&Binding> = None; + let mut exact_global: Option<&Binding> = None; + let mut has_prefix = false; + for b in &self.bindings { + if b.context != KeyContext::Global && b.context != context { + continue; + } + if b.keys.len() < self.pending.len() || b.keys[..self.pending.len()] != self.pending { + continue; + } + if b.keys.len() == self.pending.len() { + if b.context == KeyContext::Global { + exact_global.get_or_insert(b); + } else { + exact_ctx.get_or_insert(b); + } + } else { + has_prefix = true; + } + } + // An exact match fires immediately even if a longer sequence shares + // the prefix — don't bind both "g" and "g g". + if let Some(b) = exact_ctx.or(exact_global) { + Lookup::Exact(b.action.clone()) + } else if has_prefix { + Lookup::Prefix + } else { + Lookup::Nothing + } + } + + /// Layout fallback (vim langmap style): a Cyrillic key that no binding + /// uses directly is translated to the Latin key in the same physical + /// position (ЙЦУКЕН ↔ QWERTY), so bindings work in the Russian layout. + /// Text input is unaffected — this runs only inside keymap resolution. + fn localize(&self, key: KeyCombination) -> KeyCombination { + let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes else { + return key; + }; + let lower = c.to_lowercase().next().unwrap_or(c); + let Some(latin) = qwerty_equivalent(lower) else { + return key; + }; + // A binding that mentions the Cyrillic key directly wins. + if self.bindings.iter().any(|b| b.keys.contains(&key)) { + return key; + } + let mapped = if c.is_uppercase() || key.modifiers.contains(KeyModifiers::SHIFT) { + KeyCode::Char(latin.to_ascii_uppercase()) + } else { + KeyCode::Char(latin) + }; + normalize(KeyCombination::new(mapped, key.modifiers)) + } + + fn format_keys(&self, keys: &[KeyCombination]) -> String { + keys.iter() + .map(|k| self.format.to_string(*k)) + .collect::>() + .join(" ") + } + + fn format_pending(&self) -> String { + self.format_keys(&self.pending) + } +} + +enum Lookup { + Exact(Action), + Prefix, + Nothing, +} + +/// Terminals report SHIFT alongside symbol keys ('?', '+', ...) inconsistently. +/// Letters (of any alphabet) keep SHIFT (that is how "shift-g" works); +/// symbols drop it so a "?" binding matches everywhere. +fn normalize(key: KeyCombination) -> KeyCombination { + if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes { + if !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) { + return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT); + } + } + key +} + +/// The Latin character on the same physical key in the standard ЙЦУКЕН +/// layout (lowercase in, lowercase out). +fn qwerty_equivalent(c: char) -> Option { + Some(match c { + 'й' => 'q', 'ц' => 'w', 'у' => 'e', 'к' => 'r', 'е' => 't', + 'н' => 'y', 'г' => 'u', 'ш' => 'i', 'щ' => 'o', 'з' => 'p', + 'х' => '[', 'ъ' => ']', + 'ф' => 'a', 'ы' => 's', 'в' => 'd', 'а' => 'f', 'п' => 'g', + 'р' => 'h', 'о' => 'j', 'л' => 'k', 'д' => 'l', 'ж' => ';', + 'э' => '\'', + 'я' => 'z', 'ч' => 'x', 'с' => 'c', 'м' => 'v', 'и' => 'b', + 'т' => 'n', 'ь' => 'm', 'б' => ',', 'ю' => '.', + 'ё' => '`', + _ => return None, + }) +} + +pub fn user_keymap_path() -> Option { + crate::config::project_dirs().map(|dirs| dirs.config_dir().join("keymap.toml")) +} + +fn parse_bindings(text: &str) -> Result> { + let file: KeymapFile = toml::from_str(text).context("invalid TOML")?; + file.keymaps + .into_iter() + .map(|raw| { + let keys = parse_sequence(&raw.key_sequence) + .with_context(|| format!("bad key_sequence {:?}", raw.key_sequence))?; + Ok(Binding { + keys, + action: raw.command, + context: raw.context, + }) + }) + .collect() +} + +fn parse_sequence(s: &str) -> Result> { + let keys: Vec = s + .split_whitespace() + .map(parse_chord) + .collect::>()?; + if keys.is_empty() { + bail!("empty key sequence"); + } + Ok(keys) +} + +fn parse_chord(chord: &str) -> Result { + // crokey only parses single-byte characters; non-ASCII keys (Cyrillic + // bindings) are built directly. + let mut chars = chord.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) { + if !c.is_ascii() { + let modifiers = if c.is_uppercase() { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }; + return Ok(KeyCombination::new(KeyCode::Char(c), modifiers)); + } + } + KeyCombination::from_str(chord) + .map_err(|e| anyhow::anyhow!("{e}")) + .map(normalize) +} + +fn merge(bindings: &mut Vec, user: Vec) { + for b in user { + bindings.retain(|d| !(d.keys == b.keys && d.context == b.context)); + bindings.push(b); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crokey::key; + + fn keymap_from(toml: &str) -> Keymap { + Keymap { + bindings: parse_bindings(toml).unwrap(), + pending: Vec::new(), + format: KeyCombinationFormat::default(), + } + } + + #[test] + fn default_keymap_parses() { + let bindings = parse_bindings(DEFAULT_KEYMAP).unwrap(); + assert!(bindings.len() > 20); + } + + #[test] + fn single_key_resolves() { + let mut km = keymap_from(DEFAULT_KEYMAP); + assert_eq!( + km.resolve(key!(q), KeyContext::Library), + KeyResolution::Action(Action::Quit) + ); + } + + #[test] + fn chord_sequence_resolves() { + let mut km = keymap_from(DEFAULT_KEYMAP); + assert!(matches!( + km.resolve(key!(g), KeyContext::Library), + KeyResolution::Pending(_) + )); + assert_eq!( + km.resolve(key!(g), KeyContext::Library), + KeyResolution::Action(Action::SelectFirst) + ); + } + + #[test] + fn aborted_chord_retries_last_key() { + let mut km = keymap_from(DEFAULT_KEYMAP); + km.resolve(key!(g), KeyContext::Library); + assert_eq!( + km.resolve(key!(q), KeyContext::Library), + KeyResolution::Action(Action::Quit) + ); + } + + #[test] + fn esc_clears_pending_chord() { + let mut km = keymap_from(DEFAULT_KEYMAP); + km.resolve(key!(g), KeyContext::Library); + assert_eq!( + km.resolve(key!(esc), KeyContext::Library), + KeyResolution::Unmatched + ); + // Esc with no pending chord is a normal binding (Back). + assert_eq!( + km.resolve(key!(esc), KeyContext::Library), + KeyResolution::Action(Action::Back) + ); + } + + #[test] + fn context_binding_shadows_global() { + let mut km = keymap_from( + r#" + [[keymaps]] + key_sequence = "n" + command = "NextTrack" + + [[keymaps]] + key_sequence = "n" + command = "MoveDown" + context = "search" + "#, + ); + assert_eq!( + km.resolve(key!(n), KeyContext::Search), + KeyResolution::Action(Action::MoveDown) + ); + assert_eq!( + km.resolve(key!(n), KeyContext::Library), + KeyResolution::Action(Action::NextTrack) + ); + } + + #[test] + fn user_binding_overrides_default() { + let mut bindings = parse_bindings(DEFAULT_KEYMAP).unwrap(); + let user = parse_bindings( + r#" + [[keymaps]] + key_sequence = "q" + command = "Back" + "#, + ) + .unwrap(); + merge(&mut bindings, user); + let mut km = Keymap { + bindings, + pending: Vec::new(), + format: KeyCombinationFormat::default(), + }; + assert_eq!( + km.resolve(key!(q), KeyContext::Library), + KeyResolution::Action(Action::Back) + ); + } + + #[test] + fn shift_symbol_normalizes() { + let mut km = keymap_from(DEFAULT_KEYMAP); + let question_with_shift = + KeyCombination::new(KeyCode::Char('?'), KeyModifiers::SHIFT); + assert_eq!( + km.resolve(question_with_shift, KeyContext::Library), + KeyResolution::Action(Action::ToggleHelp) + ); + } + + #[test] + fn russian_layout_maps_to_physical_keys() { + let mut km = keymap_from(DEFAULT_KEYMAP); + // physical J → 'о' in ЙЦУКЕН + let o = KeyCombination::new(KeyCode::Char('о'), KeyModifiers::NONE); + assert_eq!( + km.resolve(o, KeyContext::Library), + KeyResolution::Action(Action::MoveDown) + ); + // physical Shift+G → 'П' + let cap_pe = KeyCombination::new(KeyCode::Char('П'), KeyModifiers::SHIFT); + assert_eq!( + km.resolve(cap_pe, KeyContext::Library), + KeyResolution::Action(Action::SelectLast) + ); + // chord: 'п п' = physical "g g" + let pe = KeyCombination::new(KeyCode::Char('п'), KeyModifiers::NONE); + assert!(matches!( + km.resolve(pe, KeyContext::Library), + KeyResolution::Pending(_) + )); + assert_eq!( + km.resolve(pe, KeyContext::Library), + KeyResolution::Action(Action::SelectFirst) + ); + // punctuation positions: 'ю' sits on the '.' key (SeekForward) + let yu = KeyCombination::new(KeyCode::Char('ю'), KeyModifiers::NONE); + assert_eq!( + km.resolve(yu, KeyContext::Library), + KeyResolution::Action(Action::SeekForward { seconds: 10 }) + ); + } + + #[test] + fn explicit_cyrillic_binding_wins_over_layout_fallback() { + let mut km = keymap_from( + r#" + [[keymaps]] + key_sequence = "о" + command = "Quit" + "#, + ); + let o = KeyCombination::new(KeyCode::Char('о'), KeyModifiers::NONE); + assert_eq!( + km.resolve(o, KeyContext::Library), + KeyResolution::Action(Action::Quit) + ); + } + + #[test] + fn parameterized_command_parses() { + let mut km = keymap_from(DEFAULT_KEYMAP); + let dot: KeyCombination = ".".parse().unwrap(); + assert_eq!( + km.resolve(dot, KeyContext::Library), + KeyResolution::Action(Action::SeekForward { seconds: 10 }) + ); + } +} diff --git a/src/config/logging.rs b/src/config/logging.rs new file mode 100644 index 0000000..ab2230f --- /dev/null +++ b/src/config/logging.rs @@ -0,0 +1,172 @@ +use std::collections::VecDeque; +use std::fs; +use std::sync::{Arc, Mutex, OnceLock}; + +use anyhow::{Context as _, Result}; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; + +/// Ring-buffer capacity for the in-app Logs tab. Bounded so a player left +/// running for days cannot grow memory; ~10k entries ≈ a few MB worst case. +pub const LOG_CAPACITY: usize = 10_000; + +#[derive(Debug, Clone)] +pub struct LogEntry { + pub level: tracing::Level, + /// HH:MM:SS, UTC (same clock as the log file). + pub time: String, + pub target: String, + pub message: String, +} + +#[derive(Default)] +pub struct LogBuffer { + entries: Mutex>, +} + +impl LogBuffer { + fn push(&self, entry: LogEntry) { + let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + if entries.len() == LOG_CAPACITY { + entries.pop_front(); + } + entries.push_back(entry); + } + + pub fn len(&self) -> usize { + self.entries.lock().unwrap_or_else(|e| e.into_inner()).len() + } + + /// Window for the UI, newest-last: entries at most `max_level` verbose, + /// skipping `skip` newest matches and returning up to `take`. Also + /// returns the total number of matching entries (for scroll clamping). + /// Only the visible window is cloned, so rendering stays O(buffer scan) + /// with cheap comparisons even at full capacity. + pub fn window( + &self, + max_level: tracing::Level, + skip: usize, + take: usize, + ) -> (Vec, usize) { + let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + let mut matched = 0usize; + let mut out = Vec::with_capacity(take); + for entry in entries.iter().rev() { + if entry.level > max_level { + continue; + } + matched += 1; + if matched > skip && out.len() < take { + out.push(entry.clone()); + } + } + out.reverse(); + (out, matched) + } +} + +static BUFFER: OnceLock> = OnceLock::new(); + +pub fn buffer() -> Option> { + BUFFER.get().cloned() +} + +struct MemoryLayer { + buffer: Arc, +} + +impl tracing_subscriber::Layer for MemoryLayer { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut message = String::new(); + event.record(&mut MessageVisitor { out: &mut message }); + let metadata = event.metadata(); + self.buffer.push(LogEntry { + level: *metadata.level(), + time: hms_now(), + target: metadata.target().to_string(), + message, + }); + } +} + +struct MessageVisitor<'a> { + out: &'a mut String, +} + +impl tracing::field::Visit for MessageVisitor<'_> { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + use std::fmt::Write as _; + if field.name() == "message" { + let _ = write!(self.out, "{value:?}"); + } else { + let _ = write!(self.out, " {}={:?}", field.name(), value); + } + } +} + +fn hms_now() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + format!( + "{:02}:{:02}:{:02}", + (secs / 3600) % 24, + (secs / 60) % 60, + secs % 60 + ) +} + +/// Two sinks: the log file (filtered by RUST_LOG, default info) and the +/// in-app ring buffer for the Logs tab (our crate down to TRACE, noisy +/// dependencies capped at INFO). The buffer works even when the file can't +/// be opened — the error is returned for the status bar, logging still runs. +pub fn init() -> Result<()> { + let buffer = Arc::new(LogBuffer::default()); + let _ = BUFFER.set(Arc::clone(&buffer)); + let memory_layer = MemoryLayer { buffer }.with_filter( + tracing_subscriber::filter::Targets::new() + .with_default(LevelFilter::INFO) + .with_target("furumi_cli", LevelFilter::TRACE), + ); + + match open_log_file() { + Ok(file) => { + let file = Arc::new(file); + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(move || Arc::clone(&file)) + .with_ansi(false) + .with_filter(filter); + tracing_subscriber::registry() + .with(file_layer) + .with(memory_layer) + .init(); + tracing::info!(version = env!("CARGO_PKG_VERSION"), "furumi-cli starting"); + Ok(()) + } + Err(err) => { + tracing_subscriber::registry().with(memory_layer).init(); + tracing::warn!(%err, "log file unavailable, in-app logs only"); + Err(err) + } + } +} + +fn open_log_file() -> Result { + let dirs = crate::config::project_dirs().context("cannot determine home directory")?; + let dir = dirs.cache_dir(); + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + let path = dir.join("furumi-cli.log"); + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("opening {}", path.display())) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..2f6631e --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,8 @@ +pub mod keymap; +pub mod logging; + +use directories::ProjectDirs; + +pub fn project_dirs() -> Option { + ProjectDirs::from("", "", "furumi") +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..150f826 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,111 @@ +mod api; +mod app; +mod art; +mod config; +mod media; +mod player; +mod ui; + +use std::io; + +use anyhow::Result; +use crossterm::event::{ + DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, +}; + +fn main() -> Result<()> { + let mut startup_warning = None; + if let Err(err) = config::logging::init() { + startup_warning = Some(format!("logging disabled: {err:#}")); + } + let (keymap, keymap_warning) = config::keymap::Keymap::load(); + let startup_warning = keymap_warning.or(startup_warning); + + // The app (tokio + TUI) runs on a worker thread; the main thread stays + // dedicated to the OS media-key event loop — on macOS the system only + // delivers media commands while the main thread pumps its CFRunLoop. + let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (media_tx, media_rx) = std::sync::mpsc::channel::(); + + let media_event_tx = event_tx.clone(); + let app_thread = std::thread::Builder::new() + .name("app".to_string()) + .spawn(move || run_app(keymap, startup_warning, event_tx, event_rx, media_tx)) + .expect("spawning the app thread cannot fail"); + + media::run_on_main_thread(media_rx, move |command| { + let _ = media_event_tx.send(app::event::AppEvent::Media(command)); + }); + + app_thread.join().expect("app thread panicked") +} + +fn run_app( + keymap: config::keymap::Keymap, + startup_warning: Option, + event_tx: tokio::sync::mpsc::UnboundedSender, + event_rx: tokio::sync::mpsc::UnboundedReceiver, + media_tx: std::sync::mpsc::Sender, +) -> Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + // ratatui::init() enables raw mode + alternate screen and installs a + // panic hook that restores the terminal. + let terminal = ratatui::init(); + let keyboard_enhanced = push_keyboard_enhancements(); + let bracketed_paste = crossterm::execute!(io::stdout(), EnableBracketedPaste).is_ok(); + if bracketed_paste { + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = crossterm::execute!(io::stdout(), DisableBracketedPaste); + previous_hook(info); + })); + } + + let result = runtime.block_on(app::run( + terminal, + keymap, + startup_warning, + event_tx, + event_rx, + media_tx, + )); + + if bracketed_paste { + let _ = crossterm::execute!(io::stdout(), DisableBracketedPaste); + } + if keyboard_enhanced { + let _ = crossterm::execute!(io::stdout(), PopKeyboardEnhancementFlags); + } + ratatui::restore(); + result +} + +/// Kitty keyboard protocol, where supported, disambiguates Esc from alt-keys +/// and modifier combos. The flags are popped on exit and on panic — leaving +/// them pushed corrupts the user's shell. +fn push_keyboard_enhancements() -> bool { + if !matches!( + crossterm::terminal::supports_keyboard_enhancement(), + Ok(true) + ) { + return false; + } + if crossterm::execute!( + io::stdout(), + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ) + .is_err() + { + return false; + } + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = crossterm::execute!(io::stdout(), PopKeyboardEnhancementFlags); + previous_hook(info); + })); + true +} diff --git a/src/media.rs b/src/media.rs new file mode 100644 index 0000000..10d651a --- /dev/null +++ b/src/media.rs @@ -0,0 +1,159 @@ +//! System media-key integration (play/pause/next/prev from the OS). +//! +//! Backends via souvlaki: MPRIS over D-Bus on Linux, MPNowPlayingInfoCenter / +//! MPRemoteCommandCenter on macOS, SMTC on Windows. On macOS the command +//! callbacks are only delivered while the main thread services its CFRunLoop, +//! so the app runs on a worker thread and `run_on_main_thread` keeps the main +//! thread pumping the run loop and applying metadata updates. +//! +//! Windows note: SMTC needs a window handle; creating a hidden window is not +//! wired up yet, so media keys are skipped there with a log line. + +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::Duration; + +use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig}; + +/// Commands arriving from the OS media keys, translated for the app. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaCommand { + TogglePause, + Play, + Pause, + Next, + Previous, + Stop, +} + +/// Now-playing updates pushed from the app to the OS. +#[derive(Debug)] +pub enum MediaUpdate { + Metadata { + title: String, + artist: String, + album: String, + duration_secs: f64, + }, + Playback { + playing: bool, + paused: bool, + position_secs: f64, + }, + Stopped, +} + +/// Runs until the app drops its `Sender` (i.e. until quit). +/// Must be called on the process main thread (macOS requirement). +pub fn run_on_main_thread( + updates: Receiver, + on_command: impl Fn(MediaCommand) + Send + 'static, +) { + let mut controls = match create_controls() { + Some(controls) => controls, + None => { + // No OS integration: just wait for the app to finish. + while updates.recv().is_ok() {} + return; + } + }; + if let Err(err) = controls.attach(move |event| { + let command = match event { + MediaControlEvent::Toggle => MediaCommand::TogglePause, + MediaControlEvent::Play => MediaCommand::Play, + MediaControlEvent::Pause => MediaCommand::Pause, + MediaControlEvent::Next => MediaCommand::Next, + MediaControlEvent::Previous => MediaCommand::Previous, + MediaControlEvent::Stop => MediaCommand::Stop, + _ => return, + }; + on_command(command); + }) { + tracing::warn!(?err, "attaching media key handler failed"); + while updates.recv().is_ok() {} + return; + } + tracing::info!("media keys attached"); + + loop { + pump_platform_events(); + match updates.recv_timeout(Duration::from_millis(200)) { + Ok(update) => apply(&mut controls, update), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + } + let _ = controls.detach(); +} + +fn create_controls() -> Option { + let config = PlatformConfig { + display_name: "Furumi", + dbus_name: "cy.hexor.furumi_cli", + hwnd: None, + }; + if cfg!(windows) { + tracing::info!("media keys: hidden-window SMTC setup not implemented yet, skipping"); + return None; + } + match MediaControls::new(config) { + Ok(controls) => Some(controls), + Err(err) => { + tracing::warn!(?err, "media controls unavailable"); + None + } + } +} + +fn apply(controls: &mut MediaControls, update: MediaUpdate) { + let result = match update { + MediaUpdate::Metadata { + title, + artist, + album, + duration_secs, + } => controls.set_metadata(MediaMetadata { + title: Some(&title), + artist: Some(&artist), + album: Some(&album), + duration: (duration_secs > 0.0) + .then(|| Duration::from_secs_f64(duration_secs)), + cover_url: None, + }), + MediaUpdate::Playback { + playing, + paused, + position_secs, + } => { + let progress = Some(MediaPosition(Duration::from_secs_f64( + position_secs.max(0.0), + ))); + let playback = if !playing { + MediaPlayback::Stopped + } else if paused { + MediaPlayback::Paused { progress } + } else { + MediaPlayback::Playing { progress } + }; + controls.set_playback(playback) + } + MediaUpdate::Stopped => controls.set_playback(MediaPlayback::Stopped), + }; + if let Err(err) = result { + tracing::debug!(?err, "media update failed"); + } +} + +/// On macOS, MPRemoteCommandCenter callbacks arrive only while the main +/// thread's CFRunLoop is running; pump it briefly every cycle. +#[cfg(target_os = "macos")] +fn pump_platform_events() { + use core_foundation::runloop::{CFRunLoop, kCFRunLoopDefaultMode}; + CFRunLoop::run_in_mode( + unsafe { kCFRunLoopDefaultMode }, + Duration::from_millis(50), + false, + ); +} + +#[cfg(not(target_os = "macos"))] +fn pump_platform_events() {} diff --git a/src/player/mod.rs b/src/player/mod.rs new file mode 100644 index 0000000..4999399 --- /dev/null +++ b/src/player/mod.rs @@ -0,0 +1,258 @@ +//! Playback engine: a dedicated audio thread owning the rodio output device +//! and player. The app talks to it through `Controller` (commands over a +//! channel, position/pause state over atomics) and receives `PlayerEvent`s +//! through the callback given to `spawn` — this module knows nothing about +//! the UI or app state. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; +use std::time::Duration; + +use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink}; +use stream_download::StreamDownload; +use stream_download::storage::temp::TempStorageProvider; + +pub type TrackReader = StreamDownload; + +/// Perceptual volume: cubic mapping from percent to linear amplitude, so +/// equal percent steps sound like equal loudness steps and low percentages +/// give fine-grained control. +pub fn amplitude(percent: u8) -> f32 { + let v = f32::from(percent.min(100)) / 100.0; + v * v * v +} + +#[derive(Debug)] +pub enum PlayerEvent { + /// A track played to its end. `has_next` is true when a prefetched + /// source was already queued and is now playing gaplessly. + TrackFinished { has_next: bool }, + Failed(String), +} + +enum Command { + Play { + reader: Box, + byte_len: Option, + volume: f32, + }, + /// Append the next track behind the current one without interrupting + /// playback — rodio switches sources back to back (gapless-ish). + Enqueue { + reader: Box, + byte_len: Option, + }, + TogglePause, + Stop, + Seek(Duration), + SetVolume(f32), +} + +/// Lock-free playback state for the UI tick to read. +#[derive(Debug, Default)] +pub struct Shared { + position_ms: AtomicU64, + paused: AtomicBool, +} + +impl Shared { + pub fn position(&self) -> Duration { + Duration::from_millis(self.position_ms.load(Ordering::Relaxed)) + } + + pub fn paused(&self) -> bool { + self.paused.load(Ordering::Relaxed) + } +} + +#[derive(Clone)] +pub struct Controller { + tx: Sender, + pub shared: Arc, +} + +impl Controller { + pub fn play(&self, reader: TrackReader, byte_len: Option, volume: f32) { + let _ = self.tx.send(Command::Play { + reader: Box::new(reader), + byte_len, + volume, + }); + } + + pub fn enqueue(&self, reader: TrackReader, byte_len: Option) { + let _ = self.tx.send(Command::Enqueue { + reader: Box::new(reader), + byte_len, + }); + } + + pub fn toggle_pause(&self) { + let _ = self.tx.send(Command::TogglePause); + } + + pub fn stop(&self) { + let _ = self.tx.send(Command::Stop); + } + + pub fn seek(&self, position: Duration) { + let _ = self.tx.send(Command::Seek(position)); + } + + pub fn set_volume(&self, volume: f32) { + let _ = self.tx.send(Command::SetVolume(volume)); + } +} + +pub fn spawn(on_event: impl Fn(PlayerEvent) + Send + 'static) -> Controller { + let (tx, rx) = std::sync::mpsc::channel(); + let shared = Arc::new(Shared::default()); + let thread_shared = Arc::clone(&shared); + std::thread::Builder::new() + .name("audio".to_string()) + .spawn(move || run(rx, thread_shared, on_event)) + .expect("spawning the audio thread cannot fail"); + Controller { tx, shared } +} + +struct Output { + /// Keeps the audio device open; dropping it stops the mixer. + _device: MixerDeviceSink, + player: Player, +} + +fn run(rx: Receiver, shared: Arc, on_event: impl Fn(PlayerEvent)) { + let mut output: Option = None; + let mut track_loaded = false; + let mut last_len = 0usize; + + loop { + match rx.recv_timeout(Duration::from_millis(100)) { + Ok(command) => { + // Commands change the source queue legitimately; resync the + // length so the next tick doesn't read it as a track ending. + handle(command, &mut output, &mut track_loaded, &on_event); + last_len = output.as_ref().map_or(0, |out| out.player.len()); + } + Err(RecvTimeoutError::Timeout) => { + if let Some(out) = &output { + shared + .position_ms + .store(out.player.get_pos().as_millis() as u64, Ordering::Relaxed); + shared.paused.store(out.player.is_paused(), Ordering::Relaxed); + let len = out.player.len(); + if track_loaded && len < last_len { + if len == 0 { + track_loaded = false; + } + for _ in len..last_len { + on_event(PlayerEvent::TrackFinished { has_next: len > 0 }); + } + } + last_len = len; + } + } + Err(RecvTimeoutError::Disconnected) => return, + } + } +} + +fn handle( + command: Command, + output: &mut Option, + track_loaded: &mut bool, + on_event: &impl Fn(PlayerEvent), +) { + match command { + Command::Play { + reader, + byte_len, + volume, + } => { + // The device is opened lazily on first playback so the app works + // on machines with no audio output until you actually press play. + if output.is_none() { + match DeviceSinkBuilder::open_default_sink() { + Ok(device) => { + let player = Player::connect_new(device.mixer()); + *output = Some(Output { + _device: device, + player, + }); + } + Err(err) => { + on_event(PlayerEvent::Failed(format!("no audio device: {err}"))); + return; + } + } + } + let out = output.as_ref().expect("output opened above"); + + let mut builder = Decoder::builder() + .with_data(reader) + .with_seekable(true) + .with_gapless(true); + if let Some(len) = byte_len { + builder = builder.with_byte_len(len); + } + match builder.build() { + Ok(decoder) => { + out.player.stop(); + out.player.set_volume(volume); + out.player.append(decoder); + out.player.play(); + *track_loaded = true; + } + Err(err) => { + on_event(PlayerEvent::Failed(format!("cannot decode track: {err}"))); + } + } + } + Command::Enqueue { reader, byte_len } => { + let Some(out) = output.as_ref() else { + return; + }; + let mut builder = Decoder::builder() + .with_data(reader) + .with_seekable(true) + .with_gapless(true); + if let Some(len) = byte_len { + builder = builder.with_byte_len(len); + } + match builder.build() { + Ok(decoder) => out.player.append(decoder), + Err(err) => { + on_event(PlayerEvent::Failed(format!("cannot decode next track: {err}"))); + } + } + } + Command::TogglePause => { + if let Some(out) = output { + if out.player.is_paused() { + out.player.play(); + } else { + out.player.pause(); + } + } + } + Command::Stop => { + if let Some(out) = output { + out.player.stop(); + } + *track_loaded = false; + } + Command::Seek(position) => { + if let Some(out) = output { + if let Err(err) = out.player.try_seek(position) { + tracing::warn!(%err, "seek failed"); + } + } + } + Command::SetVolume(volume) => { + if let Some(out) = output { + out.player.set_volume(volume); + } + } + } +} diff --git a/src/ui/art.rs b/src/ui/art.rs new file mode 100644 index 0000000..6bb5b38 --- /dev/null +++ b/src/ui/art.rs @@ -0,0 +1,24 @@ +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span, Text}; + +use crate::art::ArtImage; + +/// Render half-block art as ratatui text: one `▀` per cell, foreground = +/// top pixel, background = bottom pixel. +pub fn to_text(art: &ArtImage) -> Text<'static> { + let mut lines = Vec::with_capacity(usize::from(art.height_cells)); + for y in 0..art.height_cells { + let mut spans = Vec::with_capacity(usize::from(art.width_cells)); + for x in 0..art.width_cells { + let (top, bottom) = art.cell(x, y); + spans.push(Span::styled( + "▀", + Style::new() + .fg(Color::Rgb(top[0], top[1], top[2])) + .bg(Color::Rgb(bottom[0], bottom[1], bottom[2])), + )); + } + lines.push(Line::from(spans)); + } + Text::from(lines) +} diff --git a/src/ui/global.rs b/src/ui/global.rs new file mode 100644 index 0000000..e514a03 --- /dev/null +++ b/src/ui/global.rs @@ -0,0 +1,643 @@ +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Row, Table}; + +use super::{art, theme}; +use crate::api::models::{ArtistCard, ReleaseCard}; +use crate::app::state::{ + ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState, + GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups, +}; +use crate::art::cache_key; + +pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { + match state.global.stack.last() { + None => draw_grid(frame, area, state), + Some(GlobalView::Artist { id, cursor }) => draw_artist(frame, area, state, *id, *cursor), + Some(GlobalView::Release { id, cursor }) => draw_release(frame, area, state, *id, *cursor), + Some(GlobalView::Search { cursor }) => draw_search(frame, area, state, *cursor), + } +} + +fn error_style() -> Style { + Style::new().fg(Color::Red) +} + +fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect { + let block = Block::bordered() + .title(title) + .title_style(theme::header()) + .border_style(theme::dim()); + let inner = block.inner(area); + frame.render_widget(block, area); + inner +} + +fn centered_line(frame: &mut Frame, area: Rect, line: Line) { + if area.height == 0 { + return; + } + let middle = Rect { y: area.y + area.height / 2, height: 1, ..area }; + frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle); +} + +fn tile_art<'a>(state: &'a AppState, url: Option<&String>) -> Option<&'a ArtState> { + state + .art + .get(&cache_key(url?, ART_CELL_WIDTH, ART_CELL_HEIGHT)) +} + +fn header_art<'a>(state: &'a AppState, url: Option<&String>) -> Option<&'a ArtState> { + state + .art + .get(&cache_key(url?, ART_HEADER_WIDTH, ART_HEADER_HEIGHT)) +} + +fn draw_art(frame: &mut Frame, area: Rect, art_state: Option<&ArtState>) { + match art_state { + Some(ArtState::Ready(image)) => { + frame.render_widget(Paragraph::new(art::to_text(image)), area); + } + Some(ArtState::Loading) => centered_line(frame, area, Line::styled("…", theme::dim())), + _ => centered_line(frame, area, Line::styled("♪", theme::dim())), + } +} + +/// Bordered tile with artwork, a title line and a dim meta line. The +/// selected tile gets a thick accent border and an inverted (filled) +/// caption so it stands out in a large grid; the artwork stays untouched. +fn draw_tile( + frame: &mut Frame, + tile: Rect, + art_state: Option<&ArtState>, + title: &str, + meta: &str, + selected: bool, +) { + let block = if selected { + Block::bordered() + .border_type(ratatui::widgets::BorderType::Thick) + .border_style(theme::accent()) + } else { + Block::bordered().border_style(theme::dim()) + }; + let inner = block.inner(tile); + frame.render_widget(block, tile); + + let art_area = Rect { height: ART_CELL_HEIGHT.min(inner.height), ..inner }; + draw_art(frame, art_area, art_state); + + if inner.height > ART_CELL_HEIGHT { + let name_area = Rect { y: inner.y + ART_CELL_HEIGHT, height: 1, ..inner }; + frame.render_widget( + Paragraph::new(Line::raw(title.to_string())), + name_area, + ); + if selected { + frame.buffer_mut().set_style(name_area, theme::tab_active()); + } + } + if inner.height > ART_CELL_HEIGHT + 1 { + let meta_area = Rect { y: inner.y + ART_CELL_HEIGHT + 1, height: 1, ..inner }; + frame.render_widget( + Paragraph::new(Line::styled(meta.to_string(), theme::dim())), + meta_area, + ); + if selected { + frame.buffer_mut().set_style(meta_area, theme::tab_active()); + } + } +} + +/// One selectable row: left content, optional right-aligned suffix, full-row +/// highlight when selected. +fn draw_row(frame: &mut Frame, area: Rect, line: Line, right: Option, selected: bool) { + frame.render_widget(Paragraph::new(line), area); + if let Some(right) = right { + frame.render_widget( + Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right), + area, + ); + } + if selected { + frame.buffer_mut().set_style(area, theme::tab_active()); + } +} + + +// --------------------------------------------------------------------------- +// Scrollable content plan: a vertical list of items with known heights; the +// viewport is scrolled so the cursor's item stays centered. +// --------------------------------------------------------------------------- + +enum PlanItem { + /// Section header line. + Header(String), + Gap, + /// Selectable track row; the payload is the cursor index it represents. + Track { cursor_index: usize }, + /// One row of release tiles (display-order positions). + TileRow(Vec), + /// One release as a table row (display-order position). + TableRow(usize), +} + +impl PlanItem { + fn height(&self) -> u16 { + match self { + PlanItem::Header(_) | PlanItem::Gap | PlanItem::Track { .. } + | PlanItem::TableRow(_) => 1, + PlanItem::TileRow(_) => TILE_HEIGHT, + } + } +} + +fn scroll_offset(items: &[PlanItem], cursor_item: Option, viewport: u16) -> u16 { + let total: u16 = items.iter().map(PlanItem::height).sum(); + if total <= viewport { + return 0; + } + let Some(cursor_item) = cursor_item else { + return 0; + }; + let top: u16 = items[..cursor_item].iter().map(PlanItem::height).sum(); + let center = top + items[cursor_item].height() / 2; + center + .saturating_sub(viewport / 2) + .min(total.saturating_sub(viewport)) +} + +// --------------------------------------------------------------------------- +// Artist grid (stack root) +// --------------------------------------------------------------------------- + +fn draw_grid(frame: &mut Frame, area: Rect, state: &AppState) { + let global = &state.global; + let title = if global.total > 0 { + format!(" Global — {} artists ", global.total) + } else { + " Global ".to_string() + }; + let inner = bordered(frame, area, title); + + if global.artists.is_empty() { + let message = if let Some(error) = &global.error { + Line::styled(error.clone(), error_style()) + } else if global.loading { + Line::styled("loading artists…", theme::dim()) + } else { + Line::styled("no artists in the library", theme::dim()) + }; + centered_line(frame, inner, message); + return; + } + + match global.view { + ViewMode::Tiles => draw_grid_tiles(frame, inner, state), + ViewMode::Table => draw_grid_table(frame, inner, state), + } +} + +fn artist_tile_meta(artist: &ArtistCard) -> String { + format!("{} rel · {} trk", artist.release_count, artist.track_count) +} + +fn draw_grid_tiles(frame: &mut Frame, inner: Rect, state: &AppState) { + let global = &state.global; + let columns = usize::from((inner.width / TILE_WIDTH).max(1)); + let visible_rows = usize::from((inner.height / TILE_HEIGHT).max(1)); + + let selected_row = global.selected / columns; + let first_row = (selected_row / visible_rows) * visible_rows; + let first_index = first_row * columns; + let last_index = (first_index + visible_rows * columns).min(global.artists.len()); + + for (offset, artist) in global.artists[first_index..last_index].iter().enumerate() { + let index = first_index + offset; + let tile = Rect { + x: inner.x + (offset % columns) as u16 * TILE_WIDTH, + y: inner.y + (offset / columns) as u16 * TILE_HEIGHT, + width: TILE_WIDTH, + height: TILE_HEIGHT, + }; + draw_tile( + frame, + tile, + tile_art(state, artist.image_url.as_ref()), + &artist.name, + &artist_tile_meta(artist), + index == global.selected, + ); + } +} + +fn draw_grid_table(frame: &mut Frame, inner: Rect, state: &AppState) { + let global = &state.global; + let visible_rows = usize::from(inner.height.saturating_sub(1).max(1)); + let first = (global.selected / visible_rows) * visible_rows; + let last = (first + visible_rows).min(global.artists.len()); + + let rows = global.artists[first..last].iter().enumerate().map(|(offset, artist)| { + let index = first + offset; + let style = if index == global.selected { + theme::tab_active() + } else { + Style::new() + }; + Row::new(vec![ + artist.name.clone(), + artist.release_count.to_string(), + artist.track_count.to_string(), + ]) + .style(style) + }); + let table = Table::new( + rows, + [Constraint::Min(24), Constraint::Length(9), Constraint::Length(7)], + ) + .header(Row::new(vec!["Artist", "Releases", "Tracks"]).style(theme::header())); + frame.render_widget(table, inner); +} + +// --------------------------------------------------------------------------- +// Artist view +// --------------------------------------------------------------------------- + +fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) { + let loadable = state.artist_views.get(&id); + let name = match loadable { + Some(Loadable::Ready(detail)) => detail.name.clone(), + _ => "Artist".to_string(), + }; + let inner = bordered(frame, area, format!(" Global ▸ {name} ")); + + let detail = match loadable { + Some(Loadable::Ready(detail)) => detail, + Some(Loadable::Failed(error)) => { + return centered_line(frame, inner, Line::styled(error.clone(), error_style())); + } + _ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())), + }; + + let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height); + let [header_area, content_area] = + Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner); + + // Header: artwork left, metadata right. + let [art_area, _, info_area] = Layout::horizontal([ + Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)), + Constraint::Length(2), + Constraint::Min(0), + ]) + .areas(header_area); + draw_art( + frame, + Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area }, + header_art(state, detail.image_url.as_ref()), + ); + let info = vec![ + Line::default(), + Line::styled(detail.name.clone(), theme::header()), + Line::default(), + Line::styled( + format!( + "{} tracks · {} plays", + detail.total_track_count, detail.total_play_count + ), + theme::dim(), + ), + Line::styled(format!("{} releases", detail.releases.len()), theme::dim()), + ]; + frame.render_widget(Paragraph::new(info), info_area); + + // Scrollable content: top tracks, then releases grouped by type. + let tracks = detail.top_tracks.len(); + let mut items = Vec::new(); + let mut cursor_item = None; + if tracks > 0 { + items.push(PlanItem::Header("Top tracks".to_string())); + for index in 0..tracks { + if cursor == index { + cursor_item = Some(items.len()); + } + items.push(PlanItem::Track { cursor_index: index }); + } + items.push(PlanItem::Gap); + } + let columns = usize::from((content_area.width / TILE_WIDTH).max(1)); + let mut position = 0; + for (label, group) in release_groups(&detail.releases) { + items.push(PlanItem::Header(format!("{label} ({})", group.len()))); + match state.global.view { + ViewMode::Tiles => { + for chunk in group.chunks(columns) { + let row: Vec = (position..position + chunk.len()).collect(); + if row.contains(&(cursor.wrapping_sub(tracks))) { + cursor_item = Some(items.len()); + } + items.push(PlanItem::TileRow(row)); + position += chunk.len(); + } + } + ViewMode::Table => { + for _ in &group { + if cursor == tracks + position { + cursor_item = Some(items.len()); + } + items.push(PlanItem::TableRow(position)); + position += 1; + } + } + } + items.push(PlanItem::Gap); + } + + let display_order = crate::app::state::release_display_order(&detail.releases); + render_plan(frame, content_area, state, &items, cursor_item, &mut |frame, rect, item| { + match item { + PlanItem::Track { cursor_index } => { + let track = &detail.top_tracks[*cursor_index]; + super::track_row( + frame, + rect, + state, + track, + (cursor_index + 1).to_string(), + cursor == *cursor_index, + ); + } + PlanItem::TileRow(row) => { + for (column, position) in row.iter().enumerate() { + let release = &detail.releases[display_order[*position]]; + let tile = Rect { + x: rect.x + column as u16 * TILE_WIDTH, + y: rect.y, + width: TILE_WIDTH.min(rect.width.saturating_sub(column as u16 * TILE_WIDTH)), + height: rect.height, + }; + if tile.width < 3 { + break; + } + draw_tile( + frame, + tile, + tile_art(state, release.cover_url.as_ref()), + &release.title, + &release_tile_meta(release), + cursor == tracks + position, + ); + } + } + PlanItem::TableRow(position) => { + let release = &detail.releases[display_order[*position]]; + let year = release.year.map(|y| y.to_string()).unwrap_or_default(); + draw_row( + frame, + rect, + Line::from(vec![ + Span::raw(release.title.clone()), + Span::styled(format!(" {year}"), theme::dim()), + ]), + Some(format!("{} trk", release.track_count)), + cursor == tracks + position, + ); + } + _ => unreachable!("headers and gaps are rendered by render_plan"), + } + }); +} + +fn release_tile_meta(release: &ReleaseCard) -> String { + match release.year { + Some(year) => format!("{year} · {} trk", release.track_count), + None => format!("{} trk", release.track_count), + } +} + +/// Render plan items into `area`, scrolled so the cursor item is visible. +/// Headers and gaps are drawn here; everything else is delegated. +fn render_plan( + frame: &mut Frame, + area: Rect, + _state: &AppState, + items: &[PlanItem], + cursor_item: Option, + draw_item: &mut dyn FnMut(&mut Frame, Rect, &PlanItem), +) { + if area.height == 0 { + return; + } + let offset = scroll_offset(items, cursor_item, area.height); + let mut top: u16 = 0; + for item in items { + let height = item.height(); + let item_top = top; + top += height; + if item_top < offset { + continue; + } + let rel_y = item_top - offset; + if rel_y >= area.height { + break; + } + let rect = Rect { + x: area.x, + y: area.y + rel_y, + width: area.width, + height: height.min(area.height - rel_y), + }; + match item { + PlanItem::Header(label) => frame.render_widget( + Paragraph::new(Line::styled(label.clone(), theme::header())), + rect, + ), + PlanItem::Gap => {} + other => draw_item(frame, rect, other), + } + } +} + +// --------------------------------------------------------------------------- +// Release view +// --------------------------------------------------------------------------- + +fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) { + let loadable = state.release_views.get(&id); + let title = match loadable { + Some(Loadable::Ready(detail)) => detail.title.clone(), + _ => "Release".to_string(), + }; + let inner = bordered(frame, area, format!(" Global ▸ {title} ")); + + let detail = match loadable { + Some(Loadable::Ready(detail)) => detail, + Some(Loadable::Failed(error)) => { + return centered_line(frame, inner, Line::styled(error.clone(), error_style())); + } + _ => return centered_line(frame, inner, Line::styled("loading…", theme::dim())), + }; + + let header_height = (ART_HEADER_HEIGHT + 1).min(inner.height); + let [header_area, tracks_area] = + Layout::vertical([Constraint::Length(header_height), Constraint::Min(0)]).areas(inner); + + let [art_area, _, info_area] = Layout::horizontal([ + Constraint::Length(ART_HEADER_WIDTH.min(header_area.width)), + Constraint::Length(2), + Constraint::Min(0), + ]) + .areas(header_area); + draw_art( + frame, + Rect { height: ART_HEADER_HEIGHT.min(art_area.height), ..art_area }, + header_art(state, detail.cover_url.as_ref()), + ); + + let artists: Vec<&str> = detail.artists.iter().map(|a| a.name.as_str()).collect(); + let year = detail.year.map(|y| format!(" · {y}")).unwrap_or_default(); + let uploaders: Vec<&str> = detail.uploaders.iter().map(|u| u.name.as_str()).collect(); + let mut info = vec![ + Line::default(), + Line::styled(detail.title.clone(), theme::header()), + Line::raw(artists.join(", ")), + Line::default(), + Line::styled( + format!("{}{year} · {} tracks", detail.release_type, detail.tracks.len()), + theme::dim(), + ), + ]; + if !uploaders.is_empty() { + info.push(Line::styled( + format!("uploaded by {}", uploaders.join(", ")), + theme::dim(), + )); + } + frame.render_widget(Paragraph::new(info), info_area); + + // Track list with centered scrolling. + let visible = usize::from(tracks_area.height.max(1)); + let total = detail.tracks.len(); + let first = cursor + .saturating_sub(visible / 2) + .min(total.saturating_sub(visible)); + for (offset, track) in detail.tracks.iter().enumerate().skip(first).take(visible) { + let rect = Rect { + x: tracks_area.x, + y: tracks_area.y + (offset - first) as u16, + width: tracks_area.width, + height: 1, + }; + let number = track + .track_number + .map(|n| n.to_string()) + .unwrap_or_else(|| (offset + 1).to_string()); + super::track_row(frame, rect, state, track, number, cursor == offset); + } +} + +// --------------------------------------------------------------------------- +// Search view (driven by the `:/query` command) +// --------------------------------------------------------------------------- + +fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { + let search = &state.search; + let mut title = format!(" Search: {} ", search.query); + if search.loading { + title.push_str("· searching… "); + } + let inner = bordered(frame, area, title); + + let Some(results) = &search.results else { + let hint = if search.query.is_empty() { + "type to search artists, releases and tracks" + } else { + "searching…" + }; + return centered_line(frame, inner, Line::styled(hint, theme::dim())); + }; + if results.len() == 0 { + return centered_line(frame, inner, Line::styled("nothing found", theme::dim())); + } + + // All rows are one line tall: (line, right column, cursor index). + let mut rows: Vec<(Line, Option, Option)> = Vec::new(); + let mut index = 0; + if !results.artists.is_empty() { + rows.push((Line::styled("Artists", theme::header()), None, None)); + for artist in &results.artists { + rows.push(( + Line::raw(artist.name.clone()), + Some(artist_tile_meta(artist)), + Some(index), + )); + index += 1; + } + rows.push((Line::default(), None, None)); + } + if !results.releases.is_empty() { + rows.push((Line::styled("Releases", theme::header()), None, None)); + for release in &results.releases { + rows.push(( + Line::from(vec![ + Span::raw(release.title.clone()), + Span::styled(format!(" {}", release.release_type), theme::dim()), + ]), + Some(release_tile_meta(release)), + Some(index), + )); + index += 1; + } + rows.push((Line::default(), None, None)); + } + if !results.tracks.is_empty() { + rows.push((Line::styled("Tracks", theme::header()), None, None)); + for track in &results.tracks { + let heart = if state.likes.contains(&track.id) { + Span::styled("♥ ", theme::accent()) + } else { + Span::raw(" ") + }; + let tech = track.tech_label_short(); + let right = if tech.is_empty() { + track.duration_label() + } else { + format!("{tech} · {}", track.duration_label()) + }; + rows.push(( + Line::from(vec![ + heart, + Span::raw(track.title.clone()), + Span::styled( + format!(" {} · {}", track.artist_line(), track.release_title), + theme::dim(), + ), + ]), + Some(right), + Some(index), + )); + index += 1; + } + } + + let cursor_row = rows + .iter() + .position(|(_, _, c)| *c == Some(cursor)) + .unwrap_or(0); + let visible = usize::from(inner.height.max(1)); + let first = cursor_row + .saturating_sub(visible / 2) + .min(rows.len().saturating_sub(visible)); + for (offset, (line, right, row_cursor)) in + rows.into_iter().enumerate().skip(first).take(visible) + { + let rect = Rect { + x: inner.x, + y: inner.y + (offset - first) as u16, + width: inner.width, + height: 1, + }; + draw_row(frame, rect, line, right, row_cursor == Some(cursor)); + } +} diff --git a/src/ui/login.rs b/src/ui/login.rs new file mode 100644 index 0000000..e837906 --- /dev/null +++ b/src/ui/login.rs @@ -0,0 +1,176 @@ +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Paragraph, Wrap}; + +use super::theme; +use crate::app::state::{LoginField, LoginForm, LoginMode}; + +pub fn draw(frame: &mut Frame, form: &LoginForm) { + match form.mode { + LoginMode::Form => draw_form(frame, form), + LoginMode::SsoPending => draw_sso_pending(frame, form), + } +} + +fn draw_form(frame: &mut Frame, form: &LoginForm) { + let area = centered(frame.area(), 52, 19); + let block = Block::bordered() + .title(" Sign in to furumi ") + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(block, area); + + // SSO is the primary path: server URL + SSO button up top, the rarely + // used password fallback below a separator. + let [server, sso_button, separator, username, password, signin_button, message, hint] = + Layout::vertical([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(1), + ]) + .areas(inner); + + draw_field(frame, server, "Server URL", &form.server_url, false, + form.focus == LoginField::ServerUrl); + draw_button(frame, sso_button, "[ Continue with SSO ]", + form.focus == LoginField::SsoButton); + frame.render_widget( + Paragraph::new(Line::styled("── or sign in with password ──", theme::dim())) + .alignment(Alignment::Center), + separator, + ); + draw_field(frame, username, "Username", &form.username, false, + form.focus == LoginField::Username); + draw_field(frame, password, "Password", &form.password, true, + form.focus == LoginField::Password); + draw_button(frame, signin_button, "[ Sign in ]", + form.focus == LoginField::SignInButton); + + draw_message(frame, message, form); + frame.render_widget( + Paragraph::new(Line::styled( + "tab/↑↓ move · enter submit · ctrl-c quit", + theme::dim(), + )) + .alignment(Alignment::Center), + hint, + ); +} + +fn draw_sso_pending(frame: &mut Frame, form: &LoginForm) { + let area = centered(frame.area(), 64, 16); + let block = Block::bordered() + .title(" Continue with SSO ") + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let [steps, url, _, paste, message, hint] = Layout::vertical([ + Constraint::Length(4), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(2), + Constraint::Length(1), + ]) + .areas(inner); + + let lines = if let Some(port) = form.sso_port { + vec![ + Line::raw("1. Finish signing in, in the browser window."), + Line::raw("2. Sign-in completes here automatically."), + Line::styled(format!(" (waiting on 127.0.0.1:{port})"), theme::dim()), + Line::raw("3. If it doesn't, paste the code from the page below."), + ] + } else { + vec![ + Line::raw("1. Finish signing in, in the browser window."), + Line::raw("2. Copy the code shown on the final page"), + Line::raw(" (or right-click \"Open Furumi\" and copy its link)."), + Line::raw("3. Paste it below and press Enter."), + ] + }; + frame.render_widget(Paragraph::new(lines), steps); + + frame.render_widget( + Paragraph::new(Line::styled(form.sso_url.clone(), theme::dim())) + .wrap(Wrap { trim: true }) + .block(Block::bordered().title("If the browser didn't open, visit").border_style(theme::dim())), + url, + ); + + draw_field(frame, paste, "Link or code", &form.sso_paste, false, true); + draw_message(frame, message, form); + frame.render_widget( + Paragraph::new(Line::styled("enter submit · esc back · ctrl-c quit", theme::dim())) + .alignment(Alignment::Center), + hint, + ); +} + +fn draw_field(frame: &mut Frame, area: Rect, label: &str, value: &str, mask: bool, focused: bool) { + let border = if focused { theme::accent() } else { theme::dim() }; + let block = Block::bordered().title(label).border_style(border); + let shown = if mask { + "•".repeat(value.chars().count()) + } else { + value.to_string() + }; + // Keep the tail visible when the value overflows the field. + let width = block.inner(area).width.saturating_sub(1) as usize; + let mut text: String = shown + .chars() + .skip(shown.chars().count().saturating_sub(width)) + .collect(); + if focused { + text.push('█'); + } + frame.render_widget(Paragraph::new(text).block(block), area); +} + +fn draw_button(frame: &mut Frame, area: Rect, label: &str, focused: bool) { + let style = if focused { + theme::tab_active() + } else { + theme::dim() + }; + frame.render_widget( + Paragraph::new(Line::styled(label, style)).alignment(Alignment::Center), + area, + ); +} + +fn draw_message(frame: &mut Frame, area: Rect, form: &LoginForm) { + let line = if form.busy { + Line::styled("signing in…", theme::accent()) + } else if let Some(error) = &form.error { + Line::styled(error.clone(), Style::new().fg(Color::Red)) + } else { + Line::default() + }; + frame.render_widget( + Paragraph::new(line) + .wrap(Wrap { trim: true }) + .alignment(Alignment::Center), + area, + ); +} + +fn centered(area: Rect, width: u16, height: u16) -> Rect { + let [rect] = Layout::horizontal([Constraint::Length(width.min(area.width))]) + .flex(Flex::Center) + .areas(area); + let [rect] = Layout::vertical([Constraint::Length(height.min(area.height))]) + .flex(Flex::Center) + .areas(rect); + rect +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..ffa9d91 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,350 @@ +pub mod art; +mod global; +mod login; +mod playlists; +pub mod theme; + +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Paragraph, Row, Table, Tabs}; + +use crate::app::state::{AppState, Screen, Tab}; +use crate::config::keymap::Keymap; + +pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) { + if state.screen == Screen::Login { + login::draw(frame, &state.login); + return; + } + let [tabs_area, main_area, status_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(2), + ]) + .areas(frame.area()); + + draw_tabs(frame, tabs_area, state); + match state.active_tab { + Tab::Global => global::draw(frame, main_area, state), + Tab::Playlists => playlists::draw(frame, main_area, state), + Tab::Queue => draw_queue(frame, main_area, state), + Tab::Devices => draw_main(frame, main_area, state), + } + draw_status(frame, status_area, state); + + if state.help_visible { + draw_help(frame, keymap); + } +} + +fn draw_tabs(frame: &mut Frame, area: Rect, state: &AppState) { + let titles = Tab::ALL + .iter() + .map(|tab| format!(" {} {} ", tab.index() + 1, tab.title())); + let tabs = Tabs::new(titles) + .select(state.active_tab.index()) + .style(theme::dim()) + .highlight_style(theme::tab_active()) + .divider(""); + frame.render_widget(tabs, area); +} + +fn draw_main(frame: &mut Frame, area: Rect, state: &AppState) { + let block = Block::bordered() + .title(format!(" {} ", state.active_tab.title())) + .title_style(theme::header()) + .border_style(theme::dim()); + let inner = block.inner(area); + frame.render_widget(block, area); + + let (summary, milestone) = match state.active_tab { + Tab::Devices => ("Connected devices and playback transfer", "milestone 5"), + _ => ("", ""), + }; + let lines = vec![ + Line::default(), + Line::styled(summary, theme::accent()), + Line::styled(format!("coming in {milestone}"), theme::dim()), + Line::default(), + Line::styled("Tab / Shift-Tab or 1-5 to switch tabs", theme::dim()), + Line::styled("? keybindings q quit", theme::dim()), + ]; + let paragraph = Paragraph::new(lines).alignment(Alignment::Center); + frame.render_widget(paragraph, centered_vertically(inner, 6)); +} + +/// One track row used by every track list: ♥ marker for liked tracks, the +/// title and artists on the left, tech info and duration on the right. +pub(crate) fn track_row( + frame: &mut Frame, + area: Rect, + state: &AppState, + track: &crate::api::models::TrackItem, + index_label: String, + selected: bool, +) { + let heart = if state.likes.contains(&track.id) { + Span::styled("♥ ", theme::accent()) + } else { + Span::raw(" ") + }; + let line = Line::from(vec![ + Span::styled(format!("{index_label:>3} "), theme::dim()), + heart, + Span::raw(track.title.clone()), + Span::styled(format!(" {}", track.artist_line()), theme::dim()), + ]); + frame.render_widget(Paragraph::new(line), area); + + let tech = track.tech_label_short(); + let right = if tech.is_empty() || area.width < 60 { + track.duration_label() + } else { + format!("{tech} · {}", track.duration_label()) + }; + frame.render_widget( + Paragraph::new(Line::styled(right, theme::dim())).alignment(Alignment::Right), + area, + ); + if selected { + frame.buffer_mut().set_style(area, theme::tab_active()); + } +} + +/// Read-only queue listing; the playing track is highlighted. +fn draw_queue(frame: &mut Frame, area: Rect, state: &AppState) { + let player = &state.player; + let block = Block::bordered() + .title(format!(" Queue — {} tracks ", player.queue.len())) + .title_style(theme::header()) + .border_style(theme::dim()); + let inner = block.inner(area); + frame.render_widget(block, area); + + if player.queue.is_empty() { + let middle = Rect { y: inner.y + inner.height / 2, height: 1, ..inner }; + frame.render_widget( + Paragraph::new(Line::styled( + "queue is empty — open a track and press enter", + theme::dim(), + )) + .alignment(Alignment::Center), + middle, + ); + return; + } + + let visible = usize::from(inner.height.max(1)); + let first = player + .queue_pos + .saturating_sub(visible / 2) + .min(player.queue.len().saturating_sub(visible)); + for (index, track) in player.queue.iter().enumerate().skip(first).take(visible) { + let row = Rect { + x: inner.x, + y: inner.y + (index - first) as u16, + width: inner.width, + height: 1, + }; + track_row( + frame, + row, + state, + track, + (index + 1).to_string(), + index == player.queue_pos, + ); + } +} + +fn format_secs(secs: f64) -> String { + let total = secs.max(0.0).round() as i64; + format!("{}:{:02}", total / 60, total % 60) +} + +/// Playback time, progress bar, queue position, volume and mode flags. +/// Wider consoles get a longer bar and full flags; narrow ones drop pieces. +fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + if let Some(track) = &player.current { + if player.playing { + let bar_width: usize = match width { + 0..=59 => 0, + 60..=79 => 8, + 80..=109 => 14, + _ => 22, + }; + spans.push(Span::raw(format!("{} ", format_secs(player.position_secs)))); + if bar_width > 0 && track.duration_seconds > 0.0 { + let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0); + let filled = (ratio * bar_width as f64).round() as usize; + spans.push(Span::styled("━".repeat(filled), theme::accent())); + spans.push(Span::styled("─".repeat(bar_width - filled), theme::dim())); + spans.push(Span::raw(" ")); + } else { + spans.push(Span::styled("/ ", theme::dim())); + } + spans.push(Span::raw(track.duration_label())); + if !player.queue.is_empty() && width >= 70 { + spans.push(Span::styled( + format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()), + theme::dim(), + )); + } + } + } + if width >= 80 { + let volume_cells = usize::from(player.volume / 10); + spans.extend([ + Span::styled(" vol ", theme::dim()), + Span::styled("█".repeat(volume_cells), theme::accent()), + Span::styled("░".repeat(10 - volume_cells), theme::dim()), + Span::raw(format!(" {:3}%", player.volume)), + Span::styled(" shuffle ", theme::dim()), + Span::raw(if player.shuffle { "on" } else { "off" }.to_string()), + Span::styled(" repeat ", theme::dim()), + Span::raw(player.repeat.label().to_string()), + ]); + } else { + spans.push(Span::styled( + format!(" {}%", player.volume), + theme::dim(), + )); + } + Line::from(spans) +} + +fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) { + let [player_row, message_row] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); + + let player = &state.player; + // Layout: track title left, time/progress/flags centered, user right. + // The center block is built first and gets a fixed width; the title + // truncates into whatever is left. + let center = player_right_line(player, area.width); + let center_width = (center.width() as u16).min(area.width); + let user_line = state.user.as_ref().map(|user| { + Line::from(vec![ + Span::styled("◉ ", theme::accent()), + Span::raw(user.name.clone()), + ]) + }); + let user_width = user_line.as_ref().map_or(0, |l| l.width() as u16); + let [title_area, right_area, user_area] = Layout::horizontal([ + Constraint::Min(8), + Constraint::Length(center_width), + Constraint::Length(user_width), + ]) + .areas(player_row); + if let Some(user_line) = user_line { + frame.render_widget( + Paragraph::new(user_line).alignment(Alignment::Right), + user_area, + ); + } + + let mut spans = Vec::new(); + match &player.current { + Some(track) if player.playing => { + if player.paused { + spans.push(Span::styled("⏸ ", theme::dim())); + } else { + spans.push(Span::styled("▶ ", theme::accent())); + } + if state.likes.contains(&track.id) { + spans.push(Span::styled("♥ ", theme::accent())); + } + spans.push(Span::raw(track.title.clone())); + spans.push(Span::styled( + format!(" — {}", track.artist_line()), + theme::dim(), + )); + } + _ => { + spans.push(Span::styled("■ stopped", theme::dim())); + } + } + frame.render_widget(Paragraph::new(Line::from(spans)), title_area); + frame.render_widget(Paragraph::new(center), right_area); + + + if state.cmdline.active { + // Vim-style command line takes over the message row. + let line = Line::from(vec![ + Span::styled(":", theme::header()), + Span::raw(state.cmdline.input.clone()), + Span::styled("█", theme::accent()), + ]); + frame.render_widget(Paragraph::new(line), message_row); + return; + } + + let message = match &state.status_message { + Some(message) => Line::styled(message.clone(), theme::accent()), + None => match &state.player.current { + // Idle line doubles as the current track's tech data display. + Some(track) if state.player.playing && !track.tech_label_full().is_empty() => { + Line::styled(track.tech_label_full(), theme::dim()) + } + _ => Line::styled("press ? for keybindings", theme::dim()), + }, + }; + frame.render_widget(Paragraph::new(message), message_row); + + if let Some(pending) = &state.pending_keys { + let pending = Paragraph::new(Line::styled(format!("{pending} …"), theme::header())) + .alignment(Alignment::Right); + frame.render_widget(pending, message_row); + } +} + +fn draw_help(frame: &mut Frame, keymap: &Keymap) { + let entries = keymap.help_entries(); + let height = (entries.len() as u16 + 4).min(frame.area().height.saturating_sub(2)); + let width = 56.min(frame.area().width.saturating_sub(2)); + let area = centered_rect(frame.area(), width, height); + + let rows = entries.into_iter().map(|(keys, description, context)| { + Row::new(vec![ + Span::styled(keys, theme::accent()), + Span::raw(description), + Span::styled(context.label(), theme::dim()), + ]) + }); + let table = Table::new( + rows, + [ + Constraint::Length(12), + Constraint::Min(20), + Constraint::Length(9), + ], + ) + .header(Row::new(vec!["keys", "action", "context"]).style(theme::header())) + .block( + Block::bordered() + .title(" Keybindings ") + .title_style(theme::header()), + ); + + frame.render_widget(Clear, area); + frame.render_widget(table, area); +} + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + let [rect] = Layout::horizontal([Constraint::Length(width)]) + .flex(ratatui::layout::Flex::Center) + .areas(area); + let [rect] = Layout::vertical([Constraint::Length(height)]) + .flex(ratatui::layout::Flex::Center) + .areas(rect); + rect +} + +fn centered_vertically(area: Rect, content_height: u16) -> Rect { + let [rect] = Layout::vertical([Constraint::Length(content_height)]) + .flex(ratatui::layout::Flex::Center) + .areas(area); + rect +} diff --git a/src/ui/playlists.rs b/src/ui/playlists.rs new file mode 100644 index 0000000..617e461 --- /dev/null +++ b/src/ui/playlists.rs @@ -0,0 +1,144 @@ +use ratatui::Frame; +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +use super::{theme, track_row}; +use crate::app::state::{AppState, Loadable}; +use crate::app::update::playlist_tracks; + +pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) { + match state.playlists.opened { + Some(opened) => draw_opened(frame, area, state, opened.id, opened.cursor), + None => draw_list(frame, area, state), + } +} + +fn bordered(frame: &mut Frame, area: Rect, title: String) -> Rect { + let block = Block::bordered() + .title(title) + .title_style(theme::header()) + .border_style(theme::dim()); + let inner = block.inner(area); + frame.render_widget(block, area); + inner +} + +fn centered_line(frame: &mut Frame, area: Rect, line: Line) { + if area.height == 0 { + return; + } + let middle = Rect { y: area.y + area.height / 2, height: 1, ..area }; + frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), middle); +} + +fn draw_list(frame: &mut Frame, area: Rect, state: &AppState) { + let inner = bordered(frame, area, " Playlists ".to_string()); + let selected = state.playlists.selected; + + let list = match &state.playlists.list { + Some(Loadable::Ready(list)) => list, + Some(Loadable::Failed(error)) => { + return centered_line( + frame, + inner, + Line::styled(error.clone(), Style::new().fg(Color::Red)), + ); + } + _ => { + return centered_line(frame, inner, Line::styled("loading playlists…", theme::dim())); + } + }; + if list.is_empty() { + return centered_line(frame, inner, Line::styled("no playlists yet", theme::dim())); + } + + let visible = usize::from(inner.height.max(1)); + let first = selected + .saturating_sub(visible / 2) + .min(list.len().saturating_sub(visible)); + for (index, playlist) in list.iter().enumerate().skip(first).take(visible) { + let row = Rect { + x: inner.x, + y: inner.y + (index - first) as u16, + width: inner.width, + height: 1, + }; + let marker = if playlist.kind == "likes" { + Span::styled("♥ ", theme::accent()) + } else { + Span::raw(" ") + }; + let mut flags = Vec::new(); + if !playlist.is_own { + if let Some(owner) = &playlist.owner_name { + flags.push(format!("by {owner}")); + } + } + if playlist.is_public { + flags.push("public".to_string()); + } + let suffix = if flags.is_empty() { + String::new() + } else { + format!(" {}", flags.join(" · ")) + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + marker, + Span::raw(playlist.title.clone()), + Span::styled(suffix, theme::dim()), + ])), + row, + ); + frame.render_widget( + Paragraph::new(Line::styled( + format!("{} trk", playlist.track_count), + theme::dim(), + )) + .alignment(Alignment::Right), + row, + ); + if index == selected { + frame.buffer_mut().set_style(row, theme::tab_active()); + } + } +} + +fn draw_opened(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: usize) { + let loadable = state.playlist_views.get(&id); + let title = match loadable { + Some(Loadable::Ready(detail)) => format!(" Playlists ▸ {} ", detail.title), + _ => " Playlists ▸ … ".to_string(), + }; + let inner = bordered(frame, area, title); + + if let Some(Loadable::Failed(error)) = loadable { + return centered_line( + frame, + inner, + Line::styled(error.clone(), Style::new().fg(Color::Red)), + ); + } + let Some(tracks) = playlist_tracks(state, id) else { + return centered_line(frame, inner, Line::styled("loading…", theme::dim())); + }; + if tracks.is_empty() { + return centered_line(frame, inner, Line::styled("no tracks here yet", theme::dim())); + } + + let visible = usize::from(inner.height.max(1)); + let first = cursor + .saturating_sub(visible / 2) + .min(tracks.len().saturating_sub(visible)); + for (index, track) in tracks.iter().enumerate().skip(first).take(visible) { + let row = Rect { + x: inner.x, + y: inner.y + (index - first) as u16, + width: inner.width, + height: 1, + }; + track_row(frame, row, state, track, (index + 1).to_string(), index == cursor); + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..35cd6d9 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,23 @@ +use ratatui::style::{Color, Modifier, Style}; + +pub const ACCENT: Color = Color::Cyan; +pub const DIM: Color = Color::DarkGray; + +pub fn accent() -> Style { + Style::new().fg(ACCENT) +} + +pub fn dim() -> Style { + Style::new().fg(DIM) +} + +pub fn tab_active() -> Style { + Style::new() + .fg(Color::Black) + .bg(ACCENT) + .add_modifier(Modifier::BOLD) +} + +pub fn header() -> Style { + Style::new().fg(ACCENT).add_modifier(Modifier::BOLD) +}