rmptui1dcac38
Merge pull request #11 from krolyxon/v0.2
V0.2
+11172 −3571
61 files changed
- M .github/copilot-instructions.md +59 −23
- M CHANGELOG.md +30 −0
- M Cargo.lock +1693 −48
- M Cargo.toml +26 −15
- M README.md +192 −154
- A assets/browse-screen.png
- A assets/lyrics-screen.png
- A assets/playlist-editor-screen.png
- D assets/ss.png
- A src/action.rs +323 −0
- M src/app.rs +451 −422
- D src/browser.rs +0 −164
- D src/config.rs +0 −669
- A src/config/default.toml +221 −0
- A src/config/keys.rs +305 −0
- A src/config/mod.rs +554 −0
- A src/config/mpd_conf.rs +87 −0
- A src/config/theme.rs +104 −0
- D src/connection.rs +0 −741
- A src/event.rs +20 −0
- D src/event_handler/handler.rs +0 −473
- D src/event_handler/mod.rs +0 −1
- M src/lib.rs +15 −10
- D src/list.rs +0 −30
- M src/logging.rs +39 −67
- A src/lyrics/lrc.rs +165 −0
- A src/lyrics/mod.rs +373 −0
- M src/main.rs +47 −176
- A src/mpd/commands.rs +168 −0
- A src/mpd/connection.rs +173 −0
- A src/mpd/error.rs +42 −0
- A src/mpd/mod.rs +16 −0
- A src/mpd/protocol.rs +227 −0
- A src/mpd/selection.rs +36 −0
- A src/mpd/types.rs +425 −0
- A src/mpd/worker.rs +477 −0
- A src/popups/add_to_playlist.rs +114 −0
- A src/popups/confirm.rs +69 −0
- A src/popups/help.rs +133 −0
- A src/popups/input.rs +99 −0
- A src/popups/mod.rs +60 −0
- A src/screens/browser.rs +325 −0
- A src/screens/library.rs +396 −0
- A src/screens/lyrics.rs +138 −0
- A src/screens/mod.rs +168 −0
- A src/screens/playlists.rs +296 −0
- A src/screens/queue.rs +238 −0
- A src/screens/tag_editor.rs +669 −0
- A src/screens/visualizer.rs +352 −0
- A src/state.rs +177 −0
- A src/tags.rs +186 −0
- D src/tui.rs +0 −62
- D src/ui.rs +0 −489
- A src/ui/input.rs +160 −0
- A src/ui/list.rs +346 −0
- A src/ui/mod.rs +337 −0
- A src/util.rs +96 −0
- D src/utils.rs +0 −27
- A src/visualizer/bars.rs +130 −0
- A src/visualizer/mod.rs +251 −0
- A tests/mpd_client.rs +164 −0
| @@ -1,32 +1,68 @@ | |||
| 1 | − | # Copilot instructions for rmptui | |
| 1 | + | # Contributor and agent instructions for rmptui | |
| 2 | 2 | ||
| 3 | 3 | ## Build, test, and lint | |
| 4 | 4 | ||
| 5 | 5 | - `cargo build` — compile the application. | |
| 6 | − | - `cargo run` — run against MPD at `127.0.0.1:6600` by default. | |
| 7 | − | - `cargo fmt --check` — check Rust formatting; use `cargo fmt` to format. | |
| 8 | − | - `cargo test` — run all unit tests. | |
| 9 | − | - `cargo test connection::tests::parses_quoted_mpd_values_and_escapes` — run one named test. Replace the filter with a test name or module path to target another test. | |
| 10 | − | - `cargo clippy --all-targets -- -D warnings` — lint all targets and treat warnings as errors. | |
| 6 | + | - `cargo run -- --config FILE` — run with a specific config (MPD at | |
| 7 | + | `127.0.0.1:6600` by default; `MPD_HOST`/`MPD_PORT` override it). | |
| 8 | + | - `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`. | |
| 9 | + | - `cargo test mpd::protocol` — run the tests of one module; `tests/mpd_client.rs` | |
| 10 | + | exercises the client against a scripted fake MPD server. | |
| 11 | 11 | ||
| 12 | 12 | ## Architecture | |
| 13 | 13 | ||
| 14 | − | - `src/main.rs` owns the Tokio runtime, reads `MPD_HOST`/`MPD_PORT`, initializes the terminal, and selects between terminal input, redraw ticks, and MPD change notifications. A separate `MpdClient` connection blocks on MPD `idle`, then sends a fresh `MpdSnapshot` to the UI; the app's command connection stays available for user actions. | |
| 15 | − | - `src/config.rs` loads or creates the TOML configuration at `$CONFIG_HOME/rmptui/config.toml`, falling back to `$XDG_CONFIG_HOME` and then `~/.config`. Its generated TOML documents every option and keybinding. MPD host/port settings can be overridden with `MPD_HOST` and `MPD_PORT`. | |
| 16 | − | - `src/connection.rs` implements MPD's line-oriented text protocol over Tokio TCP, including command quoting, response/error parsing, and conversion of MPD responses into app models. Keep protocol I/O asynchronous. The client processes a command's complete response before issuing another command on that connection. | |
| 17 | − | - `src/app.rs` owns UI/application state and coordinates queue, browser, and playlist operations. `src/browser.rs` represents rows as `BrowserEntry::Directory` or `BrowserEntry::Song`, keeping song metadata with its row. Browser paths are MPD library-relative paths, not local filesystem paths. The Playlists view has separate list/song focus and supports stored-playlist track deletion and reordering through MPD's `playlistdelete` and `playlistmove` commands. | |
| 18 | − | - `src/event_handler/handler.rs` maps Crossterm events to application operations. `src/ui.rs` renders the current app state with Ratatui; it should not perform MPD I/O. `src/tui.rs` owns terminal mode setup, drawing, and restoration. | |
| 19 | − | - `src/list.rs` contains shared list cursor behavior. `src/utils.rs` provides the built-in audio-extension check used by the browser and directory operations. | |
| 14 | + | - `mpd/` is a self-contained MPD client. | |
| 15 | + | - `protocol.rs`: pure command encoding (every argument quoted and escaped) | |
| 16 | + | and response parsing. | |
| 17 | + | - `connection.rs`: async TCP and Unix-socket connections. | |
| 18 | + | - `types.rs`: typed models. | |
| 19 | + | - `commands.rs`: command constructors. | |
| 20 | + | - `worker.rs`: two background tasks. The *idle* connection reports changed | |
| 21 | + | subsystems. The *command* connection runs `Request`s from an `MpdHandle` | |
| 22 | + | and answers `Query`s with `Reply`s. Both reconnect on their own. | |
| 23 | + | - `app.rs` owns the state and runs one `tokio::select!` loop over terminal | |
| 24 | + | input, `AppEvent`s (MPD, lyrics, tag writes) and visualizer frames. UI code | |
| 25 | + | never awaits MPD: it sends requests and reacts to the resulting events. | |
| 26 | + | - `state.rs` (`MpdState`) mirrors the server: status, current song, the queue | |
| 27 | + | (updated incrementally via `plchanges`), stored playlists, and a database | |
| 28 | + | version used to invalidate caches. | |
| 29 | + | - `screens/` holds one component per screen behind the `Screen` trait (`draw`, | |
| 30 | + | `handle_action`, `on_mpd`, `on_enter`, `on_mouse`, `search`). | |
| 31 | + | - Screens receive a `Ctx` with read access to config and state, the | |
| 32 | + | `MpdHandle`, and an effect queue (`Effect`) for things only the app can do: | |
| 33 | + | notifications, popups, tag writes, lyric reloads. | |
| 34 | + | - Every screen sees every `MpdEvent`, so caches stay valid while hidden. | |
| 35 | + | - `popups/` holds modal components (`Popup` trait); the topmost popup gets all | |
| 36 | + | keys. Destructive actions go through `ConfirmPopup`. | |
| 37 | + | - `ui/` holds the ncmpcpp-style frame (title row, progress bar, status line), the | |
| 38 | + | virtualized searchable `ListView`, and the `LineInput` editor. | |
| 39 | + | - `config/`: | |
| 40 | + | - `default.toml` is embedded and is the single source of truth for | |
| 41 | + | defaults; user files are merged over it table by table. | |
| 42 | + | - `keys.rs` parses key specs and builds the per-context `Keymap`. | |
| 43 | + | - `action.rs` defines the `Action`, `Context` and `ScreenId` enums. Each action | |
| 44 | + | declares the contexts it may be bound in. Lookup order is: popup or screen | |
| 45 | + | context → `navigation` → `global`. | |
| 46 | + | - `lyrics/`: LRC parser and the async lookup service (local `.lrc` → cache → | |
| 47 | + | LRCLIB). `visualizer/`: fifo reader thread with FFT, plus bar smoothing. | |
| 48 | + | `tags.rs`: lofty-based tag writing. | |
| 20 | 49 | ||
| 21 | − | ## Repository conventions | |
| 50 | + | ## Conventions | |
| 22 | 51 | ||
| 23 | − | - Keep the MPD protocol models and wire-format parsing in `connection.rs`; UI and application code should use those models rather than parse raw MPD responses. | |
| 24 | − | - Use the crate-level `AppResult<T>` for errors crossing module boundaries. Avoid duplicating state that can be derived from the MPD status or other canonical model data. | |
| 25 | − | - Keep configuration defaults and validation in `config.rs`, and read tunable behavior from `App.config` rather than adding new hard-coded runtime values. Route keyboard actions through `App.config.keybindings`; keep README controls and the generated defaults in sync. Text typed into search/name fields is content, not a keybinding, except for configured input-editing actions. | |
| 26 | − | - When adding or changing config options, update all three: the runtime default/config validation, descriptive comments in the generated first-run TOML, and the README example/explanation. Existing partial configs should continue receiving defaults for newly added keybindings. | |
| 27 | − | - Pass MPD library paths as protocol arguments. The MPD command writer quotes and escapes each argument, so do not construct raw command strings from user-provided names or paths. | |
| 28 | − | - Preserve async boundaries through event handling and app operations; do not introduce blocking socket reads, polling threads, or synchronous terminal polling. | |
| 29 | − | - Browser `/` search retains all positive-scoring fuzzy matches in result order. `Enter` returns to normal navigation while retaining browser results; `n` and `N` cycle them. Starting a new search or changing directories resets those results. | |
| 30 | − | - In the Playlists view, `Left`/`h` and `Right`/`l` switch focus between playlist names and song contents. `Space`/`Enter` load a selected playlist from the playlist list; when song contents are focused, `Enter` plays that song, `d` deletes it from the stored playlist, and `J`/`K` reorder it. `D` deletes the selected stored playlist and `R` renames it. Keep these actions context-aware and all defaults documented in the README/config. | |
| 31 | − | - When changing key behavior or features, keep the controls documented in `README.md` consistent with `event_handler/handler.rs`. | |
| 32 | − | - `AppResult<T>` is `Send + Sync` because application errors cross Tokio task boundaries. | |
| 52 | + | - Build MPD commands only through `mpd::commands` or `Command::arg`; never | |
| 53 | + | format raw command strings from user data. Filter expressions go through | |
| 54 | + | `protocol::filter_eq`/`filter_and`. | |
| 55 | + | - Prefer position-based commands (`move`, `delete`) for actions users repeat | |
| 56 | + | quickly. They stay correct even when sent before the local queue refreshes. | |
| 57 | + | - Every new action needs: | |
| 58 | + | - an `Action` variant with its contexts and description; | |
| 59 | + | - a default binding in `config/default.toml` (a test enforces this); | |
| 60 | + | - a README entry. | |
| 61 | + | - Every new option needs: | |
| 62 | + | - a documented entry in `default.toml`; | |
| 63 | + | - a field in the matching config struct (`deny_unknown_fields` catches | |
| 64 | + | typos); | |
| 65 | + | - validation in `Config::validate` if it has limits; | |
| 66 | + | - README documentation. | |
| 67 | + | - Screens must not block: do file and network I/O in tasks (see lyrics) or | |
| 68 | + | `spawn_blocking` (see tag writes) and report back through `AppEvent`. | |
| @@ -1,3 +1,33 @@ | |||
| 1 | + | ## rmptui-v0.2.0 | |
| 2 | + | ||
| 3 | + | A rewrite from scratch with a new architecture modelled on ncmpcpp and rmpc. | |
| 4 | + | ||
| 5 | + | - New screens: Media Library (artists → albums → songs), Tag Editor, | |
| 6 | + | ncmpcpp-style Visualizer (wave, filled wave, spectrum and ellipse, in | |
| 7 | + | stereo, from an MPD fifo output) and synced Lyrics (local `.lrc` | |
| 8 | + | files, cache and LRCLIB), plus a lyrics pane on the queue. | |
| 9 | + | - Browser shows stored playlists at the bottom of the root folder; | |
| 10 | + | playlist editor, queue and browser keep every 0.1 feature. | |
| 11 | + | - MPD I/O runs in background tasks; key presses never wait for the server, | |
| 12 | + | and the client reconnects automatically. Unix sockets are supported. | |
| 13 | + | - Marks (`v`) for acting on several items, fuzzy search on every list, | |
| 14 | + | confirmation before clearing the queue or deleting playlists, a key help | |
| 15 | + | popup (`?`) and mouse support (select, double-click, wheel, tabs, seek). | |
| 16 | + | - New configuration format with per-context key bindings, a theme, queue | |
| 17 | + | columns and lyrics/visualizer settings. The documented default file is the | |
| 18 | + | single source of truth; partial files keep all other defaults. 0.1 config | |
| 19 | + | files are detected and rejected with instructions. | |
| 20 | + | - ncmpcpp-style interface: screen title and volume on top, column headers, | |
| 21 | + | folders spanning the whole row, reverse-video selection and a | |
| 22 | + | `Playing: "Title" by Artist in Album` status line under the progress bar. | |
| 23 | + | - The tag editor follows ncmpcpp (directories, tag types, tags) and can also | |
| 24 | + | rename files and fix letter case. | |
| 25 | + | - The music directory is read from the local `mpd.conf` when MPD runs on the | |
| 26 | + | same machine. | |
| 27 | + | - `c` clears the queue from every screen. | |
| 28 | + | - New `--config` and `--print-default-config` options; `RMPTUI_LOG` sets | |
| 29 | + | the log level. | |
| 30 | + | ||
| 1 | 31 | ## rmptui-v0.1.7 | |
| 2 | 32 | ||
| 3 | 33 | - Replace the outdated MPD crate with an asynchronous native MPD TCP protocol | |
| @@ -2,12 +2,83 @@ | |||
| 2 | 2 | # It is not intended for manual editing. | |
| 3 | 3 | version = 4 | |
| 4 | 4 | ||
| 5 | + | [[package]] | |
| 6 | + | name = "adler2" | |
| 7 | + | version = "2.0.1" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 10 | + | ||
| 11 | + | [[package]] | |
| 12 | + | name = "aho-corasick" | |
| 13 | + | version = "1.1.5" | |
| 14 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | + | checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" | |
| 16 | + | dependencies = [ | |
| 17 | + | "memchr", | |
| 18 | + | ] | |
| 19 | + | ||
| 5 | 20 | [[package]] | |
| 6 | 21 | name = "allocator-api2" | |
| 7 | 22 | version = "0.2.21" | |
| 8 | 23 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | 24 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 10 | 25 | ||
| 26 | + | [[package]] | |
| 27 | + | name = "anstream" | |
| 28 | + | version = "1.0.0" | |
| 29 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 30 | + | checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" | |
| 31 | + | dependencies = [ | |
| 32 | + | "anstyle", | |
| 33 | + | "anstyle-parse", | |
| 34 | + | "anstyle-query", | |
| 35 | + | "anstyle-wincon", | |
| 36 | + | "colorchoice", | |
| 37 | + | "is_terminal_polyfill", | |
| 38 | + | "utf8parse", | |
| 39 | + | ] | |
| 40 | + | ||
| 41 | + | [[package]] | |
| 42 | + | name = "anstyle" | |
| 43 | + | version = "1.0.14" | |
| 44 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 45 | + | checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" | |
| 46 | + | ||
| 47 | + | [[package]] | |
| 48 | + | name = "anstyle-parse" | |
| 49 | + | version = "1.0.0" | |
| 50 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 51 | + | checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" | |
| 52 | + | dependencies = [ | |
| 53 | + | "utf8parse", | |
| 54 | + | ] | |
| 55 | + | ||
| 56 | + | [[package]] | |
| 57 | + | name = "anstyle-query" | |
| 58 | + | version = "1.1.5" | |
| 59 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 60 | + | checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | |
| 61 | + | dependencies = [ | |
| 62 | + | "windows-sys 0.61.2", | |
| 63 | + | ] | |
| 64 | + | ||
| 65 | + | [[package]] | |
| 66 | + | name = "anstyle-wincon" | |
| 67 | + | version = "3.0.11" | |
| 68 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 69 | + | checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | |
| 70 | + | dependencies = [ | |
| 71 | + | "anstyle", | |
| 72 | + | "once_cell_polyfill", | |
| 73 | + | "windows-sys 0.61.2", | |
| 74 | + | ] | |
| 75 | + | ||
| 76 | + | [[package]] | |
| 77 | + | name = "anyhow" | |
| 78 | + | version = "1.0.104" | |
| 79 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 80 | + | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | |
| 81 | + | ||
| 11 | 82 | [[package]] | |
| 12 | 83 | name = "approx" | |
| 13 | 84 | version = "0.5.1" | |
| @@ -17,24 +88,71 @@ dependencies = [ | |||
| 17 | 88 | "num-traits", | |
| 18 | 89 | ] | |
| 19 | 90 | ||
| 91 | + | [[package]] | |
| 92 | + | name = "atomic-waker" | |
| 93 | + | version = "1.1.2" | |
| 94 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 95 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 96 | + | ||
| 20 | 97 | [[package]] | |
| 21 | 98 | name = "autocfg" | |
| 22 | 99 | version = "1.5.1" | |
| 23 | 100 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 24 | 101 | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 25 | 102 | ||
| 103 | + | [[package]] | |
| 104 | + | name = "aws-lc-rs" | |
| 105 | + | version = "1.18.2" | |
| 106 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 107 | + | checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" | |
| 108 | + | dependencies = [ | |
| 109 | + | "aws-lc-sys", | |
| 110 | + | "zeroize", | |
| 111 | + | ] | |
| 112 | + | ||
| 113 | + | [[package]] | |
| 114 | + | name = "aws-lc-sys" | |
| 115 | + | version = "0.46.0" | |
| 116 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 117 | + | checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" | |
| 118 | + | dependencies = [ | |
| 119 | + | "cc", | |
| 120 | + | "cmake", | |
| 121 | + | "dunce", | |
| 122 | + | "fs_extra", | |
| 123 | + | "pkg-config", | |
| 124 | + | ] | |
| 125 | + | ||
| 126 | + | [[package]] | |
| 127 | + | name = "base64" | |
| 128 | + | version = "0.23.1" | |
| 129 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 130 | + | checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" | |
| 131 | + | ||
| 26 | 132 | [[package]] | |
| 27 | 133 | name = "bitflags" | |
| 28 | 134 | version = "2.13.2" | |
| 29 | 135 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 30 | 136 | checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" | |
| 31 | 137 | ||
| 138 | + | [[package]] | |
| 139 | + | name = "bumpalo" | |
| 140 | + | version = "3.20.3" | |
| 141 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 142 | + | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 143 | + | ||
| 32 | 144 | [[package]] | |
| 33 | 145 | name = "by_address" | |
| 34 | 146 | version = "1.2.1" | |
| 35 | 147 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 36 | 148 | checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" | |
| 37 | 149 | ||
| 150 | + | [[package]] | |
| 151 | + | name = "byteorder" | |
| 152 | + | version = "1.5.0" | |
| 153 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 154 | + | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 155 | + | ||
| 38 | 156 | [[package]] | |
| 39 | 157 | name = "bytes" | |
| 40 | 158 | version = "1.12.1" | |
| @@ -50,12 +168,106 @@ dependencies = [ | |||
| 50 | 168 | "rustversion", | |
| 51 | 169 | ] | |
| 52 | 170 | ||
| 171 | + | [[package]] | |
| 172 | + | name = "cc" | |
| 173 | + | version = "1.8.0" | |
| 174 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 175 | + | checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" | |
| 176 | + | dependencies = [ | |
| 177 | + | "find-msvc-tools", | |
| 178 | + | "jobserver", | |
| 179 | + | "libc", | |
| 180 | + | "shlex", | |
| 181 | + | ] | |
| 182 | + | ||
| 53 | 183 | [[package]] | |
| 54 | 184 | name = "cfg-if" | |
| 55 | 185 | version = "1.0.5" | |
| 56 | 186 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 57 | 187 | checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" | |
| 58 | 188 | ||
| 189 | + | [[package]] | |
| 190 | + | name = "cfg_aliases" | |
| 191 | + | version = "0.2.2" | |
| 192 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 193 | + | checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" | |
| 194 | + | ||
| 195 | + | [[package]] | |
| 196 | + | name = "chacha20" | |
| 197 | + | version = "0.10.2" | |
| 198 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 199 | + | checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" | |
| 200 | + | dependencies = [ | |
| 201 | + | "cfg-if", | |
| 202 | + | "cpufeatures", | |
| 203 | + | "rand_core", | |
| 204 | + | ] | |
| 205 | + | ||
| 206 | + | [[package]] | |
| 207 | + | name = "clap" | |
| 208 | + | version = "4.6.7" | |
| 209 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 210 | + | checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" | |
| 211 | + | dependencies = [ | |
| 212 | + | "clap_builder", | |
| 213 | + | "clap_derive", | |
| 214 | + | ] | |
| 215 | + | ||
| 216 | + | [[package]] | |
| 217 | + | name = "clap_builder" | |
| 218 | + | version = "4.6.7" | |
| 219 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 220 | + | checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" | |
| 221 | + | dependencies = [ | |
| 222 | + | "anstream", | |
| 223 | + | "anstyle", | |
| 224 | + | "clap_lex", | |
| 225 | + | "strsim", | |
| 226 | + | ] | |
| 227 | + | ||
| 228 | + | [[package]] | |
| 229 | + | name = "clap_derive" | |
| 230 | + | version = "4.6.7" | |
| 231 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 232 | + | checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" | |
| 233 | + | dependencies = [ | |
| 234 | + | "heck", | |
| 235 | + | "proc-macro2", | |
| 236 | + | "quote", | |
| 237 | + | "syn 3.0.9", | |
| 238 | + | ] | |
| 239 | + | ||
| 240 | + | [[package]] | |
| 241 | + | name = "clap_lex" | |
| 242 | + | version = "1.1.1" | |
| 243 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 244 | + | checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" | |
| 245 | + | ||
| 246 | + | [[package]] | |
| 247 | + | name = "cmake" | |
| 248 | + | version = "0.1.58" | |
| 249 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 250 | + | checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" | |
| 251 | + | dependencies = [ | |
| 252 | + | "cc", | |
| 253 | + | ] | |
| 254 | + | ||
| 255 | + | [[package]] | |
| 256 | + | name = "colorchoice" | |
| 257 | + | version = "1.0.5" | |
| 258 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 259 | + | checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" | |
| 260 | + | ||
| 261 | + | [[package]] | |
| 262 | + | name = "combine" | |
| 263 | + | version = "4.6.9" | |
| 264 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 265 | + | checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" | |
| 266 | + | dependencies = [ | |
| 267 | + | "bytes", | |
| 268 | + | "memchr", | |
| 269 | + | ] | |
| 270 | + | ||
| 59 | 271 | [[package]] | |
| 60 | 272 | name = "compact_str" | |
| 61 | 273 | version = "0.9.1" | |
| @@ -79,6 +291,40 @@ dependencies = [ | |||
| 79 | 291 | "unicode-segmentation", | |
| 80 | 292 | ] | |
| 81 | 293 | ||
| 294 | + | [[package]] | |
| 295 | + | name = "core-foundation" | |
| 296 | + | version = "0.10.1" | |
| 297 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 298 | + | checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" | |
| 299 | + | dependencies = [ | |
| 300 | + | "core-foundation-sys", | |
| 301 | + | "libc", | |
| 302 | + | ] | |
| 303 | + | ||
| 304 | + | [[package]] | |
| 305 | + | name = "core-foundation-sys" | |
| 306 | + | version = "0.8.7" | |
| 307 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 308 | + | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 309 | + | ||
| 310 | + | [[package]] | |
| 311 | + | name = "cpufeatures" | |
| 312 | + | version = "0.3.1" | |
| 313 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 314 | + | checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" | |
| 315 | + | dependencies = [ | |
| 316 | + | "libc", | |
| 317 | + | ] | |
| 318 | + | ||
| 319 | + | [[package]] | |
| 320 | + | name = "crc32fast" | |
| 321 | + | version = "1.5.2" | |
| 322 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 323 | + | checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" | |
| 324 | + | dependencies = [ | |
| 325 | + | "cfg-if", | |
| 326 | + | ] | |
| 327 | + | ||
| 82 | 328 | [[package]] | |
| 83 | 329 | name = "crossterm" | |
| 84 | 330 | version = "0.29.0" | |
| @@ -141,6 +387,12 @@ dependencies = [ | |||
| 141 | 387 | "syn 3.0.9", | |
| 142 | 388 | ] | |
| 143 | 389 | ||
| 390 | + | [[package]] | |
| 391 | + | name = "data-encoding" | |
| 392 | + | version = "2.11.1" | |
| 393 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 394 | + | checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" | |
| 395 | + | ||
| 144 | 396 | [[package]] | |
| 145 | 397 | name = "deranged" | |
| 146 | 398 | version = "0.5.9" | |
| @@ -169,6 +421,17 @@ dependencies = [ | |||
| 169 | 421 | "syn 2.0.119", | |
| 170 | 422 | ] | |
| 171 | 423 | ||
| 424 | + | [[package]] | |
| 425 | + | name = "displaydoc" | |
| 426 | + | version = "0.2.7" | |
| 427 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 428 | + | checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" | |
| 429 | + | dependencies = [ | |
| 430 | + | "proc-macro2", | |
| 431 | + | "quote", | |
| 432 | + | "syn 3.0.9", | |
| 433 | + | ] | |
| 434 | + | ||
| 172 | 435 | [[package]] | |
| 173 | 436 | name = "document-features" | |
| 174 | 437 | version = "0.2.12" | |
| @@ -178,6 +441,12 @@ dependencies = [ | |||
| 178 | 441 | "litrs", | |
| 179 | 442 | ] | |
| 180 | 443 | ||
| 444 | + | [[package]] | |
| 445 | + | name = "dunce" | |
| 446 | + | version = "1.0.5" | |
| 447 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 448 | + | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" | |
| 449 | + | ||
| 181 | 450 | [[package]] | |
| 182 | 451 | name = "either" | |
| 183 | 452 | version = "1.19.0" | |
| @@ -197,7 +466,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 197 | 466 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 198 | 467 | dependencies = [ | |
| 199 | 468 | "libc", | |
| 200 | − | "windows-sys", | |
| 469 | + | "windows-sys 0.61.2", | |
| 470 | + | ] | |
| 471 | + | ||
| 472 | + | [[package]] | |
| 473 | + | name = "find-msvc-tools" | |
| 474 | + | version = "0.1.14" | |
| 475 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 476 | + | checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" | |
| 477 | + | ||
| 478 | + | [[package]] | |
| 479 | + | name = "flate2" | |
| 480 | + | version = "1.1.10" | |
| 481 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 482 | + | checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" | |
| 483 | + | dependencies = [ | |
| 484 | + | "crc32fast", | |
| 485 | + | "miniz_oxide", | |
| 486 | + | "zlib-rs", | |
| 201 | 487 | ] | |
| 202 | 488 | ||
| 203 | 489 | [[package]] | |
| @@ -206,6 +492,30 @@ version = "0.2.0" | |||
| 206 | 492 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 207 | 493 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 208 | 494 | ||
| 495 | + | [[package]] | |
| 496 | + | name = "form_urlencoded" | |
| 497 | + | version = "1.2.2" | |
| 498 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 499 | + | checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" | |
| 500 | + | dependencies = [ | |
| 501 | + | "percent-encoding", | |
| 502 | + | ] | |
| 503 | + | ||
| 504 | + | [[package]] | |
| 505 | + | name = "fs_extra" | |
| 506 | + | version = "1.3.0" | |
| 507 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 508 | + | checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" | |
| 509 | + | ||
| 510 | + | [[package]] | |
| 511 | + | name = "futures-channel" | |
| 512 | + | version = "0.3.34" | |
| 513 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 514 | + | checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" | |
| 515 | + | dependencies = [ | |
| 516 | + | "futures-core", | |
| 517 | + | ] | |
| 518 | + | ||
| 209 | 519 | [[package]] | |
| 210 | 520 | name = "futures-core" | |
| 211 | 521 | version = "0.3.34" | |
| @@ -242,6 +552,33 @@ dependencies = [ | |||
| 242 | 552 | "slab", | |
| 243 | 553 | ] | |
| 244 | 554 | ||
| 555 | + | [[package]] | |
| 556 | + | name = "getrandom" | |
| 557 | + | version = "0.2.17" | |
| 558 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 559 | + | checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" | |
| 560 | + | dependencies = [ | |
| 561 | + | "cfg-if", | |
| 562 | + | "js-sys", | |
| 563 | + | "libc", | |
| 564 | + | "wasi", | |
| 565 | + | "wasm-bindgen", | |
| 566 | + | ] | |
| 567 | + | ||
| 568 | + | [[package]] | |
| 569 | + | name = "getrandom" | |
| 570 | + | version = "0.4.3" | |
| 571 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 572 | + | checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" | |
| 573 | + | dependencies = [ | |
| 574 | + | "cfg-if", | |
| 575 | + | "js-sys", | |
| 576 | + | "libc", | |
| 577 | + | "r-efi", | |
| 578 | + | "rand_core", | |
| 579 | + | "wasm-bindgen", | |
| 580 | + | ] | |
| 581 | + | ||
| 245 | 582 | [[package]] | |
| 246 | 583 | name = "hashbrown" | |
| 247 | 584 | version = "0.16.1" | |
| @@ -271,74 +608,361 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 271 | 608 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 272 | 609 | ||
| 273 | 610 | [[package]] | |
| 274 | − | name = "ident_case" | |
| 275 | − | version = "1.0.1" | |
| 611 | + | name = "http" | |
| 612 | + | version = "1.5.0" | |
| 276 | 613 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 277 | − | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" | |
| 614 | + | checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" | |
| 615 | + | dependencies = [ | |
| 616 | + | "bytes", | |
| 617 | + | "itoa", | |
| 618 | + | ] | |
| 278 | 619 | ||
| 279 | 620 | [[package]] | |
| 280 | − | name = "indexmap" | |
| 281 | − | version = "2.14.2" | |
| 621 | + | name = "http-body" | |
| 622 | + | version = "1.1.0" | |
| 282 | 623 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 283 | − | checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" | |
| 624 | + | checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" | |
| 284 | 625 | dependencies = [ | |
| 285 | − | "equivalent", | |
| 286 | − | "hashbrown 0.17.1", | |
| 626 | + | "bytes", | |
| 627 | + | "http", | |
| 287 | 628 | ] | |
| 288 | 629 | ||
| 289 | 630 | [[package]] | |
| 290 | − | name = "indoc" | |
| 291 | − | version = "2.0.8" | |
| 631 | + | name = "http-body-util" | |
| 632 | + | version = "0.1.5" | |
| 292 | 633 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 293 | − | checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" | |
| 634 | + | checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" | |
| 294 | 635 | dependencies = [ | |
| 295 | − | "rustversion", | |
| 636 | + | "bytes", | |
| 637 | + | "futures-core", | |
| 638 | + | "http", | |
| 639 | + | "http-body", | |
| 640 | + | "pin-project-lite", | |
| 296 | 641 | ] | |
| 297 | 642 | ||
| 298 | 643 | [[package]] | |
| 299 | − | name = "instability" | |
| 300 | − | version = "0.3.14" | |
| 644 | + | name = "httparse" | |
| 645 | + | version = "1.10.1" | |
| 301 | 646 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 302 | − | checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" | |
| 647 | + | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" | |
| 648 | + | ||
| 649 | + | [[package]] | |
| 650 | + | name = "hyper" | |
| 651 | + | version = "1.12.0" | |
| 652 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 653 | + | checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" | |
| 303 | 654 | dependencies = [ | |
| 304 | − | "darling", | |
| 305 | − | "indoc", | |
| 306 | − | "proc-macro2", | |
| 307 | − | "quote", | |
| 308 | − | "syn 3.0.9", | |
| 655 | + | "atomic-waker", | |
| 656 | + | "bytes", | |
| 657 | + | "futures-core", | |
| 658 | + | "http", | |
| 659 | + | "http-body", | |
| 660 | + | "httparse", | |
| 661 | + | "itoa", | |
| 662 | + | "pin-project-lite", | |
| 663 | + | "smallvec", | |
| 664 | + | "tokio", | |
| 665 | + | "want", | |
| 309 | 666 | ] | |
| 310 | 667 | ||
| 311 | 668 | [[package]] | |
| 312 | − | name = "itertools" | |
| 313 | − | version = "0.14.0" | |
| 669 | + | name = "hyper-rustls" | |
| 670 | + | version = "0.27.10" | |
| 314 | 671 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 315 | − | checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" | |
| 672 | + | checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" | |
| 316 | 673 | dependencies = [ | |
| 317 | − | "either", | |
| 674 | + | "http", | |
| 675 | + | "hyper", | |
| 676 | + | "hyper-util", | |
| 677 | + | "rustls", | |
| 678 | + | "tokio", | |
| 679 | + | "tokio-rustls", | |
| 680 | + | "tower-service", | |
| 318 | 681 | ] | |
| 319 | 682 | ||
| 320 | 683 | [[package]] | |
| 321 | − | name = "itoa" | |
| 322 | − | version = "1.0.18" | |
| 684 | + | name = "hyper-util" | |
| 685 | + | version = "0.1.21" | |
| 323 | 686 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 324 | − | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 687 | + | checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" | |
| 688 | + | dependencies = [ | |
| 689 | + | "base64", | |
| 690 | + | "bytes", | |
| 691 | + | "futures-channel", | |
| 692 | + | "futures-util", | |
| 693 | + | "http", | |
| 694 | + | "http-body", | |
| 695 | + | "httparse", | |
| 696 | + | "hyper", | |
| 697 | + | "ipnet", | |
| 698 | + | "libc", | |
| 699 | + | "percent-encoding", | |
| 700 | + | "pin-project-lite", | |
| 701 | + | "socket2", | |
| 702 | + | "tokio", | |
| 703 | + | "tower-service", | |
| 704 | + | "tracing", | |
| 705 | + | ] | |
| 325 | 706 | ||
| 326 | 707 | [[package]] | |
| 327 | − | name = "kasuari" | |
| 328 | − | version = "0.4.12" | |
| 708 | + | name = "icu_collections" | |
| 709 | + | version = "2.1.1" | |
| 329 | 710 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 330 | − | checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" | |
| 711 | + | checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" | |
| 331 | 712 | dependencies = [ | |
| 332 | − | "hashbrown 0.16.1", | |
| 333 | − | "portable-atomic", | |
| 334 | − | "thiserror", | |
| 713 | + | "displaydoc", | |
| 714 | + | "potential_utf", | |
| 715 | + | "yoke", | |
| 716 | + | "zerofrom", | |
| 717 | + | "zerovec", | |
| 335 | 718 | ] | |
| 336 | 719 | ||
| 337 | 720 | [[package]] | |
| 338 | − | name = "libc" | |
| 339 | − | version = "0.2.190" | |
| 721 | + | name = "icu_locale_core" | |
| 722 | + | version = "2.1.1" | |
| 340 | 723 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 341 | − | checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" | |
| 724 | + | checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" | |
| 725 | + | dependencies = [ | |
| 726 | + | "displaydoc", | |
| 727 | + | "litemap", | |
| 728 | + | "tinystr", | |
| 729 | + | "writeable", | |
| 730 | + | "zerovec", | |
| 731 | + | ] | |
| 732 | + | ||
| 733 | + | [[package]] | |
| 734 | + | name = "icu_normalizer" | |
| 735 | + | version = "2.1.1" | |
| 736 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 737 | + | checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" | |
| 738 | + | dependencies = [ | |
| 739 | + | "icu_collections", | |
| 740 | + | "icu_normalizer_data", | |
| 741 | + | "icu_properties", | |
| 742 | + | "icu_provider", | |
| 743 | + | "smallvec", | |
| 744 | + | "zerovec", | |
| 745 | + | ] | |
| 746 | + | ||
| 747 | + | [[package]] | |
| 748 | + | name = "icu_normalizer_data" | |
| 749 | + | version = "2.1.1" | |
| 750 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 751 | + | checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" | |
| 752 | + | ||
| 753 | + | [[package]] | |
| 754 | + | name = "icu_properties" | |
| 755 | + | version = "2.1.2" | |
| 756 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 757 | + | checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" | |
| 758 | + | dependencies = [ | |
| 759 | + | "icu_collections", | |
| 760 | + | "icu_locale_core", | |
| 761 | + | "icu_properties_data", | |
| 762 | + | "icu_provider", | |
| 763 | + | "zerotrie", | |
| 764 | + | "zerovec", | |
| 765 | + | ] | |
| 766 | + | ||
| 767 | + | [[package]] | |
| 768 | + | name = "icu_properties_data" | |
| 769 | + | version = "2.1.2" | |
| 770 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 771 | + | checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" | |
| 772 | + | ||
| 773 | + | [[package]] | |
| 774 | + | name = "icu_provider" | |
| 775 | + | version = "2.1.1" | |
| 776 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 777 | + | checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" | |
| 778 | + | dependencies = [ | |
| 779 | + | "displaydoc", | |
| 780 | + | "icu_locale_core", | |
| 781 | + | "writeable", | |
| 782 | + | "yoke", | |
| 783 | + | "zerofrom", | |
| 784 | + | "zerotrie", | |
| 785 | + | "zerovec", | |
| 786 | + | ] | |
| 787 | + | ||
| 788 | + | [[package]] | |
| 789 | + | name = "ident_case" | |
| 790 | + | version = "1.0.1" | |
| 791 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 792 | + | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" | |
| 793 | + | ||
| 794 | + | [[package]] | |
| 795 | + | name = "idna" | |
| 796 | + | version = "1.1.0" | |
| 797 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 798 | + | checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" | |
| 799 | + | dependencies = [ | |
| 800 | + | "idna_adapter", | |
| 801 | + | "smallvec", | |
| 802 | + | "utf8_iter", | |
| 803 | + | ] | |
| 804 | + | ||
| 805 | + | [[package]] | |
| 806 | + | name = "idna_adapter" | |
| 807 | + | version = "1.2.1" | |
| 808 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 809 | + | checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" | |
| 810 | + | dependencies = [ | |
| 811 | + | "icu_normalizer", | |
| 812 | + | "icu_properties", | |
| 813 | + | ] | |
| 814 | + | ||
| 815 | + | [[package]] | |
| 816 | + | name = "indexmap" | |
| 817 | + | version = "2.14.2" | |
| 818 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 819 | + | checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" | |
| 820 | + | dependencies = [ | |
| 821 | + | "equivalent", | |
| 822 | + | "hashbrown 0.17.1", | |
| 823 | + | ] | |
| 824 | + | ||
| 825 | + | [[package]] | |
| 826 | + | name = "indoc" | |
| 827 | + | version = "2.0.8" | |
| 828 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 829 | + | checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" | |
| 830 | + | dependencies = [ | |
| 831 | + | "rustversion", | |
| 832 | + | ] | |
| 833 | + | ||
| 834 | + | [[package]] | |
| 835 | + | name = "instability" | |
| 836 | + | version = "0.3.14" | |
| 837 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 838 | + | checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" | |
| 839 | + | dependencies = [ | |
| 840 | + | "darling", | |
| 841 | + | "indoc", | |
| 842 | + | "proc-macro2", | |
| 843 | + | "quote", | |
| 844 | + | "syn 3.0.9", | |
| 845 | + | ] | |
| 846 | + | ||
| 847 | + | [[package]] | |
| 848 | + | name = "ipnet" | |
| 849 | + | version = "2.12.2" | |
| 850 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 851 | + | checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" | |
| 852 | + | ||
| 853 | + | [[package]] | |
| 854 | + | name = "is_terminal_polyfill" | |
| 855 | + | version = "1.70.2" | |
| 856 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 857 | + | checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | |
| 858 | + | ||
| 859 | + | [[package]] | |
| 860 | + | name = "itertools" | |
| 861 | + | version = "0.14.0" | |
| 862 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 863 | + | checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" | |
| 864 | + | dependencies = [ | |
| 865 | + | "either", | |
| 866 | + | ] | |
| 867 | + | ||
| 868 | + | [[package]] | |
| 869 | + | name = "itoa" | |
| 870 | + | version = "1.0.18" | |
| 871 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 872 | + | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 873 | + | ||
| 874 | + | [[package]] | |
| 875 | + | name = "jni" | |
| 876 | + | version = "0.22.4" | |
| 877 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 878 | + | checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" | |
| 879 | + | dependencies = [ | |
| 880 | + | "cfg-if", | |
| 881 | + | "combine", | |
| 882 | + | "jni-macros", | |
| 883 | + | "jni-sys", | |
| 884 | + | "log", | |
| 885 | + | "simd_cesu8", | |
| 886 | + | "thiserror", | |
| 887 | + | "walkdir", | |
| 888 | + | "windows-link", | |
| 889 | + | ] | |
| 890 | + | ||
| 891 | + | [[package]] | |
| 892 | + | name = "jni-macros" | |
| 893 | + | version = "0.22.4" | |
| 894 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 895 | + | checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" | |
| 896 | + | dependencies = [ | |
| 897 | + | "proc-macro2", | |
| 898 | + | "quote", | |
| 899 | + | "rustc_version", | |
| 900 | + | "simd_cesu8", | |
| 901 | + | "syn 2.0.119", | |
| 902 | + | ] | |
| 903 | + | ||
| 904 | + | [[package]] | |
| 905 | + | name = "jni-sys" | |
| 906 | + | version = "0.4.1" | |
| 907 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 908 | + | checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" | |
| 909 | + | dependencies = [ | |
| 910 | + | "jni-sys-macros", | |
| 911 | + | ] | |
| 912 | + | ||
| 913 | + | [[package]] | |
| 914 | + | name = "jni-sys-macros" | |
| 915 | + | version = "0.4.1" | |
| 916 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 917 | + | checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" | |
| 918 | + | dependencies = [ | |
| 919 | + | "quote", | |
| 920 | + | "syn 2.0.119", | |
| 921 | + | ] | |
| 922 | + | ||
| 923 | + | [[package]] | |
| 924 | + | name = "jobserver" | |
| 925 | + | version = "0.1.35" | |
| 926 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 927 | + | checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" | |
| 928 | + | dependencies = [ | |
| 929 | + | "getrandom 0.4.3", | |
| 930 | + | "libc", | |
| 931 | + | ] | |
| 932 | + | ||
| 933 | + | [[package]] | |
| 934 | + | name = "js-sys" | |
| 935 | + | version = "0.3.106" | |
| 936 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 937 | + | checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" | |
| 938 | + | dependencies = [ | |
| 939 | + | "cfg-if", | |
| 940 | + | "futures-util", | |
| 941 | + | "wasm-bindgen", | |
| 942 | + | ] | |
| 943 | + | ||
| 944 | + | [[package]] | |
| 945 | + | name = "kasuari" | |
| 946 | + | version = "0.4.12" | |
| 947 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 948 | + | checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" | |
| 949 | + | dependencies = [ | |
| 950 | + | "hashbrown 0.16.1", | |
| 951 | + | "portable-atomic", | |
| 952 | + | "thiserror", | |
| 953 | + | ] | |
| 954 | + | ||
| 955 | + | [[package]] | |
| 956 | + | name = "lazy_static" | |
| 957 | + | version = "1.5.1" | |
| 958 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 959 | + | checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" | |
| 960 | + | ||
| 961 | + | [[package]] | |
| 962 | + | name = "libc" | |
| 963 | + | version = "0.2.190" | |
| 964 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 965 | + | checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" | |
| 342 | 966 | ||
| 343 | 967 | [[package]] | |
| 344 | 968 | name = "libm" | |
| @@ -361,6 +985,12 @@ version = "0.12.1" | |||
| 361 | 985 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 362 | 986 | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | |
| 363 | 987 | ||
| 988 | + | [[package]] | |
| 989 | + | name = "litemap" | |
| 990 | + | version = "0.8.3" | |
| 991 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 992 | + | checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" | |
| 993 | + | ||
| 364 | 994 | [[package]] | |
| 365 | 995 | name = "litrs" | |
| 366 | 996 | version = "1.0.1" | |
| @@ -376,6 +1006,32 @@ dependencies = [ | |||
| 376 | 1006 | "scopeguard", | |
| 377 | 1007 | ] | |
| 378 | 1008 | ||
| 1009 | + | [[package]] | |
| 1010 | + | name = "lofty" | |
| 1011 | + | version = "0.25.4" | |
| 1012 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1013 | + | checksum = "bb21dd79145b0781f2423fda92dd807fcd3911e44b66044d86db3b11ec7accf9" | |
| 1014 | + | dependencies = [ | |
| 1015 | + | "byteorder", | |
| 1016 | + | "data-encoding", | |
| 1017 | + | "flate2", | |
| 1018 | + | "lofty_attr", | |
| 1019 | + | "log", | |
| 1020 | + | "ogg_pager", | |
| 1021 | + | "paste", | |
| 1022 | + | ] | |
| 1023 | + | ||
| 1024 | + | [[package]] | |
| 1025 | + | name = "lofty_attr" | |
| 1026 | + | version = "0.13.0" | |
| 1027 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1028 | + | checksum = "fdbca1b60ba7cea959ffc84ac27f3dbdbacd6dfda168c7eabbc00e48654bb9fd" | |
| 1029 | + | dependencies = [ | |
| 1030 | + | "proc-macro2", | |
| 1031 | + | "quote", | |
| 1032 | + | "syn 3.0.9", | |
| 1033 | + | ] | |
| 1034 | + | ||
| 379 | 1035 | [[package]] | |
| 380 | 1036 | name = "log" | |
| 381 | 1037 | version = "0.4.34" | |
| @@ -391,6 +1047,37 @@ dependencies = [ | |||
| 391 | 1047 | "hashbrown 0.17.1", | |
| 392 | 1048 | ] | |
| 393 | 1049 | ||
| 1050 | + | [[package]] | |
| 1051 | + | name = "lru-slab" | |
| 1052 | + | version = "0.1.3" | |
| 1053 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1054 | + | checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" | |
| 1055 | + | ||
| 1056 | + | [[package]] | |
| 1057 | + | name = "matchers" | |
| 1058 | + | version = "0.2.0" | |
| 1059 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1060 | + | checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" | |
| 1061 | + | dependencies = [ | |
| 1062 | + | "regex-automata", | |
| 1063 | + | ] | |
| 1064 | + | ||
| 1065 | + | [[package]] | |
| 1066 | + | name = "memchr" | |
| 1067 | + | version = "2.8.3" | |
| 1068 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1069 | + | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" | |
| 1070 | + | ||
| 1071 | + | [[package]] | |
| 1072 | + | name = "miniz_oxide" | |
| 1073 | + | version = "0.9.1" | |
| 1074 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1075 | + | checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" | |
| 1076 | + | dependencies = [ | |
| 1077 | + | "adler2", | |
| 1078 | + | "simd-adler32", | |
| 1079 | + | ] | |
| 1080 | + | ||
| 394 | 1081 | [[package]] | |
| 395 | 1082 | name = "mio" | |
| 396 | 1083 | version = "1.2.4" | |
| @@ -400,7 +1087,26 @@ dependencies = [ | |||
| 400 | 1087 | "libc", | |
| 401 | 1088 | "log", | |
| 402 | 1089 | "wasi", | |
| 403 | − | "windows-sys", | |
| 1090 | + | "windows-sys 0.61.2", | |
| 1091 | + | ] | |
| 1092 | + | ||
| 1093 | + | [[package]] | |
| 1094 | + | name = "nucleo-matcher" | |
| 1095 | + | version = "0.3.1" | |
| 1096 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1097 | + | checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" | |
| 1098 | + | dependencies = [ | |
| 1099 | + | "memchr", | |
| 1100 | + | "unicode-segmentation", | |
| 1101 | + | ] | |
| 1102 | + | ||
| 1103 | + | [[package]] | |
| 1104 | + | name = "num-complex" | |
| 1105 | + | version = "0.4.6" | |
| 1106 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1107 | + | checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" | |
| 1108 | + | dependencies = [ | |
| 1109 | + | "num-traits", | |
| 404 | 1110 | ] | |
| 405 | 1111 | ||
| 406 | 1112 | [[package]] | |
| @@ -409,6 +1115,15 @@ version = "0.2.2" | |||
| 409 | 1115 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 410 | 1116 | checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" | |
| 411 | 1117 | ||
| 1118 | + | [[package]] | |
| 1119 | + | name = "num-integer" | |
| 1120 | + | version = "0.1.47" | |
| 1121 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1122 | + | checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" | |
| 1123 | + | dependencies = [ | |
| 1124 | + | "num-traits", | |
| 1125 | + | ] | |
| 1126 | + | ||
| 412 | 1127 | [[package]] | |
| 413 | 1128 | name = "num-traits" | |
| 414 | 1129 | version = "0.2.19" | |
| @@ -427,6 +1142,33 @@ dependencies = [ | |||
| 427 | 1142 | "libc", | |
| 428 | 1143 | ] | |
| 429 | 1144 | ||
| 1145 | + | [[package]] | |
| 1146 | + | name = "ogg_pager" | |
| 1147 | + | version = "0.7.2" | |
| 1148 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1149 | + | checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" | |
| 1150 | + | dependencies = [ | |
| 1151 | + | "byteorder", | |
| 1152 | + | ] | |
| 1153 | + | ||
| 1154 | + | [[package]] | |
| 1155 | + | name = "once_cell" | |
| 1156 | + | version = "1.21.4" | |
| 1157 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1158 | + | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 1159 | + | ||
| 1160 | + | [[package]] | |
| 1161 | + | name = "once_cell_polyfill" | |
| 1162 | + | version = "1.70.2" | |
| 1163 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1164 | + | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" | |
| 1165 | + | ||
| 1166 | + | [[package]] | |
| 1167 | + | name = "openssl-probe" | |
| 1168 | + | version = "0.2.1" | |
| 1169 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1170 | + | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" | |
| 1171 | + | ||
| 430 | 1172 | [[package]] | |
| 431 | 1173 | name = "palette" | |
| 432 | 1174 | version = "0.7.7" | |
| @@ -483,24 +1225,60 @@ dependencies = [ | |||
| 483 | 1225 | "windows-link", | |
| 484 | 1226 | ] | |
| 485 | 1227 | ||
| 1228 | + | [[package]] | |
| 1229 | + | name = "paste" | |
| 1230 | + | version = "1.0.15" | |
| 1231 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1232 | + | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" | |
| 1233 | + | ||
| 1234 | + | [[package]] | |
| 1235 | + | name = "percent-encoding" | |
| 1236 | + | version = "2.3.2" | |
| 1237 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1238 | + | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" | |
| 1239 | + | ||
| 486 | 1240 | [[package]] | |
| 487 | 1241 | name = "pin-project-lite" | |
| 488 | 1242 | version = "0.2.17" | |
| 489 | 1243 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 490 | 1244 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 491 | 1245 | ||
| 1246 | + | [[package]] | |
| 1247 | + | name = "pkg-config" | |
| 1248 | + | version = "0.3.34" | |
| 1249 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1250 | + | checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" | |
| 1251 | + | ||
| 492 | 1252 | [[package]] | |
| 493 | 1253 | name = "portable-atomic" | |
| 494 | 1254 | version = "1.15.0" | |
| 495 | 1255 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 496 | 1256 | checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" | |
| 497 | 1257 | ||
| 1258 | + | [[package]] | |
| 1259 | + | name = "potential_utf" | |
| 1260 | + | version = "0.1.6" | |
| 1261 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1262 | + | checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" | |
| 1263 | + | dependencies = [ | |
| 1264 | + | "zerovec", | |
| 1265 | + | ] | |
| 1266 | + | ||
| 498 | 1267 | [[package]] | |
| 499 | 1268 | name = "powerfmt" | |
| 500 | 1269 | version = "0.2.1" | |
| 501 | 1270 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 502 | 1271 | checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" | |
| 503 | 1272 | ||
| 1273 | + | [[package]] | |
| 1274 | + | name = "primal-check" | |
| 1275 | + | version = "0.3.4" | |
| 1276 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1277 | + | checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" | |
| 1278 | + | dependencies = [ | |
| 1279 | + | "num-integer", | |
| 1280 | + | ] | |
| 1281 | + | ||
| 504 | 1282 | [[package]] | |
| 505 | 1283 | name = "proc-macro2" | |
| 506 | 1284 | version = "1.0.107" | |
| @@ -510,6 +1288,63 @@ dependencies = [ | |||
| 510 | 1288 | "unicode-ident", | |
| 511 | 1289 | ] | |
| 512 | 1290 | ||
| 1291 | + | [[package]] | |
| 1292 | + | name = "quinn" | |
| 1293 | + | version = "0.11.12" | |
| 1294 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1295 | + | checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" | |
| 1296 | + | dependencies = [ | |
| 1297 | + | "bytes", | |
| 1298 | + | "cfg_aliases", | |
| 1299 | + | "pin-project-lite", | |
| 1300 | + | "quinn-proto", | |
| 1301 | + | "quinn-udp", | |
| 1302 | + | "rustc-hash", | |
| 1303 | + | "rustls", | |
| 1304 | + | "socket2", | |
| 1305 | + | "thiserror", | |
| 1306 | + | "tokio", | |
| 1307 | + | "tracing", | |
| 1308 | + | "web-time", | |
| 1309 | + | ] | |
| 1310 | + | ||
| 1311 | + | [[package]] | |
| 1312 | + | name = "quinn-proto" | |
| 1313 | + | version = "0.11.19" | |
| 1314 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1315 | + | checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" | |
| 1316 | + | dependencies = [ | |
| 1317 | + | "aws-lc-rs", | |
| 1318 | + | "bytes", | |
| 1319 | + | "getrandom 0.4.3", | |
| 1320 | + | "lru-slab", | |
| 1321 | + | "rand", | |
| 1322 | + | "rand_pcg", | |
| 1323 | + | "ring", | |
| 1324 | + | "rustc-hash", | |
| 1325 | + | "rustls", | |
| 1326 | + | "rustls-pki-types", | |
| 1327 | + | "slab", | |
| 1328 | + | "thiserror", | |
| 1329 | + | "tinyvec", | |
| 1330 | + | "tracing", | |
| 1331 | + | "web-time", | |
| 1332 | + | ] | |
| 1333 | + | ||
| 1334 | + | [[package]] | |
| 1335 | + | name = "quinn-udp" | |
| 1336 | + | version = "0.5.16" | |
| 1337 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1338 | + | checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" | |
| 1339 | + | dependencies = [ | |
| 1340 | + | "cfg_aliases", | |
| 1341 | + | "libc", | |
| 1342 | + | "once_cell", | |
| 1343 | + | "socket2", | |
| 1344 | + | "tracing", | |
| 1345 | + | "windows-sys 0.61.2", | |
| 1346 | + | ] | |
| 1347 | + | ||
| 513 | 1348 | [[package]] | |
| 514 | 1349 | name = "quote" | |
| 515 | 1350 | version = "1.0.47" | |
| @@ -519,6 +1354,38 @@ dependencies = [ | |||
| 519 | 1354 | "proc-macro2", | |
| 520 | 1355 | ] | |
| 521 | 1356 | ||
| 1357 | + | [[package]] | |
| 1358 | + | name = "r-efi" | |
| 1359 | + | version = "6.0.0" | |
| 1360 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1361 | + | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| 1362 | + | ||
| 1363 | + | [[package]] | |
| 1364 | + | name = "rand" | |
| 1365 | + | version = "0.10.3" | |
| 1366 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1367 | + | checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" | |
| 1368 | + | dependencies = [ | |
| 1369 | + | "chacha20", | |
| 1370 | + | "getrandom 0.4.3", | |
| 1371 | + | "rand_core", | |
| 1372 | + | ] | |
| 1373 | + | ||
| 1374 | + | [[package]] | |
| 1375 | + | name = "rand_core" | |
| 1376 | + | version = "0.10.1" | |
| 1377 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1378 | + | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | |
| 1379 | + | ||
| 1380 | + | [[package]] | |
| 1381 | + | name = "rand_pcg" | |
| 1382 | + | version = "0.10.2" | |
| 1383 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1384 | + | checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" | |
| 1385 | + | dependencies = [ | |
| 1386 | + | "rand_core", | |
| 1387 | + | ] | |
| 1388 | + | ||
| 522 | 1389 | [[package]] | |
| 523 | 1390 | name = "ratatui" | |
| 524 | 1391 | version = "0.30.2" | |
| @@ -585,6 +1452,15 @@ dependencies = [ | |||
| 585 | 1452 | "unicode-width", | |
| 586 | 1453 | ] | |
| 587 | 1454 | ||
| 1455 | + | [[package]] | |
| 1456 | + | name = "realfft" | |
| 1457 | + | version = "3.5.0" | |
| 1458 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1459 | + | checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" | |
| 1460 | + | dependencies = [ | |
| 1461 | + | "rustfft", | |
| 1462 | + | ] | |
| 1463 | + | ||
| 588 | 1464 | [[package]] | |
| 589 | 1465 | name = "redox_syscall" | |
| 590 | 1466 | version = "0.5.18" | |
| @@ -594,24 +1470,104 @@ dependencies = [ | |||
| 594 | 1470 | "bitflags", | |
| 595 | 1471 | ] | |
| 596 | 1472 | ||
| 1473 | + | [[package]] | |
| 1474 | + | name = "regex-automata" | |
| 1475 | + | version = "0.4.18" | |
| 1476 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1477 | + | checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" | |
| 1478 | + | dependencies = [ | |
| 1479 | + | "aho-corasick", | |
| 1480 | + | "memchr", | |
| 1481 | + | "regex-syntax", | |
| 1482 | + | ] | |
| 1483 | + | ||
| 1484 | + | [[package]] | |
| 1485 | + | name = "regex-syntax" | |
| 1486 | + | version = "0.8.11" | |
| 1487 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1488 | + | checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" | |
| 1489 | + | ||
| 1490 | + | [[package]] | |
| 1491 | + | name = "reqwest" | |
| 1492 | + | version = "0.13.5" | |
| 1493 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1494 | + | checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" | |
| 1495 | + | dependencies = [ | |
| 1496 | + | "base64", | |
| 1497 | + | "bytes", | |
| 1498 | + | "futures-core", | |
| 1499 | + | "http", | |
| 1500 | + | "http-body", | |
| 1501 | + | "http-body-util", | |
| 1502 | + | "hyper", | |
| 1503 | + | "hyper-rustls", | |
| 1504 | + | "hyper-util", | |
| 1505 | + | "js-sys", | |
| 1506 | + | "log", | |
| 1507 | + | "percent-encoding", | |
| 1508 | + | "pin-project-lite", | |
| 1509 | + | "quinn", | |
| 1510 | + | "rustls", | |
| 1511 | + | "rustls-pki-types", | |
| 1512 | + | "rustls-platform-verifier", | |
| 1513 | + | "serde", | |
| 1514 | + | "serde_json", | |
| 1515 | + | "serde_urlencoded", | |
| 1516 | + | "sync_wrapper", | |
| 1517 | + | "tokio", | |
| 1518 | + | "tokio-rustls", | |
| 1519 | + | "tower", | |
| 1520 | + | "tower-http", | |
| 1521 | + | "tower-service", | |
| 1522 | + | "url", | |
| 1523 | + | "wasm-bindgen", | |
| 1524 | + | "wasm-bindgen-futures", | |
| 1525 | + | "web-sys", | |
| 1526 | + | ] | |
| 1527 | + | ||
| 1528 | + | [[package]] | |
| 1529 | + | name = "ring" | |
| 1530 | + | version = "0.17.14" | |
| 1531 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1532 | + | checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" | |
| 1533 | + | dependencies = [ | |
| 1534 | + | "cc", | |
| 1535 | + | "cfg-if", | |
| 1536 | + | "getrandom 0.2.17", | |
| 1537 | + | "libc", | |
| 1538 | + | "untrusted", | |
| 1539 | + | "windows-sys 0.52.0", | |
| 1540 | + | ] | |
| 1541 | + | ||
| 597 | 1542 | [[package]] | |
| 598 | 1543 | name = "rmptui" | |
| 599 | − | version = "0.1.6" | |
| 1544 | + | version = "0.2.0" | |
| 600 | 1545 | dependencies = [ | |
| 1546 | + | "anyhow", | |
| 1547 | + | "clap", | |
| 601 | 1548 | "crossterm", | |
| 602 | 1549 | "futures-util", | |
| 1550 | + | "libc", | |
| 1551 | + | "lofty", | |
| 1552 | + | "nucleo-matcher", | |
| 603 | 1553 | "ratatui", | |
| 604 | − | "rust-fuzzy-search", | |
| 1554 | + | "realfft", | |
| 1555 | + | "reqwest", | |
| 605 | 1556 | "serde", | |
| 1557 | + | "serde_json", | |
| 1558 | + | "thiserror", | |
| 606 | 1559 | "tokio", | |
| 607 | 1560 | "toml", | |
| 1561 | + | "tracing", | |
| 1562 | + | "tracing-subscriber", | |
| 1563 | + | "unicode-width", | |
| 608 | 1564 | ] | |
| 609 | 1565 | ||
| 610 | 1566 | [[package]] | |
| 611 | − | name = "rust-fuzzy-search" | |
| 612 | − | version = "0.1.1" | |
| 1567 | + | name = "rustc-hash" | |
| 1568 | + | version = "2.1.3" | |
| 613 | 1569 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 614 | − | checksum = "a157657054ffe556d8858504af8a672a054a6e0bd9e8ee531059100c0fa11bb2" | |
| 1570 | + | checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" | |
| 615 | 1571 | ||
| 616 | 1572 | [[package]] | |
| 617 | 1573 | name = "rustc_version" | |
| @@ -622,6 +1578,20 @@ dependencies = [ | |||
| 622 | 1578 | "semver", | |
| 623 | 1579 | ] | |
| 624 | 1580 | ||
| 1581 | + | [[package]] | |
| 1582 | + | name = "rustfft" | |
| 1583 | + | version = "6.4.1" | |
| 1584 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1585 | + | checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" | |
| 1586 | + | dependencies = [ | |
| 1587 | + | "num-complex", | |
| 1588 | + | "num-integer", | |
| 1589 | + | "num-traits", | |
| 1590 | + | "primal-check", | |
| 1591 | + | "strength_reduce", | |
| 1592 | + | "transpose", | |
| 1593 | + | ] | |
| 1594 | + | ||
| 625 | 1595 | [[package]] | |
| 626 | 1596 | name = "rustix" | |
| 627 | 1597 | version = "1.1.5" | |
| @@ -632,7 +1602,82 @@ dependencies = [ | |||
| 632 | 1602 | "errno", | |
| 633 | 1603 | "libc", | |
| 634 | 1604 | "linux-raw-sys", | |
| 635 | − | "windows-sys", | |
| 1605 | + | "windows-sys 0.61.2", | |
| 1606 | + | ] | |
| 1607 | + | ||
| 1608 | + | [[package]] | |
| 1609 | + | name = "rustls" | |
| 1610 | + | version = "0.23.46" | |
| 1611 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1612 | + | checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" | |
| 1613 | + | dependencies = [ | |
| 1614 | + | "aws-lc-rs", | |
| 1615 | + | "once_cell", | |
| 1616 | + | "rustls-pki-types", | |
| 1617 | + | "rustls-webpki", | |
| 1618 | + | "subtle", | |
| 1619 | + | "zeroize", | |
| 1620 | + | ] | |
| 1621 | + | ||
| 1622 | + | [[package]] | |
| 1623 | + | name = "rustls-native-certs" | |
| 1624 | + | version = "0.8.5" | |
| 1625 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1626 | + | checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" | |
| 1627 | + | dependencies = [ | |
| 1628 | + | "openssl-probe", | |
| 1629 | + | "rustls-pki-types", | |
| 1630 | + | "schannel", | |
| 1631 | + | "security-framework", | |
| 1632 | + | ] | |
| 1633 | + | ||
| 1634 | + | [[package]] | |
| 1635 | + | name = "rustls-pki-types" | |
| 1636 | + | version = "1.15.1" | |
| 1637 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1638 | + | checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" | |
| 1639 | + | dependencies = [ | |
| 1640 | + | "web-time", | |
| 1641 | + | "zeroize", | |
| 1642 | + | ] | |
| 1643 | + | ||
| 1644 | + | [[package]] | |
| 1645 | + | name = "rustls-platform-verifier" | |
| 1646 | + | version = "0.7.1" | |
| 1647 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1648 | + | checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" | |
| 1649 | + | dependencies = [ | |
| 1650 | + | "core-foundation", | |
| 1651 | + | "core-foundation-sys", | |
| 1652 | + | "jni", | |
| 1653 | + | "log", | |
| 1654 | + | "once_cell", | |
| 1655 | + | "rustls", | |
| 1656 | + | "rustls-native-certs", | |
| 1657 | + | "rustls-platform-verifier-android", | |
| 1658 | + | "rustls-webpki", | |
| 1659 | + | "security-framework", | |
| 1660 | + | "security-framework-sys", | |
| 1661 | + | "webpki-root-certs", | |
| 1662 | + | "windows-sys 0.61.2", | |
| 1663 | + | ] | |
| 1664 | + | ||
| 1665 | + | [[package]] | |
| 1666 | + | name = "rustls-platform-verifier-android" | |
| 1667 | + | version = "0.2.0" | |
| 1668 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1669 | + | checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" | |
| 1670 | + | ||
| 1671 | + | [[package]] | |
| 1672 | + | name = "rustls-webpki" | |
| 1673 | + | version = "0.103.15" | |
| 1674 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1675 | + | checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" | |
| 1676 | + | dependencies = [ | |
| 1677 | + | "aws-lc-rs", | |
| 1678 | + | "ring", | |
| 1679 | + | "rustls-pki-types", | |
| 1680 | + | "untrusted", | |
| 636 | 1681 | ] | |
| 637 | 1682 | ||
| 638 | 1683 | [[package]] | |
| @@ -647,12 +1692,53 @@ version = "1.0.23" | |||
| 647 | 1692 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 648 | 1693 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | |
| 649 | 1694 | ||
| 1695 | + | [[package]] | |
| 1696 | + | name = "same-file" | |
| 1697 | + | version = "1.0.6" | |
| 1698 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1699 | + | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" | |
| 1700 | + | dependencies = [ | |
| 1701 | + | "winapi-util", | |
| 1702 | + | ] | |
| 1703 | + | ||
| 1704 | + | [[package]] | |
| 1705 | + | name = "schannel" | |
| 1706 | + | version = "0.1.30" | |
| 1707 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1708 | + | checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" | |
| 1709 | + | dependencies = [ | |
| 1710 | + | "windows-sys 0.61.2", | |
| 1711 | + | ] | |
| 1712 | + | ||
| 650 | 1713 | [[package]] | |
| 651 | 1714 | name = "scopeguard" | |
| 652 | 1715 | version = "1.2.0" | |
| 653 | 1716 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 654 | 1717 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | |
| 655 | 1718 | ||
| 1719 | + | [[package]] | |
| 1720 | + | name = "security-framework" | |
| 1721 | + | version = "3.7.0" | |
| 1722 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1723 | + | checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" | |
| 1724 | + | dependencies = [ | |
| 1725 | + | "bitflags", | |
| 1726 | + | "core-foundation", | |
| 1727 | + | "core-foundation-sys", | |
| 1728 | + | "libc", | |
| 1729 | + | "security-framework-sys", | |
| 1730 | + | ] | |
| 1731 | + | ||
| 1732 | + | [[package]] | |
| 1733 | + | name = "security-framework-sys" | |
| 1734 | + | version = "2.17.0" | |
| 1735 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1736 | + | checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" | |
| 1737 | + | dependencies = [ | |
| 1738 | + | "core-foundation-sys", | |
| 1739 | + | "libc", | |
| 1740 | + | ] | |
| 1741 | + | ||
| 656 | 1742 | [[package]] | |
| 657 | 1743 | name = "semver" | |
| 658 | 1744 | version = "1.0.28" | |
| @@ -689,6 +1775,19 @@ dependencies = [ | |||
| 689 | 1775 | "syn 3.0.9", | |
| 690 | 1776 | ] | |
| 691 | 1777 | ||
| 1778 | + | [[package]] | |
| 1779 | + | name = "serde_json" | |
| 1780 | + | version = "1.0.154" | |
| 1781 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1782 | + | checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" | |
| 1783 | + | dependencies = [ | |
| 1784 | + | "itoa", | |
| 1785 | + | "memchr", | |
| 1786 | + | "serde", | |
| 1787 | + | "serde_core", | |
| 1788 | + | "zmij", | |
| 1789 | + | ] | |
| 1790 | + | ||
| 692 | 1791 | [[package]] | |
| 693 | 1792 | name = "serde_spanned" | |
| 694 | 1793 | version = "1.1.2" | |
| @@ -698,6 +1797,33 @@ dependencies = [ | |||
| 698 | 1797 | "serde_core", | |
| 699 | 1798 | ] | |
| 700 | 1799 | ||
| 1800 | + | [[package]] | |
| 1801 | + | name = "serde_urlencoded" | |
| 1802 | + | version = "0.7.1" | |
| 1803 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1804 | + | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" | |
| 1805 | + | dependencies = [ | |
| 1806 | + | "form_urlencoded", | |
| 1807 | + | "itoa", | |
| 1808 | + | "ryu", | |
| 1809 | + | "serde", | |
| 1810 | + | ] | |
| 1811 | + | ||
| 1812 | + | [[package]] | |
| 1813 | + | name = "sharded-slab" | |
| 1814 | + | version = "0.1.7" | |
| 1815 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1816 | + | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" | |
| 1817 | + | dependencies = [ | |
| 1818 | + | "lazy_static", | |
| 1819 | + | ] | |
| 1820 | + | ||
| 1821 | + | [[package]] | |
| 1822 | + | name = "shlex" | |
| 1823 | + | version = "2.0.1" | |
| 1824 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1825 | + | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 1826 | + | ||
| 701 | 1827 | [[package]] | |
| 702 | 1828 | name = "signal-hook" | |
| 703 | 1829 | version = "0.3.18" | |
| @@ -729,6 +1855,28 @@ dependencies = [ | |||
| 729 | 1855 | "libc", | |
| 730 | 1856 | ] | |
| 731 | 1857 | ||
| 1858 | + | [[package]] | |
| 1859 | + | name = "simd-adler32" | |
| 1860 | + | version = "0.3.10" | |
| 1861 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1862 | + | checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" | |
| 1863 | + | ||
| 1864 | + | [[package]] | |
| 1865 | + | name = "simd_cesu8" | |
| 1866 | + | version = "1.2.0" | |
| 1867 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1868 | + | checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" | |
| 1869 | + | dependencies = [ | |
| 1870 | + | "rustc_version", | |
| 1871 | + | "simdutf8", | |
| 1872 | + | ] | |
| 1873 | + | ||
| 1874 | + | [[package]] | |
| 1875 | + | name = "simdutf8" | |
| 1876 | + | version = "0.1.5" | |
| 1877 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1878 | + | checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" | |
| 1879 | + | ||
| 732 | 1880 | [[package]] | |
| 733 | 1881 | name = "slab" | |
| 734 | 1882 | version = "0.4.12" | |
| @@ -748,15 +1896,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 748 | 1896 | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 749 | 1897 | dependencies = [ | |
| 750 | 1898 | "libc", | |
| 751 | − | "windows-sys", | |
| 1899 | + | "windows-sys 0.61.2", | |
| 752 | 1900 | ] | |
| 753 | 1901 | ||
| 1902 | + | [[package]] | |
| 1903 | + | name = "stable_deref_trait" | |
| 1904 | + | version = "1.2.1" | |
| 1905 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1906 | + | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" | |
| 1907 | + | ||
| 754 | 1908 | [[package]] | |
| 755 | 1909 | name = "static_assertions" | |
| 756 | 1910 | version = "1.1.0" | |
| 757 | 1911 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 758 | 1912 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" | |
| 759 | 1913 | ||
| 1914 | + | [[package]] | |
| 1915 | + | name = "strength_reduce" | |
| 1916 | + | version = "0.2.4" | |
| 1917 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1918 | + | checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" | |
| 1919 | + | ||
| 760 | 1920 | [[package]] | |
| 761 | 1921 | name = "strsim" | |
| 762 | 1922 | version = "0.11.1" | |
| @@ -784,6 +1944,12 @@ dependencies = [ | |||
| 784 | 1944 | "syn 2.0.119", | |
| 785 | 1945 | ] | |
| 786 | 1946 | ||
| 1947 | + | [[package]] | |
| 1948 | + | name = "subtle" | |
| 1949 | + | version = "2.6.1" | |
| 1950 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1951 | + | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" | |
| 1952 | + | ||
| 787 | 1953 | [[package]] | |
| 788 | 1954 | name = "syn" | |
| 789 | 1955 | version = "2.0.119" | |
| @@ -799,11 +1965,31 @@ dependencies = [ | |||
| 799 | 1965 | name = "syn" | |
| 800 | 1966 | version = "3.0.9" | |
| 801 | 1967 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 802 | − | checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" | |
| 1968 | + | checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" | |
| 1969 | + | dependencies = [ | |
| 1970 | + | "proc-macro2", | |
| 1971 | + | "quote", | |
| 1972 | + | "unicode-ident", | |
| 1973 | + | ] | |
| 1974 | + | ||
| 1975 | + | [[package]] | |
| 1976 | + | name = "sync_wrapper" | |
| 1977 | + | version = "1.0.2" | |
| 1978 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1979 | + | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" | |
| 1980 | + | dependencies = [ | |
| 1981 | + | "futures-core", | |
| 1982 | + | ] | |
| 1983 | + | ||
| 1984 | + | [[package]] | |
| 1985 | + | name = "synstructure" | |
| 1986 | + | version = "0.14.0" | |
| 1987 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1988 | + | checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" | |
| 803 | 1989 | dependencies = [ | |
| 804 | 1990 | "proc-macro2", | |
| 805 | 1991 | "quote", | |
| 806 | − | "unicode-ident", | |
| 1992 | + | "syn 3.0.9", | |
| 807 | 1993 | ] | |
| 808 | 1994 | ||
| 809 | 1995 | [[package]] | |
| @@ -826,6 +2012,15 @@ dependencies = [ | |||
| 826 | 2012 | "syn 3.0.9", | |
| 827 | 2013 | ] | |
| 828 | 2014 | ||
| 2015 | + | [[package]] | |
| 2016 | + | name = "thread_local" | |
| 2017 | + | version = "1.1.10" | |
| 2018 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2019 | + | checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" | |
| 2020 | + | dependencies = [ | |
| 2021 | + | "cfg-if", | |
| 2022 | + | ] | |
| 2023 | + | ||
| 829 | 2024 | [[package]] | |
| 830 | 2025 | name = "time" | |
| 831 | 2026 | version = "0.3.55" | |
| @@ -847,6 +2042,22 @@ version = "0.1.9" | |||
| 847 | 2042 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 848 | 2043 | checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" | |
| 849 | 2044 | ||
| 2045 | + | [[package]] | |
| 2046 | + | name = "tinystr" | |
| 2047 | + | version = "0.8.4" | |
| 2048 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2049 | + | checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" | |
| 2050 | + | dependencies = [ | |
| 2051 | + | "displaydoc", | |
| 2052 | + | "zerovec", | |
| 2053 | + | ] | |
| 2054 | + | ||
| 2055 | + | [[package]] | |
| 2056 | + | name = "tinyvec" | |
| 2057 | + | version = "1.13.3" | |
| 2058 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2059 | + | checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" | |
| 2060 | + | ||
| 850 | 2061 | [[package]] | |
| 851 | 2062 | name = "tokio" | |
| 852 | 2063 | version = "1.53.3" | |
| @@ -859,7 +2070,7 @@ dependencies = [ | |||
| 859 | 2070 | "pin-project-lite", | |
| 860 | 2071 | "socket2", | |
| 861 | 2072 | "tokio-macros", | |
| 862 | − | "windows-sys", | |
| 2073 | + | "windows-sys 0.61.2", | |
| 863 | 2074 | ] | |
| 864 | 2075 | ||
| 865 | 2076 | [[package]] | |
| @@ -873,6 +2084,16 @@ dependencies = [ | |||
| 873 | 2084 | "syn 3.0.9", | |
| 874 | 2085 | ] | |
| 875 | 2086 | ||
| 2087 | + | [[package]] | |
| 2088 | + | name = "tokio-rustls" | |
| 2089 | + | version = "0.26.6" | |
| 2090 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2091 | + | checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" | |
| 2092 | + | dependencies = [ | |
| 2093 | + | "rustls", | |
| 2094 | + | "tokio", | |
| 2095 | + | ] | |
| 2096 | + | ||
| 876 | 2097 | [[package]] | |
| 877 | 2098 | name = "toml" | |
| 878 | 2099 | version = "1.1.8+spec-1.1.0" | |
| @@ -912,6 +2133,113 @@ version = "1.1.3+spec-1.1.0" | |||
| 912 | 2133 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 913 | 2134 | checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" | |
| 914 | 2135 | ||
| 2136 | + | [[package]] | |
| 2137 | + | name = "tower" | |
| 2138 | + | version = "0.5.3" | |
| 2139 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2140 | + | checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" | |
| 2141 | + | dependencies = [ | |
| 2142 | + | "futures-core", | |
| 2143 | + | "futures-util", | |
| 2144 | + | "pin-project-lite", | |
| 2145 | + | "sync_wrapper", | |
| 2146 | + | "tokio", | |
| 2147 | + | "tower-layer", | |
| 2148 | + | "tower-service", | |
| 2149 | + | ] | |
| 2150 | + | ||
| 2151 | + | [[package]] | |
| 2152 | + | name = "tower-http" | |
| 2153 | + | version = "0.6.11" | |
| 2154 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2155 | + | checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" | |
| 2156 | + | dependencies = [ | |
| 2157 | + | "bitflags", | |
| 2158 | + | "bytes", | |
| 2159 | + | "futures-util", | |
| 2160 | + | "http", | |
| 2161 | + | "http-body", | |
| 2162 | + | "pin-project-lite", | |
| 2163 | + | "tower", | |
| 2164 | + | "tower-layer", | |
| 2165 | + | "tower-service", | |
| 2166 | + | "url", | |
| 2167 | + | ] | |
| 2168 | + | ||
| 2169 | + | [[package]] | |
| 2170 | + | name = "tower-layer" | |
| 2171 | + | version = "0.3.3" | |
| 2172 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2173 | + | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" | |
| 2174 | + | ||
| 2175 | + | [[package]] | |
| 2176 | + | name = "tower-service" | |
| 2177 | + | version = "0.3.3" | |
| 2178 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2179 | + | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" | |
| 2180 | + | ||
| 2181 | + | [[package]] | |
| 2182 | + | name = "tracing" | |
| 2183 | + | version = "0.1.44" | |
| 2184 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2185 | + | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" | |
| 2186 | + | dependencies = [ | |
| 2187 | + | "pin-project-lite", | |
| 2188 | + | "tracing-attributes", | |
| 2189 | + | "tracing-core", | |
| 2190 | + | ] | |
| 2191 | + | ||
| 2192 | + | [[package]] | |
| 2193 | + | name = "tracing-attributes" | |
| 2194 | + | version = "0.1.31" | |
| 2195 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2196 | + | checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" | |
| 2197 | + | dependencies = [ | |
| 2198 | + | "proc-macro2", | |
| 2199 | + | "quote", | |
| 2200 | + | "syn 2.0.119", | |
| 2201 | + | ] | |
| 2202 | + | ||
| 2203 | + | [[package]] | |
| 2204 | + | name = "tracing-core" | |
| 2205 | + | version = "0.1.36" | |
| 2206 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2207 | + | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" | |
| 2208 | + | dependencies = [ | |
| 2209 | + | "once_cell", | |
| 2210 | + | ] | |
| 2211 | + | ||
| 2212 | + | [[package]] | |
| 2213 | + | name = "tracing-subscriber" | |
| 2214 | + | version = "0.3.23" | |
| 2215 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2216 | + | checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" | |
| 2217 | + | dependencies = [ | |
| 2218 | + | "matchers", | |
| 2219 | + | "once_cell", | |
| 2220 | + | "regex-automata", | |
| 2221 | + | "sharded-slab", | |
| 2222 | + | "thread_local", | |
| 2223 | + | "tracing", | |
| 2224 | + | "tracing-core", | |
| 2225 | + | ] | |
| 2226 | + | ||
| 2227 | + | [[package]] | |
| 2228 | + | name = "transpose" | |
| 2229 | + | version = "0.2.3" | |
| 2230 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2231 | + | checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" | |
| 2232 | + | dependencies = [ | |
| 2233 | + | "num-integer", | |
| 2234 | + | "strength_reduce", | |
| 2235 | + | ] | |
| 2236 | + | ||
| 2237 | + | [[package]] | |
| 2238 | + | name = "try-lock" | |
| 2239 | + | version = "0.2.5" | |
| 2240 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2241 | + | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | |
| 2242 | + | ||
| 915 | 2243 | [[package]] | |
| 916 | 2244 | name = "unicode-ident" | |
| 917 | 2245 | version = "1.0.27" | |
| @@ -941,12 +2269,146 @@ version = "0.2.2" | |||
| 941 | 2269 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 942 | 2270 | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" | |
| 943 | 2271 | ||
| 2272 | + | [[package]] | |
| 2273 | + | name = "untrusted" | |
| 2274 | + | version = "0.9.0" | |
| 2275 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2276 | + | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" | |
| 2277 | + | ||
| 2278 | + | [[package]] | |
| 2279 | + | name = "url" | |
| 2280 | + | version = "2.5.8" | |
| 2281 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2282 | + | checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" | |
| 2283 | + | dependencies = [ | |
| 2284 | + | "form_urlencoded", | |
| 2285 | + | "idna", | |
| 2286 | + | "percent-encoding", | |
| 2287 | + | "serde", | |
| 2288 | + | ] | |
| 2289 | + | ||
| 2290 | + | [[package]] | |
| 2291 | + | name = "utf8_iter" | |
| 2292 | + | version = "1.0.4" | |
| 2293 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2294 | + | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | |
| 2295 | + | ||
| 2296 | + | [[package]] | |
| 2297 | + | name = "utf8parse" | |
| 2298 | + | version = "0.2.2" | |
| 2299 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2300 | + | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | |
| 2301 | + | ||
| 2302 | + | [[package]] | |
| 2303 | + | name = "walkdir" | |
| 2304 | + | version = "2.5.0" | |
| 2305 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2306 | + | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" | |
| 2307 | + | dependencies = [ | |
| 2308 | + | "same-file", | |
| 2309 | + | "winapi-util", | |
| 2310 | + | ] | |
| 2311 | + | ||
| 2312 | + | [[package]] | |
| 2313 | + | name = "want" | |
| 2314 | + | version = "0.3.2" | |
| 2315 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2316 | + | checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" | |
| 2317 | + | dependencies = [ | |
| 2318 | + | "try-lock", | |
| 2319 | + | ] | |
| 2320 | + | ||
| 944 | 2321 | [[package]] | |
| 945 | 2322 | name = "wasi" | |
| 946 | 2323 | version = "0.11.1+wasi-snapshot-preview1" | |
| 947 | 2324 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 948 | 2325 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" | |
| 949 | 2326 | ||
| 2327 | + | [[package]] | |
| 2328 | + | name = "wasm-bindgen" | |
| 2329 | + | version = "0.2.129" | |
| 2330 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2331 | + | checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" | |
| 2332 | + | dependencies = [ | |
| 2333 | + | "cfg-if", | |
| 2334 | + | "once_cell", | |
| 2335 | + | "rustversion", | |
| 2336 | + | "wasm-bindgen-macro", | |
| 2337 | + | "wasm-bindgen-shared", | |
| 2338 | + | ] | |
| 2339 | + | ||
| 2340 | + | [[package]] | |
| 2341 | + | name = "wasm-bindgen-futures" | |
| 2342 | + | version = "0.4.79" | |
| 2343 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2344 | + | checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" | |
| 2345 | + | dependencies = [ | |
| 2346 | + | "js-sys", | |
| 2347 | + | "tokio", | |
| 2348 | + | "wasm-bindgen", | |
| 2349 | + | ] | |
| 2350 | + | ||
| 2351 | + | [[package]] | |
| 2352 | + | name = "wasm-bindgen-macro" | |
| 2353 | + | version = "0.2.129" | |
| 2354 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2355 | + | checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" | |
| 2356 | + | dependencies = [ | |
| 2357 | + | "quote", | |
| 2358 | + | "wasm-bindgen-macro-support", | |
| 2359 | + | ] | |
| 2360 | + | ||
| 2361 | + | [[package]] | |
| 2362 | + | name = "wasm-bindgen-macro-support" | |
| 2363 | + | version = "0.2.129" | |
| 2364 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2365 | + | checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" | |
| 2366 | + | dependencies = [ | |
| 2367 | + | "bumpalo", | |
| 2368 | + | "proc-macro2", | |
| 2369 | + | "quote", | |
| 2370 | + | "syn 3.0.9", | |
| 2371 | + | "wasm-bindgen-shared", | |
| 2372 | + | ] | |
| 2373 | + | ||
| 2374 | + | [[package]] | |
| 2375 | + | name = "wasm-bindgen-shared" | |
| 2376 | + | version = "0.2.129" | |
| 2377 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2378 | + | checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" | |
| 2379 | + | dependencies = [ | |
| 2380 | + | "unicode-ident", | |
| 2381 | + | ] | |
| 2382 | + | ||
| 2383 | + | [[package]] | |
| 2384 | + | name = "web-sys" | |
| 2385 | + | version = "0.3.106" | |
| 2386 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2387 | + | checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" | |
| 2388 | + | dependencies = [ | |
| 2389 | + | "js-sys", | |
| 2390 | + | "wasm-bindgen", | |
| 2391 | + | ] | |
| 2392 | + | ||
| 2393 | + | [[package]] | |
| 2394 | + | name = "web-time" | |
| 2395 | + | version = "1.1.0" | |
| 2396 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2397 | + | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" | |
| 2398 | + | dependencies = [ | |
| 2399 | + | "js-sys", | |
| 2400 | + | "wasm-bindgen", | |
| 2401 | + | ] | |
| 2402 | + | ||
| 2403 | + | [[package]] | |
| 2404 | + | name = "webpki-root-certs" | |
| 2405 | + | version = "1.0.9" | |
| 2406 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2407 | + | checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" | |
| 2408 | + | dependencies = [ | |
| 2409 | + | "rustls-pki-types", | |
| 2410 | + | ] | |
| 2411 | + | ||
| 950 | 2412 | [[package]] | |
| 951 | 2413 | name = "winapi" | |
| 952 | 2414 | version = "0.3.9" | |
| @@ -963,6 +2425,15 @@ version = "0.4.0" | |||
| 963 | 2425 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 964 | 2426 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | |
| 965 | 2427 | ||
| 2428 | + | [[package]] | |
| 2429 | + | name = "winapi-util" | |
| 2430 | + | version = "0.1.11" | |
| 2431 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2432 | + | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | |
| 2433 | + | dependencies = [ | |
| 2434 | + | "windows-sys 0.61.2", | |
| 2435 | + | ] | |
| 2436 | + | ||
| 966 | 2437 | [[package]] | |
| 967 | 2438 | name = "winapi-x86_64-pc-windows-gnu" | |
| 968 | 2439 | version = "0.4.0" | |
| @@ -975,6 +2446,15 @@ version = "0.2.1" | |||
| 975 | 2446 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 976 | 2447 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 977 | 2448 | ||
| 2449 | + | [[package]] | |
| 2450 | + | name = "windows-sys" | |
| 2451 | + | version = "0.52.0" | |
| 2452 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2453 | + | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" | |
| 2454 | + | dependencies = [ | |
| 2455 | + | "windows-targets", | |
| 2456 | + | ] | |
| 2457 | + | ||
| 978 | 2458 | [[package]] | |
| 979 | 2459 | name = "windows-sys" | |
| 980 | 2460 | version = "0.61.2" | |
| @@ -984,8 +2464,173 @@ dependencies = [ | |||
| 984 | 2464 | "windows-link", | |
| 985 | 2465 | ] | |
| 986 | 2466 | ||
| 2467 | + | [[package]] | |
| 2468 | + | name = "windows-targets" | |
| 2469 | + | version = "0.52.6" | |
| 2470 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2471 | + | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" | |
| 2472 | + | dependencies = [ | |
| 2473 | + | "windows_aarch64_gnullvm", | |
| 2474 | + | "windows_aarch64_msvc", | |
| 2475 | + | "windows_i686_gnu", | |
| 2476 | + | "windows_i686_gnullvm", | |
| 2477 | + | "windows_i686_msvc", | |
| 2478 | + | "windows_x86_64_gnu", | |
| 2479 | + | "windows_x86_64_gnullvm", | |
| 2480 | + | "windows_x86_64_msvc", | |
| 2481 | + | ] | |
| 2482 | + | ||
| 2483 | + | [[package]] | |
| 2484 | + | name = "windows_aarch64_gnullvm" | |
| 2485 | + | version = "0.52.6" | |
| 2486 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2487 | + | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" | |
| 2488 | + | ||
| 2489 | + | [[package]] | |
| 2490 | + | name = "windows_aarch64_msvc" | |
| 2491 | + | version = "0.52.6" | |
| 2492 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2493 | + | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" | |
| 2494 | + | ||
| 2495 | + | [[package]] | |
| 2496 | + | name = "windows_i686_gnu" | |
| 2497 | + | version = "0.52.6" | |
| 2498 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2499 | + | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" | |
| 2500 | + | ||
| 2501 | + | [[package]] | |
| 2502 | + | name = "windows_i686_gnullvm" | |
| 2503 | + | version = "0.52.6" | |
| 2504 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2505 | + | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" | |
| 2506 | + | ||
| 2507 | + | [[package]] | |
| 2508 | + | name = "windows_i686_msvc" | |
| 2509 | + | version = "0.52.6" | |
| 2510 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2511 | + | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" | |
| 2512 | + | ||
| 2513 | + | [[package]] | |
| 2514 | + | name = "windows_x86_64_gnu" | |
| 2515 | + | version = "0.52.6" | |
| 2516 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2517 | + | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" | |
| 2518 | + | ||
| 2519 | + | [[package]] | |
| 2520 | + | name = "windows_x86_64_gnullvm" | |
| 2521 | + | version = "0.52.6" | |
| 2522 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2523 | + | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" | |
| 2524 | + | ||
| 2525 | + | [[package]] | |
| 2526 | + | name = "windows_x86_64_msvc" | |
| 2527 | + | version = "0.52.6" | |
| 2528 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2529 | + | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" | |
| 2530 | + | ||
| 987 | 2531 | [[package]] | |
| 988 | 2532 | name = "winnow" | |
| 989 | 2533 | version = "1.0.4" | |
| 990 | 2534 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 991 | 2535 | checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" | |
| 2536 | + | ||
| 2537 | + | [[package]] | |
| 2538 | + | name = "writeable" | |
| 2539 | + | version = "0.6.4" | |
| 2540 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2541 | + | checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" | |
| 2542 | + | ||
| 2543 | + | [[package]] | |
| 2544 | + | name = "yoke" | |
| 2545 | + | version = "0.8.3" | |
| 2546 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2547 | + | checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" | |
| 2548 | + | dependencies = [ | |
| 2549 | + | "stable_deref_trait", | |
| 2550 | + | "yoke-derive", | |
| 2551 | + | "zerofrom", | |
| 2552 | + | ] | |
| 2553 | + | ||
| 2554 | + | [[package]] | |
| 2555 | + | name = "yoke-derive" | |
| 2556 | + | version = "0.8.4" | |
| 2557 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2558 | + | checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" | |
| 2559 | + | dependencies = [ | |
| 2560 | + | "proc-macro2", | |
| 2561 | + | "quote", | |
| 2562 | + | "syn 3.0.9", | |
| 2563 | + | "synstructure", | |
| 2564 | + | ] | |
| 2565 | + | ||
| 2566 | + | [[package]] | |
| 2567 | + | name = "zerofrom" | |
| 2568 | + | version = "0.1.8" | |
| 2569 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2570 | + | checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" | |
| 2571 | + | dependencies = [ | |
| 2572 | + | "zerofrom-derive", | |
| 2573 | + | ] | |
| 2574 | + | ||
| 2575 | + | [[package]] | |
| 2576 | + | name = "zerofrom-derive" | |
| 2577 | + | version = "0.1.8" | |
| 2578 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2579 | + | checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" | |
| 2580 | + | dependencies = [ | |
| 2581 | + | "proc-macro2", | |
| 2582 | + | "quote", | |
| 2583 | + | "syn 3.0.9", | |
| 2584 | + | "synstructure", | |
| 2585 | + | ] | |
| 2586 | + | ||
| 2587 | + | [[package]] | |
| 2588 | + | name = "zeroize" | |
| 2589 | + | version = "1.9.1" | |
| 2590 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2591 | + | checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" | |
| 2592 | + | ||
| 2593 | + | [[package]] | |
| 2594 | + | name = "zerotrie" | |
| 2595 | + | version = "0.2.5" | |
| 2596 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2597 | + | checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" | |
| 2598 | + | dependencies = [ | |
| 2599 | + | "displaydoc", | |
| 2600 | + | "yoke", | |
| 2601 | + | "zerofrom", | |
| 2602 | + | ] | |
| 2603 | + | ||
| 2604 | + | [[package]] | |
| 2605 | + | name = "zerovec" | |
| 2606 | + | version = "0.11.8" | |
| 2607 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2608 | + | checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" | |
| 2609 | + | dependencies = [ | |
| 2610 | + | "yoke", | |
| 2611 | + | "zerofrom", | |
| 2612 | + | "zerovec-derive", | |
| 2613 | + | ] | |
| 2614 | + | ||
| 2615 | + | [[package]] | |
| 2616 | + | name = "zerovec-derive" | |
| 2617 | + | version = "0.11.6" | |
| 2618 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2619 | + | checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" | |
| 2620 | + | dependencies = [ | |
| 2621 | + | "proc-macro2", | |
| 2622 | + | "quote", | |
| 2623 | + | "syn 3.0.9", | |
| 2624 | + | ] | |
| 2625 | + | ||
| 2626 | + | [[package]] | |
| 2627 | + | name = "zlib-rs" | |
| 2628 | + | version = "0.6.8" | |
| 2629 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2630 | + | checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" | |
| 2631 | + | ||
| 2632 | + | [[package]] | |
| 2633 | + | name = "zmij" | |
| 2634 | + | version = "1.0.23" | |
| 2635 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2636 | + | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| @@ -1,28 +1,39 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "rmptui" | |
| 3 | 3 | authors = ["krolyxon"] | |
| 4 | − | description = "A fast and minimal terminal MPD client" | |
| 4 | + | description = "A fast, keyboard-driven MPD client for the terminal" | |
| 5 | 5 | readme = "README.md" | |
| 6 | − | version = "0.1.6" | |
| 7 | − | edition = "2021" | |
| 6 | + | version = "0.2.0" | |
| 7 | + | edition = "2024" | |
| 8 | + | rust-version = "1.88" | |
| 8 | 9 | license = "AGPL-3.0-only" | |
| 9 | 10 | repository = "https://github.com/krolyxon/rmptui" | |
| 10 | 11 | keywords = ["mpd", "music", "cli", "tui", "client"] | |
| 12 | + | categories = ["command-line-utilities", "multimedia::audio"] | |
| 11 | 13 | ||
| 12 | 14 | [dependencies] | |
| 13 | − | ratatui = { version = "0.30.2", default-features = false, features = [ | |
| 14 | − | "crossterm_0_29", | |
| 15 | − | ] } | |
| 15 | + | anyhow = "1.0.104" | |
| 16 | + | clap = { version = "4.6.7", features = ["derive"] } | |
| 16 | 17 | crossterm = { version = "0.29.0", features = ["event-stream"] } | |
| 17 | − | rust-fuzzy-search = "0.1.1" | |
| 18 | 18 | futures-util = "0.3.34" | |
| 19 | + | libc = "0.2.186" | |
| 20 | + | lofty = "0.25.4" | |
| 21 | + | nucleo-matcher = "0.3.1" | |
| 22 | + | ratatui = { version = "0.30.2", default-features = false, features = ["crossterm_0_29"] } | |
| 23 | + | realfft = "3.5.0" | |
| 24 | + | reqwest = { version = "0.13.5", default-features = false, features = ["json", "query", "rustls"] } | |
| 19 | 25 | serde = { version = "1.0.229", features = ["derive"] } | |
| 26 | + | serde_json = "1.0.154" | |
| 27 | + | thiserror = "2.0.21" | |
| 28 | + | tokio = { version = "1.53.3", features = ["macros", "rt-multi-thread", "net", "io-util", "time", "sync", "fs"] } | |
| 20 | 29 | toml = "1.1.8" | |
| 21 | − | tokio = { version = "1.53.3", features = [ | |
| 22 | − | "macros", | |
| 23 | − | "rt-multi-thread", | |
| 24 | − | "net", | |
| 25 | − | "io-util", | |
| 26 | − | "time", | |
| 27 | − | "sync", | |
| 28 | − | ] } | |
| 30 | + | tracing = "0.1.44" | |
| 31 | + | tracing-subscriber = { version = "0.3.23", default-features = false, features = ["fmt", "std", "env-filter"] } | |
| 32 | + | unicode-width = "0.2.2" | |
| 33 | + | ||
| 34 | + | [dev-dependencies] | |
| 35 | + | tokio = { version = "1.53.3", features = ["test-util"] } | |
| 36 | + | ||
| 37 | + | [profile.release] | |
| 38 | + | lto = "thin" | |
| 39 | + | codegen-units = 1 | |
| @@ -5,33 +5,50 @@ | |||
| 5 | 5 | ||
| 6 | 6 | **A fast, keyboard-driven MPD client for your terminal.** | |
| 7 | 7 | ||
| 8 | − | rmptui is a lightweight music player interface for [Music Player Daemon | |
| 9 | − | (MPD)](https://www.musicpd.org/). Browse your library, manage the play queue, | |
| 10 | − | work with playlists, and control playback without leaving the terminal. | |
| 11 | − | ||
| 12 | − |  | |
| 13 | − | ||
| 14 | − | ## Highlights | |
| 15 | − | ||
| 16 | − | - **Three focused views:** browse your music library, control the current queue, | |
| 17 | − | and inspect saved playlists. | |
| 18 | − | - **Metadata at a glance:** see artist, track, title, album, and duration in | |
| 19 | − | the browser and queue. | |
| 20 | − | - **Fuzzy, repeatable search:** search the current view with `/`; in the song | |
| 21 | − | browser, keep the query and cycle matching folders and songs with `n` and | |
| 22 | − | `N`. | |
| 23 | − | - **Playlist tools:** load playlists, add selected tracks or folders to a | |
| 24 | − | playlist, create playlists, and rename them. | |
| 25 | − | - **Queue control:** play, remove, reorder, and append tracks. | |
| 26 | − | - **Responsive MPD updates:** the client uses MPD's asynchronous `idle` | |
| 27 | − | notifications instead of regularly polling the server. | |
| 28 | − | - **Direct MPD connection:** speaks MPD's standard text protocol over | |
| 29 | − | asynchronous TCP; no `mpd` crate or WebSocket proxy is required. | |
| 8 | + | rmptui is a terminal interface for [Music Player Daemon | |
| 9 | + | (MPD)](https://www.musicpd.org/) that looks and feels like ncmpcpp. Browse your | |
| 10 | + | library, manage the queue and playlists, edit tags, read synced lyrics and | |
| 11 | + | watch a music visualizer without leaving the terminal. | |
| 12 | + | ||
| 13 | + |  | |
| 14 | + |  | |
| 15 | + |  | |
| 16 | + | ||
| 17 | + | ||
| 18 | + | ## Screens | |
| 19 | + | ||
| 20 | + | | Key | Screen | What it does | | |
| 21 | + | | --- | --- | --- | | |
| 22 | + | | `1` | **Playlist** | The play queue as a table (Artist, Track, Title, Album, Time). Play, remove, reorder, mark, shuffle or save it as a playlist. `L` shows a synced lyrics pane next to it. | | |
| 23 | + | | `2` | **Browse** | Your music folders. Folders (`[name]`) span the whole row, songs use the same columns as the queue, and stored playlists are listed at the bottom of the root folder. | | |
| 24 | + | | `3` | **Playlist editor** | Stored playlists and their songs: load, play from a song, remove or reorder songs, rename or delete playlists. | | |
| 25 | + | | `4` | **Media library** | Album artists → albums (with year) → songs. `t` switches between AlbumArtist and Artist. | | |
| 26 | + | | `5` | **Tag editor** | Directories, tag types and tags, like ncmpcpp: edit Title, Artist, Album Artist, Album, Date, Track, Genre, Composer, Performer, Disc and Comment of one or many songs, rename files, fix letter case, then save. | | |
| 27 | + | | `6` | **Music visualizer** | ncmpcpp-style wave, filled wave, spectrum and ellipse visualizers fed by an MPD fifo output; `Space` cycles them. | | |
| 28 | + | | `7` | **Lyrics** | Synced lyrics that follow playback, or plain lyrics you can scroll. | | |
| 29 | + | ||
| 30 | + | Every screen shares ncmpcpp's frame: the screen title and volume on top | |
| 31 | + | (active modes such as `[rz]` appear on the line below), and at the bottom a | |
| 32 | + | progress bar above `Playing: "Title" by Artist in Album (Year)` with the | |
| 33 | + | bitrate and elapsed/total time. | |
| 34 | + | ||
| 35 | + | Other highlights: | |
| 36 | + | ||
| 37 | + | - **Instant controls.** All MPD traffic runs in background tasks over MPD's | |
| 38 | + | `idle` notifications, so seeking, volume changes and skipping never wait for | |
| 39 | + | the UI, and the UI never waits for the server. | |
| 40 | + | - **Fuzzy search everywhere.** `/` searches the focused list; `n`/`N` cycle | |
| 41 | + | through the matches. | |
| 42 | + | - **Marks.** `v` marks items; queue removal, add-to-playlist and tag edits | |
| 43 | + | then apply to every marked item. | |
| 44 | + | - **Mouse support.** Click to select, double-click to play/open, scroll with | |
| 45 | + | the wheel and click the progress bar to seek. | |
| 46 | + | - **TCP or Unix socket**, with password support and automatic reconnection. | |
| 30 | 47 | ||
| 31 | 48 | ## Requirements | |
| 32 | 49 | ||
| 33 | 50 | - A running MPD server with its music library configured. | |
| 34 | − | - Rust and Cargo to build from source. | |
| 51 | + | - Rust 1.88 or newer and Cargo to build from source. | |
| 35 | 52 | ||
| 36 | 53 | ## Install | |
| 37 | 54 | ||
| @@ -42,9 +59,8 @@ copy it to `/usr/local/bin/rmptui`: | |||
| 42 | 59 | ./install.sh | |
| 43 | 60 | ``` | |
| 44 | 61 | ||
| 45 | − | The script uses `sudo` for the system-directory operations when needed. It | |
| 46 | − | requires Rust and Cargo to build from source. To use a different binary | |
| 47 | − | directory or uninstall rmptui: | |
| 62 | + | The script uses `sudo` for the system-directory operations when needed. To use | |
| 63 | + | a different binary directory or uninstall rmptui: | |
| 48 | 64 | ||
| 49 | 65 | ```sh | |
| 50 | 66 | ./install.sh --bin-dir "$HOME/.local/bin" | |
| @@ -63,170 +79,192 @@ cd rmptui | |||
| 63 | 79 | cargo run --release | |
| 64 | 80 | ``` | |
| 65 | 81 | ||
| 66 | − | To build and run without installing, use `cargo run --release`. | |
| 82 | + | ## Configuration | |
| 67 | 83 | ||
| 68 | − | On first launch, rmptui creates a default configuration file at | |
| 84 | + | On first launch rmptui writes a fully commented configuration to | |
| 69 | 85 | `$CONFIG_HOME/rmptui/config.toml`. If `CONFIG_HOME` is not set, it uses | |
| 70 | 86 | `$XDG_CONFIG_HOME/rmptui/config.toml`, then `~/.config/rmptui/config.toml`. | |
| 71 | − | Edit the file and restart rmptui to apply changes: | |
| 87 | + | Use `rmptui --config FILE` to load a different file, and | |
| 88 | + | `rmptui --print-default-config` to see every option with its default. Restart | |
| 89 | + | rmptui to apply changes. | |
| 90 | + | ||
| 91 | + | You only need to keep the settings you change: anything left out keeps its | |
| 92 | + | default, table by table. Unknown options and conflicting key bindings are | |
| 93 | + | reported with the file and the offending value. | |
| 94 | + | ||
| 95 | + | > **Upgrading from 0.1:** the configuration format changed. rmptui refuses to | |
| 96 | + | > start with a 0.1 file and tells you so; move the old file aside, start | |
| 97 | + | > rmptui to generate the new one, and copy your MPD settings over. | |
| 98 | + | ||
| 99 | + | The main sections: | |
| 72 | 100 | ||
| 73 | 101 | ```toml | |
| 74 | 102 | [mpd] | |
| 75 | − | host = "127.0.0.1" | |
| 103 | + | host = "127.0.0.1" # or a Unix socket path such as "~/.config/mpd/socket" | |
| 76 | 104 | port = 6600 | |
| 77 | − | password = "" # Set this only if your MPD server requires a password. | |
| 105 | + | password = "" | |
| 106 | + | music_directory = "" # for the tag editor and local .lrc files (see below) | |
| 78 | 107 | ||
| 79 | 108 | [playback] | |
| 80 | − | seek_seconds = 2 | |
| 81 | − | volume_step = 2 | |
| 82 | − | ||
| 83 | − | [keybindings] | |
| 84 | − | quit = ["q", "Ctrl+c"] | |
| 85 | − | clear_queue = ["c", "C"] | |
| 86 | − | toggle_pause = ["p"] | |
| 87 | − | pause = ["s"] | |
| 88 | − | toggle_repeat = ["r"] | |
| 89 | − | toggle_random = ["z"] | |
| 90 | − | open_playlist_menu = ["a"] | |
| 91 | − | seek_forward = ["f"] | |
| 92 | − | seek_backward = ["b"] | |
| 93 | − | cycle_tabs = ["Tab"] | |
| 94 | − | select_queue = ["1"] | |
| 95 | − | select_browser = ["2"] | |
| 96 | − | select_playlists = ["3"] | |
| 97 | − | next_track = [">"] | |
| 98 | − | previous_track = ["<"] | |
| 99 | − | volume_up = ["=", "+"] | |
| 100 | − | volume_down = ["-"] | |
| 101 | − | toggle_mute = ["m"] | |
| 102 | − | update_database = ["U"] | |
| 103 | − | search = ["/"] | |
| 104 | − | toggle_selected_item = ["Space"] | |
| 105 | − | search_next = ["n"] | |
| 106 | − | search_previous = ["N"] | |
| 107 | − | move_down = ["j", "Down"] | |
| 108 | − | move_up = ["k", "Up"] | |
| 109 | − | activate = ["Enter", "l", "Right"] | |
| 110 | − | go_back = ["h", "Left"] | |
| 111 | − | go_to_top = ["g"] | |
| 112 | − | go_to_bottom = ["G"] | |
| 113 | − | queue_delete = ["d"] | |
| 114 | − | queue_move_down = ["J"] | |
| 115 | − | queue_move_up = ["K"] | |
| 116 | − | playlist_rename = ["R"] | |
| 117 | − | playlist_focus_contents = ["Right", "l"] | |
| 118 | − | playlist_focus_list = ["Left", "h"] | |
| 119 | − | playlist_delete_song = ["d"] | |
| 120 | − | playlist_move_song_down = ["J"] | |
| 121 | − | playlist_move_song_up = ["K"] | |
| 122 | − | playlist_delete = ["D"] | |
| 123 | − | playlist_load = ["Space", "Enter"] | |
| 124 | − | popup_close = ["q", "Esc"] | |
| 125 | − | popup_select = ["Enter"] | |
| 126 | − | input_cancel = ["Esc"] | |
| 127 | − | input_backspace = ["Backspace"] | |
| 128 | − | input_cursor_left = ["Left"] | |
| 129 | − | input_cursor_right = ["Right"] | |
| 130 | − | input_submit = ["Enter"] | |
| 109 | + | seek_seconds = 5 | |
| 110 | + | volume_step = 5 | |
| 111 | + | ||
| 112 | + | [ui] | |
| 113 | + | startup_screen = "queue" | |
| 114 | + | mouse = true | |
| 115 | + | confirm_clear_queue = false | |
| 116 | + | lyrics_pane = false | |
| 117 | + | song_columns = [ # queue, browser and playlist editor | |
| 118 | + | { field = "artist", width = 34 }, | |
| 119 | + | { field = "track", width = 5 }, | |
| 120 | + | { field = "title", width = 33 }, | |
| 121 | + | { field = "album", width = 30 }, | |
| 122 | + | { field = "duration", width = 5 }, | |
| 123 | + | ] | |
| 124 | + | ||
| 125 | + | [theme] # color names, 256-color indices or "#rrggbb" | |
| 126 | + | text = "reset" | |
| 127 | + | dim = "darkgray" | |
| 128 | + | selection_fg = "reset" # both "reset": reverse video, like ncmpcpp | |
| 129 | + | selection_bg = "reset" | |
| 130 | + | visualizer = ["blue", "cyan", "green", "yellow", "red"] | |
| 131 | + | ||
| 132 | + | [lyrics] | |
| 133 | + | online = true # look up lyrics on lrclib.net | |
| 134 | + | cache = true | |
| 135 | + | ||
| 136 | + | [visualizer] | |
| 137 | + | fifo = "/tmp/mpd.fifo" | |
| 131 | 138 | ``` | |
| 132 | 139 | ||
| 133 | − | Runtime errors and panic details are appended to `/tmp/rmptui.log`. If logging | |
| 134 | − | cannot be initialized, rmptui prints a warning and continues without a log | |
| 135 | − | file. | |
| 140 | + | The MPD connection can also be set with environment variables, which take | |
| 141 | + | precedence over the file: | |
| 136 | 142 | ||
| 137 | − | Every listed action is configurable in `[keybindings]`; each value is an array | |
| 138 | − | so actions can have multiple aliases. Remove an alias or set an action to `[]` | |
| 139 | − | to disable it. A key can be a single character or a named key such as `Enter`, | |
| 140 | − | `Esc`, `Tab`, `Space`, `Up`, `Down`, `Left`, `Right`, or `Backspace`. Add | |
| 141 | − | modifiers with `Ctrl+`, `Alt+`, or `Shift+`, for example `Ctrl+x` or | |
| 142 | − | `Alt+Enter`. Bindings are matched in the current UI context, so avoid assigning | |
| 143 | − | the same key to different actions that can be active in the same context. | |
| 144 | − | Characters typed into search and playlist-name fields remain text unless | |
| 145 | − | configured as an input action. | |
| 143 | + | | Variable | Description | | |
| 144 | + | | --- | --- | | |
| 145 | + | | `MPD_HOST` | Hostname, address or Unix socket path. Use `password@host` to set both host and password. | | |
| 146 | + | | `MPD_PORT` | Port; an explicit port in `MPD_HOST` takes precedence. | | |
| 146 | 147 | ||
| 147 | − | `playback.seek_seconds` sets the `seek_forward`/`seek_backward` distance, and | |
| 148 | − | `playback.volume_step` sets the `volume_up`/`volume_down` amount. Supported | |
| 149 | − | audio formats remain built in. The config stores an MPD password as plain text; | |
| 150 | − | newly created config files are restricted to the current user. | |
| 148 | + | ```sh | |
| 149 | + | MPD_HOST=192.168.1.20 MPD_PORT=6600 rmptui | |
| 150 | + | MPD_HOST='secret@localhost' rmptui | |
| 151 | + | MPD_HOST=~/.config/mpd/socket rmptui | |
| 152 | + | ``` | |
| 151 | 153 | ||
| 152 | − | The MPD connection settings can also be overridden with environment variables: | |
| 154 | + | Logs (and panic details) are appended to `/tmp/rmptui.log`. Set | |
| 155 | + | `RMPTUI_LOG=debug` for more detail. | |
| 153 | 156 | ||
| 154 | − | | Variable | Default | Description | | |
| 155 | − | | --- | --- | --- | | |
| 156 | − | | `MPD_HOST` | Config file | MPD hostname or address. Use `password@host` to set both host and password. | | |
| 157 | − | | `MPD_PORT` | Config file | MPD port; an explicit port in `MPD_HOST` takes precedence. | | |
| 157 | + | ### Visualizer | |
| 158 | 158 | ||
| 159 | − | For example: | |
| 159 | + | The visualizer reads raw audio from an MPD fifo output. Add this to | |
| 160 | + | `mpd.conf` and restart MPD: | |
| 160 | 161 | ||
| 161 | − | ```sh | |
| 162 | − | MPD_HOST=192.168.1.20 MPD_PORT=6600 ./target/release/rmptui | |
| 162 | + | ``` | |
| 163 | + | audio_output { | |
| 164 | + | type "fifo" | |
| 165 | + | name "Visualizer feed" | |
| 166 | + | path "/tmp/mpd.fifo" | |
| 167 | + | format "44100:16:2" | |
| 168 | + | } | |
| 163 | 169 | ``` | |
| 164 | 170 | ||
| 165 | − | Or, for a password-protected local server: | |
| 171 | + | `[visualizer]` must match the `path` and `format`. The fifo is only read while | |
| 172 | + | the Visualizer screen is visible. | |
| 166 | 173 | ||
| 167 | − | ```sh | |
| 168 | − | MPD_HOST='secret@localhost' ./target/release/rmptui | |
| 169 | − | ``` | |
| 174 | + | Like ncmpcpp, there are four styles: `wave` (the default), `wave_filled`, | |
| 175 | + | `spectrum` and `ellipse`. Set the startup one with `style` and press `Space` on | |
| 176 | + | the screen to cycle through them. With `stereo = true` the wave styles draw the | |
| 177 | + | left channel in the top half and the right channel in the bottom half. | |
| 170 | 178 | ||
| 171 | − | ## Controls | |
| 179 | + | ### Lyrics | |
| 172 | 180 | ||
| 173 | − | The keys below are the defaults; edit the `[keybindings]` table above to change | |
| 174 | − | them. | |
| 181 | + | Lyrics are looked up in this order: | |
| 175 | 182 | ||
| 176 | − | ### Navigation | |
| 183 | + | 1. An `.lrc` file next to the song (`Song.flac` → `Song.lrc`), when the music | |
| 184 | + | directory is known. | |
| 185 | + | 2. The cache in `$XDG_CACHE_HOME/rmptui/lyrics` (`~/.cache` by default). | |
| 186 | + | 3. [LRCLIB](https://lrclib.net), a free lyrics database. Results are cached. | |
| 177 | 187 | ||
| 178 | − | | Key | Action | | |
| 179 | − | | --- | --- | | |
| 180 | − | | `Tab` | Cycle between Queue, Browser, and Playlists | | |
| 181 | − | | `1` / `2` / `3` | Open Queue / Browser / Playlists | | |
| 182 | − | | `j` / `Down` | Move selection down | | |
| 183 | − | | `k` / `Up` | Move selection up | | |
| 184 | − | | `g` / `G` | Jump to the top / bottom of the current list | | |
| 185 | − | | `Enter` / `l` / `Right` | Activate the selected item | | |
| 186 | − | | `h` / `Left` | Go to the parent directory in the browser | | |
| 187 | − | | `q` / `Ctrl+C` | Quit | | |
| 188 | + | Set `lyrics.online = false` to stay offline. `F5` on the Lyrics screen fetches | |
| 189 | + | the lyrics of the current song again. | |
| 190 | + | ||
| 191 | + | ### Tag editor | |
| 188 | 192 | ||
| 189 | − | ### Search | |
| 193 | + | MPD cannot change tags, so the tag editor writes the files directly, which | |
| 194 | + | requires the music files to be on this machine. rmptui finds the music | |
| 195 | + | directory in this order: `mpd.music_directory` in its config, MPD itself (only | |
| 196 | + | answered over a Unix socket), and finally `music_directory` from the local | |
| 197 | + | MPD's `mpd.conf` (`$XDG_CONFIG_HOME/mpd/mpd.conf`, `~/.config/mpd/mpd.conf`, | |
| 198 | + | `~/.mpdconf`, `~/.mpd/mpd.conf` or `/etc/mpd.conf`) when MPD runs on this | |
| 199 | + | machine. | |
| 190 | 200 | ||
| 191 | − | 1. Press `/` and type a query. | |
| 192 | − | 2. In the browser, press `Enter` to keep the query and return to the list. | |
| 193 | − | 3. Press `n` for the next fuzzy match or `N` for the previous match. Results | |
| 194 | − | wrap around when you reach either end. | |
| 195 | − | 4. Press `/` to start a new search, or `Esc` to cancel search input. | |
| 201 | + | Highlight a directory to see its songs in the `Tags` column, which shows the | |
| 202 | + | tag selected in `Tag types`. `Enter` on a tag type edits it for all marked | |
| 203 | + | songs (or all songs), `Enter` on a song edits it for that song only, and | |
| 204 | + | `Filename` renames files. Changes stay pending until you choose `Save` (or | |
| 205 | + | press `w`); `Reset` (or `u`) discards them. After saving, rmptui asks MPD to | |
| 206 | + | rescan the folder. | |
| 196 | 207 | ||
| 197 | − | Search matches the names shown in the current view. Browser search includes | |
| 198 | − | both folders and songs. | |
| 208 | + | ## Key bindings | |
| 199 | 209 | ||
| 200 | − | ### Playback and queue | |
| 210 | + | These are the defaults; every binding is configurable in the `[keys.*]` tables | |
| 211 | + | of the config. `?` shows the active bindings inside rmptui. | |
| 212 | + | ||
| 213 | + | Bindings are grouped by context. A key is looked up in the focused popup or | |
| 214 | + | screen first, then in `navigation`, then in `global`, so screen bindings | |
| 215 | + | shadow global ones on that screen (for example `R` renames a playlist on the | |
| 216 | + | Playlists screen and toggles consume elsewhere). | |
| 217 | + | ||
| 218 | + | ### Global | |
| 201 | 219 | ||
| 202 | 220 | | Key | Action | | |
| 203 | 221 | | --- | --- | | |
| 222 | + | | `1`–`7` | Switch screen | | |
| 223 | + | | `Tab` / `Shift+Tab` | Next / previous screen | | |
| 204 | 224 | | `p` | Toggle play/pause | | |
| 205 | − | | `s` | Pause | | |
| 206 | − | | `>` / `<` | Play next / previous track | | |
| 207 | − | | `f` / `b` | Seek forward / backward by two seconds | | |
| 208 | − | | `+` / `=` / `-` | Increase / decrease volume | | |
| 225 | + | | `s` / `x` | Pause / stop | | |
| 226 | + | | `>` / `<` | Next / previous track | | |
| 227 | + | | `f` / `b` | Seek forward / backward | | |
| 228 | + | | `+` `=` / `-` | Volume up / down | | |
| 209 | 229 | | `m` | Toggle mute | | |
| 210 | − | | `r` / `z` | Toggle repeat / random | | |
| 211 | − | | `d` | Remove the selected queue item | | |
| 212 | − | | `Space` | Remove a queue item; toggle a browser song in the queue | | |
| 213 | − | | `J` / `K` | Move the selected queue item down / up | | |
| 230 | + | | `r` `z` `y` `R` | Toggle repeat, random, single, consume | | |
| 214 | 231 | | `c` | Clear the queue | | |
| 215 | − | | `a` | Open the add-to-playlist menu | | |
| 216 | − | | `U` | Rescan the MPD music database | | |
| 217 | − | ||
| 218 | − | ### Playlists | |
| 232 | + | | `U` | Rescan the music database | | |
| 233 | + | | `?` `F1` | Show key bindings | | |
| 234 | + | | `q` `Ctrl+c` | Quit | | |
| 219 | 235 | ||
| 220 | − | The Playlists view has a playlist pane and a contents pane. Select a playlist | |
| 221 | − | with `j`/`k` and press `Space` or `Enter` to load it into the queue and start | |
| 222 | − | playback. Press `Right` or `l` to focus its songs and `Left` or `h` to return | |
| 223 | − | to the playlist pane. In the contents pane, `j`/`k` selects a song, `Enter` | |
| 224 | − | plays it, `d` removes it from the saved playlist, and `J`/`K` moves it | |
| 225 | − | down/up. `D` deletes the selected saved playlist. `R` renames a playlist. | |
| 236 | + | ### Lists | |
| 226 | 237 | ||
| 227 | − | Press `a` to open the add-to-playlist menu; choose **Current Playlist**, **New | |
| 228 | − | Playlist**, or a saved playlist. When creating a playlist, type its name and | |
| 229 | − | press `Enter` to save. Playlist contents update after each edit. | |
| 238 | + | | Key | Action | | |
| 239 | + | | --- | --- | | |
| 240 | + | | `j` `k` / arrows | Move down / up | | |
| 241 | + | | `g` `G` / `Home` `End` | Top / bottom | | |
| 242 | + | | `Ctrl+d` `Ctrl+u`, `PageDown` `PageUp` | Half page / page | | |
| 243 | + | | `h` `l` / arrows | Back or previous column / open or next column | | |
| 244 | + | | `Enter` | Play or open the selected item | | |
| 245 | + | | `Space` | Add to the queue (toggles songs in the browser) | | |
| 246 | + | | `a` | Add to the queue, a new playlist or an existing one | | |
| 247 | + | | `v` / `V` | Mark item / clear marks | | |
| 248 | + | | `/`, `n` / `N` | Search, next / previous match | | |
| 249 | + | ||
| 250 | + | ### Screens | |
| 251 | + | ||
| 252 | + | | Screen | Key | Action | | |
| 253 | + | | --- | --- | --- | | |
| 254 | + | | Queue | `d` `Delete` | Remove the song (or all marked songs) | | |
| 255 | + | | Queue | `J` / `K` | Move the song down / up | | |
| 256 | + | | Queue | `Z` / `S` | Shuffle / save as playlist | | |
| 257 | + | | Queue | `o` | Jump to the playing song | | |
| 258 | + | | Queue | `L` | Toggle the lyrics pane | | |
| 259 | + | | Playlists | `d`, `J` / `K` | Remove / move the selected song | | |
| 260 | + | | Playlists | `R` / `D` | Rename / delete the playlist | | |
| 261 | + | | Library | `t` | Switch between AlbumArtist and Artist | | |
| 262 | + | | Tags | `Enter` | Edit a tag, rename a file, or run the highlighted action | | |
| 263 | + | | Tags | `w` / `u` | Write / discard pending changes | | |
| 264 | + | | Lyrics | `F5` | Fetch lyrics again | | |
| 265 | + | ||
| 266 | + | In text fields, `Enter` submits, `Esc` cancels, `Ctrl+w` deletes a word and | |
| 267 | + | `Ctrl+a` / `Ctrl+e` jump to the start / end. | |
| 230 | 268 | ||
| 231 | 269 | ## License | |
| 232 | 270 | ||
[Binary file]
[Binary file]
[Binary file]
[Diff too large] View file
| @@ -0,0 +1,323 @@ | |||
| 1 | + | //! Every user-triggerable action and the key contexts they are bound in. | |
| 2 | + | ||
| 3 | + | use std::{fmt, str::FromStr}; | |
| 4 | + | ||
| 5 | + | use serde::{Deserialize, Deserializer, de}; | |
| 6 | + | ||
| 7 | + | macro_rules! named_enum { | |
| 8 | + | ( | |
| 9 | + | $(#[$meta:meta])* | |
| 10 | + | pub enum $name:ident { $($variant:ident = $str:literal),* $(,)? } | |
| 11 | + | ) => { | |
| 12 | + | $(#[$meta])* | |
| 13 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 14 | + | pub enum $name { $($variant),* } | |
| 15 | + | ||
| 16 | + | impl $name { | |
| 17 | + | pub const ALL: &[Self] = &[$(Self::$variant),*]; | |
| 18 | + | ||
| 19 | + | pub fn name(self) -> &'static str { | |
| 20 | + | match self { $(Self::$variant => $str),* } | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | impl fmt::Display for $name { | |
| 25 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 26 | + | f.write_str(self.name()) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl FromStr for $name { | |
| 31 | + | type Err = String; | |
| 32 | + | ||
| 33 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 34 | + | Self::ALL | |
| 35 | + | .iter() | |
| 36 | + | .copied() | |
| 37 | + | .find(|v| v.name() == s) | |
| 38 | + | .ok_or_else(|| { | |
| 39 | + | let names: Vec<_> = Self::ALL.iter().map(|v| v.name()).collect(); | |
| 40 | + | format!("unknown value `{s}`, expected one of: {}", names.join(", ")) | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | impl<'de> Deserialize<'de> for $name { | |
| 46 | + | fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { | |
| 47 | + | let s = String::deserialize(deserializer)?; | |
| 48 | + | s.parse().map_err(de::Error::custom) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | }; | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | named_enum! { | |
| 55 | + | /// A set of bindings that is active in a certain part of the UI. | |
| 56 | + | /// | |
| 57 | + | /// Lookups go from the most specific context (popup or screen) through | |
| 58 | + | /// `navigation` to `global`, so specific bindings shadow general ones. | |
| 59 | + | pub enum Context { | |
| 60 | + | Global = "global", | |
| 61 | + | Navigation = "navigation", | |
| 62 | + | Queue = "queue", | |
| 63 | + | Browser = "browser", | |
| 64 | + | Playlists = "playlists", | |
| 65 | + | Library = "library", | |
| 66 | + | TagEditor = "tag_editor", | |
| 67 | + | Visualizer = "visualizer", | |
| 68 | + | Lyrics = "lyrics", | |
| 69 | + | Popup = "popup", | |
| 70 | + | Input = "input", | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | named_enum! { | |
| 75 | + | pub enum Action { | |
| 76 | + | // Global | |
| 77 | + | Quit = "quit", | |
| 78 | + | Help = "help", | |
| 79 | + | TogglePause = "toggle_pause", | |
| 80 | + | Pause = "pause", | |
| 81 | + | Stop = "stop", | |
| 82 | + | NextTrack = "next_track", | |
| 83 | + | PreviousTrack = "previous_track", | |
| 84 | + | SeekForward = "seek_forward", | |
| 85 | + | SeekBackward = "seek_backward", | |
| 86 | + | VolumeUp = "volume_up", | |
| 87 | + | VolumeDown = "volume_down", | |
| 88 | + | ToggleMute = "toggle_mute", | |
| 89 | + | ToggleRepeat = "toggle_repeat", | |
| 90 | + | ToggleRandom = "toggle_random", | |
| 91 | + | ToggleSingle = "toggle_single", | |
| 92 | + | ToggleConsume = "toggle_consume", | |
| 93 | + | UpdateDatabase = "update_database", | |
| 94 | + | NextScreen = "next_screen", | |
| 95 | + | PreviousScreen = "previous_screen", | |
| 96 | + | ScreenQueue = "screen_queue", | |
| 97 | + | ScreenBrowser = "screen_browser", | |
| 98 | + | ScreenPlaylists = "screen_playlists", | |
| 99 | + | ScreenLibrary = "screen_library", | |
| 100 | + | ScreenTagEditor = "screen_tag_editor", | |
| 101 | + | ScreenVisualizer = "screen_visualizer", | |
| 102 | + | ScreenLyrics = "screen_lyrics", | |
| 103 | + | // Navigation (lists) | |
| 104 | + | Up = "up", | |
| 105 | + | Down = "down", | |
| 106 | + | Top = "top", | |
| 107 | + | Bottom = "bottom", | |
| 108 | + | PageUp = "page_up", | |
| 109 | + | PageDown = "page_down", | |
| 110 | + | HalfPageUp = "half_page_up", | |
| 111 | + | HalfPageDown = "half_page_down", | |
| 112 | + | Left = "left", | |
| 113 | + | Right = "right", | |
| 114 | + | Activate = "activate", | |
| 115 | + | ToggleMark = "toggle_mark", | |
| 116 | + | ClearMarks = "clear_marks", | |
| 117 | + | Search = "search", | |
| 118 | + | SearchNext = "search_next", | |
| 119 | + | SearchPrevious = "search_previous", | |
| 120 | + | AddToQueue = "add_to_queue", | |
| 121 | + | AddToPlaylist = "add_to_playlist", | |
| 122 | + | // Screen specific | |
| 123 | + | Delete = "delete", | |
| 124 | + | MoveItemUp = "move_item_up", | |
| 125 | + | MoveItemDown = "move_item_down", | |
| 126 | + | ClearQueue = "clear_queue", | |
| 127 | + | ShuffleQueue = "shuffle_queue", | |
| 128 | + | SaveQueue = "save_queue", | |
| 129 | + | JumpToCurrent = "jump_to_current", | |
| 130 | + | ToggleLyricsPane = "toggle_lyrics_pane", | |
| 131 | + | DeletePlaylist = "delete_playlist", | |
| 132 | + | RenamePlaylist = "rename_playlist", | |
| 133 | + | ToggleArtistTag = "toggle_artist_tag", | |
| 134 | + | SaveTags = "save_tags", | |
| 135 | + | RevertTags = "revert_tags", | |
| 136 | + | CycleVisualizer = "cycle_visualizer", | |
| 137 | + | ReloadLyrics = "reload_lyrics", | |
| 138 | + | // Popups and text input | |
| 139 | + | Close = "close", | |
| 140 | + | Submit = "submit", | |
| 141 | + | Cancel = "cancel", | |
| 142 | + | Backspace = "backspace", | |
| 143 | + | DeleteChar = "delete_char", | |
| 144 | + | DeleteWord = "delete_word", | |
| 145 | + | CursorLeft = "cursor_left", | |
| 146 | + | CursorRight = "cursor_right", | |
| 147 | + | CursorHome = "cursor_home", | |
| 148 | + | CursorEnd = "cursor_end", | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | named_enum! { | |
| 153 | + | /// The screens, in tab order. | |
| 154 | + | pub enum ScreenId { | |
| 155 | + | Queue = "queue", | |
| 156 | + | Browser = "browser", | |
| 157 | + | Playlists = "playlists", | |
| 158 | + | Library = "library", | |
| 159 | + | TagEditor = "tag_editor", | |
| 160 | + | Visualizer = "visualizer", | |
| 161 | + | Lyrics = "lyrics", | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | impl ScreenId { | |
| 166 | + | pub fn title(self) -> &'static str { | |
| 167 | + | match self { | |
| 168 | + | Self::Queue => "Queue", | |
| 169 | + | Self::Browser => "Browse", | |
| 170 | + | Self::Playlists => "Playlists", | |
| 171 | + | Self::Library => "Library", | |
| 172 | + | Self::TagEditor => "Tags", | |
| 173 | + | Self::Visualizer => "Visualizer", | |
| 174 | + | Self::Lyrics => "Lyrics", | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// The key context that is active while the screen is focused. | |
| 179 | + | pub fn context(self) -> Context { | |
| 180 | + | match self { | |
| 181 | + | Self::Queue => Context::Queue, | |
| 182 | + | Self::Browser => Context::Browser, | |
| 183 | + | Self::Playlists => Context::Playlists, | |
| 184 | + | Self::Library => Context::Library, | |
| 185 | + | Self::TagEditor => Context::TagEditor, | |
| 186 | + | Self::Visualizer => Context::Visualizer, | |
| 187 | + | Self::Lyrics => Context::Lyrics, | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | fn index(self) -> usize { | |
| 192 | + | Self::ALL.iter().position(|&s| s == self).unwrap_or(0) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | pub fn next(self) -> Self { | |
| 196 | + | Self::ALL[(self.index() + 1) % Self::ALL.len()] | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | pub fn previous(self) -> Self { | |
| 200 | + | Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | impl Action { | |
| 205 | + | /// The contexts this action may be bound in. | |
| 206 | + | pub fn contexts(self) -> &'static [Context] { | |
| 207 | + | use Action::*; | |
| 208 | + | use Context as C; | |
| 209 | + | match self { | |
| 210 | + | Quit | Help | TogglePause | Pause | Stop | NextTrack | PreviousTrack | SeekForward | |
| 211 | + | | SeekBackward | VolumeUp | VolumeDown | ToggleMute | ToggleRepeat | ToggleRandom | |
| 212 | + | | ToggleSingle | ToggleConsume | UpdateDatabase | NextScreen | PreviousScreen | |
| 213 | + | | ScreenQueue | ScreenBrowser | ScreenPlaylists | ScreenLibrary | ScreenTagEditor | |
| 214 | + | | ScreenVisualizer | ScreenLyrics | ClearQueue => &[C::Global], | |
| 215 | + | Up | Down | Top | Bottom | PageUp | PageDown | HalfPageUp | HalfPageDown | Left | |
| 216 | + | | Right | Activate | ToggleMark | ClearMarks | Search | SearchNext | SearchPrevious | |
| 217 | + | | AddToQueue | AddToPlaylist => &[C::Navigation], | |
| 218 | + | Delete | MoveItemUp | MoveItemDown => &[C::Queue, C::Playlists], | |
| 219 | + | ShuffleQueue | SaveQueue | JumpToCurrent | ToggleLyricsPane => &[C::Queue], | |
| 220 | + | DeletePlaylist | RenamePlaylist => &[C::Playlists], | |
| 221 | + | ToggleArtistTag => &[C::Library], | |
| 222 | + | SaveTags | RevertTags => &[C::TagEditor], | |
| 223 | + | CycleVisualizer => &[C::Visualizer], | |
| 224 | + | ReloadLyrics => &[C::Lyrics], | |
| 225 | + | Close => &[C::Popup], | |
| 226 | + | Submit | Cancel | Backspace | DeleteChar | DeleteWord | CursorLeft | CursorRight | |
| 227 | + | | CursorHome | CursorEnd => &[C::Input], | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// A short human-readable description for the help popup. | |
| 232 | + | pub fn description(self) -> &'static str { | |
| 233 | + | use Action::*; | |
| 234 | + | match self { | |
| 235 | + | Quit => "Quit rmptui", | |
| 236 | + | Help => "Show key bindings", | |
| 237 | + | TogglePause => "Toggle play/pause", | |
| 238 | + | Pause => "Pause", | |
| 239 | + | Stop => "Stop playback", | |
| 240 | + | NextTrack => "Play next track", | |
| 241 | + | PreviousTrack => "Play previous track", | |
| 242 | + | SeekForward => "Seek forward", | |
| 243 | + | SeekBackward => "Seek backward", | |
| 244 | + | VolumeUp => "Increase volume", | |
| 245 | + | VolumeDown => "Decrease volume", | |
| 246 | + | ToggleMute => "Toggle mute", | |
| 247 | + | ToggleRepeat => "Toggle repeat", | |
| 248 | + | ToggleRandom => "Toggle random", | |
| 249 | + | ToggleSingle => "Toggle single", | |
| 250 | + | ToggleConsume => "Toggle consume", | |
| 251 | + | UpdateDatabase => "Rescan the music database", | |
| 252 | + | NextScreen => "Next screen", | |
| 253 | + | PreviousScreen => "Previous screen", | |
| 254 | + | ScreenQueue => "Show the queue", | |
| 255 | + | ScreenBrowser => "Show the song browser", | |
| 256 | + | ScreenPlaylists => "Show the playlist editor", | |
| 257 | + | ScreenLibrary => "Show the media library", | |
| 258 | + | ScreenTagEditor => "Show the tag editor", | |
| 259 | + | ScreenVisualizer => "Show the visualizer", | |
| 260 | + | ScreenLyrics => "Show the lyrics", | |
| 261 | + | Up => "Move up", | |
| 262 | + | Down => "Move down", | |
| 263 | + | Top => "Go to top", | |
| 264 | + | Bottom => "Go to bottom", | |
| 265 | + | PageUp => "Page up", | |
| 266 | + | PageDown => "Page down", | |
| 267 | + | HalfPageUp => "Half page up", | |
| 268 | + | HalfPageDown => "Half page down", | |
| 269 | + | Left => "Go back / previous column", | |
| 270 | + | Right => "Open / next column", | |
| 271 | + | Activate => "Play or open the selected item", | |
| 272 | + | ToggleMark => "Mark or unmark the item", | |
| 273 | + | ClearMarks => "Clear all marks", | |
| 274 | + | Search => "Search the current list", | |
| 275 | + | SearchNext => "Next search match", | |
| 276 | + | SearchPrevious => "Previous search match", | |
| 277 | + | AddToQueue => "Add to (or toggle in) the queue", | |
| 278 | + | AddToPlaylist => "Add to a playlist", | |
| 279 | + | Delete => "Remove the item", | |
| 280 | + | MoveItemUp => "Move the item up", | |
| 281 | + | MoveItemDown => "Move the item down", | |
| 282 | + | ClearQueue => "Clear the queue", | |
| 283 | + | ShuffleQueue => "Shuffle the queue", | |
| 284 | + | SaveQueue => "Save the queue as a playlist", | |
| 285 | + | JumpToCurrent => "Jump to the playing song", | |
| 286 | + | ToggleLyricsPane => "Toggle the lyrics pane", | |
| 287 | + | DeletePlaylist => "Delete the playlist", | |
| 288 | + | RenamePlaylist => "Rename the playlist", | |
| 289 | + | ToggleArtistTag => "Switch between AlbumArtist and Artist", | |
| 290 | + | SaveTags => "Write pending tag changes", | |
| 291 | + | RevertTags => "Discard pending tag changes", | |
| 292 | + | CycleVisualizer => "Switch to the next visualizer style", | |
| 293 | + | ReloadLyrics => "Fetch lyrics again", | |
| 294 | + | Close => "Close the popup", | |
| 295 | + | Submit => "Submit", | |
| 296 | + | Cancel => "Cancel", | |
| 297 | + | Backspace => "Delete previous character", | |
| 298 | + | DeleteChar => "Delete next character", | |
| 299 | + | DeleteWord => "Delete previous word", | |
| 300 | + | CursorLeft => "Cursor left", | |
| 301 | + | CursorRight => "Cursor right", | |
| 302 | + | CursorHome => "Cursor to start", | |
| 303 | + | CursorEnd => "Cursor to end", | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | #[cfg(test)] | |
| 309 | + | mod tests { | |
| 310 | + | use super::*; | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn names_round_trip() { | |
| 314 | + | for action in Action::ALL { | |
| 315 | + | assert_eq!(action.name().parse::<Action>().unwrap(), *action); | |
| 316 | + | assert!(!action.contexts().is_empty()); | |
| 317 | + | } | |
| 318 | + | for context in Context::ALL { | |
| 319 | + | assert_eq!(context.name().parse::<Context>().unwrap(), *context); | |
| 320 | + | } | |
| 321 | + | assert!("nope".parse::<Action>().is_err()); | |
| 322 | + | } | |
| 323 | + | } | |
Mode 100755 → 100644
| @@ -1,501 +1,530 @@ | |||
| 1 | − | use std::time::Duration; | |
| 1 | + | //! The application: owns the state, routes events, and draws the frame. | |
| 2 | 2 | ||
| 3 | − | use crate::{ | |
| 4 | − | browser::{BrowserEntry, FileBrowser}, | |
| 5 | − | config::Config, | |
| 6 | − | connection::{Connection, MpdSnapshot, Song}, | |
| 7 | − | list::ContentList, | |
| 8 | − | ui::InputMode, | |
| 9 | − | utils::is_audio_file, | |
| 10 | − | AppResult, | |
| 3 | + | use std::time::{Duration, Instant}; | |
| 4 | + | ||
| 5 | + | use crossterm::event::{Event, EventStream, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; | |
| 6 | + | use futures_util::StreamExt; | |
| 7 | + | use ratatui::{DefaultTerminal, Frame, layout::Position}; | |
| 8 | + | use tokio::{ | |
| 9 | + | sync::mpsc, | |
| 10 | + | time::{MissedTickBehavior, interval}, | |
| 11 | 11 | }; | |
| 12 | − | use ratatui::widgets::{ListState, TableState}; | |
| 12 | + | use tracing::info; | |
| 13 | 13 | ||
| 14 | − | #[derive(Debug)] | |
| 15 | − | pub struct App { | |
| 16 | − | pub running: bool, | |
| 17 | − | pub conn: Connection, | |
| 18 | − | pub config: Config, | |
| 19 | − | pub browser: FileBrowser, | |
| 20 | − | pub queue_list: ContentList<Song>, | |
| 21 | − | pub pl_list: ContentList<String>, | |
| 22 | − | pub playlist_focus: PlaylistFocus, | |
| 23 | − | pub playlist_song_index: usize, | |
| 24 | − | pub selected_tab: SelectedTab, | |
| 25 | − | pub inputmode: InputMode, | |
| 26 | − | pub search_input: String, | |
| 27 | − | pub search_cursor_pos: usize, | |
| 28 | − | pub browser_search_results: Vec<usize>, | |
| 29 | − | pub browser_search_index: usize, | |
| 30 | − | pub pl_newname_input: String, | |
| 31 | − | pub pl_cursor_pos: usize, | |
| 32 | − | pub pl_new_pl_input: String, | |
| 33 | − | pub pl_new_pl_cursor_pos: usize, | |
| 34 | − | pub pl_new_pl_songs_buffer: Vec<Song>, | |
| 35 | − | pub playlist_popup: bool, | |
| 36 | − | pub append_list: ContentList<String>, | |
| 37 | − | pub should_update_song_list: bool, | |
| 38 | − | pub queue_state: TableState, | |
| 39 | − | pub browser_state: TableState, | |
| 40 | − | pub playlists_state: ListState, | |
| 41 | − | pub playlist_songs_state: TableState, | |
| 42 | − | pub playlist_contents: Vec<Song>, | |
| 43 | − | } | |
| 14 | + | use crate::{ | |
| 15 | + | action::{Action, Context, ScreenId}, | |
| 16 | + | config::{self, Config, KeyCombo}, | |
| 17 | + | event::AppEvent, | |
| 18 | + | lyrics::LyricsService, | |
| 19 | + | mpd::{self, MpdEvent, MpdHandle, PlayState, Query, Reply, Subsystem, commands}, | |
| 20 | + | popups::{ConfirmPopup, HelpPopup, Popup, PopupState}, | |
| 21 | + | screens::{Ctx, Effect, Screens}, | |
| 22 | + | state::MpdState, | |
| 23 | + | tags, | |
| 24 | + | ui::{self, FrameLayout, input::InputEvent, input::LineInput}, | |
| 25 | + | visualizer::Visualizer, | |
| 26 | + | }; | |
| 44 | 27 | ||
| 45 | − | #[derive(Debug, PartialEq, Clone, Copy)] | |
| 46 | − | pub enum SelectedTab { | |
| 47 | − | DirectoryBrowser, | |
| 48 | − | Queue, | |
| 49 | − | Playlists, | |
| 28 | + | struct Message { | |
| 29 | + | text: String, | |
| 30 | + | error: bool, | |
| 31 | + | until: Instant, | |
| 50 | 32 | } | |
| 51 | 33 | ||
| 52 | − | #[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] | |
| 53 | − | pub enum PlaylistFocus { | |
| 54 | − | #[default] | |
| 55 | − | List, | |
| 56 | − | Songs, | |
| 34 | + | pub struct App { | |
| 35 | + | config: Config, | |
| 36 | + | mpd: MpdState, | |
| 37 | + | client: MpdHandle, | |
| 38 | + | events_tx: mpsc::UnboundedSender<AppEvent>, | |
| 39 | + | events_rx: Option<mpsc::UnboundedReceiver<AppEvent>>, | |
| 40 | + | screens: Screens, | |
| 41 | + | active: ScreenId, | |
| 42 | + | popups: Vec<Box<dyn Popup>>, | |
| 43 | + | /// The search prompt, while it is open. | |
| 44 | + | search: Option<LineInput>, | |
| 45 | + | message: Option<Message>, | |
| 46 | + | lyrics: LyricsService, | |
| 47 | + | visualizer: Visualizer, | |
| 48 | + | layout: Option<FrameLayout>, | |
| 49 | + | /// The volume before muting. | |
| 50 | + | muted_volume: Option<u8>, | |
| 51 | + | queue_requested: bool, | |
| 52 | + | running: bool, | |
| 57 | 53 | } | |
| 58 | 54 | ||
| 59 | 55 | impl App { | |
| 60 | − | pub async fn new(address: &str, password: Option<&str>, config: Config) -> AppResult<Self> { | |
| 61 | − | let mut conn = Connection::connect(address, password).await?; | |
| 62 | − | let mut queue_list = ContentList::new(); | |
| 63 | − | queue_list.list = conn.queue().await?; | |
| 64 | − | let mut pl_list = ContentList::new(); | |
| 65 | − | pl_list.list = conn.playlists().await?; | |
| 66 | − | pl_list.list.sort(); | |
| 67 | − | let append_list = Self::get_append_list(&mut conn).await?; | |
| 68 | − | let mut browser = FileBrowser::new(); | |
| 69 | − | browser.update_directory(&mut conn).await?; | |
| 70 | − | ||
| 71 | − | if let Some(position) = conn.current_song.place { | |
| 72 | − | queue_list.index = position.pos as usize; | |
| 73 | − | } | |
| 74 | − | ||
| 56 | + | pub fn new(config: Config) -> anyhow::Result<Self> { | |
| 57 | + | let (address, password) = config.mpd_connection()?; | |
| 58 | + | info!("connecting to MPD at {address}"); | |
| 59 | + | // MPD only reports its music directory over a Unix socket; for a | |
| 60 | + | // local server, its config file is the next best source. | |
| 61 | + | let music_dir = config | |
| 62 | + | .music_directory() | |
| 63 | + | .or_else(|| config::local_music_directory(&address)); | |
| 64 | + | let (events_tx, events_rx) = mpsc::unbounded_channel(); | |
| 65 | + | let client = mpd::spawn(address, password, events_tx.clone()); | |
| 66 | + | let visualizer = Visualizer::spawn(config.visualizer.clone()); | |
| 67 | + | let screens = Screens::new(&config, visualizer.subscribe()); | |
| 68 | + | let mut mpd = MpdState::default(); | |
| 69 | + | mpd.music_dir = music_dir; | |
| 75 | 70 | let mut app = Self { | |
| 76 | − | running: true, | |
| 77 | − | conn, | |
| 71 | + | lyrics: LyricsService::new(config.lyrics.clone(), events_tx.clone()), | |
| 72 | + | active: config.ui.startup_screen, | |
| 78 | 73 | config, | |
| 79 | − | browser, | |
| 80 | − | queue_list, | |
| 81 | − | pl_list, | |
| 82 | − | playlist_focus: PlaylistFocus::List, | |
| 83 | − | playlist_song_index: 0, | |
| 84 | − | selected_tab: SelectedTab::Queue, | |
| 85 | − | inputmode: InputMode::Normal, | |
| 86 | − | search_input: String::new(), | |
| 87 | − | search_cursor_pos: 0, | |
| 88 | − | browser_search_results: Vec::new(), | |
| 89 | − | browser_search_index: 0, | |
| 90 | − | pl_newname_input: String::new(), | |
| 91 | − | pl_cursor_pos: 0, | |
| 92 | − | pl_new_pl_input: String::new(), | |
| 93 | − | pl_new_pl_cursor_pos: 0, | |
| 94 | − | pl_new_pl_songs_buffer: Vec::new(), | |
| 95 | − | playlist_popup: false, | |
| 96 | − | append_list, | |
| 97 | − | should_update_song_list: false, | |
| 98 | − | queue_state: TableState::default(), | |
| 99 | − | browser_state: TableState::default(), | |
| 100 | − | playlists_state: ListState::default(), | |
| 101 | − | playlist_songs_state: TableState::default(), | |
| 102 | − | playlist_contents: Vec::new(), | |
| 74 | + | mpd, | |
| 75 | + | client, | |
| 76 | + | events_tx, | |
| 77 | + | events_rx: Some(events_rx), | |
| 78 | + | screens, | |
| 79 | + | popups: Vec::new(), | |
| 80 | + | search: None, | |
| 81 | + | message: None, | |
| 82 | + | visualizer, | |
| 83 | + | layout: None, | |
| 84 | + | muted_volume: None, | |
| 85 | + | queue_requested: false, | |
| 86 | + | running: true, | |
| 103 | 87 | }; | |
| 104 | − | app.refresh_playlist_view().await?; | |
| 88 | + | app.switch_to(app.active); | |
| 105 | 89 | Ok(app) | |
| 106 | 90 | } | |
| 107 | 91 | ||
| 108 | − | pub async fn apply_snapshot(&mut self, snapshot: MpdSnapshot) -> AppResult<()> { | |
| 109 | − | let MpdSnapshot { | |
| 110 | − | status, | |
| 111 | − | stats, | |
| 112 | − | current_song, | |
| 113 | − | queue, | |
| 114 | − | } = snapshot; | |
| 115 | − | self.conn.apply_snapshot(status, stats, current_song); | |
| 116 | − | self.queue_list.list = queue; | |
| 117 | − | if self.queue_list.list.is_empty() { | |
| 118 | − | self.queue_list.index = 0; | |
| 119 | − | } else { | |
| 120 | − | self.queue_list.index = self.queue_list.index.min(self.queue_list.list.len() - 1); | |
| 121 | − | } | |
| 122 | − | ||
| 123 | − | if self.should_update_song_list && self.conn.status.updating_db.is_none() { | |
| 124 | − | self.conn.refresh_song_list().await?; | |
| 125 | − | self.browser.update_directory(&mut self.conn).await?; | |
| 126 | − | self.should_update_song_list = false; | |
| 92 | + | pub async fn run(mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> { | |
| 93 | + | let mut events = self.events_rx.take().expect("run is called once"); | |
| 94 | + | let mut input = EventStream::new(); | |
| 95 | + | let mut spectrum = self.visualizer.subscribe(); | |
| 96 | + | // Redraws the progress bar while playing and expires messages. | |
| 97 | + | let mut tick = interval(Duration::from_millis(250)); | |
| 98 | + | tick.set_missed_tick_behavior(MissedTickBehavior::Skip); | |
| 99 | + | ||
| 100 | + | while self.running { | |
| 101 | + | terminal.draw(|frame| self.draw(frame))?; | |
| 102 | + | tokio::select! { | |
| 103 | + | event = input.next() => match event { | |
| 104 | + | Some(Ok(event)) => self.on_terminal_event(event), | |
| 105 | + | Some(Err(error)) => return Err(error.into()), | |
| 106 | + | None => break, | |
| 107 | + | }, | |
| 108 | + | Some(event) = events.recv() => { | |
| 109 | + | self.on_app_event(event); | |
| 110 | + | // Apply everything that is already queued before redrawing. | |
| 111 | + | while let Ok(event) = events.try_recv() { | |
| 112 | + | self.on_app_event(event); | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | _ = tick.tick() => {} | |
| 116 | + | Ok(()) = spectrum.changed(), if self.visualizer.is_active() => {} | |
| 117 | + | } | |
| 127 | 118 | } | |
| 128 | 119 | Ok(()) | |
| 129 | 120 | } | |
| 130 | 121 | ||
| 131 | − | pub fn quit(&mut self) { | |
| 132 | − | self.running = false; | |
| 122 | + | /// Runs `f` with a context, then applies the effects it queued. | |
| 123 | + | fn with_ctx<R>(&mut self, f: impl FnOnce(&mut Screens, &mut Ctx) -> R) -> R { | |
| 124 | + | let mut ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 125 | + | let result = f(&mut self.screens, &mut ctx); | |
| 126 | + | let effects = ctx.effects; | |
| 127 | + | self.apply_effects(effects); | |
| 128 | + | result | |
| 133 | 129 | } | |
| 134 | 130 | ||
| 135 | − | pub async fn update_queue(&mut self) -> AppResult<()> { | |
| 136 | − | self.queue_list.list = self.conn.queue().await?; | |
| 137 | − | if self.queue_list.list.is_empty() { | |
| 138 | − | self.queue_list.index = 0; | |
| 139 | − | } else { | |
| 140 | − | self.queue_list.index = self.queue_list.index.min(self.queue_list.list.len() - 1); | |
| 131 | + | fn apply_effects(&mut self, effects: Vec<Effect>) { | |
| 132 | + | for effect in effects { | |
| 133 | + | match effect { | |
| 134 | + | Effect::Notify { text, error } => self.notify(text, error), | |
| 135 | + | Effect::Popup(popup) => self.popups.push(popup), | |
| 136 | + | Effect::Message(id, message) => { | |
| 137 | + | self.with_ctx(|screens, ctx| screens.get_mut(id).on_message(message, ctx)); | |
| 138 | + | } | |
| 139 | + | Effect::WriteTags(writes) => self.write_tags(writes), | |
| 140 | + | Effect::ReloadLyrics => { | |
| 141 | + | self.lyrics.ensure( | |
| 142 | + | self.mpd.current.as_ref(), | |
| 143 | + | self.mpd.music_dir.as_deref(), | |
| 144 | + | true, | |
| 145 | + | ); | |
| 146 | + | } | |
| 147 | + | } | |
| 141 | 148 | } | |
| 142 | − | Ok(()) | |
| 143 | 149 | } | |
| 144 | 150 | ||
| 145 | − | pub async fn refresh_playlists(&mut self) -> AppResult<()> { | |
| 146 | − | self.pl_list.list = self.conn.playlists().await?; | |
| 147 | − | self.pl_list.list.sort(); | |
| 148 | − | self.pl_list.index = self | |
| 149 | − | .pl_list | |
| 150 | − | .index | |
| 151 | − | .min(self.pl_list.list.len().saturating_sub(1)); | |
| 152 | − | self.append_list = Self::get_append_list(&mut self.conn).await?; | |
| 153 | − | self.refresh_playlist_view().await | |
| 151 | + | fn notify(&mut self, text: String, error: bool) { | |
| 152 | + | self.message = Some(Message { | |
| 153 | + | text, | |
| 154 | + | error, | |
| 155 | + | until: Instant::now() + self.config.message_timeout(), | |
| 156 | + | }); | |
| 154 | 157 | } | |
| 155 | 158 | ||
| 156 | − | pub async fn refresh_playlist_view(&mut self) -> AppResult<()> { | |
| 157 | − | self.playlist_contents = match self.pl_list.list.get(self.pl_list.index) { | |
| 158 | − | Some(name) => self.conn.playlist(name).await?, | |
| 159 | − | None => Vec::new(), | |
| 160 | − | }; | |
| 161 | − | self.playlist_song_index = self | |
| 162 | − | .playlist_song_index | |
| 163 | − | .min(self.playlist_contents.len().saturating_sub(1)); | |
| 164 | − | Ok(()) | |
| 159 | + | fn switch_to(&mut self, screen: ScreenId) { | |
| 160 | + | self.active = screen; | |
| 161 | + | self.search = None; | |
| 162 | + | self.visualizer.set_active(screen == ScreenId::Visualizer); | |
| 163 | + | self.with_ctx(|screens, ctx| screens.get_mut(screen).on_enter(ctx)); | |
| 164 | + | self.refresh_lyrics(); | |
| 165 | 165 | } | |
| 166 | 166 | ||
| 167 | − | pub async fn delete_selected_playlist_song(&mut self) -> AppResult<()> { | |
| 168 | − | let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else { | |
| 169 | − | return Ok(()); | |
| 170 | − | }; | |
| 171 | − | if self.playlist_song_index >= self.playlist_contents.len() { | |
| 172 | − | return Ok(()); | |
| 167 | + | fn lyrics_visible(&self) -> bool { | |
| 168 | + | match self.active { | |
| 169 | + | ScreenId::Lyrics => true, | |
| 170 | + | ScreenId::Queue => self.screens.queue.lyrics_pane_visible(), | |
| 171 | + | _ => false, | |
| 173 | 172 | } | |
| 174 | − | self.conn | |
| 175 | − | .delete_playlist_song(playlist, self.playlist_song_index) | |
| 176 | − | .await?; | |
| 177 | − | self.refresh_playlist_view().await | |
| 178 | 173 | } | |
| 179 | 174 | ||
| 180 | − | pub async fn move_selected_playlist_song(&mut self, down: bool) -> AppResult<()> { | |
| 181 | − | let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else { | |
| 182 | − | return Ok(()); | |
| 183 | − | }; | |
| 184 | − | let target = if down { | |
| 185 | − | self.playlist_song_index.checked_add(1) | |
| 186 | − | } else { | |
| 187 | − | self.playlist_song_index.checked_sub(1) | |
| 188 | − | }; | |
| 189 | − | let Some(target) = target.filter(|target| *target < self.playlist_contents.len()) else { | |
| 190 | − | return Ok(()); | |
| 191 | − | }; | |
| 192 | − | self.conn | |
| 193 | − | .move_playlist_song(playlist, self.playlist_song_index, target) | |
| 194 | − | .await?; | |
| 195 | − | self.playlist_song_index = target; | |
| 196 | − | self.refresh_playlist_view().await | |
| 175 | + | /// Loads lyrics for the current song if they are on screen. | |
| 176 | + | fn refresh_lyrics(&mut self) { | |
| 177 | + | if self.lyrics_visible() { | |
| 178 | + | self.lyrics.ensure( | |
| 179 | + | self.mpd.current.as_ref(), | |
| 180 | + | self.mpd.music_dir.as_deref(), | |
| 181 | + | false, | |
| 182 | + | ); | |
| 183 | + | } | |
| 197 | 184 | } | |
| 198 | 185 | ||
| 199 | − | pub async fn delete_selected_playlist(&mut self) -> AppResult<()> { | |
| 200 | − | let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else { | |
| 201 | − | return Ok(()); | |
| 202 | − | }; | |
| 203 | − | self.conn.delete_playlist(playlist).await?; | |
| 204 | − | self.refresh_playlists().await?; | |
| 205 | − | Ok(()) | |
| 206 | − | } | |
| 186 | + | // ----- terminal input ------------------------------------------------- | |
| 207 | 187 | ||
| 208 | − | pub async fn play_selected_playlist_song(&mut self) -> AppResult<()> { | |
| 209 | − | let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else { | |
| 210 | − | return Ok(()); | |
| 211 | − | }; | |
| 212 | − | if self.playlist_song_index >= self.playlist_contents.len() { | |
| 213 | − | return Ok(()); | |
| 188 | + | fn on_terminal_event(&mut self, event: Event) { | |
| 189 | + | match event { | |
| 190 | + | Event::Key(key) if key.kind != KeyEventKind::Release => self.on_key(key.into()), | |
| 191 | + | Event::Mouse(mouse) if self.config.ui.mouse => self.on_mouse(mouse), | |
| 192 | + | _ => {} | |
| 214 | 193 | } | |
| 215 | − | let queue_position = self.conn.queue().await?.len(); | |
| 216 | − | self.conn.append_playlist(playlist).await?; | |
| 217 | − | self.conn | |
| 218 | − | .play(Some((queue_position + self.playlist_song_index) as u32)) | |
| 219 | − | .await?; | |
| 220 | − | self.update_queue().await?; | |
| 221 | − | self.conn.update_status().await | |
| 222 | − | } | |
| 223 | − | ||
| 224 | − | pub async fn get_append_list(conn: &mut Connection) -> AppResult<ContentList<String>> { | |
| 225 | − | let mut list = ContentList::new(); | |
| 226 | − | list.list.push("Current Playlist".to_string()); | |
| 227 | − | list.list.push("New Playlist".to_string()); | |
| 228 | − | list.list.extend(conn.playlists().await?); | |
| 229 | − | Ok(list) | |
| 230 | 194 | } | |
| 231 | 195 | ||
| 232 | − | pub async fn toggle_selected_item(&mut self) -> AppResult<()> { | |
| 233 | − | match self.selected_tab { | |
| 234 | − | SelectedTab::DirectoryBrowser => { | |
| 235 | − | let Some(entry) = self.browser.entries.get(self.browser.selected).cloned() else { | |
| 236 | − | return Ok(()); | |
| 237 | − | }; | |
| 238 | − | match entry { | |
| 239 | − | BrowserEntry::Directory(name) => { | |
| 240 | − | self.conn.add(&name).await?; | |
| 241 | − | } | |
| 242 | − | BrowserEntry::Song(song) => { | |
| 243 | − | let positions: Vec<u32> = self | |
| 244 | − | .queue_list | |
| 245 | − | .list | |
| 246 | − | .iter() | |
| 247 | − | .enumerate() | |
| 248 | − | .filter_map(|(index, queued)| { | |
| 249 | − | (queued.file == song.file).then_some(index as u32) | |
| 250 | − | }) | |
| 251 | − | .collect(); | |
| 252 | − | if positions.is_empty() { | |
| 253 | − | self.conn.add(&song.file).await?; | |
| 254 | − | } else { | |
| 255 | − | for position in positions.into_iter().rev() { | |
| 256 | − | self.conn.delete(position).await?; | |
| 257 | − | } | |
| 258 | − | } | |
| 259 | − | } | |
| 260 | − | } | |
| 261 | − | if self.browser.selected < self.browser.entries.len().saturating_sub(1) { | |
| 262 | − | self.browser.selected += 1; | |
| 263 | − | } | |
| 196 | + | fn on_key(&mut self, key: KeyCombo) { | |
| 197 | + | if let Some(mut popup) = self.popups.pop() { | |
| 198 | + | let mut ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 199 | + | let state = popup.handle_key(key, &mut ctx); | |
| 200 | + | let effects = ctx.effects; | |
| 201 | + | if state == PopupState::Open { | |
| 202 | + | self.popups.push(popup); | |
| 264 | 203 | } | |
| 265 | − | SelectedTab::Queue => { | |
| 266 | − | if !self.queue_list.list.is_empty() { | |
| 267 | − | self.conn.delete(self.queue_list.index as u32).await?; | |
| 268 | − | } | |
| 204 | + | self.apply_effects(effects); | |
| 205 | + | return; | |
| 206 | + | } | |
| 207 | + | if self.search.is_some() { | |
| 208 | + | self.on_search_key(key); | |
| 209 | + | return; | |
| 210 | + | } | |
| 211 | + | let contexts = [self.active.context(), Context::Navigation, Context::Global]; | |
| 212 | + | let Some(action) = self.config.keymap.resolve(&contexts, key) else { | |
| 213 | + | return; | |
| 214 | + | }; | |
| 215 | + | if action.contexts().contains(&Context::Global) { | |
| 216 | + | self.global_action(action); | |
| 217 | + | } else if action == Action::Search { | |
| 218 | + | self.open_search(); | |
| 219 | + | } else { | |
| 220 | + | let active = self.active; | |
| 221 | + | let lyrics_before = self.lyrics_visible(); | |
| 222 | + | self.with_ctx(|screens, ctx| screens.get_mut(active).handle_action(action, ctx)); | |
| 223 | + | if !lyrics_before { | |
| 224 | + | self.refresh_lyrics(); | |
| 269 | 225 | } | |
| 270 | − | SelectedTab::Playlists => {} | |
| 271 | 226 | } | |
| 272 | − | self.update_queue().await?; | |
| 273 | − | self.conn.update_status().await?; | |
| 274 | − | Ok(()) | |
| 275 | 227 | } | |
| 276 | 228 | ||
| 277 | − | pub fn cycle_tabs(&mut self) { | |
| 278 | − | self.selected_tab = match self.selected_tab { | |
| 279 | − | SelectedTab::Queue => SelectedTab::DirectoryBrowser, | |
| 280 | − | SelectedTab::DirectoryBrowser => SelectedTab::Playlists, | |
| 281 | − | SelectedTab::Playlists => SelectedTab::Queue, | |
| 282 | − | }; | |
| 229 | + | fn open_search(&mut self) { | |
| 230 | + | let active = self.active; | |
| 231 | + | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 232 | + | if self.screens.get_mut(active).search("", &ctx) { | |
| 233 | + | self.search = Some(LineInput::default()); | |
| 234 | + | } else { | |
| 235 | + | self.notify("Nothing to search on this screen".into(), false); | |
| 236 | + | } | |
| 283 | 237 | } | |
| 284 | 238 | ||
| 285 | − | pub async fn handle_enter(&mut self) -> AppResult<()> { | |
| 286 | − | let Some(entry) = self.browser.entries.get(self.browser.selected).cloned() else { | |
| 287 | − | return Ok(()); | |
| 239 | + | fn on_search_key(&mut self, key: KeyCombo) { | |
| 240 | + | let Some(input) = self.search.as_mut() else { | |
| 241 | + | return; | |
| 288 | 242 | }; | |
| 289 | − | match entry { | |
| 290 | − | BrowserEntry::Directory(name) => { | |
| 291 | − | self.browser.prev_selected = self.browser.selected; | |
| 292 | − | self.browser.path = name; | |
| 293 | − | self.browser.selected = 0; | |
| 294 | − | self.browser.update_directory(&mut self.conn).await?; | |
| 295 | − | } | |
| 296 | − | BrowserEntry::Song(song) => { | |
| 297 | − | if let Some(index) = self | |
| 298 | − | .queue_list | |
| 299 | − | .list | |
| 300 | − | .iter() | |
| 301 | − | .position(|queued| queued.file == song.file) | |
| 302 | − | { | |
| 303 | − | self.conn.play(Some(index as u32)).await?; | |
| 304 | − | } else { | |
| 305 | − | self.conn.push(&song).await?; | |
| 306 | − | self.update_queue().await?; | |
| 243 | + | let event = match self.config.keymap.resolve(&[Context::Input], key) { | |
| 244 | + | Some(action) => input.apply(action), | |
| 245 | + | None => match key.as_text() { | |
| 246 | + | Some(ch) => { | |
| 247 | + | input.insert(ch); | |
| 248 | + | InputEvent::Changed | |
| 307 | 249 | } | |
| 308 | − | self.conn.update_status().await?; | |
| 250 | + | None => InputEvent::Ignored, | |
| 251 | + | }, | |
| 252 | + | }; | |
| 253 | + | let query = input.text().to_owned(); | |
| 254 | + | let active = self.active; | |
| 255 | + | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 256 | + | match event { | |
| 257 | + | InputEvent::Changed => { | |
| 258 | + | self.screens.get_mut(active).search(&query, &ctx); | |
| 259 | + | } | |
| 260 | + | // Keep the results for `n`/`N`. | |
| 261 | + | InputEvent::Submit => self.search = None, | |
| 262 | + | InputEvent::Cancel => { | |
| 263 | + | self.screens.get_mut(active).search("", &ctx); | |
| 264 | + | self.search = None; | |
| 309 | 265 | } | |
| 266 | + | InputEvent::Moved | InputEvent::Ignored => {} | |
| 310 | 267 | } | |
| 311 | − | Ok(()) | |
| 312 | 268 | } | |
| 313 | 269 | ||
| 314 | − | pub async fn append_selected_to(&mut self) -> AppResult<()> { | |
| 315 | − | let Some(playlist) = self.append_list.list.get(self.append_list.index).cloned() else { | |
| 316 | − | return Ok(()); | |
| 317 | − | }; | |
| 318 | − | let songs = match self.selected_tab { | |
| 319 | − | SelectedTab::Queue => self | |
| 320 | − | .queue_list | |
| 321 | − | .list | |
| 322 | − | .get(self.queue_list.index) | |
| 323 | − | .cloned() | |
| 324 | − | .into_iter() | |
| 325 | − | .collect(), | |
| 326 | − | SelectedTab::DirectoryBrowser => { | |
| 327 | − | let Some(entry) = self.browser.entries.get(self.browser.selected).cloned() else { | |
| 328 | − | return Ok(()); | |
| 329 | − | }; | |
| 330 | − | match entry { | |
| 331 | − | BrowserEntry::Song(song) => vec![song], | |
| 332 | − | BrowserEntry::Directory(name) => self | |
| 333 | − | .conn | |
| 334 | − | .list_files(&name) | |
| 335 | − | .await? | |
| 336 | − | .into_iter() | |
| 337 | − | .filter_map(|(kind, file)| { | |
| 338 | − | (kind == "file" && is_audio_file(&file)) | |
| 339 | − | .then(|| Connection::song_from_file(&file)) | |
| 340 | − | }) | |
| 341 | − | .collect(), | |
| 342 | − | } | |
| 270 | + | fn global_action(&mut self, action: Action) { | |
| 271 | + | let status = &self.mpd.status; | |
| 272 | + | let seek = i64::from(self.config.playback.seek_seconds); | |
| 273 | + | let step = i32::from(self.config.playback.volume_step); | |
| 274 | + | let client = &self.client; | |
| 275 | + | match action { | |
| 276 | + | Action::Quit => self.running = false, | |
| 277 | + | Action::Help => { | |
| 278 | + | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 279 | + | let popup = HelpPopup::new(self.active.context(), &ctx); | |
| 280 | + | self.popups.push(Box::new(popup)); | |
| 343 | 281 | } | |
| 344 | − | SelectedTab::Playlists => { | |
| 345 | − | let Some(name) = self.pl_list.list.get(self.pl_list.index) else { | |
| 346 | − | return Ok(()); | |
| 282 | + | Action::TogglePause => client.exec(match status.state { | |
| 283 | + | PlayState::Play => commands::pause(true), | |
| 284 | + | PlayState::Pause => commands::pause(false), | |
| 285 | + | PlayState::Stop => commands::play(), | |
| 286 | + | }), | |
| 287 | + | Action::Pause => client.exec(commands::pause(true)), | |
| 288 | + | Action::Stop => client.exec(commands::stop()), | |
| 289 | + | Action::NextTrack => client.exec(commands::next()), | |
| 290 | + | Action::PreviousTrack => client.exec(commands::previous()), | |
| 291 | + | Action::SeekForward | Action::SeekBackward if status.state != PlayState::Stop => { | |
| 292 | + | let delta = if action == Action::SeekForward { | |
| 293 | + | seek | |
| 294 | + | } else { | |
| 295 | + | -seek | |
| 347 | 296 | }; | |
| 348 | − | self.conn.playlist(name).await? | |
| 297 | + | client.exec(commands::seek_relative(delta)); | |
| 349 | 298 | } | |
| 350 | − | }; | |
| 351 | − | ||
| 352 | − | if playlist == "Current Playlist" { | |
| 353 | − | if self.selected_tab == SelectedTab::Playlists { | |
| 354 | − | if let Some(name) = self.pl_list.list.get(self.pl_list.index) { | |
| 355 | − | self.conn.load_playlist(name).await?; | |
| 356 | − | self.update_queue().await?; | |
| 299 | + | Action::SeekForward | Action::SeekBackward => {} | |
| 300 | + | Action::VolumeUp | Action::VolumeDown => match status.volume { | |
| 301 | + | Some(_) => { | |
| 302 | + | let delta = if action == Action::VolumeUp { | |
| 303 | + | step | |
| 304 | + | } else { | |
| 305 | + | -step | |
| 306 | + | }; | |
| 307 | + | client.exec(commands::change_volume(delta)); | |
| 357 | 308 | } | |
| 358 | − | } else { | |
| 359 | − | let selected_directory = if self.selected_tab == SelectedTab::DirectoryBrowser { | |
| 360 | − | self.browser | |
| 361 | − | .entries | |
| 362 | − | .get(self.browser.selected) | |
| 363 | − | .and_then(|entry| { | |
| 364 | − | if let BrowserEntry::Directory(path) = entry { | |
| 365 | − | Some(path.as_str()) | |
| 366 | − | } else { | |
| 367 | − | None | |
| 368 | − | } | |
| 369 | − | }) | |
| 370 | − | } else { | |
| 371 | − | None | |
| 372 | − | }; | |
| 373 | − | if let Some(path) = selected_directory { | |
| 374 | − | self.conn.add(path).await?; | |
| 375 | − | } else { | |
| 376 | − | for song in songs { | |
| 377 | − | self.conn.add(&song.file).await?; | |
| 378 | − | } | |
| 309 | + | None => self.notify("MPD has no mixer".into(), true), | |
| 310 | + | }, | |
| 311 | + | Action::ToggleMute => match status.volume { | |
| 312 | + | Some(0) => { | |
| 313 | + | let volume = self.muted_volume.take().unwrap_or(50); | |
| 314 | + | client.exec(commands::set_volume(volume)); | |
| 315 | + | } | |
| 316 | + | Some(volume) => { | |
| 317 | + | self.muted_volume = Some(volume); | |
| 318 | + | client.exec(commands::set_volume(0)); | |
| 379 | 319 | } | |
| 380 | − | self.update_queue().await?; | |
| 320 | + | None => self.notify("MPD has no mixer".into(), true), | |
| 321 | + | }, | |
| 322 | + | Action::ToggleRepeat => client.exec(commands::repeat(!status.repeat)), | |
| 323 | + | Action::ToggleRandom => client.exec(commands::random(!status.random)), | |
| 324 | + | Action::ToggleSingle => client.exec(commands::single(!status.single.is_on())), | |
| 325 | + | Action::ToggleConsume => client.exec(commands::consume(!status.consume.is_on())), | |
| 326 | + | Action::ClearQueue if self.mpd.queue.is_empty() => {} | |
| 327 | + | Action::ClearQueue if self.config.ui.confirm_clear_queue => { | |
| 328 | + | let popup = ConfirmPopup::new( | |
| 329 | + | format!("Remove all {} songs from the queue?", self.mpd.queue.len()), | |
| 330 | + | vec![commands::clear()], | |
| 331 | + | ); | |
| 332 | + | self.popups.push(Box::new(popup)); | |
| 381 | 333 | } | |
| 382 | − | } else if playlist == "New Playlist" { | |
| 383 | − | self.pl_new_pl_songs_buffer = songs; | |
| 384 | − | self.inputmode = InputMode::NewPlaylist; | |
| 385 | − | } else { | |
| 386 | − | for song in songs { | |
| 387 | − | self.conn.add_to_playlist(&playlist, &song).await?; | |
| 334 | + | Action::ClearQueue => { | |
| 335 | + | client.exec(commands::clear()); | |
| 336 | + | self.notify("Cleared the queue".into(), false); | |
| 388 | 337 | } | |
| 389 | − | self.refresh_playlists().await?; | |
| 338 | + | Action::UpdateDatabase => { | |
| 339 | + | client.exec(commands::update(None)); | |
| 340 | + | self.notify("Updating the database…".into(), false); | |
| 341 | + | } | |
| 342 | + | Action::NextScreen => self.switch_to(self.active.next()), | |
| 343 | + | Action::PreviousScreen => self.switch_to(self.active.previous()), | |
| 344 | + | Action::ScreenQueue => self.switch_to(ScreenId::Queue), | |
| 345 | + | Action::ScreenBrowser => self.switch_to(ScreenId::Browser), | |
| 346 | + | Action::ScreenPlaylists => self.switch_to(ScreenId::Playlists), | |
| 347 | + | Action::ScreenLibrary => self.switch_to(ScreenId::Library), | |
| 348 | + | Action::ScreenTagEditor => self.switch_to(ScreenId::TagEditor), | |
| 349 | + | Action::ScreenVisualizer => self.switch_to(ScreenId::Visualizer), | |
| 350 | + | Action::ScreenLyrics => self.switch_to(ScreenId::Lyrics), | |
| 351 | + | _ => {} | |
| 390 | 352 | } | |
| 391 | − | self.playlist_popup = false; | |
| 392 | − | self.append_list.index = 0; | |
| 393 | − | Ok(()) | |
| 394 | − | } | |
| 395 | − | ||
| 396 | − | pub fn move_cursor_left(&mut self) { | |
| 397 | − | let cursor = self.cursor_position().saturating_sub(1); | |
| 398 | − | self.set_cursor_position(cursor); | |
| 399 | 353 | } | |
| 400 | 354 | ||
| 401 | − | pub fn move_cursor_right(&mut self) { | |
| 402 | − | let cursor = self.cursor_position().saturating_add(1); | |
| 403 | − | self.set_cursor_position(cursor); | |
| 404 | − | } | |
| 405 | − | ||
| 406 | − | fn cursor_position(&self) -> usize { | |
| 407 | − | match self.inputmode { | |
| 408 | − | InputMode::PlaylistRename => self.pl_cursor_pos, | |
| 409 | − | InputMode::Editing => self.search_cursor_pos, | |
| 410 | − | InputMode::NewPlaylist => self.pl_new_pl_cursor_pos, | |
| 411 | − | InputMode::Normal => 0, | |
| 355 | + | fn on_mouse(&mut self, mouse: MouseEvent) { | |
| 356 | + | if !self.popups.is_empty() { | |
| 357 | + | return; | |
| 412 | 358 | } | |
| 413 | − | } | |
| 414 | − | ||
| 415 | − | fn set_cursor_position(&mut self, position: usize) { | |
| 416 | − | let length = match self.inputmode { | |
| 417 | − | InputMode::PlaylistRename => self.pl_newname_input.chars().count(), | |
| 418 | − | InputMode::Editing => self.search_input.chars().count(), | |
| 419 | − | InputMode::NewPlaylist => self.pl_new_pl_input.chars().count(), | |
| 420 | − | InputMode::Normal => 0, | |
| 359 | + | let Some(layout) = &self.layout else { | |
| 360 | + | return; | |
| 421 | 361 | }; | |
| 422 | − | let position = position.min(length); | |
| 423 | − | match self.inputmode { | |
| 424 | − | InputMode::PlaylistRename => self.pl_cursor_pos = position, | |
| 425 | − | InputMode::Editing => self.search_cursor_pos = position, | |
| 426 | − | InputMode::NewPlaylist => self.pl_new_pl_cursor_pos = position, | |
| 427 | − | InputMode::Normal => {} | |
| 362 | + | let position = Position::new(mouse.column, mouse.row); | |
| 363 | + | let left_click = mouse.kind == MouseEventKind::Down(MouseButton::Left); | |
| 364 | + | if layout.progress.contains(position) { | |
| 365 | + | if left_click | |
| 366 | + | && self.mpd.status.state != PlayState::Stop | |
| 367 | + | && let Some(target) = | |
| 368 | + | ui::progress_seek_target(layout.progress, mouse.column, &self.mpd) | |
| 369 | + | { | |
| 370 | + | self.client.exec(commands::seek_to(target.as_secs_f64())); | |
| 371 | + | } | |
| 372 | + | } else if layout.body.contains(position) { | |
| 373 | + | let active = self.active; | |
| 374 | + | self.with_ctx(|screens, ctx| screens.get_mut(active).on_mouse(mouse, ctx)); | |
| 428 | 375 | } | |
| 429 | 376 | } | |
| 430 | 377 | ||
| 431 | − | pub fn enter_char(&mut self, character: char) { | |
| 432 | − | let (text, cursor) = match self.inputmode { | |
| 433 | − | InputMode::PlaylistRename => (&mut self.pl_newname_input, self.pl_cursor_pos), | |
| 434 | − | InputMode::Editing => (&mut self.search_input, self.search_cursor_pos), | |
| 435 | − | InputMode::NewPlaylist => (&mut self.pl_new_pl_input, self.pl_new_pl_cursor_pos), | |
| 436 | − | InputMode::Normal => return, | |
| 437 | − | }; | |
| 438 | − | let byte_index = text | |
| 439 | − | .char_indices() | |
| 440 | − | .nth(cursor) | |
| 441 | − | .map(|(index, _)| index) | |
| 442 | − | .unwrap_or(text.len()); | |
| 443 | − | text.insert(byte_index, character); | |
| 444 | − | self.move_cursor_right(); | |
| 445 | − | } | |
| 378 | + | // ----- background events ---------------------------------------------- | |
| 446 | 379 | ||
| 447 | − | pub fn delete_char(&mut self) { | |
| 448 | − | let cursor = self.cursor_position(); | |
| 449 | − | if cursor == 0 { | |
| 450 | − | return; | |
| 451 | − | } | |
| 452 | − | let text = match self.inputmode { | |
| 453 | − | InputMode::PlaylistRename => &mut self.pl_newname_input, | |
| 454 | − | InputMode::Editing => &mut self.search_input, | |
| 455 | − | InputMode::NewPlaylist => &mut self.pl_new_pl_input, | |
| 456 | − | InputMode::Normal => return, | |
| 457 | − | }; | |
| 458 | − | let start = text.char_indices().nth(cursor - 1).map(|(i, _)| i); | |
| 459 | − | let end = text | |
| 460 | − | .char_indices() | |
| 461 | − | .nth(cursor) | |
| 462 | − | .map(|(i, _)| i) | |
| 463 | − | .unwrap_or(text.len()); | |
| 464 | − | if let Some(start) = start { | |
| 465 | − | text.replace_range(start..end, ""); | |
| 466 | − | self.move_cursor_left(); | |
| 380 | + | fn on_app_event(&mut self, event: AppEvent) { | |
| 381 | + | match event { | |
| 382 | + | AppEvent::Mpd(event) => self.on_mpd_event(event), | |
| 383 | + | AppEvent::Lyrics { file, result } => { | |
| 384 | + | self.lyrics.finish(file, result); | |
| 385 | + | } | |
| 386 | + | AppEvent::TagsWritten(Ok(dirs)) => { | |
| 387 | + | let count = dirs.len(); | |
| 388 | + | self.client | |
| 389 | + | .exec_all(dirs.iter().map(|dir| commands::update(Some(dir))).collect()); | |
| 390 | + | self.notify(format!("Saved tags; rescanning {count} folder(s)"), false); | |
| 391 | + | } | |
| 392 | + | AppEvent::TagsWritten(Err(error)) => self.notify(error, true), | |
| 467 | 393 | } | |
| 468 | 394 | } | |
| 469 | 395 | ||
| 470 | − | pub fn reset_cursor(&mut self) { | |
| 471 | − | self.set_cursor_position(0); | |
| 396 | + | fn on_mpd_event(&mut self, event: MpdEvent) { | |
| 397 | + | match &event { | |
| 398 | + | MpdEvent::Connected { version } => { | |
| 399 | + | info!("connected to MPD {version}"); | |
| 400 | + | self.mpd.connected = true; | |
| 401 | + | self.mpd.reset_queue(); | |
| 402 | + | self.queue_requested = false; | |
| 403 | + | self.client.query(Query::Status); | |
| 404 | + | self.client.query(Query::Playlists); | |
| 405 | + | if self.config.music_directory().is_none() { | |
| 406 | + | self.client.query(Query::MusicDirectory); | |
| 407 | + | } | |
| 408 | + | self.notify(format!("Connected to MPD {version}"), false); | |
| 409 | + | } | |
| 410 | + | MpdEvent::Disconnected(reason) => { | |
| 411 | + | self.mpd.connected = false; | |
| 412 | + | self.notify(reason.clone(), true); | |
| 413 | + | } | |
| 414 | + | MpdEvent::Changed(subsystems) => { | |
| 415 | + | let mut status = false; | |
| 416 | + | for subsystem in subsystems { | |
| 417 | + | match subsystem { | |
| 418 | + | Subsystem::StoredPlaylist => self.client.query(Query::Playlists), | |
| 419 | + | Subsystem::Database => { | |
| 420 | + | self.mpd.db_version += 1; | |
| 421 | + | status = true; | |
| 422 | + | } | |
| 423 | + | _ => status = true, | |
| 424 | + | } | |
| 425 | + | } | |
| 426 | + | if status { | |
| 427 | + | self.client.query(Query::Status); | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | MpdEvent::Reply(Reply::Status { status, current }) => { | |
| 431 | + | let song_changed = self.mpd.set_status(status.clone(), current.clone()); | |
| 432 | + | self.request_queue_if_stale(); | |
| 433 | + | if song_changed { | |
| 434 | + | self.refresh_lyrics(); | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | MpdEvent::Reply(Reply::QueueChanges { | |
| 438 | + | status, | |
| 439 | + | since, | |
| 440 | + | changes, | |
| 441 | + | }) => { | |
| 442 | + | self.queue_requested = false; | |
| 443 | + | self.mpd | |
| 444 | + | .apply_queue_changes(status, *since, changes.clone()); | |
| 445 | + | self.request_queue_if_stale(); | |
| 446 | + | } | |
| 447 | + | MpdEvent::Reply(Reply::Playlists(playlists)) => { | |
| 448 | + | self.mpd.playlists.clone_from(playlists) | |
| 449 | + | } | |
| 450 | + | MpdEvent::Reply(Reply::MusicDirectory(dir)) => { | |
| 451 | + | if self.config.music_directory().is_none() && dir.is_some() { | |
| 452 | + | self.mpd.music_dir.clone_from(dir); | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | MpdEvent::Reply(_) => {} | |
| 456 | + | MpdEvent::Error(error) => self.notify(error.clone(), true), | |
| 457 | + | } | |
| 458 | + | self.with_ctx(|screens, ctx| screens.for_each(|screen| screen.on_mpd(&event, ctx))); | |
| 472 | 459 | } | |
| 473 | 460 | ||
| 474 | − | pub fn format_time(time: Duration) -> String { | |
| 475 | − | let seconds = time.as_secs(); | |
| 476 | − | let hours = seconds / 3600; | |
| 477 | − | let minutes = (seconds % 3600) / 60; | |
| 478 | − | let seconds = seconds % 60; | |
| 479 | − | if hours == 0 { | |
| 480 | − | format!("{minutes:02}:{seconds:02}") | |
| 481 | − | } else { | |
| 482 | − | format!("{hours:02}:{minutes:02}:{seconds:02}") | |
| 461 | + | fn request_queue_if_stale(&mut self) { | |
| 462 | + | if !self.queue_requested | |
| 463 | + | && let Some(since) = self.mpd.stale_queue_since() | |
| 464 | + | { | |
| 465 | + | self.queue_requested = true; | |
| 466 | + | self.client.query(Query::QueueChanges { since }); | |
| 483 | 467 | } | |
| 484 | 468 | } | |
| 485 | 469 | ||
| 486 | − | pub fn handle_scroll_up(&mut self) { | |
| 487 | − | match self.selected_tab { | |
| 488 | − | SelectedTab::Queue => self.queue_list.prev(), | |
| 489 | − | SelectedTab::DirectoryBrowser => self.browser.prev(), | |
| 490 | − | SelectedTab::Playlists => self.pl_list.prev(), | |
| 491 | − | } | |
| 470 | + | fn write_tags(&mut self, writes: Vec<tags::TagWrite>) { | |
| 471 | + | let events = self.events_tx.clone(); | |
| 472 | + | self.notify(format!("Writing tags of {} file(s)…", writes.len()), false); | |
| 473 | + | tokio::task::spawn_blocking(move || { | |
| 474 | + | let mut dirs: Vec<String> = Vec::new(); | |
| 475 | + | let mut errors = Vec::new(); | |
| 476 | + | for write in &writes { | |
| 477 | + | match tags::write(write) { | |
| 478 | + | Ok(()) => { | |
| 479 | + | let dir = write | |
| 480 | + | .uri | |
| 481 | + | .rsplit_once('/') | |
| 482 | + | .map_or("", |(dir, _)| dir) | |
| 483 | + | .to_owned(); | |
| 484 | + | if !dirs.contains(&dir) { | |
| 485 | + | dirs.push(dir); | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | Err(error) => errors.push(error), | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | let result = match errors.as_slice() { | |
| 492 | + | [] => Ok(dirs), | |
| 493 | + | [error] => Err(error.clone()), | |
| 494 | + | [first, rest @ ..] => Err(format!("{first} (and {} more errors)", rest.len())), | |
| 495 | + | }; | |
| 496 | + | let _ = events.send(AppEvent::TagsWritten(result)); | |
| 497 | + | }); | |
| 492 | 498 | } | |
| 493 | 499 | ||
| 494 | − | pub fn handle_scroll_down(&mut self) { | |
| 495 | − | match self.selected_tab { | |
| 496 | − | SelectedTab::Queue => self.queue_list.next(), | |
| 497 | − | SelectedTab::DirectoryBrowser => self.browser.next(), | |
| 498 | − | SelectedTab::Playlists => self.pl_list.next(), | |
| 500 | + | // ----- drawing -------------------------------------------------------- | |
| 501 | + | ||
| 502 | + | fn draw(&mut self, frame: &mut Frame) { | |
| 503 | + | let layout = FrameLayout::new(frame.area()); | |
| 504 | + | let theme = &self.config.theme; | |
| 505 | + | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 506 | + | let screen = self.screens.get_mut(self.active); | |
| 507 | + | ui::draw_title(frame, &layout, screen.title(&ctx), &self.mpd, theme); | |
| 508 | + | screen.draw(frame, layout.body, &ctx); | |
| 509 | + | ui::draw_progress(frame, layout.progress, &self.mpd, theme); | |
| 510 | + | ||
| 511 | + | if self | |
| 512 | + | .message | |
| 513 | + | .as_ref() | |
| 514 | + | .is_some_and(|m| m.until <= Instant::now()) | |
| 515 | + | { | |
| 516 | + | self.message = None; | |
| 517 | + | } | |
| 518 | + | if let Some(search) = &self.search { | |
| 519 | + | search.render(frame, layout.status, "/", theme); | |
| 520 | + | } else if let Some(message) = &self.message { | |
| 521 | + | ui::draw_message(frame, layout.status, &message.text, message.error, theme); | |
| 522 | + | } else { | |
| 523 | + | ui::draw_now_playing(frame, layout.status, &self.mpd, theme); | |
| 524 | + | } | |
| 525 | + | for popup in &mut self.popups { | |
| 526 | + | popup.draw(frame, frame.area(), &ctx); | |
| 499 | 527 | } | |
| 528 | + | self.layout = Some(layout); | |
| 500 | 529 | } | |
| 501 | 530 | } | |
| @@ -1,164 +0,0 @@ | |||
| 1 | − | use std::path::Path; | |
| 2 | − | ||
| 3 | − | use crate::{ | |
| 4 | − | connection::{Connection, Song}, | |
| 5 | − | utils::is_audio_file, | |
| 6 | − | AppResult, | |
| 7 | − | }; | |
| 8 | − | ||
| 9 | − | #[derive(Debug, Default)] | |
| 10 | − | pub struct FileBrowser { | |
| 11 | − | pub entries: Vec<BrowserEntry>, | |
| 12 | − | pub selected: usize, | |
| 13 | − | pub prev_selected: usize, | |
| 14 | − | pub path: String, | |
| 15 | − | } | |
| 16 | − | ||
| 17 | − | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 18 | − | pub enum BrowserEntry { | |
| 19 | − | Directory(String), | |
| 20 | − | Song(Song), | |
| 21 | − | } | |
| 22 | − | ||
| 23 | − | impl BrowserEntry { | |
| 24 | − | pub fn name(&self) -> &str { | |
| 25 | − | match self { | |
| 26 | − | Self::Directory(path) => Path::new(path) | |
| 27 | − | .file_name() | |
| 28 | − | .and_then(|name| name.to_str()) | |
| 29 | − | .unwrap_or(path), | |
| 30 | − | Self::Song(song) => Path::new(&song.file) | |
| 31 | − | .file_name() | |
| 32 | − | .and_then(|name| name.to_str()) | |
| 33 | − | .unwrap_or(&song.file), | |
| 34 | − | } | |
| 35 | − | } | |
| 36 | − | } | |
| 37 | − | ||
| 38 | − | impl FileBrowser { | |
| 39 | − | pub fn new() -> Self { | |
| 40 | − | Self { | |
| 41 | − | path: ".".to_string(), | |
| 42 | − | ..Self::default() | |
| 43 | − | } | |
| 44 | − | } | |
| 45 | − | ||
| 46 | − | pub async fn update_directory(&mut self, conn: &mut Connection) -> AppResult<()> { | |
| 47 | − | let query_path = self.path.trim_start_matches("./").trim_matches('/'); | |
| 48 | − | let (directories, songs) = conn.ls_info(query_path).await?; | |
| 49 | − | let mut entries = Vec::new(); | |
| 50 | − | for directory in directories { | |
| 51 | − | if let Some(name) = Path::new(&directory) | |
| 52 | − | .file_name() | |
| 53 | − | .and_then(|name| name.to_str()) | |
| 54 | − | { | |
| 55 | − | if !name.starts_with('.') { | |
| 56 | − | entries.push(BrowserEntry::Directory(directory)); | |
| 57 | − | } | |
| 58 | − | } | |
| 59 | − | } | |
| 60 | − | ||
| 61 | − | for song in songs { | |
| 62 | − | let Some(name) = Path::new(&song.file) | |
| 63 | − | .file_name() | |
| 64 | − | .and_then(|name| name.to_str()) | |
| 65 | − | else { | |
| 66 | − | continue; | |
| 67 | − | }; | |
| 68 | − | if is_audio_file(name) { | |
| 69 | − | entries.push(BrowserEntry::Song(song)); | |
| 70 | − | } | |
| 71 | − | } | |
| 72 | − | ||
| 73 | − | entries.sort_by(|a, b| { | |
| 74 | − | let kind_order = |entry: &BrowserEntry| match entry { | |
| 75 | − | BrowserEntry::Directory(_) => 0, | |
| 76 | − | BrowserEntry::Song(_) => 1, | |
| 77 | − | }; | |
| 78 | − | kind_order(a).cmp(&kind_order(b)).then_with(|| { | |
| 79 | − | let name_a = a.name(); | |
| 80 | − | let name_b = b.name(); | |
| 81 | − | let number_a = name_a.parse::<u32>().unwrap_or(u32::MAX); | |
| 82 | − | let number_b = name_b.parse::<u32>().unwrap_or(u32::MAX); | |
| 83 | − | number_a | |
| 84 | − | .cmp(&number_b) | |
| 85 | − | .then_with(|| name_a.to_lowercase().cmp(&name_b.to_lowercase())) | |
| 86 | − | }) | |
| 87 | − | }); | |
| 88 | − | ||
| 89 | − | self.entries = entries; | |
| 90 | − | self.selected = self.selected.min(self.entries.len().saturating_sub(1)); | |
| 91 | − | Ok(()) | |
| 92 | − | } | |
| 93 | − | ||
| 94 | − | pub fn next(&mut self) { | |
| 95 | − | if self.selected < self.entries.len().saturating_sub(1) { | |
| 96 | − | self.selected += 1; | |
| 97 | − | } | |
| 98 | − | } | |
| 99 | − | ||
| 100 | − | pub fn prev(&mut self) { | |
| 101 | − | self.selected = self.selected.saturating_sub(1); | |
| 102 | − | } | |
| 103 | − | ||
| 104 | − | pub async fn handle_go_back(&mut self, conn: &mut Connection) -> AppResult<()> { | |
| 105 | − | if self.path != "." { | |
| 106 | − | self.path = Path::new(&self.path) | |
| 107 | − | .parent() | |
| 108 | − | .and_then(Path::to_str) | |
| 109 | − | .filter(|path| !path.is_empty()) | |
| 110 | − | .unwrap_or(".") | |
| 111 | − | .to_string(); | |
| 112 | − | self.update_directory(conn).await?; | |
| 113 | − | } | |
| 114 | − | self.selected = self.prev_selected.min(self.entries.len().saturating_sub(1)); | |
| 115 | − | Ok(()) | |
| 116 | − | } | |
| 117 | − | } | |
| 118 | − | ||
| 119 | − | #[cfg(test)] | |
| 120 | − | mod tests { | |
| 121 | − | use super::*; | |
| 122 | − | use tokio::{ | |
| 123 | − | io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, | |
| 124 | − | net::TcpListener, | |
| 125 | − | }; | |
| 126 | − | ||
| 127 | − | #[tokio::test] | |
| 128 | − | async fn preserves_mpd_directory_uris_and_displays_their_basename() { | |
| 129 | − | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 130 | − | let address = listener.local_addr().unwrap(); | |
| 131 | − | let server = tokio::spawn(async move { | |
| 132 | − | let (stream, _) = listener.accept().await.unwrap(); | |
| 133 | − | let mut stream = BufReader::new(stream); | |
| 134 | − | stream | |
| 135 | − | .get_mut() | |
| 136 | − | .write_all(b"OK MPD 0.23.0\n") | |
| 137 | − | .await | |
| 138 | − | .unwrap(); | |
| 139 | − | ||
| 140 | − | for response in ["OK\n", "OK\n", "OK\n", "directory: library/Artist\nOK\n"] { | |
| 141 | − | let mut command = String::new(); | |
| 142 | − | stream.read_line(&mut command).await.unwrap(); | |
| 143 | − | stream | |
| 144 | − | .get_mut() | |
| 145 | − | .write_all(response.as_bytes()) | |
| 146 | − | .await | |
| 147 | − | .unwrap(); | |
| 148 | − | } | |
| 149 | − | }); | |
| 150 | − | ||
| 151 | − | let mut conn = Connection::connect(&address.to_string(), None) | |
| 152 | − | .await | |
| 153 | − | .unwrap(); | |
| 154 | − | let mut browser = FileBrowser::new(); | |
| 155 | − | browser.update_directory(&mut conn).await.unwrap(); | |
| 156 | − | ||
| 157 | − | assert_eq!( | |
| 158 | − | browser.entries, | |
| 159 | − | vec![BrowserEntry::Directory("library/Artist".to_string())] | |
| 160 | − | ); | |
| 161 | − | assert_eq!(browser.entries[0].name(), "Artist"); | |
| 162 | − | server.await.unwrap(); | |
| 163 | − | } | |
| 164 | − | } | |
| @@ -1,669 +0,0 @@ | |||
| 1 | − | use std::{ | |
| 2 | − | collections::BTreeMap, | |
| 3 | − | env, | |
| 4 | − | error::Error, | |
| 5 | − | fs::{self, OpenOptions}, | |
| 6 | − | io::{self, Write}, | |
| 7 | − | path::PathBuf, | |
| 8 | − | }; | |
| 9 | − | ||
| 10 | − | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; | |
| 11 | − | use serde::{Deserialize, Serialize}; | |
| 12 | − | ||
| 13 | − | use crate::AppResult; | |
| 14 | − | ||
| 15 | − | #[derive(Clone, Debug, Default, Deserialize, Serialize)] | |
| 16 | − | #[serde(default)] | |
| 17 | − | pub struct Config { | |
| 18 | − | pub mpd: MpdConfig, | |
| 19 | − | pub playback: PlaybackConfig, | |
| 20 | − | pub keybindings: Keybindings, | |
| 21 | − | } | |
| 22 | − | ||
| 23 | − | #[derive(Clone, Debug, Deserialize, Serialize)] | |
| 24 | − | #[serde(default)] | |
| 25 | − | pub struct MpdConfig { | |
| 26 | − | pub host: String, | |
| 27 | − | pub port: u16, | |
| 28 | − | pub password: String, | |
| 29 | − | } | |
| 30 | − | ||
| 31 | − | impl Default for MpdConfig { | |
| 32 | − | fn default() -> Self { | |
| 33 | − | Self { | |
| 34 | − | host: "127.0.0.1".to_string(), | |
| 35 | − | port: 6600, | |
| 36 | − | password: String::new(), | |
| 37 | − | } | |
| 38 | − | } | |
| 39 | − | } | |
| 40 | − | ||
| 41 | − | #[derive(Clone, Debug, Deserialize, Serialize)] | |
| 42 | − | #[serde(default)] | |
| 43 | − | pub struct PlaybackConfig { | |
| 44 | − | pub seek_seconds: u64, | |
| 45 | − | pub volume_step: u8, | |
| 46 | − | } | |
| 47 | − | ||
| 48 | − | impl Default for PlaybackConfig { | |
| 49 | − | fn default() -> Self { | |
| 50 | − | Self { | |
| 51 | − | seek_seconds: 2, | |
| 52 | − | volume_step: 2, | |
| 53 | − | } | |
| 54 | − | } | |
| 55 | − | } | |
| 56 | − | ||
| 57 | − | #[derive(Clone, Debug, Deserialize, Serialize)] | |
| 58 | − | #[serde(default)] | |
| 59 | − | pub struct Keybindings { | |
| 60 | − | #[serde(flatten)] | |
| 61 | − | bindings: BTreeMap<String, Vec<String>>, | |
| 62 | − | } | |
| 63 | − | ||
| 64 | − | impl Default for Keybindings { | |
| 65 | − | fn default() -> Self { | |
| 66 | − | let defaults: &[(&str, &[&str])] = &[ | |
| 67 | − | ("quit", &["q", "Ctrl+c"]), | |
| 68 | − | ("clear_queue", &["c", "C"]), | |
| 69 | − | ("toggle_pause", &["p"]), | |
| 70 | − | ("pause", &["s"]), | |
| 71 | − | ("toggle_repeat", &["r"]), | |
| 72 | − | ("toggle_random", &["z"]), | |
| 73 | − | ("open_playlist_menu", &["a"]), | |
| 74 | − | ("seek_forward", &["f"]), | |
| 75 | − | ("seek_backward", &["b"]), | |
| 76 | − | ("cycle_tabs", &["Tab"]), | |
| 77 | − | ("select_queue", &["1"]), | |
| 78 | − | ("select_browser", &["2"]), | |
| 79 | − | ("select_playlists", &["3"]), | |
| 80 | − | ("next_track", &[">"]), | |
| 81 | − | ("previous_track", &["<"]), | |
| 82 | − | ("volume_up", &["=", "+"]), | |
| 83 | − | ("volume_down", &["-"]), | |
| 84 | − | ("toggle_mute", &["m"]), | |
| 85 | − | ("update_database", &["U"]), | |
| 86 | − | ("search", &["/"]), | |
| 87 | − | ("toggle_selected_item", &["Space"]), | |
| 88 | − | ("search_next", &["n"]), | |
| 89 | − | ("search_previous", &["N"]), | |
| 90 | − | ("move_down", &["j", "Down"]), | |
| 91 | − | ("move_up", &["k", "Up"]), | |
| 92 | − | ("activate", &["Enter", "l", "Right"]), | |
| 93 | − | ("go_back", &["h", "Left"]), | |
| 94 | − | ("go_to_top", &["g"]), | |
| 95 | − | ("go_to_bottom", &["G"]), | |
| 96 | − | ("queue_delete", &["d"]), | |
| 97 | − | ("queue_move_down", &["J"]), | |
| 98 | − | ("queue_move_up", &["K"]), | |
| 99 | − | ("playlist_rename", &["R"]), | |
| 100 | − | ("playlist_focus_contents", &["Right", "l"]), | |
| 101 | − | ("playlist_focus_list", &["Left", "h"]), | |
| 102 | − | ("playlist_delete_song", &["d"]), | |
| 103 | − | ("playlist_move_song_down", &["J"]), | |
| 104 | − | ("playlist_move_song_up", &["K"]), | |
| 105 | − | ("playlist_delete", &["D"]), | |
| 106 | − | ("playlist_load", &["Space", "Enter"]), | |
| 107 | − | ("popup_close", &["q", "Esc"]), | |
| 108 | − | ("popup_select", &["Enter"]), | |
| 109 | − | ("input_cancel", &["Esc"]), | |
| 110 | − | ("input_backspace", &["Backspace"]), | |
| 111 | − | ("input_cursor_left", &["Left"]), | |
| 112 | − | ("input_cursor_right", &["Right"]), | |
| 113 | − | ("input_submit", &["Enter"]), | |
| 114 | − | ]; | |
| 115 | − | Self { | |
| 116 | − | bindings: defaults | |
| 117 | − | .iter() | |
| 118 | − | .map(|(action, keys)| { | |
| 119 | − | ( | |
| 120 | − | (*action).to_string(), | |
| 121 | − | keys.iter().map(|key| (*key).to_string()).collect(), | |
| 122 | − | ) | |
| 123 | − | }) | |
| 124 | − | .collect(), | |
| 125 | − | } | |
| 126 | − | } | |
| 127 | − | } | |
| 128 | − | ||
| 129 | − | impl Keybindings { | |
| 130 | − | pub fn matches(&self, action: &str, key: KeyEvent) -> bool { | |
| 131 | − | self.bindings.get(action).is_some_and(|keys| { | |
| 132 | − | keys.iter() | |
| 133 | − | .any(|spec| parse_key_spec(spec).is_ok_and(|binding| key_matches(binding, key))) | |
| 134 | − | }) | |
| 135 | − | } | |
| 136 | − | ||
| 137 | − | fn validate(&self) -> AppResult<()> { | |
| 138 | − | let defaults = Self::default(); | |
| 139 | − | for (action, keys) in &self.bindings { | |
| 140 | − | if !defaults.bindings.contains_key(action) { | |
| 141 | − | return Err(invalid_config(&format!( | |
| 142 | − | "keybindings contains unknown action `{action}`" | |
| 143 | − | ))); | |
| 144 | − | } | |
| 145 | − | for key in keys { | |
| 146 | − | parse_key_spec(key).map_err(|error| { | |
| 147 | − | invalid_config(&format!( | |
| 148 | − | "invalid keybinding `{key}` for `{action}`: {error}" | |
| 149 | − | )) | |
| 150 | − | })?; | |
| 151 | − | } | |
| 152 | − | } | |
| 153 | − | Ok(()) | |
| 154 | − | } | |
| 155 | − | ||
| 156 | − | fn apply_missing_defaults(&mut self) { | |
| 157 | − | let defaults = Self::default().bindings; | |
| 158 | − | for (action, keys) in &defaults { | |
| 159 | − | if self | |
| 160 | − | .bindings | |
| 161 | − | .get(action) | |
| 162 | − | .is_some_and(|current| current == &vec!["Right".to_string()]) | |
| 163 | − | && action == "playlist_focus_contents" | |
| 164 | − | { | |
| 165 | − | self.bindings.insert(action.clone(), keys.clone()); | |
| 166 | − | } | |
| 167 | − | if self | |
| 168 | − | .bindings | |
| 169 | − | .get(action) | |
| 170 | − | .is_some_and(|current| current == &vec!["Left".to_string()]) | |
| 171 | − | && action == "playlist_focus_list" | |
| 172 | − | { | |
| 173 | − | self.bindings.insert(action.clone(), keys.clone()); | |
| 174 | − | } | |
| 175 | − | } | |
| 176 | − | for (action, keys) in defaults { | |
| 177 | − | self.bindings.entry(action).or_insert(keys); | |
| 178 | − | } | |
| 179 | − | } | |
| 180 | − | } | |
| 181 | − | ||
| 182 | − | impl Config { | |
| 183 | − | pub fn load_or_create() -> AppResult<(Self, PathBuf)> { | |
| 184 | − | let path = config_path()?; | |
| 185 | − | let config = Self::load_or_create_at(&path)?; | |
| 186 | − | Ok((config, path)) | |
| 187 | − | } | |
| 188 | − | ||
| 189 | − | fn load_or_create_at(path: &std::path::Path) -> AppResult<Self> { | |
| 190 | − | if let Some(parent) = path.parent() { | |
| 191 | − | fs::create_dir_all(parent)?; | |
| 192 | − | } | |
| 193 | − | ||
| 194 | − | let contents = match fs::read_to_string(path) { | |
| 195 | − | Ok(contents) => contents, | |
| 196 | − | Err(error) if error.kind() == io::ErrorKind::NotFound => { | |
| 197 | − | let default_contents = default_config_text()?; | |
| 198 | − | match create_config_file(path, default_contents.as_bytes()) { | |
| 199 | − | Ok(()) => default_contents, | |
| 200 | − | Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { | |
| 201 | − | fs::read_to_string(path)? | |
| 202 | − | } | |
| 203 | − | Err(error) => return Err(Box::new(error)), | |
| 204 | − | } | |
| 205 | − | } | |
| 206 | − | Err(error) => return Err(Box::new(error)), | |
| 207 | − | }; | |
| 208 | − | ||
| 209 | − | let mut config: Self = toml::from_str(&contents)?; | |
| 210 | − | config.keybindings.apply_missing_defaults(); | |
| 211 | − | config.validate()?; | |
| 212 | − | Ok(config) | |
| 213 | − | } | |
| 214 | − | ||
| 215 | − | fn validate(&self) -> AppResult<()> { | |
| 216 | − | if self.mpd.host.trim().is_empty() { | |
| 217 | − | return Err(invalid_config("mpd.host cannot be empty")); | |
| 218 | − | } | |
| 219 | − | if self.mpd.port == 0 { | |
| 220 | − | return Err(invalid_config("mpd.port must be between 1 and 65535")); | |
| 221 | − | } | |
| 222 | − | if self.playback.seek_seconds == 0 { | |
| 223 | − | return Err(invalid_config( | |
| 224 | − | "playback.seek_seconds must be greater than zero", | |
| 225 | − | )); | |
| 226 | − | } | |
| 227 | − | if !(1..=100).contains(&self.playback.volume_step) { | |
| 228 | − | return Err(invalid_config( | |
| 229 | − | "playback.volume_step must be between 1 and 100", | |
| 230 | − | )); | |
| 231 | − | } | |
| 232 | − | self.keybindings.validate() | |
| 233 | − | } | |
| 234 | − | } | |
| 235 | − | ||
| 236 | − | fn parse_key_spec(spec: &str) -> Result<KeyEvent, String> { | |
| 237 | − | if spec == "+" { | |
| 238 | − | return Ok(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::empty())); | |
| 239 | − | } | |
| 240 | − | let mut modifiers = KeyModifiers::empty(); | |
| 241 | − | let mut base = spec; | |
| 242 | − | while !base.starts_with("Char(") { | |
| 243 | − | let Some((modifier, rest)) = base.split_once('+') else { | |
| 244 | − | break; | |
| 245 | − | }; | |
| 246 | − | match modifier.to_ascii_lowercase().as_str() { | |
| 247 | − | "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, | |
| 248 | − | "alt" => modifiers |= KeyModifiers::ALT, | |
| 249 | − | "shift" => modifiers |= KeyModifiers::SHIFT, | |
| 250 | − | _ => return Err(format!("unknown modifier `{modifier}`")), | |
| 251 | − | } | |
| 252 | − | base = rest; | |
| 253 | − | } | |
| 254 | − | if base.is_empty() { | |
| 255 | − | return Err("missing key".into()); | |
| 256 | − | } | |
| 257 | − | let lowered_base = base.to_ascii_lowercase(); | |
| 258 | − | let code = if let Some(number) = lowered_base | |
| 259 | − | .strip_prefix('f') | |
| 260 | − | .and_then(|number| number.parse::<u8>().ok()) | |
| 261 | − | .filter(|number| (1..=24).contains(number)) | |
| 262 | − | { | |
| 263 | − | KeyCode::F(number) | |
| 264 | − | } else { | |
| 265 | − | match lowered_base.as_str() { | |
| 266 | − | "esc" | "escape" => KeyCode::Esc, | |
| 267 | − | "enter" | "return" => KeyCode::Enter, | |
| 268 | − | "tab" => KeyCode::Tab, | |
| 269 | − | "backtab" => KeyCode::BackTab, | |
| 270 | − | "backspace" => KeyCode::Backspace, | |
| 271 | − | "delete" | "del" => KeyCode::Delete, | |
| 272 | − | "insert" => KeyCode::Insert, | |
| 273 | − | "up" => KeyCode::Up, | |
| 274 | − | "down" => KeyCode::Down, | |
| 275 | − | "left" => KeyCode::Left, | |
| 276 | − | "right" => KeyCode::Right, | |
| 277 | − | "home" => KeyCode::Home, | |
| 278 | − | "end" => KeyCode::End, | |
| 279 | − | "pageup" => KeyCode::PageUp, | |
| 280 | − | "pagedown" => KeyCode::PageDown, | |
| 281 | − | "space" => KeyCode::Char(' '), | |
| 282 | − | _ if lowered_base.starts_with("char(") && base.ends_with(')') => { | |
| 283 | − | let inner = &base[5..base.len() - 1]; | |
| 284 | − | let mut chars = inner.chars(); | |
| 285 | − | let character = chars | |
| 286 | − | .next() | |
| 287 | − | .ok_or_else(|| "empty Char(...) key".to_string())?; | |
| 288 | − | if chars.next().is_some() { | |
| 289 | − | return Err("Char(...) must contain exactly one character".into()); | |
| 290 | − | } | |
| 291 | − | KeyCode::Char(character) | |
| 292 | − | } | |
| 293 | − | _ => { | |
| 294 | − | let mut chars = base.chars(); | |
| 295 | − | let character = chars.next().ok_or_else(|| "missing key".to_string())?; | |
| 296 | − | if chars.next().is_some() { | |
| 297 | − | return Err(format!("unknown key `{base}`")); | |
| 298 | − | } | |
| 299 | − | KeyCode::Char(character) | |
| 300 | − | } | |
| 301 | − | } | |
| 302 | − | }; | |
| 303 | − | Ok(KeyEvent::new(code, modifiers)) | |
| 304 | − | } | |
| 305 | − | ||
| 306 | − | fn key_matches(binding: KeyEvent, event: KeyEvent) -> bool { | |
| 307 | − | if binding.code != event.code { | |
| 308 | − | return false; | |
| 309 | − | } | |
| 310 | − | let mut event_modifiers = event.modifiers; | |
| 311 | − | if !binding.modifiers.contains(KeyModifiers::SHIFT) { | |
| 312 | − | event_modifiers.remove(KeyModifiers::SHIFT); | |
| 313 | − | } | |
| 314 | − | binding.modifiers == event_modifiers | |
| 315 | − | } | |
| 316 | − | ||
| 317 | − | pub fn config_path() -> io::Result<PathBuf> { | |
| 318 | − | let base = env::var_os("CONFIG_HOME") | |
| 319 | − | .or_else(|| env::var_os("XDG_CONFIG_HOME")) | |
| 320 | − | .map(PathBuf::from) | |
| 321 | − | .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) | |
| 322 | − | .ok_or_else(|| { | |
| 323 | − | io::Error::new( | |
| 324 | − | io::ErrorKind::NotFound, | |
| 325 | − | "CONFIG_HOME, XDG_CONFIG_HOME, and HOME are all unset", | |
| 326 | − | ) | |
| 327 | − | })?; | |
| 328 | − | if base.as_os_str().is_empty() { | |
| 329 | − | return Err(io::Error::new( | |
| 330 | − | io::ErrorKind::InvalidInput, | |
| 331 | − | "configuration home environment variable cannot be empty", | |
| 332 | − | )); | |
| 333 | − | } | |
| 334 | − | Ok(base.join("rmptui").join("config.toml")) | |
| 335 | − | } | |
| 336 | − | ||
| 337 | − | fn default_config_text() -> AppResult<String> { | |
| 338 | − | let serialized = toml::to_string_pretty(&Config::default())?; | |
| 339 | − | let mut documented = String::from( | |
| 340 | − | "# rmptui configuration. Values below are the built-in defaults.\n\ | |
| 341 | − | # Save changes and restart rmptui for them to take effect.\n\ | |
| 342 | − | #\n\ | |
| 343 | − | # Config file location: $CONFIG_HOME/rmptui/config.toml, falling back to\n\ | |
| 344 | − | # $XDG_CONFIG_HOME/rmptui/config.toml, then $HOME/.config/rmptui/config.toml.\n\ | |
| 345 | − | #\n\ | |
| 346 | − | # Environment overrides:\n\ | |
| 347 | − | # MPD_HOST overrides [mpd].host. It may include a port (host:port),\n\ | |
| 348 | − | # or credentials as password@host. A port embedded in MPD_HOST wins\n\ | |
| 349 | − | # over MPD_PORT.\n\ | |
| 350 | − | # MPD_PORT overrides [mpd].port when MPD_HOST has no embedded port.\n\ | |
| 351 | − | #\n\ | |
| 352 | − | # Key names may be characters or named keys (Enter, Esc, Tab, Space,\n\ | |
| 353 | − | # arrows, Backspace, F1-F24). Prefix modifiers with Ctrl+, Alt+, or\n\ | |
| 354 | − | # Shift+. Use arrays for aliases and [] to disable an action.\n\ | |
| 355 | − | # The password is stored as plain text; this file is created with\n\ | |
| 356 | − | # owner-only permissions on Unix.\n\n", | |
| 357 | − | ); | |
| 358 | − | ||
| 359 | − | let mut section = ""; | |
| 360 | − | for line in serialized.lines() { | |
| 361 | − | if line.starts_with('[') && line.ends_with(']') { | |
| 362 | − | section = &line[1..line.len() - 1]; | |
| 363 | − | let description = match section { | |
| 364 | − | "mpd" => "# MPD server connection settings.", | |
| 365 | − | "playback" => "# Playback adjustment amounts.", | |
| 366 | − | "keybindings" => "# Action keys, matched only in relevant UI contexts.", | |
| 367 | − | _ => "# Configuration options.", | |
| 368 | − | }; | |
| 369 | − | documented.push_str(description); | |
| 370 | − | documented.push('\n'); | |
| 371 | − | documented.push_str(line); | |
| 372 | − | documented.push('\n'); | |
| 373 | − | continue; | |
| 374 | − | } | |
| 375 | − | ||
| 376 | − | if let Some((key, _)) = line.split_once(" = ") { | |
| 377 | − | let description = option_description(section, key); | |
| 378 | − | documented.push_str("# "); | |
| 379 | − | documented.push_str(description); | |
| 380 | − | documented.push('\n'); | |
| 381 | − | } | |
| 382 | − | documented.push_str(line); | |
| 383 | − | documented.push('\n'); | |
| 384 | − | } | |
| 385 | − | ||
| 386 | − | Ok(documented) | |
| 387 | − | } | |
| 388 | − | ||
| 389 | − | fn option_description(section: &str, key: &str) -> &'static str { | |
| 390 | − | match (section, key) { | |
| 391 | − | ("mpd", "host") => "MPD hostname or address. MPD_HOST overrides this value.", | |
| 392 | − | ("mpd", "port") => "MPD TCP port. MPD_PORT overrides it unless MPD_HOST includes a port.", | |
| 393 | − | ("mpd", "password") => { | |
| 394 | − | "Optional MPD password; leave empty when the server has no password." | |
| 395 | − | } | |
| 396 | − | ("playback", "seek_seconds") => "Seconds moved by seek_forward and seek_backward.", | |
| 397 | − | ("playback", "volume_step") => { | |
| 398 | − | "Volume percentage changed by volume_up and volume_down (1-100)." | |
| 399 | − | } | |
| 400 | − | ("keybindings", "quit") => "Exit rmptui.", | |
| 401 | − | ("keybindings", "clear_queue") => "Remove every song from the current play queue.", | |
| 402 | − | ("keybindings", "toggle_pause") => "Toggle playback between paused and playing.", | |
| 403 | − | ("keybindings", "pause") => "Pause playback.", | |
| 404 | − | ("keybindings", "toggle_repeat") => "Toggle repeat mode.", | |
| 405 | − | ("keybindings", "toggle_random") => "Toggle random playback mode.", | |
| 406 | − | ("keybindings", "open_playlist_menu") => { | |
| 407 | − | "Open the menu for adding the selection to a playlist." | |
| 408 | − | } | |
| 409 | − | ("keybindings", "seek_forward") => "Seek forward by playback.seek_seconds.", | |
| 410 | − | ("keybindings", "seek_backward") => "Seek backward by playback.seek_seconds.", | |
| 411 | − | ("keybindings", "cycle_tabs") => "Cycle through Queue, Browser, and Playlists views.", | |
| 412 | − | ("keybindings", "select_queue") => "Open the Queue view.", | |
| 413 | − | ("keybindings", "select_browser") => "Open the Browser view.", | |
| 414 | − | ("keybindings", "select_playlists") => "Open the Playlists view.", | |
| 415 | − | ("keybindings", "next_track") => "Start the next track in the queue.", | |
| 416 | − | ("keybindings", "previous_track") => "Start the previous track in the queue.", | |
| 417 | − | ("keybindings", "volume_up") => "Increase volume by playback.volume_step.", | |
| 418 | − | ("keybindings", "volume_down") => "Decrease volume by playback.volume_step.", | |
| 419 | − | ("keybindings", "toggle_mute") => "Toggle mute.", | |
| 420 | − | ("keybindings", "update_database") => "Ask MPD to rescan its music database.", | |
| 421 | − | ("keybindings", "search") => "Start fuzzy search in the current list.", | |
| 422 | − | ("keybindings", "toggle_selected_item") => { | |
| 423 | − | "Toggle the selected song in the queue or browser." | |
| 424 | − | } | |
| 425 | − | ("keybindings", "search_next") => "Select the next browser fuzzy-search match.", | |
| 426 | − | ("keybindings", "search_previous") => "Select the previous browser fuzzy-search match.", | |
| 427 | − | ("keybindings", "move_down") => "Move selection down in the focused list.", | |
| 428 | − | ("keybindings", "move_up") => "Move selection up in the focused list.", | |
| 429 | − | ("keybindings", "activate") => { | |
| 430 | − | "Activate the focused item (for example, play a song or enter a directory)." | |
| 431 | − | } | |
| 432 | − | ("keybindings", "go_back") => "Go to the parent directory in the Browser view.", | |
| 433 | − | ("keybindings", "go_to_top") => "Select the first item in the focused list.", | |
| 434 | − | ("keybindings", "go_to_bottom") => "Select the last item in the focused list.", | |
| 435 | − | ("keybindings", "queue_delete") => "Remove the selected song from the current queue.", | |
| 436 | − | ("keybindings", "queue_move_down") => "Move the selected queue song down one position.", | |
| 437 | − | ("keybindings", "queue_move_up") => "Move the selected queue song up one position.", | |
| 438 | − | ("keybindings", "playlist_rename") => "Rename the selected saved playlist.", | |
| 439 | − | ("keybindings", "playlist_focus_contents") => { | |
| 440 | − | "Focus the selected playlist's song contents." | |
| 441 | − | } | |
| 442 | − | ("keybindings", "playlist_focus_list") => "Return focus to the saved-playlist list.", | |
| 443 | − | ("keybindings", "playlist_delete_song") => { | |
| 444 | − | "Delete the selected song from the saved playlist." | |
| 445 | − | } | |
| 446 | − | ("keybindings", "playlist_move_song_down") => { | |
| 447 | − | "Move the selected saved-playlist song down one position." | |
| 448 | − | } | |
| 449 | − | ("keybindings", "playlist_move_song_up") => { | |
| 450 | − | "Move the selected saved-playlist song up one position." | |
| 451 | − | } | |
| 452 | − | ("keybindings", "playlist_delete") => "Delete the selected saved playlist.", | |
| 453 | − | ("keybindings", "playlist_load") => { | |
| 454 | − | "Append the selected saved playlist to the queue and start playback." | |
| 455 | − | } | |
| 456 | − | ("keybindings", "popup_close") => "Close the add-to-playlist menu.", | |
| 457 | − | ("keybindings", "popup_select") => { | |
| 458 | − | "Choose the selected destination in the add-to-playlist menu." | |
| 459 | − | } | |
| 460 | − | ("keybindings", "input_cancel") => "Cancel the active search or playlist-name input.", | |
| 461 | − | ("keybindings", "input_backspace") => "Delete the character before the input cursor.", | |
| 462 | − | ("keybindings", "input_cursor_left") => "Move the input cursor left.", | |
| 463 | − | ("keybindings", "input_cursor_right") => "Move the input cursor right.", | |
| 464 | − | ("keybindings", "input_submit") => "Submit the active search or playlist-name input.", | |
| 465 | − | _ => "Key aliases for this action.", | |
| 466 | − | } | |
| 467 | − | } | |
| 468 | − | ||
| 469 | − | fn create_config_file(path: &std::path::Path, contents: &[u8]) -> io::Result<()> { | |
| 470 | − | let mut options = OpenOptions::new(); | |
| 471 | − | options.write(true).create_new(true); | |
| 472 | − | #[cfg(unix)] | |
| 473 | − | { | |
| 474 | − | use std::os::unix::fs::OpenOptionsExt; | |
| 475 | − | options.mode(0o600); | |
| 476 | − | } | |
| 477 | − | let mut file = options.open(path)?; | |
| 478 | − | file.write_all(contents) | |
| 479 | − | } | |
| 480 | − | ||
| 481 | − | fn invalid_config(message: &str) -> Box<dyn Error + Send + Sync> { | |
| 482 | − | Box::new(io::Error::new( | |
| 483 | − | io::ErrorKind::InvalidData, | |
| 484 | − | message.to_string(), | |
| 485 | − | )) | |
| 486 | − | } | |
| 487 | − | ||
| 488 | − | #[cfg(test)] | |
| 489 | − | mod tests { | |
| 490 | − | use super::*; | |
| 491 | − | ||
| 492 | − | #[test] | |
| 493 | − | fn default_config_round_trips_with_all_sections() { | |
| 494 | − | let config = Config::default(); | |
| 495 | − | let serialized = toml::to_string_pretty(&config).unwrap(); | |
| 496 | − | let parsed: Config = toml::from_str(&serialized).unwrap(); | |
| 497 | − | ||
| 498 | − | assert_eq!(parsed.mpd.host, "127.0.0.1"); | |
| 499 | − | assert_eq!(parsed.mpd.port, 6600); | |
| 500 | − | assert_eq!(parsed.playback.seek_seconds, 2); | |
| 501 | − | assert_eq!(parsed.playback.volume_step, 2); | |
| 502 | − | assert!(parsed.keybindings.matches( | |
| 503 | − | "quit", | |
| 504 | − | KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()) | |
| 505 | − | )); | |
| 506 | − | assert!(parsed.keybindings.matches( | |
| 507 | − | "quit", | |
| 508 | − | KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) | |
| 509 | − | )); | |
| 510 | − | } | |
| 511 | − | ||
| 512 | − | #[test] | |
| 513 | − | fn generated_default_config_is_toml_and_has_helpful_comments() { | |
| 514 | − | let contents = default_config_text().unwrap(); | |
| 515 | − | let parsed: Config = toml::from_str(&contents).unwrap(); | |
| 516 | − | assert_eq!(parsed.mpd.host, "127.0.0.1"); | |
| 517 | − | assert!(contents.starts_with("# rmptui configuration.")); | |
| 518 | − | assert!(contents.contains("password = \"\"")); | |
| 519 | − | assert!(contents.contains("[keybindings]")); | |
| 520 | − | assert!(contents.contains("input_submit = [\"Enter\"]")); | |
| 521 | − | assert!(contents.contains("MPD_HOST overrides [mpd].host")); | |
| 522 | − | assert!(contents.contains("MPD_PORT overrides")); | |
| 523 | − | assert!(contents.contains("# Focus the selected playlist's song contents.")); | |
| 524 | − | assert!(contents | |
| 525 | − | .contains("# Append the selected saved playlist to the queue and start playback.")); | |
| 526 | − | assert!(contents.contains("playlist_focus_contents = [")); | |
| 527 | − | for action in Keybindings::default().bindings.keys() { | |
| 528 | − | assert_ne!( | |
| 529 | − | option_description("keybindings", action), | |
| 530 | − | "Key aliases for this action.", | |
| 531 | − | "missing generated-config documentation for `{action}`" | |
| 532 | − | ); | |
| 533 | − | } | |
| 534 | − | } | |
| 535 | − | ||
| 536 | − | #[test] | |
| 537 | − | fn creates_default_config_and_reads_user_changes() { | |
| 538 | − | let unique = format!( | |
| 539 | − | "rmptui-config-test-{}-{}", | |
| 540 | − | std::process::id(), | |
| 541 | − | std::time::SystemTime::now() | |
| 542 | − | .duration_since(std::time::UNIX_EPOCH) | |
| 543 | − | .unwrap() | |
| 544 | − | .as_nanos() | |
| 545 | − | ); | |
| 546 | − | let directory = env::temp_dir().join(unique); | |
| 547 | − | let path = directory.join("rmptui").join("config.toml"); | |
| 548 | − | ||
| 549 | − | let created = Config::load_or_create_at(&path).unwrap(); | |
| 550 | − | assert_eq!(created.playback.volume_step, 2); | |
| 551 | − | let contents = fs::read_to_string(&path).unwrap(); | |
| 552 | − | assert!(contents.contains("password = \"\"")); | |
| 553 | − | ||
| 554 | − | fs::write( | |
| 555 | − | &path, | |
| 556 | − | contents.replace("volume_step = 2", "volume_step = 8"), | |
| 557 | − | ) | |
| 558 | − | .unwrap(); | |
| 559 | − | let loaded = Config::load_or_create_at(&path).unwrap(); | |
| 560 | − | assert_eq!(loaded.playback.volume_step, 8); | |
| 561 | − | ||
| 562 | − | fs::remove_dir_all(directory).unwrap(); | |
| 563 | − | } | |
| 564 | − | ||
| 565 | − | #[test] | |
| 566 | − | fn partial_configuration_uses_defaults_for_missing_values() { | |
| 567 | − | let parsed: Config = toml::from_str( | |
| 568 | − | r#" | |
| 569 | − | [mpd] | |
| 570 | − | host = "music.local" | |
| 571 | − | port = 6601 | |
| 572 | − | "#, | |
| 573 | − | ) | |
| 574 | − | .unwrap(); | |
| 575 | − | ||
| 576 | − | assert_eq!(parsed.mpd.host, "music.local"); | |
| 577 | − | assert_eq!(parsed.mpd.port, 6601); | |
| 578 | − | assert_eq!(parsed.playback.seek_seconds, 2); | |
| 579 | − | assert_eq!( | |
| 580 | − | parsed.keybindings.bindings.len(), | |
| 581 | − | Keybindings::default().bindings.len() | |
| 582 | − | ); | |
| 583 | − | } | |
| 584 | − | ||
| 585 | − | #[test] | |
| 586 | − | fn keybinding_parser_supports_aliases_modifiers_and_punctuation() { | |
| 587 | − | let bindings = Keybindings::default(); | |
| 588 | − | assert!(bindings.matches( | |
| 589 | − | "volume_up", | |
| 590 | − | KeyEvent::new(KeyCode::Char('+'), KeyModifiers::empty()) | |
| 591 | − | )); | |
| 592 | − | assert!(bindings.matches( | |
| 593 | − | "move_down", | |
| 594 | − | KeyEvent::new(KeyCode::Down, KeyModifiers::empty()) | |
| 595 | − | )); | |
| 596 | − | assert!(bindings.matches( | |
| 597 | − | "quit", | |
| 598 | − | KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) | |
| 599 | − | )); | |
| 600 | − | assert!(bindings.matches( | |
| 601 | − | "search_previous", | |
| 602 | − | KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT) | |
| 603 | − | )); | |
| 604 | − | assert!(parse_key_spec("Ctrl+Char(+)").is_ok()); | |
| 605 | − | assert_eq!(parse_key_spec("F12").unwrap().code, KeyCode::F(12)); | |
| 606 | − | } | |
| 607 | − | ||
| 608 | − | #[test] | |
| 609 | − | fn partial_keybinding_configuration_keeps_other_defaults() { | |
| 610 | − | let mut parsed: Config = toml::from_str( | |
| 611 | − | r#" | |
| 612 | − | [keybindings] | |
| 613 | − | quit = ["x"] | |
| 614 | − | "#, | |
| 615 | − | ) | |
| 616 | − | .unwrap(); | |
| 617 | − | parsed.keybindings.apply_missing_defaults(); | |
| 618 | − | ||
| 619 | − | assert!(parsed.keybindings.matches( | |
| 620 | − | "quit", | |
| 621 | − | KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty()) | |
| 622 | − | )); | |
| 623 | − | assert!(parsed.keybindings.matches( | |
| 624 | − | "move_down", | |
| 625 | − | KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()) | |
| 626 | − | )); | |
| 627 | − | } | |
| 628 | − | ||
| 629 | − | #[test] | |
| 630 | − | fn playlist_focus_defaults_migrate_with_existing_default_config() { | |
| 631 | − | let mut parsed: Config = toml::from_str( | |
| 632 | − | r#" | |
| 633 | − | [keybindings] | |
| 634 | − | playlist_focus_contents = ["Right"] | |
| 635 | − | playlist_focus_list = ["Left"] | |
| 636 | − | "#, | |
| 637 | − | ) | |
| 638 | − | .unwrap(); | |
| 639 | − | parsed.keybindings.apply_missing_defaults(); | |
| 640 | − | ||
| 641 | − | assert_eq!( | |
| 642 | − | parsed.keybindings.bindings["playlist_focus_contents"], | |
| 643 | − | vec!["Right", "l"] | |
| 644 | − | ); | |
| 645 | − | assert_eq!( | |
| 646 | − | parsed.keybindings.bindings["playlist_focus_list"], | |
| 647 | − | vec!["Left", "h"] | |
| 648 | − | ); | |
| 649 | − | assert_eq!( | |
| 650 | − | parsed.keybindings.bindings["playlist_load"], | |
| 651 | − | vec!["Space", "Enter"] | |
| 652 | − | ); | |
| 653 | − | } | |
| 654 | − | ||
| 655 | − | #[test] | |
| 656 | − | fn invalid_keybinding_actions_and_keys_are_rejected() { | |
| 657 | − | let mut bindings = Keybindings::default(); | |
| 658 | − | bindings | |
| 659 | − | .bindings | |
| 660 | − | .insert("unknown_action".to_string(), vec!["x".to_string()]); | |
| 661 | − | assert!(bindings.validate().is_err()); | |
| 662 | − | ||
| 663 | − | let mut bindings = Keybindings::default(); | |
| 664 | − | bindings | |
| 665 | − | .bindings | |
| 666 | − | .insert("quit".to_string(), vec!["not-a-key".to_string()]); | |
| 667 | − | assert!(bindings.validate().is_err()); | |
| 668 | − | } | |
| 669 | − | } | |
| @@ -0,0 +1,221 @@ | |||
| 1 | + | # rmptui configuration | |
| 2 | + | # | |
| 3 | + | # Every option is shown with its default value. Settings you leave out keep | |
| 4 | + | # their defaults, so you can trim this file down to the parts you change. | |
| 5 | + | # Restart rmptui to apply changes. | |
| 6 | + | ||
| 7 | + | [mpd] | |
| 8 | + | # Hostname, IP address, or the path of MPD's Unix socket (starting with `/` | |
| 9 | + | # or `~`). Overridden by the MPD_HOST environment variable, which also accepts | |
| 10 | + | # `password@host`. | |
| 11 | + | host = "127.0.0.1" | |
| 12 | + | # Overridden by MPD_PORT. Ignored for Unix sockets. | |
| 13 | + | port = 6600 | |
| 14 | + | # Set this only if your MPD server requires a password. It is stored in plain | |
| 15 | + | # text; rmptui creates this file readable only by you. | |
| 16 | + | password = "" | |
| 17 | + | # The music directory on this machine, required by the tag editor and for | |
| 18 | + | # `.lrc` files stored next to songs. Leave empty to ask MPD, which only answers | |
| 19 | + | # over a Unix socket. | |
| 20 | + | music_directory = "" | |
| 21 | + | ||
| 22 | + | [playback] | |
| 23 | + | # Seconds moved by seek_forward / seek_backward. | |
| 24 | + | seek_seconds = 5 | |
| 25 | + | # Percent changed by volume_up / volume_down. | |
| 26 | + | volume_step = 5 | |
| 27 | + | ||
| 28 | + | [ui] | |
| 29 | + | # Screen shown on startup: queue, browser, playlists, library, tag_editor, | |
| 30 | + | # visualizer or lyrics. | |
| 31 | + | startup_screen = "queue" | |
| 32 | + | # Enable mouse support (click to select, double-click to play/open, wheel to | |
| 33 | + | # scroll, click the progress bar to seek). | |
| 34 | + | mouse = true | |
| 35 | + | # Ask before clearing the queue. | |
| 36 | + | confirm_clear_queue = false | |
| 37 | + | # Show the lyrics pane next to the queue on startup (toggle with `L`). | |
| 38 | + | lyrics_pane = false | |
| 39 | + | # Width of the lyrics pane in percent of the screen. | |
| 40 | + | lyrics_pane_width = 40 | |
| 41 | + | # Seconds a status message stays visible. | |
| 42 | + | message_timeout = 4 | |
| 43 | + | # Columns of the song tables (queue, browser, playlist contents). `field` is | |
| 44 | + | # one of artist, album_artist, title, album, track, disc, date, genre, | |
| 45 | + | # duration or file. `width` is a relative weight, except for track, disc and | |
| 46 | + | # duration, where it is a fixed number of characters. | |
| 47 | + | song_columns = [ | |
| 48 | + | { field = "artist", width = 34 }, | |
| 49 | + | { field = "track", width = 5 }, | |
| 50 | + | { field = "title", width = 33 }, | |
| 51 | + | { field = "album", width = 30 }, | |
| 52 | + | { field = "duration", width = 5 }, | |
| 53 | + | ] | |
| 54 | + | ||
| 55 | + | [theme] | |
| 56 | + | # Colors are names (black, red, green, yellow, blue, magenta, cyan, gray, | |
| 57 | + | # darkgray, lightred, …, white, reset), 256-color indices ("208") or hex | |
| 58 | + | # values ("#c694ff"). "reset" is your terminal's default color. | |
| 59 | + | text = "reset" | |
| 60 | + | dim = "darkgray" | |
| 61 | + | # Horizontal and vertical separator lines. | |
| 62 | + | border = "gray" | |
| 63 | + | # The selected row. With both set to "reset", the row is shown in reverse | |
| 64 | + | # video like ncmpcpp. | |
| 65 | + | selection_fg = "reset" | |
| 66 | + | selection_bg = "reset" | |
| 67 | + | playing = "reset" | |
| 68 | + | marked = "yellow" | |
| 69 | + | directory = "reset" | |
| 70 | + | error = "red" | |
| 71 | + | # Song table columns. | |
| 72 | + | artist = "reset" | |
| 73 | + | track = "darkgray" | |
| 74 | + | album = "reset" | |
| 75 | + | duration = "reset" | |
| 76 | + | # The filled part of the progress bar. | |
| 77 | + | progress = "gray" | |
| 78 | + | # Visualizer bar gradient, from the bottom of the screen to the top. | |
| 79 | + | visualizer = ["blue", "cyan", "green", "yellow", "red"] | |
| 80 | + | ||
| 81 | + | [lyrics] | |
| 82 | + | # Look up lyrics on https://lrclib.net when no local `.lrc` file exists. | |
| 83 | + | online = true | |
| 84 | + | # Keep fetched lyrics in $XDG_CACHE_HOME/rmptui/lyrics (~/.cache by default). | |
| 85 | + | cache = true | |
| 86 | + | lrclib_url = "https://lrclib.net" | |
| 87 | + | ||
| 88 | + | [visualizer] | |
| 89 | + | # Path of an MPD fifo output. Add this to mpd.conf: | |
| 90 | + | # | |
| 91 | + | # audio_output { | |
| 92 | + | # type "fifo" | |
| 93 | + | # name "Visualizer feed" | |
| 94 | + | # path "/tmp/mpd.fifo" | |
| 95 | + | # format "44100:16:2" | |
| 96 | + | # } | |
| 97 | + | fifo = "/tmp/mpd.fifo" | |
| 98 | + | # Startup style: wave, wave_filled, spectrum or ellipse. Press | |
| 99 | + | # `cycle_visualizer` (Space) on the visualizer screen to switch. | |
| 100 | + | style = "wave" | |
| 101 | + | # Draw the left channel in the top half and the right one in the bottom half | |
| 102 | + | # (wave styles only). | |
| 103 | + | stereo = true | |
| 104 | + | # Character used to draw the wave and ellipse styles. | |
| 105 | + | wave_symbol = "●" | |
| 106 | + | # Must match the `format` of the fifo output. | |
| 107 | + | sample_rate = 44100 | |
| 108 | + | channels = 2 | |
| 109 | + | framerate = 60 | |
| 110 | + | bar_width = 2 | |
| 111 | + | bar_gap = 1 | |
| 112 | + | min_frequency = 40 | |
| 113 | + | max_frequency = 16000 | |
| 114 | + | # How fast bars fall, in screen heights per second. | |
| 115 | + | falloff = 1.5 | |
| 116 | + | ||
| 117 | + | # Key bindings | |
| 118 | + | # | |
| 119 | + | # Each table is a context. Lookups start with the context of the focused | |
| 120 | + | # popup or screen, then `navigation`, then `global`, so a key bound in a | |
| 121 | + | # screen context shadows the same key in `global` on that screen. | |
| 122 | + | # | |
| 123 | + | # Each action takes a list of keys. A key is a character or one of Enter, | |
| 124 | + | # Esc, Tab, Backspace, Delete, Insert, Space, Up, Down, Left, Right, Home, | |
| 125 | + | # End, PageUp, PageDown and F1–F24, optionally prefixed by Ctrl+, Alt+ or | |
| 126 | + | # Shift+ (e.g. "Ctrl+x", "Alt+Enter", "Shift+Tab"). Set an action to [] to | |
| 127 | + | # unbind it; a key may only be used once per context. | |
| 128 | + | ||
| 129 | + | [keys.global] | |
| 130 | + | quit = ["q", "Ctrl+c"] | |
| 131 | + | help = ["?", "F1"] | |
| 132 | + | toggle_pause = ["p"] | |
| 133 | + | pause = ["s"] | |
| 134 | + | stop = ["x"] | |
| 135 | + | next_track = [">"] | |
| 136 | + | previous_track = ["<"] | |
| 137 | + | seek_forward = ["f"] | |
| 138 | + | seek_backward = ["b"] | |
| 139 | + | volume_up = ["+", "="] | |
| 140 | + | volume_down = ["-"] | |
| 141 | + | toggle_mute = ["m"] | |
| 142 | + | toggle_repeat = ["r"] | |
| 143 | + | toggle_random = ["z"] | |
| 144 | + | toggle_single = ["y"] | |
| 145 | + | toggle_consume = ["R"] | |
| 146 | + | update_database = ["U"] | |
| 147 | + | clear_queue = ["c"] | |
| 148 | + | next_screen = ["Tab"] | |
| 149 | + | previous_screen = ["Shift+Tab"] | |
| 150 | + | screen_queue = ["1"] | |
| 151 | + | screen_browser = ["2"] | |
| 152 | + | screen_playlists = ["3"] | |
| 153 | + | screen_library = ["4"] | |
| 154 | + | screen_tag_editor = ["5"] | |
| 155 | + | screen_visualizer = ["6"] | |
| 156 | + | screen_lyrics = ["7"] | |
| 157 | + | ||
| 158 | + | [keys.navigation] | |
| 159 | + | up = ["k", "Up"] | |
| 160 | + | down = ["j", "Down"] | |
| 161 | + | top = ["g", "Home"] | |
| 162 | + | bottom = ["G", "End"] | |
| 163 | + | page_up = ["PageUp"] | |
| 164 | + | page_down = ["PageDown"] | |
| 165 | + | half_page_up = ["Ctrl+u"] | |
| 166 | + | half_page_down = ["Ctrl+d"] | |
| 167 | + | left = ["h", "Left", "Backspace"] | |
| 168 | + | right = ["l", "Right"] | |
| 169 | + | activate = ["Enter"] | |
| 170 | + | toggle_mark = ["v", "Insert"] | |
| 171 | + | clear_marks = ["V"] | |
| 172 | + | search = ["/"] | |
| 173 | + | search_next = ["n"] | |
| 174 | + | search_previous = ["N"] | |
| 175 | + | add_to_queue = ["Space"] | |
| 176 | + | add_to_playlist = ["a"] | |
| 177 | + | ||
| 178 | + | [keys.queue] | |
| 179 | + | delete = ["d", "Delete"] | |
| 180 | + | move_item_up = ["K"] | |
| 181 | + | move_item_down = ["J"] | |
| 182 | + | shuffle_queue = ["Z"] | |
| 183 | + | save_queue = ["S"] | |
| 184 | + | jump_to_current = ["o"] | |
| 185 | + | toggle_lyrics_pane = ["L"] | |
| 186 | + | ||
| 187 | + | [keys.browser] | |
| 188 | + | ||
| 189 | + | [keys.playlists] | |
| 190 | + | delete = ["d", "Delete"] | |
| 191 | + | move_item_up = ["K"] | |
| 192 | + | move_item_down = ["J"] | |
| 193 | + | delete_playlist = ["D"] | |
| 194 | + | rename_playlist = ["R"] | |
| 195 | + | ||
| 196 | + | [keys.library] | |
| 197 | + | toggle_artist_tag = ["t"] | |
| 198 | + | ||
| 199 | + | [keys.tag_editor] | |
| 200 | + | save_tags = ["w"] | |
| 201 | + | revert_tags = ["u"] | |
| 202 | + | ||
| 203 | + | [keys.visualizer] | |
| 204 | + | cycle_visualizer = ["Space"] | |
| 205 | + | ||
| 206 | + | [keys.lyrics] | |
| 207 | + | reload_lyrics = ["F5"] | |
| 208 | + | ||
| 209 | + | [keys.popup] | |
| 210 | + | close = ["q", "Esc"] | |
| 211 | + | ||
| 212 | + | [keys.input] | |
| 213 | + | submit = ["Enter"] | |
| 214 | + | cancel = ["Esc"] | |
| 215 | + | backspace = ["Backspace"] | |
| 216 | + | delete_char = ["Delete"] | |
| 217 | + | delete_word = ["Ctrl+w"] | |
| 218 | + | cursor_left = ["Left"] | |
| 219 | + | cursor_right = ["Right"] | |
| 220 | + | cursor_home = ["Home", "Ctrl+a"] | |
| 221 | + | cursor_end = ["End", "Ctrl+e"] | |
| @@ -0,0 +1,305 @@ | |||
| 1 | + | //! Key specifications (`Ctrl+x`, `Space`, `F5`, …) and the context keymap. | |
| 2 | + | ||
| 3 | + | use std::{ | |
| 4 | + | collections::{BTreeMap, HashMap}, | |
| 5 | + | fmt, | |
| 6 | + | str::FromStr, | |
| 7 | + | }; | |
| 8 | + | ||
| 9 | + | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; | |
| 10 | + | use serde::{Deserialize, Deserializer, de}; | |
| 11 | + | ||
| 12 | + | use crate::action::{Action, Context}; | |
| 13 | + | ||
| 14 | + | /// A key with modifiers, normalized so that lookups ignore how terminals | |
| 15 | + | /// report shifted characters. | |
| 16 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 17 | + | pub struct KeyCombo { | |
| 18 | + | pub code: KeyCode, | |
| 19 | + | pub modifiers: KeyModifiers, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | impl KeyCombo { | |
| 23 | + | pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self { | |
| 24 | + | let mut modifiers = | |
| 25 | + | modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT); | |
| 26 | + | let code = match code { | |
| 27 | + | // `Shift+a` and `A` are the same key; the case carries the shift. | |
| 28 | + | KeyCode::Char(c) if modifiers.contains(KeyModifiers::SHIFT) => { | |
| 29 | + | modifiers.remove(KeyModifiers::SHIFT); | |
| 30 | + | KeyCode::Char(c.to_uppercase().next().unwrap_or(c)) | |
| 31 | + | } | |
| 32 | + | KeyCode::Tab if modifiers.contains(KeyModifiers::SHIFT) => { | |
| 33 | + | modifiers.remove(KeyModifiers::SHIFT); | |
| 34 | + | KeyCode::BackTab | |
| 35 | + | } | |
| 36 | + | KeyCode::BackTab => { | |
| 37 | + | modifiers.remove(KeyModifiers::SHIFT); | |
| 38 | + | KeyCode::BackTab | |
| 39 | + | } | |
| 40 | + | other => other, | |
| 41 | + | }; | |
| 42 | + | Self { code, modifiers } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Whether this is a printable character without Ctrl/Alt, i.e. text. | |
| 46 | + | pub fn as_text(&self) -> Option<char> { | |
| 47 | + | match self.code { | |
| 48 | + | KeyCode::Char(c) | |
| 49 | + | if !self | |
| 50 | + | .modifiers | |
| 51 | + | .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => | |
| 52 | + | { | |
| 53 | + | Some(c) | |
| 54 | + | } | |
| 55 | + | _ => None, | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | impl From<KeyEvent> for KeyCombo { | |
| 61 | + | fn from(event: KeyEvent) -> Self { | |
| 62 | + | Self::new(event.code, event.modifiers) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | impl FromStr for KeyCombo { | |
| 67 | + | type Err = String; | |
| 68 | + | ||
| 69 | + | fn from_str(spec: &str) -> Result<Self, Self::Err> { | |
| 70 | + | let mut modifiers = KeyModifiers::NONE; | |
| 71 | + | let mut rest = spec; | |
| 72 | + | // A lone `+` (or a trailing one, as in `Ctrl++`) is the plus key. | |
| 73 | + | while let Some((modifier, tail)) = rest.split_once('+').filter(|(m, _)| !m.is_empty()) { | |
| 74 | + | match modifier.to_ascii_lowercase().as_str() { | |
| 75 | + | "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL, | |
| 76 | + | "alt" | "meta" => modifiers |= KeyModifiers::ALT, | |
| 77 | + | "shift" => modifiers |= KeyModifiers::SHIFT, | |
| 78 | + | _ => return Err(format!("unknown modifier `{modifier}` in `{spec}`")), | |
| 79 | + | } | |
| 80 | + | rest = tail; | |
| 81 | + | } | |
| 82 | + | if rest.is_empty() { | |
| 83 | + | return Err(format!("missing key in `{spec}`")); | |
| 84 | + | } | |
| 85 | + | let lower = rest.to_ascii_lowercase(); | |
| 86 | + | let code = match lower.as_str() { | |
| 87 | + | "esc" | "escape" => KeyCode::Esc, | |
| 88 | + | "enter" | "return" => KeyCode::Enter, | |
| 89 | + | "tab" => KeyCode::Tab, | |
| 90 | + | "backtab" => KeyCode::BackTab, | |
| 91 | + | "backspace" => KeyCode::Backspace, | |
| 92 | + | "delete" | "del" => KeyCode::Delete, | |
| 93 | + | "insert" | "ins" => KeyCode::Insert, | |
| 94 | + | "up" => KeyCode::Up, | |
| 95 | + | "down" => KeyCode::Down, | |
| 96 | + | "left" => KeyCode::Left, | |
| 97 | + | "right" => KeyCode::Right, | |
| 98 | + | "home" => KeyCode::Home, | |
| 99 | + | "end" => KeyCode::End, | |
| 100 | + | "pageup" | "pgup" => KeyCode::PageUp, | |
| 101 | + | "pagedown" | "pgdn" => KeyCode::PageDown, | |
| 102 | + | "space" => KeyCode::Char(' '), | |
| 103 | + | _ => { | |
| 104 | + | if let Some(n) = lower.strip_prefix('f').and_then(|n| n.parse::<u8>().ok()) { | |
| 105 | + | if (1..=24).contains(&n) { | |
| 106 | + | KeyCode::F(n) | |
| 107 | + | } else { | |
| 108 | + | return Err(format!("unknown key `{rest}` in `{spec}`")); | |
| 109 | + | } | |
| 110 | + | } else { | |
| 111 | + | let mut chars = rest.chars(); | |
| 112 | + | match (chars.next(), chars.next()) { | |
| 113 | + | (Some(c), None) => KeyCode::Char(c), | |
| 114 | + | _ => return Err(format!("unknown key `{rest}` in `{spec}`")), | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | }; | |
| 119 | + | Ok(Self::new(code, modifiers)) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | impl fmt::Display for KeyCombo { | |
| 124 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 125 | + | if self.modifiers.contains(KeyModifiers::CONTROL) { | |
| 126 | + | f.write_str("Ctrl+")?; | |
| 127 | + | } | |
| 128 | + | if self.modifiers.contains(KeyModifiers::ALT) { | |
| 129 | + | f.write_str("Alt+")?; | |
| 130 | + | } | |
| 131 | + | if self.modifiers.contains(KeyModifiers::SHIFT) { | |
| 132 | + | f.write_str("Shift+")?; | |
| 133 | + | } | |
| 134 | + | match self.code { | |
| 135 | + | KeyCode::Char(' ') => f.write_str("Space"), | |
| 136 | + | KeyCode::Char(c) => write!(f, "{c}"), | |
| 137 | + | KeyCode::F(n) => write!(f, "F{n}"), | |
| 138 | + | KeyCode::BackTab => f.write_str("Shift+Tab"), | |
| 139 | + | KeyCode::PageUp => f.write_str("PageUp"), | |
| 140 | + | KeyCode::PageDown => f.write_str("PageDown"), | |
| 141 | + | other => write!(f, "{other:?}"), | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | impl<'de> Deserialize<'de> for KeyCombo { | |
| 147 | + | fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { | |
| 148 | + | let s = String::deserialize(deserializer)?; | |
| 149 | + | s.parse().map_err(de::Error::custom) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /// The `[keys.*]` tables as written in the config file. | |
| 154 | + | pub type Bindings = BTreeMap<Context, BTreeMap<Action, Vec<KeyCombo>>>; | |
| 155 | + | ||
| 156 | + | /// Resolved bindings: one lookup table per context. | |
| 157 | + | #[derive(Clone, Debug, Default)] | |
| 158 | + | pub struct Keymap { | |
| 159 | + | contexts: HashMap<Context, HashMap<KeyCombo, Action>>, | |
| 160 | + | bindings: Bindings, | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | impl Keymap { | |
| 164 | + | /// Builds the keymap, rejecting actions bound in the wrong context and | |
| 165 | + | /// keys bound twice within one context. | |
| 166 | + | pub fn new(bindings: Bindings) -> Result<Self, String> { | |
| 167 | + | let mut contexts: HashMap<Context, HashMap<KeyCombo, Action>> = HashMap::new(); | |
| 168 | + | for (&context, actions) in &bindings { | |
| 169 | + | let table = contexts.entry(context).or_default(); | |
| 170 | + | for (&action, keys) in actions { | |
| 171 | + | if !action.contexts().contains(&context) { | |
| 172 | + | let allowed: Vec<_> = action.contexts().iter().map(|c| c.name()).collect(); | |
| 173 | + | return Err(format!( | |
| 174 | + | "action `{action}` cannot be bound in [keys.{context}] (allowed in: {})", | |
| 175 | + | allowed.join(", ") | |
| 176 | + | )); | |
| 177 | + | } | |
| 178 | + | for &key in keys { | |
| 179 | + | if let Some(previous) = table.insert(key, action) | |
| 180 | + | && previous != action | |
| 181 | + | { | |
| 182 | + | return Err(format!( | |
| 183 | + | "key `{key}` is bound to both `{previous}` and `{action}` in [keys.{context}]; \ | |
| 184 | + | unbind one of them (an action can be disabled with `action = []`)" | |
| 185 | + | )); | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | Ok(Self { contexts, bindings }) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// Finds the action for `key`, searching `contexts` in order. | |
| 194 | + | pub fn resolve(&self, contexts: &[Context], key: KeyCombo) -> Option<Action> { | |
| 195 | + | contexts | |
| 196 | + | .iter() | |
| 197 | + | .find_map(|context| self.contexts.get(context)?.get(&key).copied()) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | /// The keys bound to `action` in `context`, for help texts. | |
| 201 | + | pub fn keys_for(&self, context: Context, action: Action) -> &[KeyCombo] { | |
| 202 | + | self.bindings | |
| 203 | + | .get(&context) | |
| 204 | + | .and_then(|actions| actions.get(&action)) | |
| 205 | + | .map_or(&[], Vec::as_slice) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | pub fn bindings(&self) -> &Bindings { | |
| 209 | + | &self.bindings | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | #[cfg(test)] | |
| 214 | + | mod tests { | |
| 215 | + | use super::*; | |
| 216 | + | ||
| 217 | + | fn key(spec: &str) -> KeyCombo { | |
| 218 | + | spec.parse().unwrap() | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | #[test] | |
| 222 | + | fn parses_specs() { | |
| 223 | + | assert_eq!( | |
| 224 | + | key("q"), | |
| 225 | + | KeyCombo::new(KeyCode::Char('q'), KeyModifiers::NONE) | |
| 226 | + | ); | |
| 227 | + | assert_eq!( | |
| 228 | + | key("Ctrl+c"), | |
| 229 | + | KeyCombo::new(KeyCode::Char('c'), KeyModifiers::CONTROL) | |
| 230 | + | ); | |
| 231 | + | assert_eq!( | |
| 232 | + | key("+"), | |
| 233 | + | KeyCombo::new(KeyCode::Char('+'), KeyModifiers::NONE) | |
| 234 | + | ); | |
| 235 | + | assert_eq!( | |
| 236 | + | key("Ctrl++"), | |
| 237 | + | KeyCombo::new(KeyCode::Char('+'), KeyModifiers::CONTROL) | |
| 238 | + | ); | |
| 239 | + | assert_eq!(key("Space").code, KeyCode::Char(' ')); | |
| 240 | + | assert_eq!(key("F5").code, KeyCode::F(5)); | |
| 241 | + | assert_eq!(key("Shift+Tab").code, KeyCode::BackTab); | |
| 242 | + | assert_eq!(key("Shift+g"), key("G")); | |
| 243 | + | assert!("Hyper+x".parse::<KeyCombo>().is_err()); | |
| 244 | + | assert!("abc".parse::<KeyCombo>().is_err()); | |
| 245 | + | assert!("F99".parse::<KeyCombo>().is_err()); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[test] | |
| 249 | + | fn normalizes_terminal_events() { | |
| 250 | + | // Terminals report `G` with or without SHIFT. | |
| 251 | + | let shifted = KeyCombo::from(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT)); | |
| 252 | + | assert_eq!(shifted, key("G")); | |
| 253 | + | let backtab = KeyCombo::from(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); | |
| 254 | + | assert_eq!(backtab, key("Shift+Tab")); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | #[test] | |
| 258 | + | fn displays_specs() { | |
| 259 | + | for spec in ["q", "Ctrl+c", "Space", "F5", "Enter", "Shift+Tab", "PageUp"] { | |
| 260 | + | assert_eq!(key(spec).to_string(), spec); | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | #[test] | |
| 265 | + | fn resolves_by_context_priority() { | |
| 266 | + | let mut bindings = Bindings::new(); | |
| 267 | + | bindings | |
| 268 | + | .entry(Context::Global) | |
| 269 | + | .or_default() | |
| 270 | + | .insert(Action::ToggleConsume, vec![key("R")]); | |
| 271 | + | bindings | |
| 272 | + | .entry(Context::Playlists) | |
| 273 | + | .or_default() | |
| 274 | + | .insert(Action::RenamePlaylist, vec![key("R")]); | |
| 275 | + | let keymap = Keymap::new(bindings).unwrap(); | |
| 276 | + | assert_eq!( | |
| 277 | + | keymap.resolve(&[Context::Playlists, Context::Global], key("R")), | |
| 278 | + | Some(Action::RenamePlaylist) | |
| 279 | + | ); | |
| 280 | + | assert_eq!( | |
| 281 | + | keymap.resolve(&[Context::Queue, Context::Global], key("R")), | |
| 282 | + | Some(Action::ToggleConsume) | |
| 283 | + | ); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[test] | |
| 287 | + | fn rejects_conflicts_and_misplaced_actions() { | |
| 288 | + | let mut bindings = Bindings::new(); | |
| 289 | + | let global = bindings.entry(Context::Global).or_default(); | |
| 290 | + | global.insert(Action::Quit, vec![key("q")]); | |
| 291 | + | global.insert(Action::Pause, vec![key("q")]); | |
| 292 | + | assert!(Keymap::new(bindings).unwrap_err().contains("bound to both")); | |
| 293 | + | ||
| 294 | + | let mut bindings = Bindings::new(); | |
| 295 | + | bindings | |
| 296 | + | .entry(Context::Queue) | |
| 297 | + | .or_default() | |
| 298 | + | .insert(Action::Quit, vec![key("q")]); | |
| 299 | + | assert!( | |
| 300 | + | Keymap::new(bindings) | |
| 301 | + | .unwrap_err() | |
| 302 | + | .contains("cannot be bound") | |
| 303 | + | ); | |
| 304 | + | } | |
| 305 | + | } | |
| @@ -0,0 +1,554 @@ | |||
| 1 | + | //! Configuration: loading, defaults, validation and the MPD address. | |
| 2 | + | //! | |
| 3 | + | //! `default.toml` is the single source of truth for defaults. A user file is | |
| 4 | + | //! merged over it table by table, so partial files keep every default they | |
| 5 | + | //! leave out. | |
| 6 | + | ||
| 7 | + | mod keys; | |
| 8 | + | mod mpd_conf; | |
| 9 | + | mod theme; | |
| 10 | + | ||
| 11 | + | use std::{ | |
| 12 | + | env, | |
| 13 | + | fs::{self, OpenOptions}, | |
| 14 | + | io::{self, Write}, | |
| 15 | + | net::Ipv6Addr, | |
| 16 | + | path::{Path, PathBuf}, | |
| 17 | + | time::Duration, | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | use serde::Deserialize; | |
| 21 | + | ||
| 22 | + | pub use keys::{Bindings, KeyCombo, Keymap}; | |
| 23 | + | pub use mpd_conf::local_music_directory; | |
| 24 | + | pub use theme::Theme; | |
| 25 | + | ||
| 26 | + | use crate::{action::ScreenId, mpd::Address, util::expand_tilde}; | |
| 27 | + | ||
| 28 | + | pub const DEFAULT_CONFIG: &str = include_str!("default.toml"); | |
| 29 | + | ||
| 30 | + | #[derive(Debug, thiserror::Error)] | |
| 31 | + | pub enum ConfigError { | |
| 32 | + | #[error("cannot access {}: {source}", path.display())] | |
| 33 | + | Io { path: PathBuf, source: io::Error }, | |
| 34 | + | #[error("invalid configuration in {}: {message}", path.display())] | |
| 35 | + | Parse { path: PathBuf, message: String }, | |
| 36 | + | #[error("invalid configuration: {0}")] | |
| 37 | + | Invalid(String), | |
| 38 | + | #[error( | |
| 39 | + | "{} uses the rmptui 0.1 format, which 0.2 no longer reads.\n\ | |
| 40 | + | Move it aside (e.g. to config.toml.old) and start rmptui again to \ | |
| 41 | + | generate a documented 0.2 configuration, then copy your settings over.", | |
| 42 | + | .0.display() | |
| 43 | + | )] | |
| 44 | + | Legacy(PathBuf), | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | #[derive(Clone, Debug, Deserialize)] | |
| 48 | + | #[serde(deny_unknown_fields)] | |
| 49 | + | pub struct MpdConfig { | |
| 50 | + | pub host: String, | |
| 51 | + | pub port: u16, | |
| 52 | + | pub password: String, | |
| 53 | + | pub music_directory: String, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | #[derive(Clone, Debug, Deserialize)] | |
| 57 | + | #[serde(deny_unknown_fields)] | |
| 58 | + | pub struct PlaybackConfig { | |
| 59 | + | pub seek_seconds: u32, | |
| 60 | + | pub volume_step: u8, | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] | |
| 64 | + | #[serde(rename_all = "snake_case")] | |
| 65 | + | pub enum ColumnField { | |
| 66 | + | Artist, | |
| 67 | + | AlbumArtist, | |
| 68 | + | Title, | |
| 69 | + | Album, | |
| 70 | + | Track, | |
| 71 | + | Disc, | |
| 72 | + | Date, | |
| 73 | + | Genre, | |
| 74 | + | Duration, | |
| 75 | + | File, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | impl ColumnField { | |
| 79 | + | pub fn title(self) -> &'static str { | |
| 80 | + | match self { | |
| 81 | + | Self::Artist => "Artist", | |
| 82 | + | Self::AlbumArtist => "Album Artist", | |
| 83 | + | Self::Title => "Title", | |
| 84 | + | Self::Album => "Album", | |
| 85 | + | Self::Track => "Track", | |
| 86 | + | Self::Disc => "Disc", | |
| 87 | + | Self::Date => "Date", | |
| 88 | + | Self::Genre => "Genre", | |
| 89 | + | Self::Duration => "Time", | |
| 90 | + | Self::File => "File", | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[derive(Clone, Copy, Debug, Deserialize)] | |
| 96 | + | #[serde(deny_unknown_fields)] | |
| 97 | + | pub struct Column { | |
| 98 | + | pub field: ColumnField, | |
| 99 | + | pub width: u16, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | #[derive(Clone, Debug, Deserialize)] | |
| 103 | + | #[serde(deny_unknown_fields)] | |
| 104 | + | pub struct UiConfig { | |
| 105 | + | pub startup_screen: ScreenId, | |
| 106 | + | pub mouse: bool, | |
| 107 | + | pub confirm_clear_queue: bool, | |
| 108 | + | pub lyrics_pane: bool, | |
| 109 | + | pub lyrics_pane_width: u16, | |
| 110 | + | pub message_timeout: u64, | |
| 111 | + | pub song_columns: Vec<Column>, | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | #[derive(Clone, Debug, Deserialize)] | |
| 115 | + | #[serde(deny_unknown_fields)] | |
| 116 | + | pub struct LyricsConfig { | |
| 117 | + | pub online: bool, | |
| 118 | + | pub cache: bool, | |
| 119 | + | pub lrclib_url: String, | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | #[derive(Clone, Debug, Deserialize)] | |
| 123 | + | #[serde(deny_unknown_fields)] | |
| 124 | + | pub struct VisualizerConfig { | |
| 125 | + | pub fifo: PathBuf, | |
| 126 | + | pub style: VisualizerStyle, | |
| 127 | + | pub stereo: bool, | |
| 128 | + | pub wave_symbol: String, | |
| 129 | + | pub sample_rate: u32, | |
| 130 | + | pub channels: u16, | |
| 131 | + | pub framerate: u32, | |
| 132 | + | pub bar_width: u16, | |
| 133 | + | pub bar_gap: u16, | |
| 134 | + | pub min_frequency: f32, | |
| 135 | + | pub max_frequency: f32, | |
| 136 | + | pub falloff: f32, | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] | |
| 140 | + | #[serde(rename_all = "snake_case")] | |
| 141 | + | pub enum VisualizerStyle { | |
| 142 | + | Wave, | |
| 143 | + | WaveFilled, | |
| 144 | + | Spectrum, | |
| 145 | + | Ellipse, | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | impl VisualizerStyle { | |
| 149 | + | pub fn next(self) -> Self { | |
| 150 | + | match self { | |
| 151 | + | Self::Wave => Self::WaveFilled, | |
| 152 | + | Self::WaveFilled => Self::Spectrum, | |
| 153 | + | Self::Spectrum => Self::Ellipse, | |
| 154 | + | Self::Ellipse => Self::Wave, | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | pub fn name(self) -> &'static str { | |
| 159 | + | match self { | |
| 160 | + | Self::Wave => "wave", | |
| 161 | + | Self::WaveFilled => "wave filled", | |
| 162 | + | Self::Spectrum => "spectrum", | |
| 163 | + | Self::Ellipse => "ellipse", | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | #[derive(Debug, Deserialize)] | |
| 169 | + | #[serde(deny_unknown_fields)] | |
| 170 | + | struct RawConfig { | |
| 171 | + | mpd: MpdConfig, | |
| 172 | + | playback: PlaybackConfig, | |
| 173 | + | ui: UiConfig, | |
| 174 | + | theme: Theme, | |
| 175 | + | lyrics: LyricsConfig, | |
| 176 | + | visualizer: VisualizerConfig, | |
| 177 | + | keys: Bindings, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[derive(Clone, Debug)] | |
| 181 | + | pub struct Config { | |
| 182 | + | pub mpd: MpdConfig, | |
| 183 | + | pub playback: PlaybackConfig, | |
| 184 | + | pub ui: UiConfig, | |
| 185 | + | pub theme: Theme, | |
| 186 | + | pub lyrics: LyricsConfig, | |
| 187 | + | pub visualizer: VisualizerConfig, | |
| 188 | + | pub keymap: Keymap, | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | impl Default for Config { | |
| 192 | + | fn default() -> Self { | |
| 193 | + | Self::from_toml(DEFAULT_CONFIG).expect("the built-in default configuration is valid") | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | impl Config { | |
| 198 | + | /// Loads the config at `path`, or at the default location. A missing | |
| 199 | + | /// file at the default location is created from the documented defaults. | |
| 200 | + | pub fn load(path: Option<&Path>) -> Result<(Self, PathBuf), ConfigError> { | |
| 201 | + | let (path, create) = match path { | |
| 202 | + | Some(path) => (path.to_path_buf(), false), | |
| 203 | + | None => (default_path(), true), | |
| 204 | + | }; | |
| 205 | + | let io_error = |source| ConfigError::Io { | |
| 206 | + | path: path.clone(), | |
| 207 | + | source, | |
| 208 | + | }; | |
| 209 | + | let contents = match fs::read_to_string(&path) { | |
| 210 | + | Ok(contents) => contents, | |
| 211 | + | Err(error) if error.kind() == io::ErrorKind::NotFound && create => { | |
| 212 | + | write_default(&path).map_err(io_error)?; | |
| 213 | + | DEFAULT_CONFIG.to_owned() | |
| 214 | + | } | |
| 215 | + | Err(error) => return Err(io_error(error)), | |
| 216 | + | }; | |
| 217 | + | let user: toml::Table = toml::from_str(&contents).map_err(|error| ConfigError::Parse { | |
| 218 | + | path: path.clone(), | |
| 219 | + | message: error.to_string(), | |
| 220 | + | })?; | |
| 221 | + | if user.contains_key("keybindings") { | |
| 222 | + | return Err(ConfigError::Legacy(path)); | |
| 223 | + | } | |
| 224 | + | let config = Self::from_table(user).map_err(|error| match error { | |
| 225 | + | ConfigError::Invalid(message) => ConfigError::Parse { | |
| 226 | + | path: path.clone(), | |
| 227 | + | message, | |
| 228 | + | }, | |
| 229 | + | other => other, | |
| 230 | + | })?; | |
| 231 | + | Ok((config, path)) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// Parses a (partial) config, filling in defaults. | |
| 235 | + | pub fn from_toml(contents: &str) -> Result<Self, ConfigError> { | |
| 236 | + | let user: toml::Table = | |
| 237 | + | toml::from_str(contents).map_err(|error| ConfigError::Invalid(error.to_string()))?; | |
| 238 | + | Self::from_table(user) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | fn from_table(user: toml::Table) -> Result<Self, ConfigError> { | |
| 242 | + | let mut merged: toml::Table = | |
| 243 | + | toml::from_str(DEFAULT_CONFIG).expect("the built-in default configuration is TOML"); | |
| 244 | + | merge(&mut merged, user); | |
| 245 | + | let raw: RawConfig = toml::Value::Table(merged) | |
| 246 | + | .try_into() | |
| 247 | + | .map_err(|error: toml::de::Error| ConfigError::Invalid(error.to_string()))?; | |
| 248 | + | let config = Self { | |
| 249 | + | keymap: Keymap::new(raw.keys).map_err(ConfigError::Invalid)?, | |
| 250 | + | mpd: raw.mpd, | |
| 251 | + | playback: raw.playback, | |
| 252 | + | ui: raw.ui, | |
| 253 | + | theme: raw.theme, | |
| 254 | + | lyrics: raw.lyrics, | |
| 255 | + | visualizer: raw.visualizer, | |
| 256 | + | }; | |
| 257 | + | config.validate()?; | |
| 258 | + | Ok(config) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | fn validate(&self) -> Result<(), ConfigError> { | |
| 262 | + | let check = |ok: bool, message: &str| { | |
| 263 | + | if ok { | |
| 264 | + | Ok(()) | |
| 265 | + | } else { | |
| 266 | + | Err(ConfigError::Invalid(message.to_owned())) | |
| 267 | + | } | |
| 268 | + | }; | |
| 269 | + | check(!self.mpd.host.trim().is_empty(), "mpd.host cannot be empty")?; | |
| 270 | + | check(self.mpd.port != 0, "mpd.port must be between 1 and 65535")?; | |
| 271 | + | check( | |
| 272 | + | self.playback.seek_seconds > 0, | |
| 273 | + | "playback.seek_seconds must be greater than zero", | |
| 274 | + | )?; | |
| 275 | + | check( | |
| 276 | + | (1..=100).contains(&self.playback.volume_step), | |
| 277 | + | "playback.volume_step must be between 1 and 100", | |
| 278 | + | )?; | |
| 279 | + | check( | |
| 280 | + | (10..=90).contains(&self.ui.lyrics_pane_width), | |
| 281 | + | "ui.lyrics_pane_width must be between 10 and 90", | |
| 282 | + | )?; | |
| 283 | + | check( | |
| 284 | + | !self.ui.song_columns.is_empty(), | |
| 285 | + | "ui.song_columns cannot be empty", | |
| 286 | + | )?; | |
| 287 | + | let vis = &self.visualizer; | |
| 288 | + | check( | |
| 289 | + | vis.sample_rate > 0, | |
| 290 | + | "visualizer.sample_rate must be greater than zero", | |
| 291 | + | )?; | |
| 292 | + | check( | |
| 293 | + | matches!(vis.channels, 1 | 2), | |
| 294 | + | "visualizer.channels must be 1 or 2", | |
| 295 | + | )?; | |
| 296 | + | check( | |
| 297 | + | (1..=240).contains(&vis.framerate), | |
| 298 | + | "visualizer.framerate must be between 1 and 240", | |
| 299 | + | )?; | |
| 300 | + | check( | |
| 301 | + | vis.bar_width > 0, | |
| 302 | + | "visualizer.bar_width must be greater than zero", | |
| 303 | + | )?; | |
| 304 | + | check( | |
| 305 | + | vis.min_frequency > 0.0 && vis.min_frequency < vis.max_frequency, | |
| 306 | + | "visualizer.min_frequency must be positive and below max_frequency", | |
| 307 | + | )?; | |
| 308 | + | check( | |
| 309 | + | vis.wave_symbol.chars().count() == 1, | |
| 310 | + | "visualizer.wave_symbol must be a single character", | |
| 311 | + | )?; | |
| 312 | + | check( | |
| 313 | + | vis.falloff > 0.0, | |
| 314 | + | "visualizer.falloff must be greater than zero", | |
| 315 | + | )?; | |
| 316 | + | check( | |
| 317 | + | !self.theme.visualizer.is_empty(), | |
| 318 | + | "theme.visualizer needs at least one color", | |
| 319 | + | )?; | |
| 320 | + | Ok(()) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /// The music directory from the config, if set. | |
| 324 | + | pub fn music_directory(&self) -> Option<PathBuf> { | |
| 325 | + | let dir = self.mpd.music_directory.trim(); | |
| 326 | + | (!dir.is_empty()).then(|| expand_tilde(dir)) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | pub fn message_timeout(&self) -> Duration { | |
| 330 | + | Duration::from_secs(self.ui.message_timeout) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | /// The MPD address and password, honoring `MPD_HOST` and `MPD_PORT`. | |
| 334 | + | pub fn mpd_connection(&self) -> Result<(Address, Option<String>), ConfigError> { | |
| 335 | + | resolve_connection( | |
| 336 | + | &self.mpd, | |
| 337 | + | env::var("MPD_HOST").ok().filter(|h| !h.is_empty()), | |
| 338 | + | env::var("MPD_PORT").ok().filter(|p| !p.is_empty()), | |
| 339 | + | ) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | /// Recursively merges `user` into `base`. Tables merge; other values replace. | |
| 344 | + | fn merge(base: &mut toml::Table, user: toml::Table) { | |
| 345 | + | for (key, value) in user { | |
| 346 | + | match (base.get_mut(&key), value) { | |
| 347 | + | (Some(toml::Value::Table(base)), toml::Value::Table(user)) => merge(base, user), | |
| 348 | + | (_, value) => { | |
| 349 | + | base.insert(key, value); | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /// `$CONFIG_HOME/rmptui/config.toml`, falling back to `$XDG_CONFIG_HOME` | |
| 356 | + | /// and then `~/.config`. | |
| 357 | + | pub fn default_path() -> PathBuf { | |
| 358 | + | env::var_os("CONFIG_HOME") | |
| 359 | + | .or_else(|| env::var_os("XDG_CONFIG_HOME")) | |
| 360 | + | .filter(|dir| !dir.is_empty()) | |
| 361 | + | .map(PathBuf::from) | |
| 362 | + | .unwrap_or_else(|| expand_tilde("~/.config")) | |
| 363 | + | .join("rmptui") | |
| 364 | + | .join("config.toml") | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | fn write_default(path: &Path) -> io::Result<()> { | |
| 368 | + | if let Some(parent) = path.parent() { | |
| 369 | + | fs::create_dir_all(parent)?; | |
| 370 | + | } | |
| 371 | + | let mut options = OpenOptions::new(); | |
| 372 | + | options.write(true).create_new(true); | |
| 373 | + | #[cfg(unix)] | |
| 374 | + | { | |
| 375 | + | use std::os::unix::fs::OpenOptionsExt; | |
| 376 | + | // The file may hold the MPD password. | |
| 377 | + | options.mode(0o600); | |
| 378 | + | } | |
| 379 | + | match options.open(path) { | |
| 380 | + | Ok(mut file) => file.write_all(DEFAULT_CONFIG.as_bytes()), | |
| 381 | + | // Another instance created it in the meantime. | |
| 382 | + | Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()), | |
| 383 | + | Err(error) => Err(error), | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | fn resolve_connection( | |
| 388 | + | mpd: &MpdConfig, | |
| 389 | + | env_host: Option<String>, | |
| 390 | + | env_port: Option<String>, | |
| 391 | + | ) -> Result<(Address, Option<String>), ConfigError> { | |
| 392 | + | let configured_password = (!mpd.password.is_empty()).then(|| mpd.password.clone()); | |
| 393 | + | let (host, password) = match env_host { | |
| 394 | + | Some(host) => match host.split_once('@') { | |
| 395 | + | Some((password, host)) => (host.to_owned(), Some(password.to_owned())), | |
| 396 | + | None => (host, configured_password), | |
| 397 | + | }, | |
| 398 | + | None => (mpd.host.clone(), configured_password), | |
| 399 | + | }; | |
| 400 | + | if host.is_empty() { | |
| 401 | + | return Err(ConfigError::Invalid("MPD host cannot be empty".into())); | |
| 402 | + | } | |
| 403 | + | if host.starts_with('/') || host.starts_with('~') { | |
| 404 | + | return Ok((Address::Unix(expand_tilde(&host)), password)); | |
| 405 | + | } | |
| 406 | + | let port = match env_port { | |
| 407 | + | Some(port) => port | |
| 408 | + | .parse::<u16>() | |
| 409 | + | .ok() | |
| 410 | + | .filter(|&p| p != 0) | |
| 411 | + | .ok_or_else(|| ConfigError::Invalid(format!("invalid MPD_PORT value `{port}`")))?, | |
| 412 | + | None => mpd.port, | |
| 413 | + | }; | |
| 414 | + | Ok((Address::Tcp(tcp_address(&host, port)), password)) | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// Formats `host` and `port` for connecting, keeping an explicit port in | |
| 418 | + | /// `host` and bracketing IPv6 addresses. | |
| 419 | + | fn tcp_address(host: &str, port: u16) -> String { | |
| 420 | + | if host.starts_with('[') && host.contains("]:") { | |
| 421 | + | host.to_owned() | |
| 422 | + | } else if let Some(ipv6) = host | |
| 423 | + | .strip_prefix('[') | |
| 424 | + | .and_then(|h| h.strip_suffix(']')) | |
| 425 | + | .filter(|h| h.parse::<Ipv6Addr>().is_ok()) | |
| 426 | + | { | |
| 427 | + | format!("[{ipv6}]:{port}") | |
| 428 | + | } else if host.parse::<Ipv6Addr>().is_ok() { | |
| 429 | + | format!("[{host}]:{port}") | |
| 430 | + | } else if host | |
| 431 | + | .rsplit_once(':') | |
| 432 | + | .is_some_and(|(_, suffix)| suffix.parse::<u16>().is_ok()) | |
| 433 | + | { | |
| 434 | + | host.to_owned() | |
| 435 | + | } else { | |
| 436 | + | format!("{host}:{port}") | |
| 437 | + | } | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | #[cfg(test)] | |
| 441 | + | mod tests { | |
| 442 | + | use super::*; | |
| 443 | + | use crate::action::{Action, Context}; | |
| 444 | + | ||
| 445 | + | #[test] | |
| 446 | + | fn default_config_is_valid_and_binds_every_action() { | |
| 447 | + | let config = Config::default(); | |
| 448 | + | for &action in Action::ALL { | |
| 449 | + | let bound = action | |
| 450 | + | .contexts() | |
| 451 | + | .iter() | |
| 452 | + | .any(|&context| !config.keymap.keys_for(context, action).is_empty()); | |
| 453 | + | assert!(bound, "action `{action}` has no default binding"); | |
| 454 | + | } | |
| 455 | + | assert_eq!(config.mpd.port, 6600); | |
| 456 | + | assert_eq!(config.ui.startup_screen, ScreenId::Queue); | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | #[test] | |
| 460 | + | fn partial_config_keeps_defaults() { | |
| 461 | + | let config = Config::from_toml( | |
| 462 | + | r#" | |
| 463 | + | [playback] | |
| 464 | + | volume_step = 10 | |
| 465 | + | [keys.global] | |
| 466 | + | quit = ["Q"] | |
| 467 | + | "#, | |
| 468 | + | ) | |
| 469 | + | .unwrap(); | |
| 470 | + | assert_eq!(config.playback.volume_step, 10); | |
| 471 | + | assert_eq!(config.playback.seek_seconds, 5); | |
| 472 | + | let quit = config.keymap.keys_for(Context::Global, Action::Quit); | |
| 473 | + | assert_eq!(quit, ["Q".parse::<KeyCombo>().unwrap()]); | |
| 474 | + | // Other global bindings are untouched. | |
| 475 | + | assert!( | |
| 476 | + | !config | |
| 477 | + | .keymap | |
| 478 | + | .keys_for(Context::Global, Action::TogglePause) | |
| 479 | + | .is_empty() | |
| 480 | + | ); | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | #[test] | |
| 484 | + | fn rejects_bad_values() { | |
| 485 | + | for bad in [ | |
| 486 | + | "[mpd]\nport = 0", | |
| 487 | + | "[playback]\nvolume_step = 0", | |
| 488 | + | "[mpd]\nhots = \"typo\"", | |
| 489 | + | "[theme]\ntext = \"notacolor\"", | |
| 490 | + | "[keys.global]\nquit = [\"Hyper+q\"]", | |
| 491 | + | "[keys.global]\nnot_an_action = [\"x\"]", | |
| 492 | + | // `p` is already bound to toggle_pause. | |
| 493 | + | "[keys.global]\nquit = [\"p\"]", | |
| 494 | + | ] { | |
| 495 | + | assert!(Config::from_toml(bad).is_err(), "accepted `{bad}`"); | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | #[test] | |
| 500 | + | fn detects_legacy_config() { | |
| 501 | + | let dir = Path::new(env!("CARGO_MANIFEST_DIR")) | |
| 502 | + | .join("target/test-tmp") | |
| 503 | + | .join(format!("config-{}", std::process::id())); | |
| 504 | + | fs::create_dir_all(&dir).unwrap(); | |
| 505 | + | let path = dir.join("legacy.toml"); | |
| 506 | + | fs::write(&path, "[keybindings]\nquit = [\"q\"]\n").unwrap(); | |
| 507 | + | let result = Config::load(Some(&path)); | |
| 508 | + | fs::remove_dir_all(&dir).unwrap(); | |
| 509 | + | assert!(matches!(result, Err(ConfigError::Legacy(_)))); | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | fn mpd_config(host: &str, password: &str) -> MpdConfig { | |
| 513 | + | MpdConfig { | |
| 514 | + | host: host.into(), | |
| 515 | + | port: 6600, | |
| 516 | + | password: password.into(), | |
| 517 | + | music_directory: String::new(), | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | #[test] | |
| 522 | + | fn resolves_connection() { | |
| 523 | + | let resolve = |host: &str, env_host: Option<&str>, env_port: Option<&str>| { | |
| 524 | + | resolve_connection( | |
| 525 | + | &mpd_config(host, "cfg"), | |
| 526 | + | env_host.map(Into::into), | |
| 527 | + | env_port.map(Into::into), | |
| 528 | + | ) | |
| 529 | + | .unwrap() | |
| 530 | + | }; | |
| 531 | + | assert_eq!( | |
| 532 | + | resolve("localhost", None, None), | |
| 533 | + | (Address::Tcp("localhost:6600".into()), Some("cfg".into())) | |
| 534 | + | ); | |
| 535 | + | assert_eq!( | |
| 536 | + | resolve("x", Some("secret@host"), Some("6601")), | |
| 537 | + | (Address::Tcp("host:6601".into()), Some("secret".into())) | |
| 538 | + | ); | |
| 539 | + | assert_eq!( | |
| 540 | + | resolve("/run/mpd/socket", None, None).0, | |
| 541 | + | Address::Unix("/run/mpd/socket".into()) | |
| 542 | + | ); | |
| 543 | + | assert!(resolve_connection(&mpd_config("x", ""), None, Some("nope".into())).is_err()); | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | #[test] | |
| 547 | + | fn formats_tcp_addresses() { | |
| 548 | + | assert_eq!(tcp_address("localhost", 6600), "localhost:6600"); | |
| 549 | + | assert_eq!(tcp_address("localhost:6601", 6600), "localhost:6601"); | |
| 550 | + | assert_eq!(tcp_address("[::1]", 6600), "[::1]:6600"); | |
| 551 | + | assert_eq!(tcp_address("[::1]:6601", 6600), "[::1]:6601"); | |
| 552 | + | assert_eq!(tcp_address("::1", 6600), "[::1]:6600"); | |
| 553 | + | } | |
| 554 | + | } | |
| @@ -0,0 +1,87 @@ | |||
| 1 | + | //! Reading `music_directory` from the local MPD configuration. | |
| 2 | + | //! | |
| 3 | + | //! MPD only reveals its music directory over a Unix socket, so for a local | |
| 4 | + | //! server reached over TCP rmptui reads it from MPD's own config file. | |
| 5 | + | ||
| 6 | + | use std::{ | |
| 7 | + | env, fs, | |
| 8 | + | path::{Path, PathBuf}, | |
| 9 | + | }; | |
| 10 | + | ||
| 11 | + | use crate::{mpd::Address, util::expand_tilde}; | |
| 12 | + | ||
| 13 | + | /// The music directory of a local MPD, if `address` is local and an MPD | |
| 14 | + | /// config file names one. | |
| 15 | + | pub fn local_music_directory(address: &Address) -> Option<PathBuf> { | |
| 16 | + | if !address.is_local() { | |
| 17 | + | return None; | |
| 18 | + | } | |
| 19 | + | candidates() | |
| 20 | + | .iter() | |
| 21 | + | .find_map(|path| fs::read_to_string(path).ok()) | |
| 22 | + | .and_then(|contents| parse_music_directory(&contents)) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /// MPD's config file locations, in MPD's lookup order. | |
| 26 | + | fn candidates() -> Vec<PathBuf> { | |
| 27 | + | let mut paths = Vec::new(); | |
| 28 | + | if let Some(dir) = env::var_os("XDG_CONFIG_HOME").filter(|d| !d.is_empty()) { | |
| 29 | + | paths.push(Path::new(&dir).join("mpd/mpd.conf")); | |
| 30 | + | } | |
| 31 | + | paths.extend( | |
| 32 | + | [ | |
| 33 | + | "~/.config/mpd/mpd.conf", | |
| 34 | + | "~/.mpdconf", | |
| 35 | + | "~/.mpd/mpd.conf", | |
| 36 | + | "/etc/mpd.conf", | |
| 37 | + | ] | |
| 38 | + | .map(expand_tilde), | |
| 39 | + | ); | |
| 40 | + | paths | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | fn parse_music_directory(contents: &str) -> Option<PathBuf> { | |
| 44 | + | contents.lines().find_map(|line| { | |
| 45 | + | let line = line.trim(); | |
| 46 | + | let value = line.strip_prefix("music_directory")?; | |
| 47 | + | if !value.starts_with(char::is_whitespace) { | |
| 48 | + | return None; | |
| 49 | + | } | |
| 50 | + | let value = value.trim(); | |
| 51 | + | let value = value | |
| 52 | + | .strip_prefix('"') | |
| 53 | + | .and_then(|v| v.split_once('"')) | |
| 54 | + | .map_or(value, |(inner, _)| inner); | |
| 55 | + | (!value.is_empty()).then(|| expand_tilde(value)) | |
| 56 | + | }) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | #[cfg(test)] | |
| 60 | + | mod tests { | |
| 61 | + | use super::*; | |
| 62 | + | ||
| 63 | + | #[test] | |
| 64 | + | fn parses_music_directory() { | |
| 65 | + | let conf = "# music_directory \"/commented\"\n\ | |
| 66 | + | music_directory_x \"/no\"\n\ | |
| 67 | + | music_directory\t\t\"~/music\" # trailing comment\n"; | |
| 68 | + | let home = env::var("HOME").unwrap(); | |
| 69 | + | assert_eq!( | |
| 70 | + | parse_music_directory(conf), | |
| 71 | + | Some(PathBuf::from(format!("{home}/music"))) | |
| 72 | + | ); | |
| 73 | + | assert_eq!( | |
| 74 | + | parse_music_directory("music_directory /srv/music"), | |
| 75 | + | Some(PathBuf::from("/srv/music")) | |
| 76 | + | ); | |
| 77 | + | assert_eq!(parse_music_directory("port \"6600\""), None); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | #[test] | |
| 81 | + | fn ignores_remote_servers() { | |
| 82 | + | assert_eq!( | |
| 83 | + | local_music_directory(&Address::Tcp("192.168.1.20:6600".into())), | |
| 84 | + | None | |
| 85 | + | ); | |
| 86 | + | } | |
| 87 | + | } | |
| @@ -0,0 +1,104 @@ | |||
| 1 | + | use std::str::FromStr; | |
| 2 | + | ||
| 3 | + | use ratatui::style::{Color, Modifier, Style}; | |
| 4 | + | use serde::{Deserialize, Deserializer, de}; | |
| 5 | + | ||
| 6 | + | /// Colors used throughout the UI. | |
| 7 | + | #[derive(Clone, Debug, Deserialize)] | |
| 8 | + | #[serde(deny_unknown_fields)] | |
| 9 | + | pub struct Theme { | |
| 10 | + | #[serde(deserialize_with = "color")] | |
| 11 | + | pub text: Color, | |
| 12 | + | #[serde(deserialize_with = "color")] | |
| 13 | + | pub dim: Color, | |
| 14 | + | #[serde(deserialize_with = "color")] | |
| 15 | + | pub border: Color, | |
| 16 | + | #[serde(deserialize_with = "color")] | |
| 17 | + | pub selection_fg: Color, | |
| 18 | + | #[serde(deserialize_with = "color")] | |
| 19 | + | pub selection_bg: Color, | |
| 20 | + | #[serde(deserialize_with = "color")] | |
| 21 | + | pub playing: Color, | |
| 22 | + | #[serde(deserialize_with = "color")] | |
| 23 | + | pub marked: Color, | |
| 24 | + | #[serde(deserialize_with = "color")] | |
| 25 | + | pub directory: Color, | |
| 26 | + | #[serde(deserialize_with = "color")] | |
| 27 | + | pub error: Color, | |
| 28 | + | #[serde(deserialize_with = "color")] | |
| 29 | + | pub artist: Color, | |
| 30 | + | #[serde(deserialize_with = "color")] | |
| 31 | + | pub track: Color, | |
| 32 | + | #[serde(deserialize_with = "color")] | |
| 33 | + | pub album: Color, | |
| 34 | + | #[serde(deserialize_with = "color")] | |
| 35 | + | pub duration: Color, | |
| 36 | + | #[serde(deserialize_with = "color", rename = "progress")] | |
| 37 | + | pub progress_filled: Color, | |
| 38 | + | #[serde(deserialize_with = "colors")] | |
| 39 | + | pub visualizer: Vec<Color>, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl Theme { | |
| 43 | + | pub fn text(&self) -> Style { | |
| 44 | + | Style::new().fg(self.text) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | pub fn dim(&self) -> Style { | |
| 48 | + | Style::new().fg(self.dim) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | pub fn border(&self) -> Style { | |
| 52 | + | Style::new().fg(self.border) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Titles and column headers. | |
| 56 | + | pub fn header(&self) -> Style { | |
| 57 | + | self.text().add_modifier(Modifier::BOLD) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// The selected row of the focused list. | |
| 61 | + | pub fn selection(&self) -> Style { | |
| 62 | + | if self.selection_fg == Color::Reset && self.selection_bg == Color::Reset { | |
| 63 | + | Style::new().add_modifier(Modifier::REVERSED) | |
| 64 | + | } else { | |
| 65 | + | Style::new().fg(self.selection_fg).bg(self.selection_bg) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// The selected row of a list without focus. | |
| 70 | + | pub fn selection_inactive(&self) -> Style { | |
| 71 | + | self.selection().add_modifier(Modifier::DIM) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | pub fn playing(&self) -> Style { | |
| 75 | + | Style::new().fg(self.playing).add_modifier(Modifier::BOLD) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | pub fn marked(&self) -> Style { | |
| 79 | + | Style::new().fg(self.marked) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | pub fn directory(&self) -> Style { | |
| 83 | + | Style::new().fg(self.directory) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | pub fn error(&self) -> Style { | |
| 87 | + | Style::new().fg(self.error) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | fn parse_color<E: de::Error>(value: &str) -> Result<Color, E> { | |
| 92 | + | Color::from_str(value).map_err(|_| E::custom(format!("invalid color `{value}`"))) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | fn color<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Color, D::Error> { | |
| 96 | + | parse_color(&String::deserialize(deserializer)?) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | fn colors<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<Color>, D::Error> { | |
| 100 | + | Vec::<String>::deserialize(deserializer)? | |
| 101 | + | .iter() | |
| 102 | + | .map(|value| parse_color(value)) | |
| 103 | + | .collect() | |
| 104 | + | } | |
| @@ -1,741 +0,0 @@ | |||
| 1 | − | use std::{ | |
| 2 | − | collections::HashMap, | |
| 3 | − | error::Error, | |
| 4 | − | fmt, | |
| 5 | − | time::{Duration, Instant}, | |
| 6 | − | }; | |
| 7 | − | ||
| 8 | − | use tokio::{ | |
| 9 | − | io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, | |
| 10 | − | net::TcpStream, | |
| 11 | − | }; | |
| 12 | − | ||
| 13 | − | use crate::AppResult; | |
| 14 | − | ||
| 15 | − | #[derive(Clone, Debug, Default, PartialEq, Eq)] | |
| 16 | − | pub struct Song { | |
| 17 | − | pub file: String, | |
| 18 | − | pub title: Option<String>, | |
| 19 | − | pub artist: Option<String>, | |
| 20 | − | pub tags: Vec<(String, String)>, | |
| 21 | − | pub duration: Option<Duration>, | |
| 22 | − | pub place: Option<Place>, | |
| 23 | − | } | |
| 24 | − | ||
| 25 | − | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 26 | − | pub struct Place { | |
| 27 | − | pub pos: u32, | |
| 28 | − | } | |
| 29 | − | ||
| 30 | − | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 31 | − | pub enum PlaybackState { | |
| 32 | − | Playing, | |
| 33 | − | Paused, | |
| 34 | − | #[default] | |
| 35 | − | Stopped, | |
| 36 | − | } | |
| 37 | − | ||
| 38 | − | #[derive(Clone, Debug, Default)] | |
| 39 | − | pub struct Status { | |
| 40 | − | pub state: PlaybackState, | |
| 41 | − | pub volume: i8, | |
| 42 | − | pub repeat: bool, | |
| 43 | − | pub random: bool, | |
| 44 | − | pub song: Option<Place>, | |
| 45 | − | pub elapsed: Duration, | |
| 46 | − | pub total: Duration, | |
| 47 | − | pub updating_db: Option<u32>, | |
| 48 | − | } | |
| 49 | − | ||
| 50 | − | #[derive(Clone, Debug, Default)] | |
| 51 | − | pub struct Stats { | |
| 52 | − | pub songs: u32, | |
| 53 | − | } | |
| 54 | − | ||
| 55 | − | #[derive(Clone, Copy, Debug)] | |
| 56 | − | pub enum VolumeStatus { | |
| 57 | − | Muted(i8), | |
| 58 | − | Unmuted, | |
| 59 | − | } | |
| 60 | − | ||
| 61 | − | #[derive(Clone, Debug)] | |
| 62 | − | pub struct MpdSnapshot { | |
| 63 | − | pub status: Status, | |
| 64 | − | pub stats: Stats, | |
| 65 | − | pub current_song: Song, | |
| 66 | − | pub queue: Vec<Song>, | |
| 67 | − | } | |
| 68 | − | ||
| 69 | − | #[derive(Debug)] | |
| 70 | − | struct MpdError(String); | |
| 71 | − | ||
| 72 | − | impl fmt::Display for MpdError { | |
| 73 | − | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 74 | − | f.write_str(&self.0) | |
| 75 | − | } | |
| 76 | − | } | |
| 77 | − | ||
| 78 | − | impl Error for MpdError {} | |
| 79 | − | ||
| 80 | − | #[derive(Debug)] | |
| 81 | − | pub struct Connection { | |
| 82 | − | stream: BufReader<TcpStream>, | |
| 83 | − | pub current_song: Song, | |
| 84 | − | pub stats: Stats, | |
| 85 | − | pub status: Status, | |
| 86 | − | pub volume_status: VolumeStatus, | |
| 87 | − | last_status_update: Instant, | |
| 88 | − | } | |
| 89 | − | ||
| 90 | − | impl Connection { | |
| 91 | − | async fn open(address: &str, password: Option<&str>) -> AppResult<Self> { | |
| 92 | − | let stream = TcpStream::connect(address).await?; | |
| 93 | − | let mut client = Self { | |
| 94 | − | stream: BufReader::new(stream), | |
| 95 | − | current_song: Song::default(), | |
| 96 | − | stats: Stats::default(), | |
| 97 | − | status: Status::default(), | |
| 98 | − | volume_status: VolumeStatus::Unmuted, | |
| 99 | − | last_status_update: Instant::now(), | |
| 100 | − | }; | |
| 101 | − | let mut greeting = String::new(); | |
| 102 | − | client.stream.read_line(&mut greeting).await?; | |
| 103 | − | if !greeting.starts_with("OK MPD ") { | |
| 104 | − | return Err(Box::new(MpdError(format!( | |
| 105 | − | "unexpected MPD greeting: {}", | |
| 106 | − | greeting.trim_end() | |
| 107 | − | )))); | |
| 108 | − | } | |
| 109 | − | if let Some(password) = password { | |
| 110 | − | client.command("password", &[password]).await?; | |
| 111 | − | } | |
| 112 | − | Ok(client) | |
| 113 | − | } | |
| 114 | − | ||
| 115 | − | async fn command(&mut self, name: &str, args: &[&str]) -> AppResult<Vec<String>> { | |
| 116 | − | let mut request = name.to_string(); | |
| 117 | − | for arg in args { | |
| 118 | − | request.push(' '); | |
| 119 | − | request.push_str("e(arg)); | |
| 120 | − | } | |
| 121 | − | request.push('\n'); | |
| 122 | − | self.stream.get_mut().write_all(request.as_bytes()).await?; | |
| 123 | − | self.stream.get_mut().flush().await?; | |
| 124 | − | ||
| 125 | − | let mut lines = Vec::new(); | |
| 126 | − | loop { | |
| 127 | − | let mut line = String::new(); | |
| 128 | − | let read = self.stream.read_line(&mut line).await?; | |
| 129 | − | if read == 0 { | |
| 130 | − | return Err(Box::new(MpdError( | |
| 131 | − | "MPD closed the connection while replying to a command".to_string(), | |
| 132 | − | ))); | |
| 133 | − | } | |
| 134 | − | let line = line.trim_end_matches(['\r', '\n']); | |
| 135 | − | if line == "OK" { | |
| 136 | − | return Ok(lines); | |
| 137 | − | } | |
| 138 | − | if line.starts_with("ACK ") { | |
| 139 | − | return Err(Box::new(MpdError(format!("MPD rejected `{name}`: {line}")))); | |
| 140 | − | } | |
| 141 | − | lines.push(line.to_string()); | |
| 142 | − | } | |
| 143 | − | } | |
| 144 | − | ||
| 145 | − | async fn read_status(&mut self) -> AppResult<Status> { | |
| 146 | − | let fields = fields(self.command("status", &[]).await?); | |
| 147 | − | let time = fields.get("time").and_then(|s| { | |
| 148 | − | let (elapsed, total) = s.split_once(':')?; | |
| 149 | − | Some((parse_duration(elapsed), parse_duration(total))) | |
| 150 | − | }); | |
| 151 | − | let elapsed = fields | |
| 152 | − | .get("elapsed") | |
| 153 | − | .map(|s| parse_duration(s)) | |
| 154 | − | .or_else(|| time.map(|(elapsed, _)| elapsed)) | |
| 155 | − | .unwrap_or_default(); | |
| 156 | − | let total = fields | |
| 157 | − | .get("duration") | |
| 158 | − | .map(|s| parse_duration(s)) | |
| 159 | − | .or_else(|| time.map(|(_, total)| total)) | |
| 160 | − | .unwrap_or_default(); | |
| 161 | − | ||
| 162 | − | Ok(Status { | |
| 163 | − | state: match fields.get("state").map(String::as_str) { | |
| 164 | − | Some("play") => PlaybackState::Playing, | |
| 165 | − | Some("pause") => PlaybackState::Paused, | |
| 166 | − | _ => PlaybackState::Stopped, | |
| 167 | − | }, | |
| 168 | − | volume: fields | |
| 169 | − | .get("volume") | |
| 170 | − | .and_then(|s| s.parse().ok()) | |
| 171 | − | .unwrap_or(-1), | |
| 172 | − | repeat: bool_field(&fields, "repeat"), | |
| 173 | − | random: bool_field(&fields, "random"), | |
| 174 | − | song: fields | |
| 175 | − | .get("song") | |
| 176 | − | .and_then(|s| s.parse().ok()) | |
| 177 | − | .map(|pos| Place { pos }), | |
| 178 | − | elapsed, | |
| 179 | − | total, | |
| 180 | − | updating_db: fields.get("updating_db").and_then(|s| s.parse().ok()), | |
| 181 | − | }) | |
| 182 | − | } | |
| 183 | − | ||
| 184 | − | async fn read_stats(&mut self) -> AppResult<Stats> { | |
| 185 | − | let fields = fields(self.command("stats", &[]).await?); | |
| 186 | − | Ok(Stats { | |
| 187 | − | songs: fields | |
| 188 | − | .get("songs") | |
| 189 | − | .and_then(|s| s.parse().ok()) | |
| 190 | − | .unwrap_or_default(), | |
| 191 | − | }) | |
| 192 | − | } | |
| 193 | − | ||
| 194 | − | async fn read_current_song(&mut self) -> AppResult<Song> { | |
| 195 | − | Ok(parse_songs(self.command("currentsong", &[]).await?) | |
| 196 | − | .into_iter() | |
| 197 | − | .next() | |
| 198 | − | .unwrap_or_default()) | |
| 199 | − | } | |
| 200 | − | ||
| 201 | − | pub async fn queue(&mut self) -> AppResult<Vec<Song>> { | |
| 202 | − | Ok(parse_songs(self.command("playlistinfo", &[]).await?)) | |
| 203 | − | } | |
| 204 | − | ||
| 205 | − | pub async fn snapshot(&mut self) -> AppResult<MpdSnapshot> { | |
| 206 | − | let status = self.read_status().await?; | |
| 207 | − | let stats = self.read_stats().await?; | |
| 208 | − | let current_song = self.read_current_song().await?; | |
| 209 | − | let queue = self.queue().await?; | |
| 210 | − | Ok(MpdSnapshot { | |
| 211 | − | status, | |
| 212 | − | stats, | |
| 213 | − | current_song, | |
| 214 | − | queue, | |
| 215 | − | }) | |
| 216 | − | } | |
| 217 | − | ||
| 218 | − | pub async fn list_files(&mut self, path: &str) -> AppResult<Vec<(String, String)>> { | |
| 219 | − | let lines = if path.is_empty() || path == "." { | |
| 220 | − | self.command("listfiles", &[]).await? | |
| 221 | − | } else { | |
| 222 | − | self.command("listfiles", &[path]).await? | |
| 223 | − | }; | |
| 224 | − | Ok(lines | |
| 225 | − | .into_iter() | |
| 226 | − | .filter_map(|line| { | |
| 227 | − | let (kind, value) = parse_field(&line)?; | |
| 228 | − | matches!(kind.as_str(), "file" | "directory").then_some((kind, value)) | |
| 229 | − | }) | |
| 230 | − | .collect()) | |
| 231 | − | } | |
| 232 | − | ||
| 233 | − | pub async fn ls_info(&mut self, path: &str) -> AppResult<(Vec<String>, Vec<Song>)> { | |
| 234 | − | let lines = if path.is_empty() || path == "." { | |
| 235 | − | self.command("lsinfo", &[]).await? | |
| 236 | − | } else { | |
| 237 | − | self.command("lsinfo", &[path]).await? | |
| 238 | − | }; | |
| 239 | − | let mut directories = Vec::new(); | |
| 240 | − | let mut songs = Vec::new(); | |
| 241 | − | let mut current: Option<Song> = None; | |
| 242 | − | for line in lines { | |
| 243 | − | let Some((key, value)) = parse_field(&line) else { | |
| 244 | − | continue; | |
| 245 | − | }; | |
| 246 | − | match key.as_str() { | |
| 247 | − | "directory" => directories.push(value), | |
| 248 | − | "file" => { | |
| 249 | − | if let Some(song) = current.take() { | |
| 250 | − | songs.push(song); | |
| 251 | − | } | |
| 252 | − | current = Some(Song { | |
| 253 | − | file: value, | |
| 254 | − | ..Song::default() | |
| 255 | − | }); | |
| 256 | − | } | |
| 257 | − | "playlist" => {} | |
| 258 | − | _ => { | |
| 259 | − | if let Some(song) = current.as_mut() { | |
| 260 | − | add_metadata(song, key, value); | |
| 261 | − | } | |
| 262 | − | } | |
| 263 | − | } | |
| 264 | − | } | |
| 265 | − | if let Some(song) = current { | |
| 266 | − | songs.push(song); | |
| 267 | − | } | |
| 268 | − | Ok((directories, songs)) | |
| 269 | − | } | |
| 270 | − | ||
| 271 | − | pub async fn playlists(&mut self) -> AppResult<Vec<String>> { | |
| 272 | − | Ok(self | |
| 273 | − | .command("listplaylists", &[]) | |
| 274 | − | .await? | |
| 275 | − | .into_iter() | |
| 276 | − | .filter_map(|line| parse_field(&line)) | |
| 277 | − | .filter_map(|(key, value)| (key == "playlist").then_some(value)) | |
| 278 | − | .collect()) | |
| 279 | − | } | |
| 280 | − | ||
| 281 | − | pub async fn playlist(&mut self, name: &str) -> AppResult<Vec<Song>> { | |
| 282 | − | Ok(parse_songs( | |
| 283 | − | self.command("listplaylistinfo", &[name]).await?, | |
| 284 | − | )) | |
| 285 | − | } | |
| 286 | − | ||
| 287 | − | pub async fn add(&mut self, file: &str) -> AppResult<()> { | |
| 288 | − | self.command("add", &[file]).await?; | |
| 289 | − | Ok(()) | |
| 290 | − | } | |
| 291 | − | ||
| 292 | − | pub async fn play(&mut self, position: Option<u32>) -> AppResult<()> { | |
| 293 | − | match position { | |
| 294 | − | Some(position) => self.command("play", &[&position.to_string()]).await?, | |
| 295 | − | None => self.command("play", &[]).await?, | |
| 296 | − | }; | |
| 297 | − | Ok(()) | |
| 298 | − | } | |
| 299 | − | ||
| 300 | − | pub async fn set_repeat(&mut self, enabled: bool) -> AppResult<()> { | |
| 301 | − | self.command("repeat", &[if enabled { "1" } else { "0" }]) | |
| 302 | − | .await?; | |
| 303 | − | Ok(()) | |
| 304 | − | } | |
| 305 | − | ||
| 306 | − | pub async fn set_random(&mut self, enabled: bool) -> AppResult<()> { | |
| 307 | − | self.command("random", &[if enabled { "1" } else { "0" }]) | |
| 308 | − | .await?; | |
| 309 | − | Ok(()) | |
| 310 | − | } | |
| 311 | − | ||
| 312 | − | pub async fn set_volume(&mut self, volume: i8) -> AppResult<()> { | |
| 313 | − | self.command("setvol", &[&volume.clamp(0, 100).to_string()]) | |
| 314 | − | .await?; | |
| 315 | − | Ok(()) | |
| 316 | − | } | |
| 317 | − | ||
| 318 | − | pub async fn seek(&mut self, position: u32, elapsed: Duration) -> AppResult<()> { | |
| 319 | − | self.command( | |
| 320 | − | "seek", | |
| 321 | − | &[&position.to_string(), &elapsed.as_secs().to_string()], | |
| 322 | − | ) | |
| 323 | − | .await?; | |
| 324 | − | Ok(()) | |
| 325 | − | } | |
| 326 | − | ||
| 327 | − | pub async fn next(&mut self) -> AppResult<()> { | |
| 328 | − | self.command("next", &[]).await?; | |
| 329 | − | Ok(()) | |
| 330 | − | } | |
| 331 | − | ||
| 332 | − | pub async fn previous(&mut self) -> AppResult<()> { | |
| 333 | − | self.command("previous", &[]).await?; | |
| 334 | − | Ok(()) | |
| 335 | − | } | |
| 336 | − | ||
| 337 | − | pub async fn clear(&mut self) -> AppResult<()> { | |
| 338 | − | self.command("clear", &[]).await?; | |
| 339 | − | Ok(()) | |
| 340 | − | } | |
| 341 | − | ||
| 342 | − | pub async fn delete(&mut self, position: u32) -> AppResult<()> { | |
| 343 | − | self.command("delete", &[&position.to_string()]).await?; | |
| 344 | − | Ok(()) | |
| 345 | − | } | |
| 346 | − | ||
| 347 | − | pub async fn swap(&mut self, first: u32, second: u32) -> AppResult<()> { | |
| 348 | − | self.command("swap", &[&first.to_string(), &second.to_string()]) | |
| 349 | − | .await?; | |
| 350 | − | Ok(()) | |
| 351 | − | } | |
| 352 | − | ||
| 353 | − | pub async fn append_playlist(&mut self, name: &str) -> AppResult<()> { | |
| 354 | − | self.command("load", &[name]).await?; | |
| 355 | − | Ok(()) | |
| 356 | − | } | |
| 357 | − | ||
| 358 | − | pub async fn delete_playlist_song(&mut self, playlist: &str, position: usize) -> AppResult<()> { | |
| 359 | − | self.command("playlistdelete", &[playlist, &position.to_string()]) | |
| 360 | − | .await?; | |
| 361 | − | Ok(()) | |
| 362 | − | } | |
| 363 | − | ||
| 364 | − | pub async fn move_playlist_song( | |
| 365 | − | &mut self, | |
| 366 | − | playlist: &str, | |
| 367 | − | from: usize, | |
| 368 | − | to: usize, | |
| 369 | − | ) -> AppResult<()> { | |
| 370 | − | self.command( | |
| 371 | − | "playlistmove", | |
| 372 | − | &[playlist, &from.to_string(), &to.to_string()], | |
| 373 | − | ) | |
| 374 | − | .await?; | |
| 375 | − | Ok(()) | |
| 376 | − | } | |
| 377 | − | ||
| 378 | − | pub async fn delete_playlist(&mut self, playlist: &str) -> AppResult<()> { | |
| 379 | − | self.command("rm", &[playlist]).await?; | |
| 380 | − | Ok(()) | |
| 381 | − | } | |
| 382 | − | ||
| 383 | − | pub async fn rename_playlist(&mut self, from: &str, to: &str) -> AppResult<()> { | |
| 384 | − | self.command("rename", &[from, to]).await?; | |
| 385 | − | Ok(()) | |
| 386 | − | } | |
| 387 | − | ||
| 388 | − | pub async fn update_database(&mut self) -> AppResult<()> { | |
| 389 | − | self.command("rescan", &[]).await?; | |
| 390 | − | Ok(()) | |
| 391 | − | } | |
| 392 | − | ||
| 393 | − | pub async fn idle(&mut self) -> AppResult<()> { | |
| 394 | − | self.command( | |
| 395 | − | "idle", | |
| 396 | − | &[ | |
| 397 | − | "database", | |
| 398 | − | "stored_playlist", | |
| 399 | − | "player", | |
| 400 | − | "playlist", | |
| 401 | − | "mixer", | |
| 402 | − | "options", | |
| 403 | − | ], | |
| 404 | − | ) | |
| 405 | − | .await?; | |
| 406 | − | Ok(()) | |
| 407 | − | } | |
| 408 | − | } | |
| 409 | − | ||
| 410 | − | impl Connection { | |
| 411 | − | pub async fn connect(address: &str, password: Option<&str>) -> AppResult<Self> { | |
| 412 | − | let mut connection = Self::open(address, password).await?; | |
| 413 | − | connection.update_status().await?; | |
| 414 | − | Ok(connection) | |
| 415 | − | } | |
| 416 | − | ||
| 417 | − | pub fn apply_snapshot(&mut self, status: Status, stats: Stats, current_song: Song) { | |
| 418 | − | self.status = status; | |
| 419 | − | self.stats = stats; | |
| 420 | − | self.current_song = current_song; | |
| 421 | − | self.last_status_update = Instant::now(); | |
| 422 | − | } | |
| 423 | − | ||
| 424 | − | pub async fn update_status(&mut self) -> AppResult<()> { | |
| 425 | − | self.status = self.read_status().await?; | |
| 426 | − | self.stats = self.read_stats().await?; | |
| 427 | − | self.current_song = self.read_current_song().await?; | |
| 428 | − | self.last_status_update = Instant::now(); | |
| 429 | − | Ok(()) | |
| 430 | − | } | |
| 431 | − | ||
| 432 | − | pub fn get_progress_ratio(&self) -> f64 { | |
| 433 | − | let total = self.status.total.as_secs_f64(); | |
| 434 | − | if total <= 0.0 { | |
| 435 | − | 0.0 | |
| 436 | − | } else { | |
| 437 | − | (self.current_elapsed().as_secs_f64() / total).clamp(0.0, 1.0) | |
| 438 | − | } | |
| 439 | − | } | |
| 440 | − | ||
| 441 | − | pub fn current_elapsed(&self) -> Duration { | |
| 442 | − | let elapsed = if self.status.state == PlaybackState::Playing { | |
| 443 | − | self.status | |
| 444 | − | .elapsed | |
| 445 | − | .saturating_add(self.last_status_update.elapsed()) | |
| 446 | − | } else { | |
| 447 | − | self.status.elapsed | |
| 448 | − | }; | |
| 449 | − | if self.status.total.is_zero() { | |
| 450 | − | elapsed | |
| 451 | − | } else { | |
| 452 | − | elapsed.min(self.status.total) | |
| 453 | − | } | |
| 454 | − | } | |
| 455 | − | ||
| 456 | − | pub async fn push(&mut self, song: &Song) -> AppResult<()> { | |
| 457 | − | self.add(&song.file).await?; | |
| 458 | − | let queue = self.queue().await?; | |
| 459 | − | if !queue.is_empty() { | |
| 460 | − | self.play(Some(queue.len() as u32 - 1)).await?; | |
| 461 | − | } | |
| 462 | − | self.update_status().await | |
| 463 | − | } | |
| 464 | − | ||
| 465 | − | pub async fn load_playlist(&mut self, playlist: &str) -> AppResult<()> { | |
| 466 | − | self.append_playlist(playlist).await?; | |
| 467 | − | self.play(None).await?; | |
| 468 | − | self.update_status().await | |
| 469 | − | } | |
| 470 | − | ||
| 471 | − | pub async fn add_to_playlist(&mut self, playlist: &str, song: &Song) -> AppResult<()> { | |
| 472 | − | self.command("playlistadd", &[playlist, &song.file]).await?; | |
| 473 | − | Ok(()) | |
| 474 | − | } | |
| 475 | − | ||
| 476 | − | pub fn song_from_file(filename: &str) -> Song { | |
| 477 | − | Song { | |
| 478 | − | file: filename.to_string(), | |
| 479 | − | ..Song::default() | |
| 480 | − | } | |
| 481 | − | } | |
| 482 | − | ||
| 483 | − | pub fn now_playing(&self) -> String { | |
| 484 | − | match (&self.current_song.title, &self.current_song.artist) { | |
| 485 | − | (Some(title), Some(artist)) => format!("\"{title}\" By {artist}"), | |
| 486 | − | (Some(title), None) => title.clone(), | |
| 487 | − | _ if self.current_song.file.is_empty() => "No Song playing or in Queue".to_string(), | |
| 488 | − | _ => self.current_song.file.clone(), | |
| 489 | − | } | |
| 490 | − | } | |
| 491 | − | ||
| 492 | − | pub async fn toggle_pause(&mut self) -> AppResult<()> { | |
| 493 | − | self.command("pause", &[]).await?; | |
| 494 | − | self.update_status().await | |
| 495 | − | } | |
| 496 | − | ||
| 497 | − | pub async fn pause(&mut self) -> AppResult<()> { | |
| 498 | − | self.command("pause", &["1"]).await?; | |
| 499 | − | self.update_status().await | |
| 500 | − | } | |
| 501 | − | ||
| 502 | − | pub async fn toggle_repeat(&mut self) -> AppResult<()> { | |
| 503 | − | self.set_repeat(!self.status.repeat).await?; | |
| 504 | − | self.update_status().await | |
| 505 | − | } | |
| 506 | − | ||
| 507 | − | pub async fn toggle_random(&mut self) -> AppResult<()> { | |
| 508 | − | self.set_random(!self.status.random).await?; | |
| 509 | − | self.update_status().await | |
| 510 | − | } | |
| 511 | − | ||
| 512 | − | pub async fn inc_volume(&mut self, amount: i8) -> AppResult<()> { | |
| 513 | − | let volume = self.status.volume.saturating_add(amount).min(100); | |
| 514 | − | self.volume_status = VolumeStatus::Unmuted; | |
| 515 | − | self.set_volume(volume).await?; | |
| 516 | − | self.update_status().await | |
| 517 | − | } | |
| 518 | − | ||
| 519 | − | pub async fn dec_volume(&mut self, amount: i8) -> AppResult<()> { | |
| 520 | − | let volume = self.status.volume.saturating_sub(amount).max(0); | |
| 521 | − | self.volume_status = VolumeStatus::Unmuted; | |
| 522 | − | self.set_volume(volume).await?; | |
| 523 | − | self.update_status().await | |
| 524 | − | } | |
| 525 | − | ||
| 526 | − | pub async fn refresh_song_list(&mut self) -> AppResult<()> { | |
| 527 | − | self.stats = self.read_stats().await?; | |
| 528 | − | Ok(()) | |
| 529 | − | } | |
| 530 | − | } | |
| 531 | − | ||
| 532 | − | fn fields(lines: Vec<String>) -> HashMap<String, String> { | |
| 533 | − | lines | |
| 534 | − | .into_iter() | |
| 535 | − | .filter_map(|line| parse_field(&line)) | |
| 536 | − | .collect() | |
| 537 | − | } | |
| 538 | − | ||
| 539 | − | fn parse_songs(lines: Vec<String>) -> Vec<Song> { | |
| 540 | − | let mut songs = Vec::new(); | |
| 541 | − | let mut current: Option<Song> = None; | |
| 542 | − | for line in lines { | |
| 543 | − | let Some((key, value)) = parse_field(&line) else { | |
| 544 | − | continue; | |
| 545 | − | }; | |
| 546 | − | if key == "file" { | |
| 547 | − | if let Some(song) = current.take() { | |
| 548 | − | songs.push(song); | |
| 549 | − | } | |
| 550 | − | current = Some(Song { | |
| 551 | − | file: value, | |
| 552 | − | ..Song::default() | |
| 553 | − | }); | |
| 554 | − | } else if let Some(song) = current.as_mut() { | |
| 555 | − | if key.eq_ignore_ascii_case("pos") { | |
| 556 | − | song.place = value.parse().ok().map(|pos| Place { pos }); | |
| 557 | − | } else if key == "duration" || key == "Time" { | |
| 558 | − | song.duration = Some(parse_duration(&value)); | |
| 559 | − | } else { | |
| 560 | − | add_metadata(song, key, value); | |
| 561 | − | } | |
| 562 | − | } | |
| 563 | − | } | |
| 564 | − | if let Some(song) = current { | |
| 565 | − | songs.push(song); | |
| 566 | − | } | |
| 567 | − | songs | |
| 568 | − | } | |
| 569 | − | ||
| 570 | − | fn add_metadata(song: &mut Song, key: String, value: String) { | |
| 571 | − | match key.to_ascii_lowercase().as_str() { | |
| 572 | − | "title" => song.title = Some(value), | |
| 573 | − | "artist" => song.artist = Some(value), | |
| 574 | − | "duration" | "time" => song.duration = Some(parse_duration(&value)), | |
| 575 | − | _ => song.tags.push((key, value)), | |
| 576 | − | } | |
| 577 | − | } | |
| 578 | − | ||
| 579 | − | fn parse_field(line: &str) -> Option<(String, String)> { | |
| 580 | − | let (key, value) = line.split_once(": ")?; | |
| 581 | − | Some((key.to_string(), unquote(value))) | |
| 582 | − | } | |
| 583 | − | ||
| 584 | − | fn unquote(value: &str) -> String { | |
| 585 | − | let value = value | |
| 586 | − | .strip_prefix('"') | |
| 587 | − | .and_then(|v| v.strip_suffix('"')) | |
| 588 | − | .unwrap_or(value); | |
| 589 | − | let mut result = String::with_capacity(value.len()); | |
| 590 | − | let mut escaped = false; | |
| 591 | − | for character in value.chars() { | |
| 592 | − | if escaped { | |
| 593 | − | result.push(character); | |
| 594 | − | escaped = false; | |
| 595 | − | } else if character == '\\' { | |
| 596 | − | escaped = true; | |
| 597 | − | } else { | |
| 598 | − | result.push(character); | |
| 599 | − | } | |
| 600 | − | } | |
| 601 | − | if escaped { | |
| 602 | − | result.push('\\'); | |
| 603 | − | } | |
| 604 | − | result | |
| 605 | − | } | |
| 606 | − | ||
| 607 | − | fn quote(value: &str) -> String { | |
| 608 | − | format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) | |
| 609 | − | } | |
| 610 | − | ||
| 611 | − | fn bool_field(fields: &HashMap<String, String>, key: &str) -> bool { | |
| 612 | − | fields.get(key).is_some_and(|value| value == "1") | |
| 613 | − | } | |
| 614 | − | ||
| 615 | − | fn parse_duration(value: &str) -> Duration { | |
| 616 | − | value | |
| 617 | − | .parse::<f64>() | |
| 618 | − | .ok() | |
| 619 | − | .filter(|seconds| seconds.is_finite() && *seconds >= 0.0) | |
| 620 | − | .and_then(|seconds| Duration::try_from_secs_f64(seconds).ok()) | |
| 621 | − | .unwrap_or_default() | |
| 622 | − | } | |
| 623 | − | ||
| 624 | − | #[cfg(test)] | |
| 625 | − | mod tests { | |
| 626 | − | use super::*; | |
| 627 | − | use tokio::{ | |
| 628 | − | io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, | |
| 629 | − | net::TcpListener, | |
| 630 | − | }; | |
| 631 | − | ||
| 632 | − | #[test] | |
| 633 | − | fn parses_quoted_mpd_values_and_escapes() { | |
| 634 | − | assert_eq!( | |
| 635 | − | parse_field(r#"Title: "A \"quoted\" song\\file""#), | |
| 636 | − | Some(("Title".to_string(), "A \"quoted\" song\\file".to_string())) | |
| 637 | − | ); | |
| 638 | − | assert_eq!(quote(r#"A "song"\path"#), r#""A \"song\"\\path""#); | |
| 639 | − | } | |
| 640 | − | ||
| 641 | − | #[test] | |
| 642 | − | fn parses_songs_with_tags_and_queue_positions() { | |
| 643 | − | let songs = parse_songs(vec![ | |
| 644 | − | "file: music/example.flac".to_string(), | |
| 645 | − | "Title: Example".to_string(), | |
| 646 | − | "Artist: Someone".to_string(), | |
| 647 | − | "duration: 124.5".to_string(), | |
| 648 | − | "Pos: 3".to_string(), | |
| 649 | − | "file: music/another.ogg".to_string(), | |
| 650 | − | ]); | |
| 651 | − | assert_eq!(songs.len(), 2); | |
| 652 | − | assert_eq!(songs[0].title.as_deref(), Some("Example")); | |
| 653 | − | assert_eq!(songs[0].artist.as_deref(), Some("Someone")); | |
| 654 | − | assert_eq!(songs[0].duration, Some(Duration::from_secs_f64(124.5))); | |
| 655 | − | assert_eq!(songs[0].place, Some(Place { pos: 3 })); | |
| 656 | − | assert_eq!(songs[1].file, "music/another.ogg"); | |
| 657 | − | } | |
| 658 | − | ||
| 659 | − | #[tokio::test] | |
| 660 | − | async fn sends_quoted_commands_over_the_mpd_tcp_protocol() { | |
| 661 | − | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 662 | − | let address = listener.local_addr().unwrap(); | |
| 663 | − | let server = tokio::spawn(async move { | |
| 664 | − | let (stream, _) = listener.accept().await.unwrap(); | |
| 665 | − | let mut stream = BufReader::new(stream); | |
| 666 | − | stream | |
| 667 | − | .get_mut() | |
| 668 | − | .write_all(b"OK MPD 0.23.0\n") | |
| 669 | − | .await | |
| 670 | − | .unwrap(); | |
| 671 | − | let mut command = String::new(); | |
| 672 | − | stream.read_line(&mut command).await.unwrap(); | |
| 673 | − | assert_eq!(command, "play \"0\"\n"); | |
| 674 | − | stream.get_mut().write_all(b"OK\n").await.unwrap(); | |
| 675 | − | }); | |
| 676 | − | ||
| 677 | − | let mut client = Connection::open(&address.to_string(), None).await.unwrap(); | |
| 678 | − | client.command("play", &["0"]).await.unwrap(); | |
| 679 | − | server.await.unwrap(); | |
| 680 | − | } | |
| 681 | − | ||
| 682 | − | #[tokio::test] | |
| 683 | − | async fn omits_empty_root_paths_from_mpd_commands() { | |
| 684 | − | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 685 | − | let address = listener.local_addr().unwrap(); | |
| 686 | − | let server = tokio::spawn(async move { | |
| 687 | − | let (stream, _) = listener.accept().await.unwrap(); | |
| 688 | − | let mut stream = BufReader::new(stream); | |
| 689 | − | stream | |
| 690 | − | .get_mut() | |
| 691 | − | .write_all(b"OK MPD 0.23.0\n") | |
| 692 | − | .await | |
| 693 | − | .unwrap(); | |
| 694 | − | for expected in ["lsinfo\n", "listfiles\n"] { | |
| 695 | − | let mut command = String::new(); | |
| 696 | − | stream.read_line(&mut command).await.unwrap(); | |
| 697 | − | assert_eq!(command, expected); | |
| 698 | − | stream.get_mut().write_all(b"OK\n").await.unwrap(); | |
| 699 | − | } | |
| 700 | − | }); | |
| 701 | − | ||
| 702 | − | let mut client = Connection::open(&address.to_string(), None).await.unwrap(); | |
| 703 | − | client.ls_info("").await.unwrap(); | |
| 704 | − | client.list_files("").await.unwrap(); | |
| 705 | − | server.await.unwrap(); | |
| 706 | − | } | |
| 707 | − | ||
| 708 | − | #[tokio::test] | |
| 709 | − | async fn sends_stored_playlist_edit_commands() { | |
| 710 | − | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 711 | − | let address = listener.local_addr().unwrap(); | |
| 712 | − | let server = tokio::spawn(async move { | |
| 713 | − | let (stream, _) = listener.accept().await.unwrap(); | |
| 714 | − | let mut stream = BufReader::new(stream); | |
| 715 | − | stream | |
| 716 | − | .get_mut() | |
| 717 | − | .write_all(b"OK MPD 0.23.0\n") | |
| 718 | − | .await | |
| 719 | − | .unwrap(); | |
| 720 | − | for expected in [ | |
| 721 | − | "playlistdelete \"mix \\\"one\\\"\" \"2\"\n", | |
| 722 | − | "playlistmove \"mix \\\"one\\\"\" \"1\" \"2\"\n", | |
| 723 | − | "rm \"mix \\\"one\\\"\"\n", | |
| 724 | − | ] { | |
| 725 | − | let mut command = String::new(); | |
| 726 | − | stream.read_line(&mut command).await.unwrap(); | |
| 727 | − | assert_eq!(command, expected); | |
| 728 | − | stream.get_mut().write_all(b"OK\n").await.unwrap(); | |
| 729 | − | } | |
| 730 | − | }); | |
| 731 | − | ||
| 732 | − | let mut client = Connection::open(&address.to_string(), None).await.unwrap(); | |
| 733 | − | client.delete_playlist_song("mix \"one\"", 2).await.unwrap(); | |
| 734 | − | client | |
| 735 | − | .move_playlist_song("mix \"one\"", 1, 2) | |
| 736 | − | .await | |
| 737 | − | .unwrap(); | |
| 738 | − | client.delete_playlist("mix \"one\"").await.unwrap(); | |
| 739 | − | server.await.unwrap(); | |
| 740 | − | } | |
| 741 | − | } | |
| @@ -0,0 +1,20 @@ | |||
| 1 | + | //! Events delivered to the application loop by background tasks. | |
| 2 | + | ||
| 3 | + | use crate::{lyrics::LookupResult, mpd::MpdEvent}; | |
| 4 | + | ||
| 5 | + | #[derive(Debug)] | |
| 6 | + | pub enum AppEvent { | |
| 7 | + | Mpd(MpdEvent), | |
| 8 | + | Lyrics { | |
| 9 | + | file: String, | |
| 10 | + | result: LookupResult, | |
| 11 | + | }, | |
| 12 | + | /// Tag writes finished: the directories to rescan, or an error. | |
| 13 | + | TagsWritten(Result<Vec<String>, String>), | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | impl From<MpdEvent> for AppEvent { | |
| 17 | + | fn from(event: MpdEvent) -> Self { | |
| 18 | + | Self::Mpd(event) | |
| 19 | + | } | |
| 20 | + | } | |
| @@ -1,473 +0,0 @@ | |||
| 1 | − | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; | |
| 2 | − | use rust_fuzzy_search::fuzzy_search_sorted; | |
| 3 | − | ||
| 4 | − | use crate::{ | |
| 5 | − | app::{App, PlaylistFocus, SelectedTab}, | |
| 6 | − | connection::VolumeStatus, | |
| 7 | − | ui::InputMode, | |
| 8 | − | AppResult, | |
| 9 | − | }; | |
| 10 | − | ||
| 11 | − | pub async fn handle_key_events(key: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 12 | − | if app.inputmode != InputMode::Normal { | |
| 13 | − | if key | |
| 14 | − | .modifiers | |
| 15 | − | .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) | |
| 16 | − | && is_bound(app, "quit", key) | |
| 17 | − | { | |
| 18 | − | app.quit(); | |
| 19 | − | return Ok(()); | |
| 20 | − | } | |
| 21 | − | return handle_input_key(key, app).await; | |
| 22 | − | } | |
| 23 | − | if app.playlist_popup { | |
| 24 | − | if is_bound(app, "popup_close", key) { | |
| 25 | − | app.playlist_popup = false; | |
| 26 | − | } else if is_bound(app, "move_down", key) { | |
| 27 | − | app.append_list.next(); | |
| 28 | − | } else if is_bound(app, "move_up", key) { | |
| 29 | − | app.append_list.prev(); | |
| 30 | − | } else if is_bound(app, "popup_select", key) { | |
| 31 | − | app.append_selected_to().await?; | |
| 32 | − | } | |
| 33 | − | return Ok(()); | |
| 34 | − | } | |
| 35 | − | if app.selected_tab == SelectedTab::DirectoryBrowser && !app.browser_search_results.is_empty() { | |
| 36 | − | if is_bound(app, "search_next", key) { | |
| 37 | − | cycle_browser_search(app, true); | |
| 38 | − | return Ok(()); | |
| 39 | − | } else if is_bound(app, "search_previous", key) { | |
| 40 | − | cycle_browser_search(app, false); | |
| 41 | − | return Ok(()); | |
| 42 | − | } | |
| 43 | − | } | |
| 44 | − | ||
| 45 | − | if is_bound(app, "quit", key) { | |
| 46 | − | app.quit(); | |
| 47 | − | } else if is_bound(app, "clear_queue", key) { | |
| 48 | − | app.conn.clear().await?; | |
| 49 | − | app.update_queue().await?; | |
| 50 | − | app.conn.update_status().await?; | |
| 51 | − | } else if is_bound(app, "toggle_pause", key) { | |
| 52 | − | app.conn.toggle_pause().await?; | |
| 53 | − | } else if is_bound(app, "pause", key) { | |
| 54 | − | app.conn.pause().await?; | |
| 55 | − | } else if is_bound(app, "toggle_repeat", key) { | |
| 56 | − | app.conn.toggle_repeat().await?; | |
| 57 | − | } else if is_bound(app, "toggle_random", key) { | |
| 58 | − | app.conn.toggle_random().await?; | |
| 59 | − | } else if is_bound(app, "open_playlist_menu", key) { | |
| 60 | − | app.playlist_popup = true; | |
| 61 | − | } else if is_bound(app, "seek_forward", key) || is_bound(app, "seek_backward", key) { | |
| 62 | − | if let Some(position) = app.conn.status.song { | |
| 63 | − | let current = app.conn.status.elapsed.as_secs(); | |
| 64 | − | let target = if is_bound(app, "seek_forward", key) { | |
| 65 | − | current.saturating_add(app.config.playback.seek_seconds) | |
| 66 | − | } else { | |
| 67 | − | current.saturating_sub(app.config.playback.seek_seconds) | |
| 68 | − | }; | |
| 69 | − | app.conn | |
| 70 | − | .seek(position.pos, std::time::Duration::from_secs(target)) | |
| 71 | − | .await?; | |
| 72 | − | app.conn.update_status().await?; | |
| 73 | − | } | |
| 74 | − | } else if is_bound(app, "cycle_tabs", key) { | |
| 75 | − | app.cycle_tabs(); | |
| 76 | − | if app.selected_tab == SelectedTab::Playlists { | |
| 77 | − | app.refresh_playlist_view().await?; | |
| 78 | − | } | |
| 79 | − | } else if is_bound(app, "select_queue", key) { | |
| 80 | − | app.selected_tab = SelectedTab::Queue; | |
| 81 | − | } else if is_bound(app, "select_browser", key) { | |
| 82 | − | app.selected_tab = SelectedTab::DirectoryBrowser; | |
| 83 | − | } else if is_bound(app, "select_playlists", key) { | |
| 84 | − | app.selected_tab = SelectedTab::Playlists; | |
| 85 | − | app.refresh_playlist_view().await?; | |
| 86 | − | } else if is_bound(app, "next_track", key) { | |
| 87 | − | if !app.queue_list.list.is_empty() { | |
| 88 | − | app.conn.next().await?; | |
| 89 | − | app.update_queue().await?; | |
| 90 | − | app.conn.update_status().await?; | |
| 91 | − | } | |
| 92 | − | } else if is_bound(app, "previous_track", key) { | |
| 93 | − | if !app.queue_list.list.is_empty() { | |
| 94 | − | app.conn.previous().await?; | |
| 95 | − | app.update_queue().await?; | |
| 96 | − | app.conn.update_status().await?; | |
| 97 | − | } | |
| 98 | − | } else if is_bound(app, "volume_up", key) { | |
| 99 | − | app.conn | |
| 100 | − | .inc_volume(app.config.playback.volume_step as i8) | |
| 101 | − | .await?; | |
| 102 | − | } else if is_bound(app, "volume_down", key) { | |
| 103 | − | app.conn | |
| 104 | − | .dec_volume(app.config.playback.volume_step as i8) | |
| 105 | − | .await?; | |
| 106 | − | } else if is_bound(app, "toggle_mute", key) { | |
| 107 | − | match app.conn.volume_status { | |
| 108 | − | VolumeStatus::Muted(volume) => { | |
| 109 | − | app.conn.set_volume(volume).await?; | |
| 110 | − | app.conn.volume_status = VolumeStatus::Unmuted; | |
| 111 | − | } | |
| 112 | − | VolumeStatus::Unmuted => { | |
| 113 | − | app.conn.set_volume(0).await?; | |
| 114 | − | app.conn.volume_status = VolumeStatus::Muted(app.conn.status.volume); | |
| 115 | − | } | |
| 116 | − | } | |
| 117 | − | app.conn.update_status().await?; | |
| 118 | − | } else if is_bound(app, "update_database", key) { | |
| 119 | − | app.conn.update_database().await?; | |
| 120 | − | app.should_update_song_list = true; | |
| 121 | − | app.conn.update_status().await?; | |
| 122 | − | } else if is_bound(app, "search", key) { | |
| 123 | − | app.inputmode = InputMode::Editing; | |
| 124 | − | app.search_input.clear(); | |
| 125 | − | app.search_cursor_pos = 0; | |
| 126 | − | app.browser_search_results.clear(); | |
| 127 | − | app.browser_search_index = 0; | |
| 128 | − | } else if is_bound(app, "toggle_selected_item", key) { | |
| 129 | − | app.toggle_selected_item().await?; | |
| 130 | − | } | |
| 131 | − | ||
| 132 | − | match app.selected_tab { | |
| 133 | − | SelectedTab::Queue => handle_queue_key(key, app).await?, | |
| 134 | − | SelectedTab::DirectoryBrowser => handle_browser_key(key, app).await?, | |
| 135 | − | SelectedTab::Playlists => handle_playlists_key(key, app).await?, | |
| 136 | − | } | |
| 137 | − | Ok(()) | |
| 138 | − | } | |
| 139 | − | ||
| 140 | − | fn is_bound(app: &App, action: &str, key: KeyEvent) -> bool { | |
| 141 | − | app.config.keybindings.matches(action, key) | |
| 142 | − | } | |
| 143 | − | ||
| 144 | − | async fn handle_queue_key(key: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 145 | − | if is_bound(app, "move_down", key) { | |
| 146 | − | app.queue_list.next(); | |
| 147 | − | } else if is_bound(app, "move_up", key) { | |
| 148 | − | app.queue_list.prev(); | |
| 149 | − | } else if is_bound(app, "activate", key) { | |
| 150 | − | if app.queue_list.index < app.queue_list.list.len() { | |
| 151 | − | app.conn.play(Some(app.queue_list.index as u32)).await?; | |
| 152 | − | app.conn.update_status().await?; | |
| 153 | − | } | |
| 154 | − | } else if is_bound(app, "queue_delete", key) { | |
| 155 | − | if app.queue_list.index < app.queue_list.list.len() { | |
| 156 | − | app.conn.delete(app.queue_list.index as u32).await?; | |
| 157 | − | app.update_queue().await?; | |
| 158 | − | app.conn.update_status().await?; | |
| 159 | − | } | |
| 160 | − | } else if is_bound(app, "queue_move_down", key) { | |
| 161 | − | let position = app.queue_list.index; | |
| 162 | − | if position + 1 < app.queue_list.list.len() { | |
| 163 | − | app.conn | |
| 164 | − | .swap(position as u32, (position + 1) as u32) | |
| 165 | − | .await?; | |
| 166 | − | app.queue_list.index += 1; | |
| 167 | − | app.update_queue().await?; | |
| 168 | − | } | |
| 169 | − | } else if is_bound(app, "queue_move_up", key) { | |
| 170 | − | let position = app.queue_list.index; | |
| 171 | − | if position > 0 && position < app.queue_list.list.len() { | |
| 172 | − | app.conn | |
| 173 | − | .swap(position as u32, (position - 1) as u32) | |
| 174 | − | .await?; | |
| 175 | − | app.queue_list.index -= 1; | |
| 176 | − | app.update_queue().await?; | |
| 177 | − | } | |
| 178 | − | } else if is_bound(app, "go_to_top", key) { | |
| 179 | − | app.queue_list.index = 0; | |
| 180 | − | } else if is_bound(app, "go_to_bottom", key) { | |
| 181 | − | app.queue_list.index = app.queue_list.list.len().saturating_sub(1); | |
| 182 | − | } | |
| 183 | − | Ok(()) | |
| 184 | − | } | |
| 185 | − | ||
| 186 | − | async fn handle_browser_key(key: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 187 | − | if is_bound(app, "move_down", key) { | |
| 188 | − | app.browser.next(); | |
| 189 | − | } else if is_bound(app, "move_up", key) { | |
| 190 | − | app.browser.prev(); | |
| 191 | − | } else if is_bound(app, "activate", key) { | |
| 192 | − | let previous_path = app.browser.path.clone(); | |
| 193 | − | app.handle_enter().await?; | |
| 194 | − | if app.browser.path != previous_path { | |
| 195 | − | app.browser_search_results.clear(); | |
| 196 | − | app.search_input.clear(); | |
| 197 | − | } | |
| 198 | − | } else if is_bound(app, "go_back", key) { | |
| 199 | − | app.browser.handle_go_back(&mut app.conn).await?; | |
| 200 | − | app.browser_search_results.clear(); | |
| 201 | − | app.search_input.clear(); | |
| 202 | − | } else if is_bound(app, "go_to_top", key) { | |
| 203 | − | app.browser.selected = 0; | |
| 204 | − | } else if is_bound(app, "go_to_bottom", key) { | |
| 205 | − | app.browser.selected = app.browser.entries.len().saturating_sub(1); | |
| 206 | − | } | |
| 207 | − | Ok(()) | |
| 208 | − | } | |
| 209 | − | ||
| 210 | − | async fn handle_playlists_key(key: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 211 | − | if is_bound(app, "playlist_focus_contents", key) { | |
| 212 | − | app.playlist_focus = PlaylistFocus::Songs; | |
| 213 | − | return Ok(()); | |
| 214 | − | } | |
| 215 | − | if is_bound(app, "playlist_focus_list", key) { | |
| 216 | − | app.playlist_focus = PlaylistFocus::List; | |
| 217 | − | return Ok(()); | |
| 218 | − | } | |
| 219 | − | ||
| 220 | − | if app.playlist_focus == PlaylistFocus::Songs { | |
| 221 | − | if is_bound(app, "move_down", key) { | |
| 222 | − | app.playlist_song_index = app | |
| 223 | − | .playlist_song_index | |
| 224 | − | .saturating_add(1) | |
| 225 | − | .min(app.playlist_contents.len().saturating_sub(1)); | |
| 226 | − | } else if is_bound(app, "move_up", key) { | |
| 227 | − | app.playlist_song_index = app.playlist_song_index.saturating_sub(1); | |
| 228 | − | } else if is_bound(app, "go_to_top", key) { | |
| 229 | − | app.playlist_song_index = 0; | |
| 230 | − | } else if is_bound(app, "go_to_bottom", key) { | |
| 231 | − | app.playlist_song_index = app.playlist_contents.len().saturating_sub(1); | |
| 232 | − | } else if is_bound(app, "playlist_delete_song", key) { | |
| 233 | − | app.delete_selected_playlist_song().await?; | |
| 234 | − | } else if is_bound(app, "playlist_move_song_down", key) { | |
| 235 | − | app.move_selected_playlist_song(true).await?; | |
| 236 | − | } else if is_bound(app, "playlist_move_song_up", key) { | |
| 237 | − | app.move_selected_playlist_song(false).await?; | |
| 238 | − | } else if is_bound(app, "activate", key) { | |
| 239 | − | app.play_selected_playlist_song().await?; | |
| 240 | − | } else if is_bound(app, "playlist_delete", key) { | |
| 241 | − | app.delete_selected_playlist().await?; | |
| 242 | − | } | |
| 243 | − | return Ok(()); | |
| 244 | − | } | |
| 245 | − | ||
| 246 | − | let previous_index = app.pl_list.index; | |
| 247 | − | if is_bound(app, "move_down", key) { | |
| 248 | − | app.pl_list.next(); | |
| 249 | − | } else if is_bound(app, "move_up", key) { | |
| 250 | − | app.pl_list.prev(); | |
| 251 | − | } else if is_bound(app, "go_to_top", key) { | |
| 252 | − | app.pl_list.index = 0; | |
| 253 | − | } else if is_bound(app, "go_to_bottom", key) { | |
| 254 | − | app.pl_list.index = app.pl_list.list.len().saturating_sub(1); | |
| 255 | − | } else if is_bound(app, "playlist_rename", key) && !app.pl_list.list.is_empty() { | |
| 256 | − | app.inputmode = InputMode::PlaylistRename; | |
| 257 | − | app.pl_newname_input.clear(); | |
| 258 | − | app.pl_cursor_pos = 0; | |
| 259 | − | } else if is_bound(app, "playlist_delete", key) { | |
| 260 | − | app.delete_selected_playlist().await?; | |
| 261 | − | } else if is_bound(app, "playlist_load", key) { | |
| 262 | − | if let Some(playlist) = app.pl_list.list.get(app.pl_list.index) { | |
| 263 | − | app.conn.load_playlist(playlist).await?; | |
| 264 | − | app.update_queue().await?; | |
| 265 | − | } | |
| 266 | − | } | |
| 267 | − | if previous_index != app.pl_list.index { | |
| 268 | − | app.playlist_song_index = 0; | |
| 269 | − | app.refresh_playlist_view().await?; | |
| 270 | − | } | |
| 271 | − | Ok(()) | |
| 272 | − | } | |
| 273 | − | ||
| 274 | − | async fn handle_input_key(key: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 275 | − | if is_bound(app, "input_cancel", key) { | |
| 276 | − | match app.inputmode { | |
| 277 | − | InputMode::Editing => { | |
| 278 | − | app.search_input.clear(); | |
| 279 | − | app.browser_search_results.clear(); | |
| 280 | − | app.inputmode = InputMode::Normal; | |
| 281 | − | } | |
| 282 | − | InputMode::PlaylistRename => { | |
| 283 | − | app.pl_newname_input.clear(); | |
| 284 | − | app.inputmode = InputMode::Normal; | |
| 285 | − | } | |
| 286 | − | InputMode::NewPlaylist => { | |
| 287 | − | app.pl_new_pl_input.clear(); | |
| 288 | − | app.inputmode = InputMode::Normal; | |
| 289 | − | } | |
| 290 | − | InputMode::Normal => {} | |
| 291 | − | } | |
| 292 | − | app.reset_cursor(); | |
| 293 | − | } else if is_bound(app, "input_backspace", key) { | |
| 294 | − | app.delete_char(); | |
| 295 | − | if app.inputmode == InputMode::Editing { | |
| 296 | − | update_search_selection(app); | |
| 297 | − | } | |
| 298 | − | } else if is_bound(app, "input_cursor_left", key) { | |
| 299 | − | app.move_cursor_left(); | |
| 300 | − | } else if is_bound(app, "input_cursor_right", key) { | |
| 301 | − | app.move_cursor_right(); | |
| 302 | − | } else if is_bound(app, "input_submit", key) { | |
| 303 | − | match app.inputmode { | |
| 304 | − | InputMode::Editing => { | |
| 305 | − | update_search_selection(app); | |
| 306 | − | if app.selected_tab != SelectedTab::DirectoryBrowser { | |
| 307 | − | app.search_input.clear(); | |
| 308 | − | } | |
| 309 | − | if app.selected_tab == SelectedTab::Playlists { | |
| 310 | − | app.refresh_playlist_view().await?; | |
| 311 | − | } | |
| 312 | − | app.reset_cursor(); | |
| 313 | − | app.inputmode = InputMode::Normal; | |
| 314 | − | } | |
| 315 | − | InputMode::PlaylistRename => { | |
| 316 | − | if let Some(old_name) = app.pl_list.list.get(app.pl_list.index).cloned() { | |
| 317 | − | if !app.pl_newname_input.trim().is_empty() { | |
| 318 | − | app.conn | |
| 319 | − | .rename_playlist(&old_name, &app.pl_newname_input) | |
| 320 | − | .await?; | |
| 321 | − | app.refresh_playlists().await?; | |
| 322 | − | } | |
| 323 | − | } | |
| 324 | − | app.pl_newname_input.clear(); | |
| 325 | − | app.reset_cursor(); | |
| 326 | − | app.inputmode = InputMode::Normal; | |
| 327 | − | } | |
| 328 | − | InputMode::NewPlaylist => { | |
| 329 | − | if !app.pl_new_pl_input.trim().is_empty() { | |
| 330 | − | for song in &app.pl_new_pl_songs_buffer { | |
| 331 | − | app.conn.add_to_playlist(&app.pl_new_pl_input, song).await?; | |
| 332 | − | } | |
| 333 | − | app.refresh_playlists().await?; | |
| 334 | − | } | |
| 335 | − | app.pl_new_pl_input.clear(); | |
| 336 | − | app.reset_cursor(); | |
| 337 | − | app.inputmode = InputMode::Normal; | |
| 338 | − | } | |
| 339 | − | InputMode::Normal => {} | |
| 340 | − | } | |
| 341 | − | } else if let KeyCode::Char(character) = key.code { | |
| 342 | − | if !key | |
| 343 | − | .modifiers | |
| 344 | − | .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) | |
| 345 | − | { | |
| 346 | − | app.enter_char(character); | |
| 347 | − | if app.inputmode == InputMode::Editing { | |
| 348 | − | update_search_selection(app); | |
| 349 | − | } | |
| 350 | − | } | |
| 351 | − | } | |
| 352 | − | Ok(()) | |
| 353 | − | } | |
| 354 | − | ||
| 355 | − | fn update_search_selection(app: &mut App) { | |
| 356 | − | if app.search_input.is_empty() { | |
| 357 | − | return; | |
| 358 | − | } | |
| 359 | − | match app.selected_tab { | |
| 360 | − | SelectedTab::Queue => { | |
| 361 | − | let items: Vec<&str> = app | |
| 362 | − | .queue_list | |
| 363 | − | .list | |
| 364 | − | .iter() | |
| 365 | − | .map(|song| song.file.as_str()) | |
| 366 | − | .collect(); | |
| 367 | − | if let Some((best, _)) = fuzzy_search_sorted(&app.search_input, &items).first() { | |
| 368 | − | if let Some(index) = app | |
| 369 | − | .queue_list | |
| 370 | − | .list | |
| 371 | − | .iter() | |
| 372 | − | .position(|song| song.file == *best) | |
| 373 | − | { | |
| 374 | − | app.queue_list.index = index; | |
| 375 | − | } | |
| 376 | − | } | |
| 377 | − | } | |
| 378 | − | SelectedTab::DirectoryBrowser => { | |
| 379 | − | let items: Vec<String> = app | |
| 380 | − | .browser | |
| 381 | − | .entries | |
| 382 | − | .iter() | |
| 383 | − | .map(|entry| entry.name().to_string()) | |
| 384 | − | .collect(); | |
| 385 | − | app.browser_search_results = fuzzy_indices(&app.search_input, &items); | |
| 386 | − | app.browser_search_index = 0; | |
| 387 | − | if let Some(index) = app.browser_search_results.first() { | |
| 388 | − | app.browser.selected = *index; | |
| 389 | − | } | |
| 390 | − | } | |
| 391 | − | SelectedTab::Playlists => { | |
| 392 | − | let items: Vec<&str> = app.pl_list.list.iter().map(String::as_str).collect(); | |
| 393 | − | if let Some((best, _)) = fuzzy_search_sorted(&app.search_input, &items).first() { | |
| 394 | − | if let Some(index) = app.pl_list.list.iter().position(|name| name == *best) { | |
| 395 | − | app.pl_list.index = index; | |
| 396 | − | } | |
| 397 | − | } | |
| 398 | − | } | |
| 399 | − | } | |
| 400 | − | } | |
| 401 | − | ||
| 402 | − | fn cycle_browser_search(app: &mut App, forward: bool) { | |
| 403 | − | let len = app.browser_search_results.len(); | |
| 404 | − | if len == 0 { | |
| 405 | − | return; | |
| 406 | − | } | |
| 407 | − | app.browser_search_index = if forward { | |
| 408 | − | (app.browser_search_index + 1) % len | |
| 409 | − | } else { | |
| 410 | − | app.browser_search_index.checked_sub(1).unwrap_or(len - 1) | |
| 411 | − | }; | |
| 412 | − | app.browser.selected = app.browser_search_results[app.browser_search_index]; | |
| 413 | − | } | |
| 414 | − | ||
| 415 | − | fn fuzzy_indices(query: &str, items: &[String]) -> Vec<usize> { | |
| 416 | − | if query.is_empty() { | |
| 417 | − | return Vec::new(); | |
| 418 | − | } | |
| 419 | − | let candidates: Vec<&str> = items.iter().map(String::as_str).collect(); | |
| 420 | − | let ranked = fuzzy_search_sorted(query, &candidates); | |
| 421 | − | let mut used = vec![false; items.len()]; | |
| 422 | − | ranked | |
| 423 | − | .into_iter() | |
| 424 | − | .filter(|(_, score)| *score > 0.0) | |
| 425 | − | .filter_map(|(name, _)| { | |
| 426 | − | let index = items | |
| 427 | − | .iter() | |
| 428 | − | .enumerate() | |
| 429 | − | .find(|(index, item)| !used[*index] && item.as_str() == name) | |
| 430 | − | .map(|(index, _)| index)?; | |
| 431 | − | used[index] = true; | |
| 432 | − | Some(index) | |
| 433 | − | }) | |
| 434 | − | .collect() | |
| 435 | − | } | |
| 436 | − | ||
| 437 | − | pub async fn handle_mouse_events(mouse: MouseEvent, app: &mut App) -> AppResult<()> { | |
| 438 | − | let previous_playlist = app.pl_list.index; | |
| 439 | − | match mouse.kind { | |
| 440 | − | MouseEventKind::ScrollUp => app.handle_scroll_up(), | |
| 441 | − | MouseEventKind::ScrollDown => app.handle_scroll_down(), | |
| 442 | − | _ => {} | |
| 443 | − | } | |
| 444 | − | if app.selected_tab == SelectedTab::Playlists && app.pl_list.index != previous_playlist { | |
| 445 | − | app.playlist_song_index = 0; | |
| 446 | − | app.refresh_playlist_view().await?; | |
| 447 | − | } | |
| 448 | − | Ok(()) | |
| 449 | − | } | |
| 450 | − | ||
| 451 | − | #[cfg(test)] | |
| 452 | − | mod tests { | |
| 453 | − | use super::fuzzy_indices; | |
| 454 | − | ||
| 455 | − | #[test] | |
| 456 | − | fn fuzzy_search_returns_all_matches_and_keeps_duplicate_names() { | |
| 457 | − | let items = vec![ | |
| 458 | − | "mr-robot-vol1".to_string(), | |
| 459 | − | "mr-robot-vol2".to_string(), | |
| 460 | − | "mr-robot-vol3".to_string(), | |
| 461 | − | "other-album".to_string(), | |
| 462 | − | ]; | |
| 463 | − | let results = fuzzy_indices("robot", &items); | |
| 464 | − | assert_eq!(results.len(), 3); | |
| 465 | − | assert_eq!( | |
| 466 | − | results | |
| 467 | − | .iter() | |
| 468 | − | .map(|index| items[*index].as_str()) | |
| 469 | − | .collect::<Vec<_>>(), | |
| 470 | − | vec!["mr-robot-vol1", "mr-robot-vol2", "mr-robot-vol3"] | |
| 471 | − | ); | |
| 472 | − | } | |
| 473 | − | } | |
| @@ -1 +0,0 @@ | |||
| 1 | − | pub mod handler; | |
Mode 100755 → 100644
| @@ -1,15 +1,20 @@ | |||
| 1 | − | //! An asynchronous terminal client for Music Player Daemon. | |
| 1 | + | //! rmptui — a keyboard-driven MPD client for the terminal. | |
| 2 | + | //! | |
| 3 | + | //! The crate is split into an MPD client (`mpd`), configuration (`config`), | |
| 4 | + | //! the application loop (`app`) and its UI components (`screens`, `popups`, | |
| 5 | + | //! `ui`), plus the lyrics and visualizer services. | |
| 2 | 6 | ||
| 7 | + | pub mod action; | |
| 3 | 8 | pub mod app; | |
| 4 | − | pub mod browser; | |
| 5 | 9 | pub mod config; | |
| 6 | − | pub mod connection; | |
| 7 | − | pub mod event_handler; | |
| 8 | − | pub mod list; | |
| 10 | + | pub mod event; | |
| 9 | 11 | pub mod logging; | |
| 10 | − | pub mod tui; | |
| 12 | + | pub mod lyrics; | |
| 13 | + | pub mod mpd; | |
| 14 | + | pub mod popups; | |
| 15 | + | pub mod screens; | |
| 16 | + | pub mod state; | |
| 17 | + | pub mod tags; | |
| 11 | 18 | pub mod ui; | |
| 12 | − | pub mod utils; | |
| 13 | − | ||
| 14 | − | /// The application's error type. | |
| 15 | − | pub type AppResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; | |
| 19 | + | pub mod util; | |
| 20 | + | pub mod visualizer; | |
| @@ -1,30 +0,0 @@ | |||
| 1 | − | #[derive(Debug)] | |
| 2 | − | pub struct ContentList<T> { | |
| 3 | − | pub list: Vec<T>, | |
| 4 | − | pub index: usize, | |
| 5 | − | } | |
| 6 | − | ||
| 7 | − | impl<T> ContentList<T> { | |
| 8 | − | pub fn new() -> Self { | |
| 9 | − | Self { | |
| 10 | − | list: Vec::new(), | |
| 11 | − | index: 0, | |
| 12 | − | } | |
| 13 | − | } | |
| 14 | − | ||
| 15 | − | pub fn next(&mut self) { | |
| 16 | − | if let Some(last) = self.list.len().checked_sub(1) { | |
| 17 | − | self.index = self.index.saturating_add(1).min(last); | |
| 18 | − | } | |
| 19 | − | } | |
| 20 | − | ||
| 21 | − | pub fn prev(&mut self) { | |
| 22 | − | self.index = self.index.saturating_sub(1); | |
| 23 | − | } | |
| 24 | − | } | |
| 25 | − | ||
| 26 | − | impl<T> Default for ContentList<T> { | |
| 27 | − | fn default() -> Self { | |
| 28 | − | Self::new() | |
| 29 | − | } | |
| 30 | − | } | |
| @@ -1,20 +1,19 @@ | |||
| 1 | + | //! File logging. The terminal belongs to the UI, so diagnostics go to a file. | |
| 2 | + | ||
| 1 | 3 | use std::{ | |
| 2 | − | fs::{self, OpenOptions}, | |
| 3 | − | io::{self, Write}, | |
| 4 | − | path::{Path, PathBuf}, | |
| 5 | − | sync::{Mutex, OnceLock}, | |
| 6 | − | time::{SystemTime, UNIX_EPOCH}, | |
| 4 | + | fs::OpenOptions, | |
| 5 | + | io, | |
| 6 | + | path::Path, | |
| 7 | + | sync::{Arc, Mutex}, | |
| 7 | 8 | }; | |
| 8 | 9 | ||
| 9 | − | use crate::AppResult; | |
| 10 | − | ||
| 11 | − | static LOG_FILE: OnceLock<Mutex<fs::File>> = OnceLock::new(); | |
| 10 | + | use tracing_subscriber::{EnvFilter, fmt}; | |
| 12 | 11 | ||
| 13 | − | pub fn init(path: &Path) -> AppResult<PathBuf> { | |
| 14 | − | if let Some(parent) = path.parent() { | |
| 15 | − | fs::create_dir_all(parent)?; | |
| 16 | − | } | |
| 12 | + | pub const LOG_PATH: &str = "/tmp/rmptui.log"; | |
| 17 | 13 | ||
| 14 | + | /// Appends logs to `path`. The level can be set with `RMPTUI_LOG` | |
| 15 | + | /// (e.g. `RMPTUI_LOG=debug`); the default is `info`. | |
| 16 | + | pub fn init(path: &Path) -> io::Result<()> { | |
| 18 | 17 | let mut options = OpenOptions::new(); | |
| 19 | 18 | options.create(true).append(true); | |
| 20 | 19 | #[cfg(unix)] | |
| @@ -22,65 +21,38 @@ pub fn init(path: &Path) -> AppResult<PathBuf> { | |||
| 22 | 21 | use std::os::unix::fs::OpenOptionsExt; | |
| 23 | 22 | options.mode(0o600); | |
| 24 | 23 | } | |
| 25 | − | let file = options.open(path)?; | |
| 26 | − | let _ = LOG_FILE.set(Mutex::new(file)); | |
| 27 | − | ||
| 28 | − | let panic_hook = std::panic::take_hook(); | |
| 29 | − | std::panic::set_hook(Box::new(move |panic| { | |
| 30 | − | let message = panic | |
| 31 | − | .payload() | |
| 32 | − | .downcast_ref::<&str>() | |
| 33 | − | .copied() | |
| 34 | − | .or_else(|| panic.payload().downcast_ref::<String>().map(String::as_str)) | |
| 35 | − | .unwrap_or("non-string panic payload"); | |
| 36 | − | let location = panic | |
| 37 | − | .location() | |
| 38 | − | .map(|location| { | |
| 39 | − | format!( | |
| 40 | − | " at {}:{}:{}", | |
| 41 | − | location.file(), | |
| 42 | − | location.line(), | |
| 43 | − | location.column() | |
| 44 | − | ) | |
| 45 | − | }) | |
| 46 | − | .unwrap_or_default(); | |
| 47 | − | error(&format!("panic: {message}{location}")); | |
| 48 | − | panic_hook(panic); | |
| 24 | + | let file = Arc::new(Mutex::new(options.open(path)?)); | |
| 25 | + | let filter = EnvFilter::try_from_env("RMPTUI_LOG").unwrap_or_else(|_| EnvFilter::new("info")); | |
| 26 | + | fmt() | |
| 27 | + | .with_env_filter(filter) | |
| 28 | + | .with_ansi(false) | |
| 29 | + | .with_writer(move || MutexWriter(Arc::clone(&file))) | |
| 30 | + | .try_init() | |
| 31 | + | .map_err(io::Error::other)?; | |
| 32 | + | ||
| 33 | + | // Record panics in the log as well as on the (restored) terminal. | |
| 34 | + | let previous = std::panic::take_hook(); | |
| 35 | + | std::panic::set_hook(Box::new(move |info| { | |
| 36 | + | tracing::error!("panic: {info}"); | |
| 37 | + | previous(info); | |
| 49 | 38 | })); | |
| 50 | − | ||
| 51 | − | info(&format!("logging initialized: {}", path.display())); | |
| 52 | − | Ok(path.to_path_buf()) | |
| 39 | + | Ok(()) | |
| 53 | 40 | } | |
| 54 | 41 | ||
| 55 | − | pub fn info(message: &str) { | |
| 56 | − | write("INFO", message); | |
| 57 | − | } | |
| 42 | + | struct MutexWriter(Arc<Mutex<std::fs::File>>); | |
| 58 | 43 | ||
| 59 | − | pub fn error(message: &str) { | |
| 60 | − | write("ERROR", message); | |
| 61 | − | } | |
| 62 | − | ||
| 63 | − | fn write(level: &str, message: &str) { | |
| 64 | − | let Some(file) = LOG_FILE.get() else { | |
| 65 | − | return; | |
| 66 | − | }; | |
| 67 | − | ||
| 68 | − | let timestamp = SystemTime::now() | |
| 69 | − | .duration_since(UNIX_EPOCH) | |
| 70 | − | .unwrap_or_default(); | |
| 71 | − | let line = format!( | |
| 72 | − | "[{}.{:03}] {level} {message}", | |
| 73 | − | timestamp.as_secs(), | |
| 74 | − | timestamp.subsec_millis() | |
| 75 | − | ); | |
| 76 | − | let mut file = file | |
| 77 | − | .lock() | |
| 78 | − | .unwrap_or_else(std::sync::PoisonError::into_inner); | |
| 79 | − | if let Err(error) = writeln!(file, "{line}") { | |
| 80 | − | report_write_error(error); | |
| 44 | + | impl io::Write for MutexWriter { | |
| 45 | + | fn write(&mut self, buf: &[u8]) -> io::Result<usize> { | |
| 46 | + | self.0 | |
| 47 | + | .lock() | |
| 48 | + | .unwrap_or_else(std::sync::PoisonError::into_inner) | |
| 49 | + | .write(buf) | |
| 81 | 50 | } | |
| 82 | − | } | |
| 83 | 51 | ||
| 84 | − | fn report_write_error(error: io::Error) { | |
| 85 | − | eprintln!("failed to write rmptui log: {error}"); | |
| 52 | + | fn flush(&mut self) -> io::Result<()> { | |
| 53 | + | self.0 | |
| 54 | + | .lock() | |
| 55 | + | .unwrap_or_else(std::sync::PoisonError::into_inner) | |
| 56 | + | .flush() | |
| 57 | + | } | |
| 86 | 58 | } | |
| @@ -0,0 +1,165 @@ | |||
| 1 | + | //! Parser for LRC (synced lyrics) files. | |
| 2 | + | ||
| 3 | + | use std::time::Duration; | |
| 4 | + | ||
| 5 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 6 | + | pub struct LyricLine { | |
| 7 | + | pub time: Duration, | |
| 8 | + | pub text: String, | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 12 | + | pub enum Lyrics { | |
| 13 | + | /// Lines with timestamps, sorted by time. | |
| 14 | + | Synced(Vec<LyricLine>), | |
| 15 | + | Plain(Vec<String>), | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | impl Lyrics { | |
| 19 | + | /// Parses LRC text. Text without any timestamps is treated as plain | |
| 20 | + | /// lyrics. | |
| 21 | + | pub fn parse(text: &str) -> Self { | |
| 22 | + | let mut offset_ms: i64 = 0; | |
| 23 | + | let mut synced = Vec::new(); | |
| 24 | + | for raw in text.lines() { | |
| 25 | + | let mut rest = raw.trim(); | |
| 26 | + | let mut times = Vec::new(); | |
| 27 | + | while let Some(tag) = rest.strip_prefix('[').and_then(|r| r.split_once(']')) { | |
| 28 | + | let (inner, tail) = tag; | |
| 29 | + | if let Some(time) = parse_timestamp(inner) { | |
| 30 | + | times.push(time); | |
| 31 | + | } else if let Some(value) = inner.strip_prefix("offset:") { | |
| 32 | + | offset_ms = value.trim().parse().unwrap_or(0); | |
| 33 | + | } else if !is_id_tag(inner) { | |
| 34 | + | break; | |
| 35 | + | } | |
| 36 | + | rest = tail; | |
| 37 | + | } | |
| 38 | + | let text = rest.trim().to_owned(); | |
| 39 | + | synced.extend(times.into_iter().map(|time| (time, text.clone()))); | |
| 40 | + | } | |
| 41 | + | if synced.is_empty() { | |
| 42 | + | return Self::Plain(text.lines().map(|l| l.trim_end().to_owned()).collect()); | |
| 43 | + | } | |
| 44 | + | // A positive offset makes lyrics appear sooner. | |
| 45 | + | let mut lines: Vec<LyricLine> = synced | |
| 46 | + | .into_iter() | |
| 47 | + | .map(|(time, text)| LyricLine { | |
| 48 | + | time: shift(time, -offset_ms), | |
| 49 | + | text, | |
| 50 | + | }) | |
| 51 | + | .collect(); | |
| 52 | + | lines.sort_by_key(|line| line.time); | |
| 53 | + | Self::Synced(lines) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | pub fn is_empty(&self) -> bool { | |
| 57 | + | match self { | |
| 58 | + | Self::Synced(lines) => lines.is_empty(), | |
| 59 | + | Self::Plain(lines) => lines.iter().all(|l| l.trim().is_empty()), | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | pub fn len(&self) -> usize { | |
| 64 | + | match self { | |
| 65 | + | Self::Synced(lines) => lines.len(), | |
| 66 | + | Self::Plain(lines) => lines.len(), | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// The index of the line being sung at `elapsed`. | |
| 71 | + | pub fn current_line(&self, elapsed: Duration) -> Option<usize> { | |
| 72 | + | match self { | |
| 73 | + | Self::Synced(lines) => lines | |
| 74 | + | .partition_point(|line| line.time <= elapsed) | |
| 75 | + | .checked_sub(1), | |
| 76 | + | Self::Plain(_) => None, | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | pub fn line(&self, index: usize) -> &str { | |
| 81 | + | match self { | |
| 82 | + | Self::Synced(lines) => lines.get(index).map_or("", |l| l.text.as_str()), | |
| 83 | + | Self::Plain(lines) => lines.get(index).map_or("", String::as_str), | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// `mm:ss`, `mm:ss.xx` or `mm:ss.xxx`. | |
| 89 | + | fn parse_timestamp(tag: &str) -> Option<Duration> { | |
| 90 | + | let (minutes, seconds) = tag.split_once(':')?; | |
| 91 | + | let minutes: u64 = minutes.trim().parse().ok()?; | |
| 92 | + | let (secs, fraction) = seconds.split_once(['.', ':']).unwrap_or((seconds, "0")); | |
| 93 | + | let secs: u64 = secs.trim().parse().ok()?; | |
| 94 | + | if secs >= 60 || fraction.is_empty() || !fraction.chars().all(|c| c.is_ascii_digit()) { | |
| 95 | + | return None; | |
| 96 | + | } | |
| 97 | + | let millis = match fraction.len() { | |
| 98 | + | 1 => fraction.parse::<u64>().ok()? * 100, | |
| 99 | + | 2 => fraction.parse::<u64>().ok()? * 10, | |
| 100 | + | _ => fraction[..3].parse::<u64>().ok()?, | |
| 101 | + | }; | |
| 102 | + | Some(Duration::from_millis((minutes * 60 + secs) * 1000 + millis)) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | fn is_id_tag(tag: &str) -> bool { | |
| 106 | + | tag.split_once(':') | |
| 107 | + | .is_some_and(|(key, _)| !key.is_empty() && key.chars().all(|c| c.is_ascii_alphabetic())) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | fn shift(time: Duration, ms: i64) -> Duration { | |
| 111 | + | let shifted = time.as_millis() as i64 + ms; | |
| 112 | + | Duration::from_millis(shifted.max(0) as u64) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[cfg(test)] | |
| 116 | + | mod tests { | |
| 117 | + | use super::*; | |
| 118 | + | ||
| 119 | + | fn ms(ms: u64) -> Duration { | |
| 120 | + | Duration::from_millis(ms) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[test] | |
| 124 | + | fn parses_synced_lyrics() { | |
| 125 | + | let lyrics = Lyrics::parse( | |
| 126 | + | "[ar:Someone]\n[ti:Song]\n[00:12.50]First\n[00:05.1][01:00.000]Chorus\n\n[00:20]Last", | |
| 127 | + | ); | |
| 128 | + | let Lyrics::Synced(lines) = &lyrics else { | |
| 129 | + | panic!("expected synced lyrics"); | |
| 130 | + | }; | |
| 131 | + | let times: Vec<_> = lines.iter().map(|l| l.time).collect(); | |
| 132 | + | assert_eq!(times, [ms(5_100), ms(12_500), ms(20_000), ms(60_000)]); | |
| 133 | + | assert_eq!(lines[0].text, "Chorus"); | |
| 134 | + | assert_eq!(lyrics.current_line(ms(0)), None); | |
| 135 | + | assert_eq!(lyrics.current_line(ms(12_500)), Some(1)); | |
| 136 | + | assert_eq!(lyrics.current_line(ms(90_000)), Some(3)); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn applies_offset() { | |
| 141 | + | let lyrics = Lyrics::parse("[offset:+500]\n[00:01.00]a\n[00:00.20]b"); | |
| 142 | + | let Lyrics::Synced(lines) = lyrics else { | |
| 143 | + | panic!("expected synced lyrics"); | |
| 144 | + | }; | |
| 145 | + | assert_eq!(lines[0].time, ms(0)); | |
| 146 | + | assert_eq!(lines[1].time, ms(500)); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[test] | |
| 150 | + | fn falls_back_to_plain() { | |
| 151 | + | let lyrics = Lyrics::parse("Just some\nwords [not a tag]\n"); | |
| 152 | + | assert_eq!( | |
| 153 | + | lyrics, | |
| 154 | + | Lyrics::Plain(vec!["Just some".into(), "words [not a tag]".into()]) | |
| 155 | + | ); | |
| 156 | + | assert_eq!(lyrics.current_line(ms(1000)), None); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[test] | |
| 160 | + | fn rejects_bad_timestamps() { | |
| 161 | + | assert_eq!(parse_timestamp("1:75.00"), None); | |
| 162 | + | assert_eq!(parse_timestamp("ab:10"), None); | |
| 163 | + | assert_eq!(parse_timestamp("02:03.4567"), Some(ms(123_456))); | |
| 164 | + | } | |
| 165 | + | } | |
| @@ -0,0 +1,373 @@ | |||
| 1 | + | //! Finding lyrics for the current song. | |
| 2 | + | //! | |
| 3 | + | //! Sources, in order: an `.lrc` file next to the song, the on-disk cache, and | |
| 4 | + | //! [LRCLIB](https://lrclib.net). | |
| 5 | + | ||
| 6 | + | mod lrc; | |
| 7 | + | ||
| 8 | + | use std::{ | |
| 9 | + | collections::HashMap, | |
| 10 | + | path::{Path, PathBuf}, | |
| 11 | + | time::Duration, | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | pub use lrc::{LyricLine, Lyrics}; | |
| 15 | + | use serde::Deserialize; | |
| 16 | + | use tokio::{sync::mpsc, task::JoinHandle}; | |
| 17 | + | use tracing::{debug, warn}; | |
| 18 | + | ||
| 19 | + | use crate::{config::LyricsConfig, event::AppEvent, mpd::Song, util}; | |
| 20 | + | ||
| 21 | + | /// What the UI knows about the lyrics of the current song. | |
| 22 | + | #[derive(Clone, Debug, Default)] | |
| 23 | + | pub enum LyricsStatus { | |
| 24 | + | #[default] | |
| 25 | + | Idle, | |
| 26 | + | Loading, | |
| 27 | + | Found(Lyrics), | |
| 28 | + | NotFound, | |
| 29 | + | Failed(String), | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | #[derive(Debug, Default)] | |
| 33 | + | pub struct LyricsState { | |
| 34 | + | /// The song the status belongs to. | |
| 35 | + | pub file: Option<String>, | |
| 36 | + | pub status: LyricsStatus, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | impl LyricsState { | |
| 40 | + | pub fn lyrics(&self) -> Option<&Lyrics> { | |
| 41 | + | match &self.status { | |
| 42 | + | LyricsStatus::Found(lyrics) => Some(lyrics), | |
| 43 | + | _ => None, | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// The result of a lookup, sent back as an [`AppEvent::Lyrics`]. | |
| 49 | + | pub type LookupResult = Result<Option<Lyrics>, String>; | |
| 50 | + | ||
| 51 | + | pub struct LyricsService { | |
| 52 | + | config: LyricsConfig, | |
| 53 | + | http: reqwest::Client, | |
| 54 | + | events: mpsc::UnboundedSender<AppEvent>, | |
| 55 | + | task: Option<JoinHandle<()>>, | |
| 56 | + | /// Results of this session, including misses, to avoid repeated lookups. | |
| 57 | + | memory: HashMap<String, Option<Lyrics>>, | |
| 58 | + | pub state: LyricsState, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | impl LyricsService { | |
| 62 | + | pub fn new(config: LyricsConfig, events: mpsc::UnboundedSender<AppEvent>) -> Self { | |
| 63 | + | let http = reqwest::Client::builder() | |
| 64 | + | .user_agent(concat!( | |
| 65 | + | "rmptui/", | |
| 66 | + | env!("CARGO_PKG_VERSION"), | |
| 67 | + | " (https://github.com/krolyxon/rmptui)" | |
| 68 | + | )) | |
| 69 | + | .timeout(Duration::from_secs(10)) | |
| 70 | + | .build() | |
| 71 | + | .unwrap_or_default(); | |
| 72 | + | Self { | |
| 73 | + | config, | |
| 74 | + | http, | |
| 75 | + | events, | |
| 76 | + | task: None, | |
| 77 | + | memory: HashMap::new(), | |
| 78 | + | state: LyricsState::default(), | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Makes sure lyrics for `song` are loaded or being loaded. With `force`, | |
| 83 | + | /// skips the session memory and the cache. | |
| 84 | + | pub fn ensure(&mut self, song: Option<&Song>, music_dir: Option<&Path>, force: bool) { | |
| 85 | + | let Some(song) = song else { | |
| 86 | + | self.cancel(); | |
| 87 | + | self.state = LyricsState::default(); | |
| 88 | + | return; | |
| 89 | + | }; | |
| 90 | + | if !force && self.state.file.as_deref() == Some(song.file.as_str()) { | |
| 91 | + | return; | |
| 92 | + | } | |
| 93 | + | self.cancel(); | |
| 94 | + | self.state.file = Some(song.file.clone()); | |
| 95 | + | if !force && let Some(known) = self.memory.get(&song.file) { | |
| 96 | + | self.state.status = known | |
| 97 | + | .clone() | |
| 98 | + | .map_or(LyricsStatus::NotFound, LyricsStatus::Found); | |
| 99 | + | return; | |
| 100 | + | } | |
| 101 | + | self.state.status = LyricsStatus::Loading; | |
| 102 | + | let request = Lookup { | |
| 103 | + | file: song.file.clone(), | |
| 104 | + | artist: song.artist().map(str::to_owned), | |
| 105 | + | title: lookup_title(song), | |
| 106 | + | album: song.album().map(str::to_owned), | |
| 107 | + | duration: song.duration, | |
| 108 | + | local: music_dir.map(|dir| dir.join(&song.file).with_extension("lrc")), | |
| 109 | + | use_cache: self.config.cache && !force, | |
| 110 | + | write_cache: self.config.cache, | |
| 111 | + | online: self.config.online, | |
| 112 | + | base_url: self.config.lrclib_url.trim_end_matches('/').to_owned(), | |
| 113 | + | }; | |
| 114 | + | let http = self.http.clone(); | |
| 115 | + | let events = self.events.clone(); | |
| 116 | + | self.task = Some(tokio::spawn(async move { | |
| 117 | + | let file = request.file.clone(); | |
| 118 | + | let result = request.run(&http).await; | |
| 119 | + | let _ = events.send(AppEvent::Lyrics { file, result }); | |
| 120 | + | })); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Applies a finished lookup. Returns whether it was for the current song. | |
| 124 | + | pub fn finish(&mut self, file: String, result: LookupResult) -> bool { | |
| 125 | + | match &result { | |
| 126 | + | Ok(found) => { | |
| 127 | + | self.memory.insert(file.clone(), found.clone()); | |
| 128 | + | } | |
| 129 | + | Err(error) => warn!("lyrics lookup for {file} failed: {error}"), | |
| 130 | + | } | |
| 131 | + | if self.state.file.as_deref() != Some(file.as_str()) { | |
| 132 | + | return false; | |
| 133 | + | } | |
| 134 | + | self.task = None; | |
| 135 | + | self.state.status = match result { | |
| 136 | + | Ok(Some(lyrics)) => LyricsStatus::Found(lyrics), | |
| 137 | + | Ok(None) => LyricsStatus::NotFound, | |
| 138 | + | Err(error) => LyricsStatus::Failed(error), | |
| 139 | + | }; | |
| 140 | + | true | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | fn cancel(&mut self) { | |
| 144 | + | if let Some(task) = self.task.take() { | |
| 145 | + | task.abort(); | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | struct Lookup { | |
| 151 | + | file: String, | |
| 152 | + | artist: Option<String>, | |
| 153 | + | title: Option<String>, | |
| 154 | + | album: Option<String>, | |
| 155 | + | duration: Option<Duration>, | |
| 156 | + | local: Option<PathBuf>, | |
| 157 | + | use_cache: bool, | |
| 158 | + | write_cache: bool, | |
| 159 | + | online: bool, | |
| 160 | + | base_url: String, | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | impl Lookup { | |
| 164 | + | async fn run(self, http: &reqwest::Client) -> LookupResult { | |
| 165 | + | if let Some(path) = &self.local | |
| 166 | + | && let Some(lyrics) = read_lyrics(path).await | |
| 167 | + | { | |
| 168 | + | debug!("lyrics for {} from {}", self.file, path.display()); | |
| 169 | + | return Ok(Some(lyrics)); | |
| 170 | + | } | |
| 171 | + | let (Some(artist), Some(title)) = (&self.artist, &self.title) else { | |
| 172 | + | return Ok(None); | |
| 173 | + | }; | |
| 174 | + | let cache_path = cache_path(artist, title); | |
| 175 | + | if self.use_cache | |
| 176 | + | && let Some(lyrics) = read_lyrics(&cache_path).await | |
| 177 | + | { | |
| 178 | + | return Ok(Some(lyrics)); | |
| 179 | + | } | |
| 180 | + | if !self.online { | |
| 181 | + | return Ok(None); | |
| 182 | + | } | |
| 183 | + | let Some(text) = self.fetch(http, artist, title).await? else { | |
| 184 | + | return Ok(None); | |
| 185 | + | }; | |
| 186 | + | if self.write_cache { | |
| 187 | + | if let Some(parent) = cache_path.parent() { | |
| 188 | + | let _ = tokio::fs::create_dir_all(parent).await; | |
| 189 | + | } | |
| 190 | + | if let Err(error) = tokio::fs::write(&cache_path, &text).await { | |
| 191 | + | warn!( | |
| 192 | + | "cannot write lyrics cache {}: {error}", | |
| 193 | + | cache_path.display() | |
| 194 | + | ); | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | let lyrics = Lyrics::parse(&text); | |
| 198 | + | Ok((!lyrics.is_empty()).then_some(lyrics)) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// Asks LRCLIB for an exact match first, then falls back to a search. | |
| 202 | + | async fn fetch( | |
| 203 | + | &self, | |
| 204 | + | http: &reqwest::Client, | |
| 205 | + | artist: &str, | |
| 206 | + | title: &str, | |
| 207 | + | ) -> Result<Option<String>, String> { | |
| 208 | + | let mut query = vec![ | |
| 209 | + | ("artist_name", artist.to_owned()), | |
| 210 | + | ("track_name", title.to_owned()), | |
| 211 | + | ]; | |
| 212 | + | if let Some(album) = &self.album { | |
| 213 | + | query.push(("album_name", album.clone())); | |
| 214 | + | } | |
| 215 | + | if let Some(duration) = self.duration { | |
| 216 | + | query.push(("duration", duration.as_secs().to_string())); | |
| 217 | + | } | |
| 218 | + | let response = http | |
| 219 | + | .get(format!("{}/api/get", self.base_url)) | |
| 220 | + | .query(&query) | |
| 221 | + | .send() | |
| 222 | + | .await | |
| 223 | + | .map_err(|e| e.to_string())?; | |
| 224 | + | if response.status().is_success() { | |
| 225 | + | let record: Record = response.json().await.map_err(|e| e.to_string())?; | |
| 226 | + | if let Some(text) = record.text() { | |
| 227 | + | return Ok(Some(text)); | |
| 228 | + | } | |
| 229 | + | } else if response.status() != reqwest::StatusCode::NOT_FOUND { | |
| 230 | + | return Err(format!("LRCLIB answered {}", response.status())); | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | let records: Vec<Record> = http | |
| 234 | + | .get(format!("{}/api/search", self.base_url)) | |
| 235 | + | .query(&[("artist_name", artist), ("track_name", title)]) | |
| 236 | + | .send() | |
| 237 | + | .await | |
| 238 | + | .and_then(reqwest::Response::error_for_status) | |
| 239 | + | .map_err(|e| e.to_string())? | |
| 240 | + | .json() | |
| 241 | + | .await | |
| 242 | + | .map_err(|e| e.to_string())?; | |
| 243 | + | // Prefer synced lyrics, then plain ones. | |
| 244 | + | let synced = records | |
| 245 | + | .iter() | |
| 246 | + | .find_map(|r| r.synced_lyrics.clone().filter(|s| !s.trim().is_empty())); | |
| 247 | + | Ok(synced.or_else(|| records.iter().find_map(Record::text))) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | #[derive(Debug, Deserialize)] | |
| 252 | + | #[serde(rename_all = "camelCase")] | |
| 253 | + | struct Record { | |
| 254 | + | #[serde(default)] | |
| 255 | + | instrumental: bool, | |
| 256 | + | plain_lyrics: Option<String>, | |
| 257 | + | synced_lyrics: Option<String>, | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | impl Record { | |
| 261 | + | fn text(&self) -> Option<String> { | |
| 262 | + | if self.instrumental { | |
| 263 | + | return Some("♪ Instrumental ♪".to_owned()); | |
| 264 | + | } | |
| 265 | + | self.synced_lyrics | |
| 266 | + | .clone() | |
| 267 | + | .filter(|s| !s.trim().is_empty()) | |
| 268 | + | .or_else(|| self.plain_lyrics.clone().filter(|s| !s.trim().is_empty())) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// The title to look lyrics up by: the Title tag, or a title derived from | |
| 273 | + | /// the file name (`05. Artist - Title (Explicit).mp3` → `Title`), without | |
| 274 | + | /// markers that lyric databases do not include. | |
| 275 | + | fn lookup_title(song: &Song) -> Option<String> { | |
| 276 | + | let mut title = song | |
| 277 | + | .title() | |
| 278 | + | .map_or_else(|| title_from_file_name(song), str::to_owned); | |
| 279 | + | for marker in [ | |
| 280 | + | "(Explicit)", | |
| 281 | + | "[Explicit]", | |
| 282 | + | "(explicit)", | |
| 283 | + | "[explicit]", | |
| 284 | + | "(Clean)", | |
| 285 | + | "[Clean]", | |
| 286 | + | ] { | |
| 287 | + | title = title.replace(marker, ""); | |
| 288 | + | } | |
| 289 | + | let title = title.trim().to_owned(); | |
| 290 | + | // A bare track number is not a title. | |
| 291 | + | (!title.chars().all(|c| c.is_ascii_digit())).then_some(title) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /// Strips a leading track number (`05. `, `05 - `, `05_`) and `Artist - `. | |
| 295 | + | fn title_from_file_name(song: &Song) -> String { | |
| 296 | + | let stem = song.display_title(); | |
| 297 | + | let without_artist = |s: &str| -> Option<String> { | |
| 298 | + | let rest = s.strip_prefix(song.artist()?)?; | |
| 299 | + | Some(rest.trim_start_matches([' ', '-']).to_owned()) | |
| 300 | + | }; | |
| 301 | + | let digits = stem.len() - stem.trim_start_matches(|c: char| c.is_ascii_digit()).len(); | |
| 302 | + | let without_track = (digits > 0 && stem[digits..].starts_with(['.', '-', '_', ' '])) | |
| 303 | + | .then(|| stem[digits..].trim_start_matches(['.', '-', '_', ' '])); | |
| 304 | + | without_artist(stem) | |
| 305 | + | .or_else(|| without_track.and_then(without_artist)) | |
| 306 | + | .or_else(|| without_track.map(str::to_owned)) | |
| 307 | + | .unwrap_or_else(|| stem.to_owned()) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | async fn read_lyrics(path: &Path) -> Option<Lyrics> { | |
| 311 | + | let text = tokio::fs::read_to_string(path).await.ok()?; | |
| 312 | + | let lyrics = Lyrics::parse(&text); | |
| 313 | + | (!lyrics.is_empty()).then_some(lyrics) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | fn cache_path(artist: &str, title: &str) -> PathBuf { | |
| 317 | + | let name: String = format!("{artist} - {title}") | |
| 318 | + | .chars() | |
| 319 | + | .map(|c| { | |
| 320 | + | if matches!(c, '/' | '\\' | '\0') || c.is_control() { | |
| 321 | + | '_' | |
| 322 | + | } else { | |
| 323 | + | c | |
| 324 | + | } | |
| 325 | + | }) | |
| 326 | + | .take(200) | |
| 327 | + | .collect(); | |
| 328 | + | util::cache_dir().join("lyrics").join(format!("{name}.lrc")) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[cfg(test)] | |
| 332 | + | mod tests { | |
| 333 | + | use super::*; | |
| 334 | + | ||
| 335 | + | #[test] | |
| 336 | + | fn derives_lookup_titles() { | |
| 337 | + | let song = |file: &str, tags: &[(&str, &str)]| Song { | |
| 338 | + | file: file.into(), | |
| 339 | + | tags: tags | |
| 340 | + | .iter() | |
| 341 | + | .map(|(k, v)| ((*k).into(), (*v).into())) | |
| 342 | + | .collect(), | |
| 343 | + | ..Song::default() | |
| 344 | + | }; | |
| 345 | + | let untagged = song( | |
| 346 | + | "x/05. 50 Cent - Candy Shop (Explicit).mp3", | |
| 347 | + | &[("Artist", "50 Cent")], | |
| 348 | + | ); | |
| 349 | + | assert_eq!(lookup_title(&untagged).as_deref(), Some("Candy Shop")); | |
| 350 | + | let tagged = song("x/a.flac", &[("Title", "Song [Explicit]")]); | |
| 351 | + | assert_eq!(lookup_title(&tagged).as_deref(), Some("Song")); | |
| 352 | + | assert_eq!(lookup_title(&song("x/01.mp3", &[])), None); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | #[test] | |
| 356 | + | fn sanitizes_cache_names() { | |
| 357 | + | let path = cache_path("AC/DC", "T.N.T."); | |
| 358 | + | assert_eq!(path.file_name().unwrap(), "AC_DC - T.N.T..lrc"); | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | #[test] | |
| 362 | + | fn prefers_synced_record_text() { | |
| 363 | + | let record: Record = serde_json::from_str( | |
| 364 | + | r#"{"instrumental":false,"plainLyrics":"plain","syncedLyrics":"[00:01.00]synced"}"#, | |
| 365 | + | ) | |
| 366 | + | .unwrap(); | |
| 367 | + | assert_eq!(record.text().as_deref(), Some("[00:01.00]synced")); | |
| 368 | + | let record: Record = | |
| 369 | + | serde_json::from_str(r#"{"instrumental":true,"plainLyrics":null,"syncedLyrics":null}"#) | |
| 370 | + | .unwrap(); | |
| 371 | + | assert!(record.text().unwrap().contains("Instrumental")); | |
| 372 | + | } | |
| 373 | + | } | |
Mode 100755 → 100644
| @@ -1,188 +1,59 @@ | |||
| 1 | − | use std::{env, io, net::Ipv6Addr, str::FromStr, time::Duration}; | |
| 1 | + | use std::{io, path::PathBuf, process::ExitCode}; | |
| 2 | 2 | ||
| 3 | − | use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind}; | |
| 4 | − | use futures_util::StreamExt; | |
| 5 | − | use ratatui::prelude::*; | |
| 6 | − | use rmptui::{ | |
| 7 | − | app::App, | |
| 8 | − | config::Config, | |
| 9 | − | connection::{Connection, MpdSnapshot}, | |
| 10 | − | event_handler::handler, | |
| 11 | − | logging, tui, AppResult, | |
| 3 | + | use clap::Parser; | |
| 4 | + | use crossterm::{ | |
| 5 | + | event::{DisableMouseCapture, EnableMouseCapture}, | |
| 6 | + | execute, | |
| 12 | 7 | }; | |
| 13 | − | use tokio::{sync::mpsc, time}; | |
| 14 | − | ||
| 15 | − | #[tokio::main] | |
| 16 | − | async fn main() -> AppResult<()> { | |
| 17 | − | let log_path = std::path::Path::new("/tmp/rmptui.log"); | |
| 18 | − | if let Err(error) = logging::init(log_path) { | |
| 19 | − | eprintln!( | |
| 20 | − | "Could not initialize logging at {}: {error}", | |
| 21 | − | log_path.display() | |
| 22 | − | ); | |
| 23 | − | } | |
| 24 | − | ||
| 25 | − | let result = run().await; | |
| 26 | − | if let Err(error) = &result { | |
| 27 | − | logging::error(&format!("application exited with error: {error}")); | |
| 28 | − | } | |
| 29 | − | result | |
| 8 | + | use rmptui::{app::App, config::Config, logging}; | |
| 9 | + | ||
| 10 | + | /// A fast, keyboard-driven MPD client for the terminal. | |
| 11 | + | #[derive(Debug, Parser)] | |
| 12 | + | #[command(version, about)] | |
| 13 | + | struct Args { | |
| 14 | + | /// Use this configuration file instead of the default location. | |
| 15 | + | #[arg(short, long, value_name = "FILE")] | |
| 16 | + | config: Option<PathBuf>, | |
| 17 | + | /// Print the default configuration and exit. | |
| 18 | + | #[arg(long)] | |
| 19 | + | print_default_config: bool, | |
| 30 | 20 | } | |
| 31 | 21 | ||
| 32 | − | async fn run() -> AppResult<()> { | |
| 33 | − | let (config, config_path) = Config::load_or_create()?; | |
| 34 | − | eprintln!("Using configuration: {}", config_path.display()); | |
| 35 | − | ||
| 36 | − | let configured_host = env::var("MPD_HOST").unwrap_or_else(|_| config.mpd.host.clone()); | |
| 37 | − | let (host, password) = configured_host | |
| 38 | − | .split_once('@') | |
| 39 | − | .map(|(password, host)| (host, Some(password.to_string()))) | |
| 40 | − | .unwrap_or_else(|| { | |
| 41 | − | ( | |
| 42 | − | configured_host.as_str(), | |
| 43 | − | (!config.mpd.password.is_empty()).then(|| config.mpd.password.clone()), | |
| 44 | − | ) | |
| 45 | − | }); | |
| 46 | − | if host.is_empty() { | |
| 47 | − | return Err(io::Error::new(io::ErrorKind::InvalidInput, "MPD host cannot be empty").into()); | |
| 48 | − | } | |
| 49 | − | let port = env::var("MPD_PORT") | |
| 50 | − | .map(|port| { | |
| 51 | − | port.parse::<u16>().map_err(|error| { | |
| 52 | − | io::Error::new( | |
| 53 | − | std::io::ErrorKind::InvalidInput, | |
| 54 | − | format!("invalid MPD_PORT value `{port}`: {error}"), | |
| 55 | − | ) | |
| 56 | − | }) | |
| 57 | − | }) | |
| 58 | − | .unwrap_or(Ok(config.mpd.port))?; | |
| 59 | − | if port == 0 { | |
| 60 | − | return Err(io::Error::new( | |
| 61 | − | std::io::ErrorKind::InvalidInput, | |
| 62 | − | "MPD port must be between 1 and 65535", | |
| 63 | − | ) | |
| 64 | − | .into()); | |
| 65 | − | } | |
| 66 | − | let address = mpd_address(host, port); | |
| 67 | − | let mut app = App::new(&address, password.as_deref(), config).await?; | |
| 68 | − | ||
| 69 | − | let backend = CrosstermBackend::new(io::stderr()); | |
| 70 | − | let terminal = Terminal::new(backend)?; | |
| 71 | − | let mut tui = tui::Tui::new(terminal); | |
| 72 | − | tui.init()?; | |
| 73 | − | ||
| 74 | − | let (updates_tx, mut updates_rx) = mpsc::channel(16); | |
| 75 | − | let watcher_address = address.clone(); | |
| 76 | − | let watcher_password = password.clone(); | |
| 77 | − | let watcher = tokio::spawn(async move { | |
| 78 | − | let result = watch_mpd(watcher_address, watcher_password, updates_tx).await; | |
| 79 | − | if let Err(error) = &result { | |
| 80 | − | logging::error(&format!("MPD change watcher failed: {error}")); | |
| 81 | − | } | |
| 82 | − | result | |
| 83 | − | }); | |
| 84 | − | let mut terminal_events = EventStream::new(); | |
| 85 | − | let mut redraw = time::interval(Duration::from_millis(50)); | |
| 86 | − | redraw.set_missed_tick_behavior(time::MissedTickBehavior::Skip); | |
| 87 | − | ||
| 88 | − | let run_result: AppResult<()> = async { | |
| 89 | − | while app.running { | |
| 90 | − | tui.draw(&mut app)?; | |
| 91 | − | tokio::select! { | |
| 92 | − | event = terminal_events.next() => { | |
| 93 | − | match event { | |
| 94 | − | Some(Ok(CrosstermEvent::Key(key))) | |
| 95 | − | if key.kind != KeyEventKind::Release => | |
| 96 | − | { | |
| 97 | − | handler::handle_key_events(key, &mut app).await?; | |
| 98 | − | } | |
| 99 | − | Some(Ok(CrosstermEvent::Key(_))) => {} | |
| 100 | − | Some(Ok(CrosstermEvent::Mouse(mouse))) => { | |
| 101 | − | handler::handle_mouse_events(mouse, &mut app).await?; | |
| 102 | − | } | |
| 103 | − | Some(Ok(CrosstermEvent::Resize(_, _))) | |
| 104 | − | | Some(Ok(CrosstermEvent::FocusGained)) | |
| 105 | − | | Some(Ok(CrosstermEvent::FocusLost)) | |
| 106 | − | | Some(Ok(CrosstermEvent::Paste(_))) => {} | |
| 107 | − | Some(Err(error)) => { | |
| 108 | − | return Err(Box::new(error) as Box<dyn std::error::Error + Send + Sync>); | |
| 109 | − | } | |
| 110 | − | None => { | |
| 111 | − | return Err(std::io::Error::other("terminal event stream ended").into()); | |
| 112 | − | } | |
| 113 | − | } | |
| 114 | − | } | |
| 115 | − | _ = redraw.tick() => {} | |
| 116 | − | snapshot = updates_rx.recv() => { | |
| 117 | − | match snapshot { | |
| 118 | − | Some(snapshot) => app.apply_snapshot(snapshot).await?, | |
| 119 | − | None => { | |
| 120 | − | return Err(std::io::Error::other( | |
| 121 | − | "MPD change watcher stopped unexpectedly", | |
| 122 | − | ) | |
| 123 | − | .into()); | |
| 124 | − | } | |
| 125 | − | } | |
| 126 | − | } | |
| 127 | − | } | |
| 22 | + | #[tokio::main] | |
| 23 | + | async fn main() -> ExitCode { | |
| 24 | + | let args = Args::parse(); | |
| 25 | + | if args.print_default_config { | |
| 26 | + | print!("{}", rmptui::config::DEFAULT_CONFIG); | |
| 27 | + | return ExitCode::SUCCESS; | |
| 28 | + | } | |
| 29 | + | if let Err(error) = logging::init(logging::LOG_PATH.as_ref()) { | |
| 30 | + | eprintln!("rmptui: cannot log to {}: {error}", logging::LOG_PATH); | |
| 31 | + | } | |
| 32 | + | match run(args).await { | |
| 33 | + | Ok(()) => ExitCode::SUCCESS, | |
| 34 | + | Err(error) => { | |
| 35 | + | tracing::error!("exiting: {error:#}"); | |
| 36 | + | eprintln!("rmptui: {error:#}"); | |
| 37 | + | ExitCode::FAILURE | |
| 128 | 38 | } | |
| 129 | − | Ok(()) | |
| 130 | 39 | } | |
| 131 | − | .await; | |
| 132 | − | ||
| 133 | − | watcher.abort(); | |
| 134 | − | let exit_result = tui.exit(); | |
| 135 | − | run_result?; | |
| 136 | − | exit_result?; | |
| 137 | − | Ok(()) | |
| 138 | 40 | } | |
| 139 | 41 | ||
| 140 | − | async fn watch_mpd( | |
| 141 | − | address: String, | |
| 142 | − | password: Option<String>, | |
| 143 | − | updates: mpsc::Sender<MpdSnapshot>, | |
| 144 | − | ) -> AppResult<()> { | |
| 145 | − | let mut client = Connection::connect(&address, password.as_deref()).await?; | |
| 146 | − | loop { | |
| 147 | − | client.idle().await?; | |
| 148 | − | let snapshot = client.snapshot().await?; | |
| 149 | − | if updates.send(snapshot).await.is_err() { | |
| 150 | − | return Ok(()); | |
| 151 | − | } | |
| 152 | − | } | |
| 153 | − | } | |
| 42 | + | async fn run(args: Args) -> anyhow::Result<()> { | |
| 43 | + | let (config, path) = Config::load(args.config.as_deref())?; | |
| 44 | + | tracing::info!("using configuration {}", path.display()); | |
| 45 | + | let mouse = config.ui.mouse; | |
| 46 | + | let app = App::new(config)?; | |
| 154 | 47 | ||
| 155 | − | fn mpd_address(host: &str, port: u16) -> String { | |
| 156 | − | if host.starts_with('[') && host.contains("]:") { | |
| 157 | − | host.to_string() | |
| 158 | − | } else if let Some(ipv6) = host | |
| 159 | − | .strip_prefix('[') | |
| 160 | − | .and_then(|host| host.strip_suffix(']')) | |
| 161 | − | .filter(|host| Ipv6Addr::from_str(host).is_ok()) | |
| 162 | − | { | |
| 163 | − | format!("[{ipv6}]:{port}") | |
| 164 | − | } else if host.contains(':') && Ipv6Addr::from_str(host).is_ok() { | |
| 165 | − | format!("[{host}]:{port}") | |
| 166 | − | } else if host | |
| 167 | − | .rsplit_once(':') | |
| 168 | − | .is_some_and(|(_, suffix)| suffix.parse::<u16>().is_ok()) | |
| 169 | − | { | |
| 170 | − | host.to_string() | |
| 171 | − | } else { | |
| 172 | − | format!("{host}:{port}") | |
| 48 | + | // `init` installs a panic hook that restores the terminal. | |
| 49 | + | let mut terminal = ratatui::init(); | |
| 50 | + | if mouse { | |
| 51 | + | execute!(io::stdout(), EnableMouseCapture)?; | |
| 173 | 52 | } | |
| 174 | − | } | |
| 175 | − | ||
| 176 | − | #[cfg(test)] | |
| 177 | − | mod tests { | |
| 178 | − | use super::mpd_address; | |
| 179 | − | ||
| 180 | − | #[test] | |
| 181 | − | fn formats_mpd_host_addresses() { | |
| 182 | − | assert_eq!(mpd_address("localhost", 6600), "localhost:6600"); | |
| 183 | − | assert_eq!(mpd_address("localhost:6601", 6600), "localhost:6601"); | |
| 184 | − | assert_eq!(mpd_address("[::1]", 6600), "[::1]:6600"); | |
| 185 | − | assert_eq!(mpd_address("[::1]:6601", 6600), "[::1]:6601"); | |
| 186 | − | assert_eq!(mpd_address("::1", 6600), "[::1]:6600"); | |
| 53 | + | let result = app.run(&mut terminal).await; | |
| 54 | + | if mouse { | |
| 55 | + | let _ = execute!(io::stdout(), DisableMouseCapture); | |
| 187 | 56 | } | |
| 57 | + | ratatui::restore(); | |
| 58 | + | result | |
| 188 | 59 | } | |
| @@ -0,0 +1,168 @@ | |||
| 1 | + | //! Constructors for the commands rmptui sends. | |
| 2 | + | ||
| 3 | + | use super::protocol::Command; | |
| 4 | + | ||
| 5 | + | fn bool_arg(value: bool) -> &'static str { | |
| 6 | + | if value { "1" } else { "0" } | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | pub fn play() -> Command { | |
| 10 | + | Command::new("play") | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | pub fn play_pos(pos: u32) -> Command { | |
| 14 | + | Command::new("play").arg(pos.to_string()) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | pub fn play_id(id: u32) -> Command { | |
| 18 | + | Command::new("playid").arg(id.to_string()) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | pub fn pause(paused: bool) -> Command { | |
| 22 | + | Command::new("pause").arg(bool_arg(paused)) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | pub fn stop() -> Command { | |
| 26 | + | Command::new("stop") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | pub fn next() -> Command { | |
| 30 | + | Command::new("next") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | pub fn previous() -> Command { | |
| 34 | + | Command::new("previous") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Seeks relative to the current position. | |
| 38 | + | pub fn seek_relative(seconds: i64) -> Command { | |
| 39 | + | Command::new("seekcur").arg(format!("{seconds:+}")) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | pub fn seek_to(seconds: f64) -> Command { | |
| 43 | + | Command::new("seekcur").arg(format!("{seconds:.3}")) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Changes the volume relative to the current level. The server applies the | |
| 47 | + | /// change atomically, so fast repeated key presses are never lost. | |
| 48 | + | pub fn change_volume(delta: i32) -> Command { | |
| 49 | + | Command::new("volume").arg(format!("{delta:+}")) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | pub fn set_volume(volume: u8) -> Command { | |
| 53 | + | Command::new("setvol").arg(volume.min(100).to_string()) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | pub fn repeat(on: bool) -> Command { | |
| 57 | + | Command::new("repeat").arg(bool_arg(on)) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | pub fn random(on: bool) -> Command { | |
| 61 | + | Command::new("random").arg(bool_arg(on)) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | pub fn single(on: bool) -> Command { | |
| 65 | + | Command::new("single").arg(bool_arg(on)) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | pub fn consume(on: bool) -> Command { | |
| 69 | + | Command::new("consume").arg(bool_arg(on)) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | pub fn update(path: Option<&str>) -> Command { | |
| 73 | + | let command = Command::new("update"); | |
| 74 | + | match path { | |
| 75 | + | Some(path) if !path.is_empty() => command.arg(path), | |
| 76 | + | _ => command, | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | pub fn clear() -> Command { | |
| 81 | + | Command::new("clear") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | pub fn shuffle() -> Command { | |
| 85 | + | Command::new("shuffle") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Adds a song or (recursively) a directory to the queue. | |
| 89 | + | pub fn add(uri: &str) -> Command { | |
| 90 | + | Command::new("add").arg(uri) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | pub fn add_id(uri: &str) -> Command { | |
| 94 | + | Command::new("addid").arg(uri) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | pub fn delete_pos(pos: u32) -> Command { | |
| 98 | + | Command::new("delete").arg(pos.to_string()) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | pub fn delete_id(id: u32) -> Command { | |
| 102 | + | Command::new("deleteid").arg(id.to_string()) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Moves the song at queue position `from` to `to`. Positions (unlike ids) | |
| 106 | + | /// stay correct when several moves are sent before the queue is refreshed. | |
| 107 | + | pub fn move_pos(from: u32, to: u32) -> Command { | |
| 108 | + | Command::new("move") | |
| 109 | + | .arg(from.to_string()) | |
| 110 | + | .arg(to.to_string()) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | pub fn find_add(filter: &str) -> Command { | |
| 114 | + | Command::new("findadd").arg(filter) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | pub fn load(playlist: &str) -> Command { | |
| 118 | + | Command::new("load").arg(playlist) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | pub fn save(playlist: &str) -> Command { | |
| 122 | + | Command::new("save").arg(playlist) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | pub fn rename(from: &str, to: &str) -> Command { | |
| 126 | + | Command::new("rename").arg(from).arg(to) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | pub fn remove_playlist(playlist: &str) -> Command { | |
| 130 | + | Command::new("rm").arg(playlist) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// Adds a song or directory to a stored playlist, creating it if needed. | |
| 134 | + | pub fn playlist_add(playlist: &str, uri: &str) -> Command { | |
| 135 | + | Command::new("playlistadd").arg(playlist).arg(uri) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// Adds every song matching `filter` to a stored playlist. | |
| 139 | + | pub fn playlist_add_filter(playlist: &str, filter: &str) -> Command { | |
| 140 | + | Command::new("searchaddpl").arg(playlist).arg(filter) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | pub fn playlist_delete(playlist: &str, pos: u32) -> Command { | |
| 144 | + | Command::new("playlistdelete") | |
| 145 | + | .arg(playlist) | |
| 146 | + | .arg(pos.to_string()) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | pub fn playlist_move(playlist: &str, from: u32, to: u32) -> Command { | |
| 150 | + | Command::new("playlistmove") | |
| 151 | + | .arg(playlist) | |
| 152 | + | .arg(from.to_string()) | |
| 153 | + | .arg(to.to_string()) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[cfg(test)] | |
| 157 | + | mod tests { | |
| 158 | + | use super::*; | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn formats_relative_arguments() { | |
| 162 | + | assert_eq!(seek_relative(5).as_str(), r#"seekcur "+5""#); | |
| 163 | + | assert_eq!(seek_relative(-5).as_str(), r#"seekcur "-5""#); | |
| 164 | + | assert_eq!(change_volume(-2).as_str(), r#"volume "-2""#); | |
| 165 | + | assert_eq!(update(None).as_str(), "update"); | |
| 166 | + | assert_eq!(set_volume(140).as_str(), r#"setvol "100""#); | |
| 167 | + | } | |
| 168 | + | } | |
| @@ -0,0 +1,173 @@ | |||
| 1 | + | //! An asynchronous MPD connection over TCP or a Unix socket. | |
| 2 | + | ||
| 3 | + | use std::{fmt, path::PathBuf}; | |
| 4 | + | ||
| 5 | + | use tokio::{ | |
| 6 | + | io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufStream}, | |
| 7 | + | net::TcpStream, | |
| 8 | + | }; | |
| 9 | + | ||
| 10 | + | use super::{ | |
| 11 | + | error::{MpdError, MpdResult}, | |
| 12 | + | protocol::{self, Command, Line, Pair}, | |
| 13 | + | }; | |
| 14 | + | ||
| 15 | + | /// Where the server listens. | |
| 16 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 17 | + | pub enum Address { | |
| 18 | + | /// `host:port`, with IPv6 hosts in brackets. | |
| 19 | + | Tcp(String), | |
| 20 | + | Unix(PathBuf), | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | impl Address { | |
| 24 | + | /// Whether the server runs on this machine. | |
| 25 | + | pub fn is_local(&self) -> bool { | |
| 26 | + | match self { | |
| 27 | + | Self::Unix(_) => true, | |
| 28 | + | Self::Tcp(addr) => { | |
| 29 | + | let host = addr | |
| 30 | + | .rsplit_once(':') | |
| 31 | + | .map_or(addr.as_str(), |(host, _)| host) | |
| 32 | + | .trim_start_matches('[') | |
| 33 | + | .trim_end_matches(']'); | |
| 34 | + | matches!(host, "localhost" | "127.0.0.1" | "::1") || host.starts_with("127.") | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | impl fmt::Display for Address { | |
| 41 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 42 | + | match self { | |
| 43 | + | Self::Tcp(addr) => f.write_str(addr), | |
| 44 | + | Self::Unix(path) => write!(f, "{}", path.display()), | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | trait Socket: AsyncRead + AsyncWrite + Unpin + Send {} | |
| 50 | + | impl<T: AsyncRead + AsyncWrite + Unpin + Send> Socket for T {} | |
| 51 | + | ||
| 52 | + | pub struct Connection { | |
| 53 | + | stream: BufStream<Box<dyn Socket>>, | |
| 54 | + | version: String, | |
| 55 | + | line: Vec<u8>, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | impl fmt::Debug for Connection { | |
| 59 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 60 | + | f.debug_struct("Connection") | |
| 61 | + | .field("version", &self.version) | |
| 62 | + | .finish_non_exhaustive() | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | impl Connection { | |
| 67 | + | /// Connects, reads the greeting and authenticates if `password` is set. | |
| 68 | + | pub async fn connect(address: &Address, password: Option<&str>) -> MpdResult<Self> { | |
| 69 | + | let socket: Box<dyn Socket> = match address { | |
| 70 | + | Address::Tcp(addr) => { | |
| 71 | + | let stream = TcpStream::connect(addr).await?; | |
| 72 | + | stream.set_nodelay(true)?; | |
| 73 | + | Box::new(stream) | |
| 74 | + | } | |
| 75 | + | #[cfg(unix)] | |
| 76 | + | Address::Unix(path) => Box::new(tokio::net::UnixStream::connect(path).await?), | |
| 77 | + | #[cfg(not(unix))] | |
| 78 | + | Address::Unix(_) => { | |
| 79 | + | return Err(MpdError::Protocol( | |
| 80 | + | "Unix sockets are not supported on this platform".into(), | |
| 81 | + | )); | |
| 82 | + | } | |
| 83 | + | }; | |
| 84 | + | let mut conn = Self { | |
| 85 | + | stream: BufStream::new(socket), | |
| 86 | + | version: String::new(), | |
| 87 | + | line: Vec::new(), | |
| 88 | + | }; | |
| 89 | + | let greeting = conn.read_line().await?; | |
| 90 | + | conn.version = protocol::parse_greeting(&greeting)?; | |
| 91 | + | if let Some(password) = password.filter(|p| !p.is_empty()) { | |
| 92 | + | conn.execute(&Command::new("password").arg(password)) | |
| 93 | + | .await?; | |
| 94 | + | } | |
| 95 | + | Ok(conn) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// The protocol version announced by the server. | |
| 99 | + | pub fn version(&self) -> &str { | |
| 100 | + | &self.version | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Runs one command and collects its response. | |
| 104 | + | pub async fn execute(&mut self, command: &Command) -> MpdResult<Vec<Pair>> { | |
| 105 | + | self.send(command.as_str()).await?; | |
| 106 | + | self.read_response().await | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// Runs commands atomically as a command list, returning one response per | |
| 110 | + | /// command. | |
| 111 | + | pub async fn execute_list(&mut self, commands: &[Command]) -> MpdResult<Vec<Vec<Pair>>> { | |
| 112 | + | let mut request = String::from("command_list_ok_begin\n"); | |
| 113 | + | for command in commands { | |
| 114 | + | request.push_str(command.as_str()); | |
| 115 | + | request.push('\n'); | |
| 116 | + | } | |
| 117 | + | request.push_str("command_list_end"); | |
| 118 | + | self.send(&request).await?; | |
| 119 | + | ||
| 120 | + | let mut responses = Vec::with_capacity(commands.len()); | |
| 121 | + | let mut current = Vec::new(); | |
| 122 | + | loop { | |
| 123 | + | let line = self.read_line().await?; | |
| 124 | + | match protocol::parse_line(&line)? { | |
| 125 | + | Line::Pair(pair) => current.push(pair), | |
| 126 | + | Line::ListOk => responses.push(std::mem::take(&mut current)), | |
| 127 | + | Line::Ok => return Ok(responses), | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Runs commands as a list when there is more than one, discarding output. | |
| 133 | + | pub async fn execute_all(&mut self, commands: &[Command]) -> MpdResult<()> { | |
| 134 | + | match commands { | |
| 135 | + | [] => Ok(()), | |
| 136 | + | [command] => self.execute(command).await.map(drop), | |
| 137 | + | _ => self.execute_list(commands).await.map(drop), | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | async fn send(&mut self, request: &str) -> MpdResult<()> { | |
| 142 | + | self.stream.write_all(request.as_bytes()).await?; | |
| 143 | + | self.stream.write_all(b"\n").await?; | |
| 144 | + | self.stream.flush().await?; | |
| 145 | + | Ok(()) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | async fn read_response(&mut self) -> MpdResult<Vec<Pair>> { | |
| 149 | + | let mut pairs = Vec::new(); | |
| 150 | + | loop { | |
| 151 | + | let line = self.read_line().await?; | |
| 152 | + | match protocol::parse_line(&line)? { | |
| 153 | + | Line::Pair(pair) => pairs.push(pair), | |
| 154 | + | Line::Ok => return Ok(pairs), | |
| 155 | + | Line::ListOk => { | |
| 156 | + | return Err(MpdError::Protocol("unexpected list_OK".into())); | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | async fn read_line(&mut self) -> MpdResult<String> { | |
| 163 | + | self.line.clear(); | |
| 164 | + | if self.stream.read_until(b'\n', &mut self.line).await? == 0 { | |
| 165 | + | return Err(MpdError::Closed); | |
| 166 | + | } | |
| 167 | + | if self.line.last() == Some(&b'\n') { | |
| 168 | + | self.line.pop(); | |
| 169 | + | } | |
| 170 | + | // Tags are UTF-8 by protocol; tolerate broken files instead of failing. | |
| 171 | + | Ok(String::from_utf8_lossy(&self.line).into_owned()) | |
| 172 | + | } | |
| 173 | + | } | |
| @@ -0,0 +1,42 @@ | |||
| 1 | + | use std::{fmt, io}; | |
| 2 | + | ||
| 3 | + | /// An `ACK` error returned by the server. | |
| 4 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 5 | + | pub struct Ack { | |
| 6 | + | pub code: u32, | |
| 7 | + | /// Index of the failing command inside a command list. | |
| 8 | + | pub index: u32, | |
| 9 | + | pub command: String, | |
| 10 | + | pub message: String, | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | impl fmt::Display for Ack { | |
| 14 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 15 | + | if self.command.is_empty() { | |
| 16 | + | write!(f, "{}", self.message) | |
| 17 | + | } else { | |
| 18 | + | write!(f, "{}: {}", self.command, self.message) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | #[derive(Debug, thiserror::Error)] | |
| 24 | + | pub enum MpdError { | |
| 25 | + | #[error("connection error: {0}")] | |
| 26 | + | Io(#[from] io::Error), | |
| 27 | + | #[error("MPD error: {0}")] | |
| 28 | + | Ack(Ack), | |
| 29 | + | #[error("protocol error: {0}")] | |
| 30 | + | Protocol(String), | |
| 31 | + | #[error("connection closed by server")] | |
| 32 | + | Closed, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | impl MpdError { | |
| 36 | + | /// Whether the connection is unusable after this error. | |
| 37 | + | pub fn is_fatal(&self) -> bool { | |
| 38 | + | !matches!(self, Self::Ack(_)) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | pub type MpdResult<T> = Result<T, MpdError>; | |
| @@ -0,0 +1,16 @@ | |||
| 1 | + | //! MPD client: protocol, connection, typed models and background workers. | |
| 2 | + | ||
| 3 | + | pub mod commands; | |
| 4 | + | mod connection; | |
| 5 | + | mod error; | |
| 6 | + | pub mod protocol; | |
| 7 | + | mod selection; | |
| 8 | + | mod types; | |
| 9 | + | mod worker; | |
| 10 | + | ||
| 11 | + | pub use connection::{Address, Connection}; | |
| 12 | + | pub use error::{Ack, MpdError, MpdResult}; | |
| 13 | + | pub use protocol::Command; | |
| 14 | + | pub use selection::Selection; | |
| 15 | + | pub use types::{Album, DirEntry, OnOff, PlayState, PlaylistInfo, Song, Status, Subsystem}; | |
| 16 | + | pub use worker::{MpdEvent, MpdHandle, Query, Reply, Request, spawn}; | |
| @@ -0,0 +1,227 @@ | |||
| 1 | + | //! Wire format of MPD's text protocol: command encoding and response parsing. | |
| 2 | + | //! | |
| 3 | + | //! Everything here is pure so it can be tested without a server. | |
| 4 | + | ||
| 5 | + | use std::fmt; | |
| 6 | + | ||
| 7 | + | use super::error::{Ack, MpdError}; | |
| 8 | + | ||
| 9 | + | /// A single key/value line of an MPD response. | |
| 10 | + | pub type Pair = (String, String); | |
| 11 | + | ||
| 12 | + | /// A command ready to be written to the socket. | |
| 13 | + | /// | |
| 14 | + | /// Arguments are always quoted and escaped, so user-provided names and paths | |
| 15 | + | /// can never inject additional commands. | |
| 16 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 17 | + | pub struct Command { | |
| 18 | + | line: String, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl Command { | |
| 22 | + | pub fn new(name: &str) -> Self { | |
| 23 | + | Self { | |
| 24 | + | line: name.to_owned(), | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// Appends a quoted argument. | |
| 29 | + | #[must_use] | |
| 30 | + | pub fn arg(mut self, value: impl AsRef<str>) -> Self { | |
| 31 | + | self.line.push(' '); | |
| 32 | + | push_quoted(&mut self.line, value.as_ref()); | |
| 33 | + | self | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// The encoded command line, without the trailing newline. | |
| 37 | + | pub fn as_str(&self) -> &str { | |
| 38 | + | &self.line | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// The command name, used in error messages. | |
| 42 | + | pub fn name(&self) -> &str { | |
| 43 | + | self.line.split(' ').next().unwrap_or_default() | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | impl fmt::Display for Command { | |
| 48 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 49 | + | f.write_str(&self.line) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | fn push_quoted(out: &mut String, value: &str) { | |
| 54 | + | out.push('"'); | |
| 55 | + | out.push_str(&escape(value)); | |
| 56 | + | out.push('"'); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Escapes `\` and `"` so that `value` can be placed inside double quotes. | |
| 60 | + | pub fn escape(value: &str) -> String { | |
| 61 | + | let mut escaped = String::with_capacity(value.len()); | |
| 62 | + | for ch in value.chars() { | |
| 63 | + | if matches!(ch, '"' | '\\') { | |
| 64 | + | escaped.push('\\'); | |
| 65 | + | } | |
| 66 | + | escaped.push(ch); | |
| 67 | + | } | |
| 68 | + | escaped | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Builds an MPD filter expression `(TAG == "VALUE")`. | |
| 72 | + | pub fn filter_eq(tag: &str, value: &str) -> String { | |
| 73 | + | format!("({tag} == \"{}\")", escape(value)) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Combines filter expressions with `AND`. | |
| 77 | + | pub fn filter_and<I, S>(filters: I) -> String | |
| 78 | + | where | |
| 79 | + | I: IntoIterator<Item = S>, | |
| 80 | + | S: AsRef<str>, | |
| 81 | + | { | |
| 82 | + | let parts: Vec<String> = filters.into_iter().map(|f| f.as_ref().to_owned()).collect(); | |
| 83 | + | match parts.as_slice() { | |
| 84 | + | [single] => single.clone(), | |
| 85 | + | _ => format!("({})", parts.join(" AND ")), | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /// One parsed response line. | |
| 90 | + | #[derive(Debug, PartialEq, Eq)] | |
| 91 | + | pub enum Line { | |
| 92 | + | /// `OK`: the response is complete. | |
| 93 | + | Ok, | |
| 94 | + | /// `list_OK`: one command of a `command_list_ok_begin` list is complete. | |
| 95 | + | ListOk, | |
| 96 | + | /// A `key: value` pair. | |
| 97 | + | Pair(Pair), | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// Parses one response line (without its trailing newline). | |
| 101 | + | pub fn parse_line(line: &str) -> Result<Line, MpdError> { | |
| 102 | + | match line { | |
| 103 | + | "OK" => Ok(Line::Ok), | |
| 104 | + | "list_OK" => Ok(Line::ListOk), | |
| 105 | + | _ if line.starts_with("ACK ") => Err(MpdError::Ack(parse_ack(line)?)), | |
| 106 | + | _ => line | |
| 107 | + | .split_once(": ") | |
| 108 | + | .map(|(key, value)| Line::Pair((key.to_owned(), value.to_owned()))) | |
| 109 | + | .ok_or_else(|| MpdError::Protocol(format!("unexpected response line `{line}`"))), | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Parses `ACK [error@command_listNum] {current_command} message_text`. | |
| 114 | + | fn parse_ack(line: &str) -> Result<Ack, MpdError> { | |
| 115 | + | let malformed = || MpdError::Protocol(format!("malformed ACK line `{line}`")); | |
| 116 | + | let rest = line.strip_prefix("ACK [").ok_or_else(malformed)?; | |
| 117 | + | let (codes, rest) = rest.split_once("] {").ok_or_else(malformed)?; | |
| 118 | + | let (command, message) = rest.split_once('}').ok_or_else(malformed)?; | |
| 119 | + | let (code, index) = codes.split_once('@').ok_or_else(malformed)?; | |
| 120 | + | Ok(Ack { | |
| 121 | + | code: code.parse().map_err(|_| malformed())?, | |
| 122 | + | index: index.parse().map_err(|_| malformed())?, | |
| 123 | + | command: command.to_owned(), | |
| 124 | + | message: message.trim_start().to_owned(), | |
| 125 | + | }) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// Parses the `OK MPD <version>` greeting. | |
| 129 | + | pub fn parse_greeting(line: &str) -> Result<String, MpdError> { | |
| 130 | + | line.strip_prefix("OK MPD ") | |
| 131 | + | .map(str::to_owned) | |
| 132 | + | .ok_or_else(|| MpdError::Protocol(format!("not an MPD server (greeting `{line}`)"))) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Splits a flat list of pairs into groups, starting a new group whenever a | |
| 136 | + | /// key from `starts` is encountered. Pairs before the first start key are | |
| 137 | + | /// dropped. | |
| 138 | + | pub fn split_groups<'a>(pairs: &'a [Pair], starts: &[&str]) -> Vec<&'a [Pair]> { | |
| 139 | + | let mut bounds: Vec<usize> = pairs | |
| 140 | + | .iter() | |
| 141 | + | .enumerate() | |
| 142 | + | .filter(|(_, (key, _))| starts.contains(&key.as_str())) | |
| 143 | + | .map(|(index, _)| index) | |
| 144 | + | .collect(); | |
| 145 | + | bounds.push(pairs.len()); | |
| 146 | + | bounds.windows(2).map(|w| &pairs[w[0]..w[1]]).collect() | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[cfg(test)] | |
| 150 | + | mod tests { | |
| 151 | + | use super::*; | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn quotes_and_escapes_arguments() { | |
| 155 | + | let cmd = Command::new("add").arg(r#"dir/a "quoted" \ name"#); | |
| 156 | + | assert_eq!(cmd.as_str(), r#"add "dir/a \"quoted\" \\ name""#); | |
| 157 | + | assert_eq!(cmd.name(), "add"); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn newlines_cannot_start_a_new_command() { | |
| 162 | + | // Newlines stay inside the quoted argument; MPD rejects the argument | |
| 163 | + | // rather than executing a second command. | |
| 164 | + | let cmd = Command::new("load").arg("x\"\nclear"); | |
| 165 | + | assert_eq!(cmd.as_str().matches('"').count(), 3); | |
| 166 | + | assert!(cmd.as_str().contains("\\\"")); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | #[test] | |
| 170 | + | fn builds_filters() { | |
| 171 | + | assert_eq!(filter_eq("Artist", r#"A "B""#), r#"(Artist == "A \"B\"")"#); | |
| 172 | + | assert_eq!( | |
| 173 | + | filter_and([filter_eq("Artist", "x"), filter_eq("Album", "y")]), | |
| 174 | + | r#"((Artist == "x") AND (Album == "y"))"# | |
| 175 | + | ); | |
| 176 | + | assert_eq!(filter_and([filter_eq("Album", "y")]), r#"(Album == "y")"#); | |
| 177 | + | // Filters are quoted once more when used as an argument. | |
| 178 | + | let cmd = Command::new("find").arg(filter_eq("Album", r"a\b")); | |
| 179 | + | assert_eq!(cmd.as_str(), r#"find "(Album == \"a\\\\b\")""#); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[test] | |
| 183 | + | fn parses_lines() { | |
| 184 | + | assert_eq!(parse_line("OK").unwrap(), Line::Ok); | |
| 185 | + | assert_eq!(parse_line("list_OK").unwrap(), Line::ListOk); | |
| 186 | + | assert_eq!( | |
| 187 | + | parse_line("Title: a: b").unwrap(), | |
| 188 | + | Line::Pair(("Title".into(), "a: b".into())) | |
| 189 | + | ); | |
| 190 | + | assert!(matches!(parse_line("garbage"), Err(MpdError::Protocol(_)))); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | #[test] | |
| 194 | + | fn parses_ack() { | |
| 195 | + | let Err(MpdError::Ack(ack)) = parse_line("ACK [50@1] {play} No such song") else { | |
| 196 | + | panic!("expected ACK"); | |
| 197 | + | }; | |
| 198 | + | assert_eq!(ack.code, 50); | |
| 199 | + | assert_eq!(ack.index, 1); | |
| 200 | + | assert_eq!(ack.command, "play"); | |
| 201 | + | assert_eq!(ack.message, "No such song"); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | #[test] | |
| 205 | + | fn parses_greeting() { | |
| 206 | + | assert_eq!(parse_greeting("OK MPD 0.24.0").unwrap(), "0.24.0"); | |
| 207 | + | assert!(parse_greeting("HTTP/1.1 400").is_err()); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[test] | |
| 211 | + | fn splits_groups() { | |
| 212 | + | let pairs: Vec<Pair> = [ | |
| 213 | + | ("x", "0"), | |
| 214 | + | ("file", "a"), | |
| 215 | + | ("Title", "A"), | |
| 216 | + | ("directory", "d"), | |
| 217 | + | ("file", "b"), | |
| 218 | + | ] | |
| 219 | + | .iter() | |
| 220 | + | .map(|(k, v)| ((*k).into(), (*v).into())) | |
| 221 | + | .collect(); | |
| 222 | + | let groups = split_groups(&pairs, &["file", "directory"]); | |
| 223 | + | assert_eq!(groups.len(), 3); | |
| 224 | + | assert_eq!(groups[0].len(), 2); | |
| 225 | + | assert_eq!(groups[1][0].1, "d"); | |
| 226 | + | } | |
| 227 | + | } | |
| @@ -0,0 +1,36 @@ | |||
| 1 | + | use super::{Command, commands}; | |
| 2 | + | ||
| 3 | + | /// A set of songs chosen in the UI, to be added to the queue or a playlist. | |
| 4 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 5 | + | pub enum Selection { | |
| 6 | + | /// Songs or directories (added recursively). | |
| 7 | + | Uris(Vec<String>), | |
| 8 | + | /// Every song matching a filter expression. | |
| 9 | + | Filter(String), | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | impl Selection { | |
| 13 | + | pub fn is_empty(&self) -> bool { | |
| 14 | + | match self { | |
| 15 | + | Self::Uris(uris) => uris.is_empty(), | |
| 16 | + | Self::Filter(_) => false, | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | pub fn add_to_queue(&self) -> Vec<Command> { | |
| 21 | + | match self { | |
| 22 | + | Self::Uris(uris) => uris.iter().map(|uri| commands::add(uri)).collect(), | |
| 23 | + | Self::Filter(filter) => vec![commands::find_add(filter)], | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | pub fn add_to_playlist(&self, playlist: &str) -> Vec<Command> { | |
| 28 | + | match self { | |
| 29 | + | Self::Uris(uris) => uris | |
| 30 | + | .iter() | |
| 31 | + | .map(|uri| commands::playlist_add(playlist, uri)) | |
| 32 | + | .collect(), | |
| 33 | + | Self::Filter(filter) => vec![commands::playlist_add_filter(playlist, filter)], | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| @@ -0,0 +1,425 @@ | |||
| 1 | + | //! Typed models built from MPD responses. | |
| 2 | + | ||
| 3 | + | use std::{path::Path, str::FromStr, time::Duration}; | |
| 4 | + | ||
| 5 | + | use super::protocol::{Pair, split_groups}; | |
| 6 | + | ||
| 7 | + | /// A song as reported by MPD (library entry or queue item). | |
| 8 | + | #[derive(Clone, Debug, Default, PartialEq)] | |
| 9 | + | pub struct Song { | |
| 10 | + | /// Library-relative path or URL. | |
| 11 | + | pub file: String, | |
| 12 | + | /// Queue id, present for queue items only. | |
| 13 | + | pub id: Option<u32>, | |
| 14 | + | /// Queue position, present for queue items only. | |
| 15 | + | pub pos: Option<u32>, | |
| 16 | + | pub duration: Option<Duration>, | |
| 17 | + | /// Tag values in server order. A tag may occur multiple times. | |
| 18 | + | pub tags: Vec<(String, String)>, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl Song { | |
| 22 | + | pub fn from_pairs(pairs: &[Pair]) -> Self { | |
| 23 | + | let mut song = Self::default(); | |
| 24 | + | for (key, value) in pairs { | |
| 25 | + | match key.as_str() { | |
| 26 | + | "file" => song.file.clone_from(value), | |
| 27 | + | "Id" => song.id = value.parse().ok(), | |
| 28 | + | "Pos" => song.pos = value.parse().ok(), | |
| 29 | + | "duration" => song.duration = parse_seconds(value), | |
| 30 | + | // Older servers only report the integral `Time`. | |
| 31 | + | "Time" if song.duration.is_none() => song.duration = parse_seconds(value), | |
| 32 | + | "Last-Modified" | "Added" | "Format" | "Time" | "Range" | "Prio" => {} | |
| 33 | + | _ => song.tags.push((key.clone(), value.clone())), | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | song | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// The first value of `tag` (case-insensitive). | |
| 40 | + | pub fn tag(&self, tag: &str) -> Option<&str> { | |
| 41 | + | self.tags | |
| 42 | + | .iter() | |
| 43 | + | .find(|(key, _)| key.eq_ignore_ascii_case(tag)) | |
| 44 | + | .map(|(_, value)| value.as_str()) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | pub fn title(&self) -> Option<&str> { | |
| 48 | + | self.tag("Title") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | pub fn artist(&self) -> Option<&str> { | |
| 52 | + | self.tag("Artist") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | pub fn album(&self) -> Option<&str> { | |
| 56 | + | self.tag("Album") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | pub fn album_artist(&self) -> Option<&str> { | |
| 60 | + | self.tag("AlbumArtist").or_else(|| self.artist()) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// The file name without its directory. | |
| 64 | + | pub fn file_name(&self) -> &str { | |
| 65 | + | self.file.rsplit('/').next().unwrap_or(&self.file) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// The title, falling back to the file name without extension. | |
| 69 | + | pub fn display_title(&self) -> &str { | |
| 70 | + | self.title().unwrap_or_else(|| { | |
| 71 | + | let name = self.file_name(); | |
| 72 | + | Path::new(name) | |
| 73 | + | .file_stem() | |
| 74 | + | .and_then(|stem| stem.to_str()) | |
| 75 | + | .unwrap_or(name) | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Track number without a `/total` suffix. | |
| 80 | + | pub fn track(&self) -> Option<&str> { | |
| 81 | + | self.tag("Track").map(|t| t.split('/').next().unwrap_or(t)) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Sort key for album order: disc, then track number. | |
| 85 | + | pub fn disc_track(&self) -> (u32, u32) { | |
| 86 | + | let number = |tag: &str| { | |
| 87 | + | self.tag(tag) | |
| 88 | + | .and_then(|t| t.split('/').next()) | |
| 89 | + | .and_then(|t| t.trim().parse().ok()) | |
| 90 | + | .unwrap_or(0) | |
| 91 | + | }; | |
| 92 | + | (number("Disc"), number("Track")) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | pub fn parse_songs(pairs: &[Pair]) -> Vec<Song> { | |
| 97 | + | split_groups(pairs, &["file"]) | |
| 98 | + | .into_iter() | |
| 99 | + | .map(Song::from_pairs) | |
| 100 | + | .collect() | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// An entry of `lsinfo`. | |
| 104 | + | #[derive(Clone, Debug, PartialEq)] | |
| 105 | + | pub enum DirEntry { | |
| 106 | + | Directory(String), | |
| 107 | + | Song(Song), | |
| 108 | + | Playlist(String), | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | impl DirEntry { | |
| 112 | + | /// The library path of the entry. | |
| 113 | + | pub fn path(&self) -> &str { | |
| 114 | + | match self { | |
| 115 | + | Self::Directory(path) | Self::Playlist(path) => path, | |
| 116 | + | Self::Song(song) => &song.file, | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// The last path component, as shown in the browser. | |
| 121 | + | pub fn name(&self) -> &str { | |
| 122 | + | let path = self.path(); | |
| 123 | + | path.rsplit('/').next().unwrap_or(path) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | pub fn parse_entries(pairs: &[Pair]) -> Vec<DirEntry> { | |
| 128 | + | split_groups(pairs, &["file", "directory", "playlist"]) | |
| 129 | + | .into_iter() | |
| 130 | + | .filter_map(|group| { | |
| 131 | + | let (key, value) = group.first()?; | |
| 132 | + | Some(match key.as_str() { | |
| 133 | + | "directory" => DirEntry::Directory(value.clone()), | |
| 134 | + | "playlist" => DirEntry::Playlist(value.clone()), | |
| 135 | + | _ => DirEntry::Song(Song::from_pairs(group)), | |
| 136 | + | }) | |
| 137 | + | }) | |
| 138 | + | .collect() | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 142 | + | pub enum PlayState { | |
| 143 | + | #[default] | |
| 144 | + | Stop, | |
| 145 | + | Play, | |
| 146 | + | Pause, | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// The tri-state used by `single` and `consume`. | |
| 150 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 151 | + | pub enum OnOff { | |
| 152 | + | #[default] | |
| 153 | + | Off, | |
| 154 | + | On, | |
| 155 | + | Oneshot, | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | impl OnOff { | |
| 159 | + | fn parse(value: &str) -> Self { | |
| 160 | + | match value { | |
| 161 | + | "1" => Self::On, | |
| 162 | + | "oneshot" => Self::Oneshot, | |
| 163 | + | _ => Self::Off, | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | pub fn is_on(self) -> bool { | |
| 168 | + | self != Self::Off | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[derive(Clone, Debug, Default, PartialEq)] | |
| 173 | + | pub struct Status { | |
| 174 | + | /// `None` when the server has no mixer. | |
| 175 | + | pub volume: Option<u8>, | |
| 176 | + | pub repeat: bool, | |
| 177 | + | pub random: bool, | |
| 178 | + | pub single: OnOff, | |
| 179 | + | pub consume: OnOff, | |
| 180 | + | /// The queue version, bumped on every queue change. | |
| 181 | + | pub playlist_version: u32, | |
| 182 | + | pub playlist_length: u32, | |
| 183 | + | pub state: PlayState, | |
| 184 | + | /// Queue position of the current song. | |
| 185 | + | pub song: Option<u32>, | |
| 186 | + | pub song_id: Option<u32>, | |
| 187 | + | pub elapsed: Duration, | |
| 188 | + | pub duration: Option<Duration>, | |
| 189 | + | /// Kilobits per second. | |
| 190 | + | pub bitrate: Option<u32>, | |
| 191 | + | /// `samplerate:bits:channels`. | |
| 192 | + | pub audio: Option<String>, | |
| 193 | + | pub updating_db: bool, | |
| 194 | + | pub error: Option<String>, | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | impl Status { | |
| 198 | + | pub fn from_pairs(pairs: &[Pair]) -> Self { | |
| 199 | + | let mut status = Self::default(); | |
| 200 | + | for (key, value) in pairs { | |
| 201 | + | match key.as_str() { | |
| 202 | + | "volume" => status.volume = value.parse().ok(), | |
| 203 | + | "repeat" => status.repeat = value == "1", | |
| 204 | + | "random" => status.random = value == "1", | |
| 205 | + | "single" => status.single = OnOff::parse(value), | |
| 206 | + | "consume" => status.consume = OnOff::parse(value), | |
| 207 | + | "playlist" => status.playlist_version = parse_or_default(value), | |
| 208 | + | "playlistlength" => status.playlist_length = parse_or_default(value), | |
| 209 | + | "state" => { | |
| 210 | + | status.state = match value.as_str() { | |
| 211 | + | "play" => PlayState::Play, | |
| 212 | + | "pause" => PlayState::Pause, | |
| 213 | + | _ => PlayState::Stop, | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | "song" => status.song = value.parse().ok(), | |
| 217 | + | "songid" => status.song_id = value.parse().ok(), | |
| 218 | + | "elapsed" => status.elapsed = parse_seconds(value).unwrap_or_default(), | |
| 219 | + | "duration" => status.duration = parse_seconds(value), | |
| 220 | + | "bitrate" => status.bitrate = value.parse().ok().filter(|&b| b > 0), | |
| 221 | + | "audio" => status.audio = Some(value.clone()), | |
| 222 | + | "updating_db" => status.updating_db = true, | |
| 223 | + | "error" => status.error = Some(value.clone()), | |
| 224 | + | _ => {} | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | status | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// A stored playlist. | |
| 232 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 233 | + | pub struct PlaylistInfo { | |
| 234 | + | pub name: String, | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | pub fn parse_playlists(pairs: &[Pair]) -> Vec<PlaylistInfo> { | |
| 238 | + | let mut playlists: Vec<PlaylistInfo> = pairs | |
| 239 | + | .iter() | |
| 240 | + | .filter(|(key, _)| key == "playlist") | |
| 241 | + | .map(|(_, name)| PlaylistInfo { name: name.clone() }) | |
| 242 | + | .collect(); | |
| 243 | + | playlists.sort_by_key(|p| p.name.to_lowercase()); | |
| 244 | + | playlists | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | /// An album with its (optional) release date, from `list album group date`. | |
| 248 | + | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] | |
| 249 | + | pub struct Album { | |
| 250 | + | pub date: String, | |
| 251 | + | pub name: String, | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | pub fn parse_albums(pairs: &[Pair]) -> Vec<Album> { | |
| 255 | + | let mut albums = Vec::new(); | |
| 256 | + | let mut date = String::new(); | |
| 257 | + | for (key, value) in pairs { | |
| 258 | + | if key.eq_ignore_ascii_case("Date") { | |
| 259 | + | date.clone_from(value); | |
| 260 | + | } else if key.eq_ignore_ascii_case("Album") { | |
| 261 | + | albums.push(Album { | |
| 262 | + | date: date.clone(), | |
| 263 | + | name: value.clone(), | |
| 264 | + | }); | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | albums.sort(); | |
| 268 | + | albums.dedup(); | |
| 269 | + | albums | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// Values of a single tag from `list <tag>`. | |
| 273 | + | pub fn parse_tag_values(pairs: &[Pair], tag: &str) -> Vec<String> { | |
| 274 | + | let mut values: Vec<String> = pairs | |
| 275 | + | .iter() | |
| 276 | + | .filter(|(key, _)| key.eq_ignore_ascii_case(tag)) | |
| 277 | + | .map(|(_, value)| value.clone()) | |
| 278 | + | .collect(); | |
| 279 | + | values.sort_by_cached_key(|v| v.to_lowercase()); | |
| 280 | + | values.dedup(); | |
| 281 | + | values | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// An MPD idle subsystem. | |
| 285 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 286 | + | pub enum Subsystem { | |
| 287 | + | Database, | |
| 288 | + | Update, | |
| 289 | + | StoredPlaylist, | |
| 290 | + | Playlist, | |
| 291 | + | Player, | |
| 292 | + | Mixer, | |
| 293 | + | Output, | |
| 294 | + | Options, | |
| 295 | + | Other, | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | impl FromStr for Subsystem { | |
| 299 | + | type Err = std::convert::Infallible; | |
| 300 | + | ||
| 301 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 302 | + | Ok(match s { | |
| 303 | + | "database" => Self::Database, | |
| 304 | + | "update" => Self::Update, | |
| 305 | + | "stored_playlist" => Self::StoredPlaylist, | |
| 306 | + | "playlist" => Self::Playlist, | |
| 307 | + | "player" => Self::Player, | |
| 308 | + | "mixer" => Self::Mixer, | |
| 309 | + | "output" => Self::Output, | |
| 310 | + | "options" => Self::Options, | |
| 311 | + | _ => Self::Other, | |
| 312 | + | }) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | pub fn parse_subsystems(pairs: &[Pair]) -> Vec<Subsystem> { | |
| 317 | + | pairs | |
| 318 | + | .iter() | |
| 319 | + | .filter(|(key, _)| key == "changed") | |
| 320 | + | .filter_map(|(_, value)| value.parse().ok()) | |
| 321 | + | .collect() | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | fn parse_seconds(value: &str) -> Option<Duration> { | |
| 325 | + | value | |
| 326 | + | .parse::<f64>() | |
| 327 | + | .ok() | |
| 328 | + | .filter(|s| s.is_finite() && *s >= 0.0) | |
| 329 | + | .map(Duration::from_secs_f64) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | fn parse_or_default<T: FromStr + Default>(value: &str) -> T { | |
| 333 | + | value.parse().unwrap_or_default() | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | #[cfg(test)] | |
| 337 | + | mod tests { | |
| 338 | + | use super::*; | |
| 339 | + | ||
| 340 | + | fn pairs(items: &[(&str, &str)]) -> Vec<Pair> { | |
| 341 | + | items | |
| 342 | + | .iter() | |
| 343 | + | .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) | |
| 344 | + | .collect() | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | #[test] | |
| 348 | + | fn parses_queue_songs() { | |
| 349 | + | let songs = parse_songs(&pairs(&[ | |
| 350 | + | ("file", "a/01 - One.flac"), | |
| 351 | + | ("Title", "One"), | |
| 352 | + | ("Artist", "X"), | |
| 353 | + | ("Track", "1/10"), | |
| 354 | + | ("duration", "61.5"), | |
| 355 | + | ("Pos", "0"), | |
| 356 | + | ("Id", "7"), | |
| 357 | + | ("file", "b/two.mp3"), | |
| 358 | + | ("Time", "30"), | |
| 359 | + | ])); | |
| 360 | + | assert_eq!(songs.len(), 2); | |
| 361 | + | assert_eq!(songs[0].id, Some(7)); | |
| 362 | + | assert_eq!(songs[0].track(), Some("1")); | |
| 363 | + | assert_eq!(songs[0].duration, Some(Duration::from_millis(61_500))); | |
| 364 | + | assert_eq!(songs[1].display_title(), "two"); | |
| 365 | + | assert_eq!(songs[1].duration, Some(Duration::from_secs(30))); | |
| 366 | + | assert_eq!(songs[1].artist(), None); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[test] | |
| 370 | + | fn parses_lsinfo_entries() { | |
| 371 | + | let entries = parse_entries(&pairs(&[ | |
| 372 | + | ("directory", "music/rock"), | |
| 373 | + | ("Last-Modified", "2024"), | |
| 374 | + | ("file", "music/song.mp3"), | |
| 375 | + | ("Title", "S"), | |
| 376 | + | ("playlist", "mix"), | |
| 377 | + | ])); | |
| 378 | + | assert_eq!(entries.len(), 3); | |
| 379 | + | assert_eq!(entries[0], DirEntry::Directory("music/rock".into())); | |
| 380 | + | assert_eq!(entries[0].name(), "rock"); | |
| 381 | + | assert!(matches!(&entries[1], DirEntry::Song(s) if s.title() == Some("S"))); | |
| 382 | + | assert_eq!(entries[2], DirEntry::Playlist("mix".into())); | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | #[test] | |
| 386 | + | fn parses_status() { | |
| 387 | + | let status = Status::from_pairs(&pairs(&[ | |
| 388 | + | ("volume", "-1"), | |
| 389 | + | ("repeat", "1"), | |
| 390 | + | ("single", "oneshot"), | |
| 391 | + | ("playlist", "12"), | |
| 392 | + | ("playlistlength", "3"), | |
| 393 | + | ("state", "pause"), | |
| 394 | + | ("songid", "4"), | |
| 395 | + | ("elapsed", "1.250"), | |
| 396 | + | ("bitrate", "0"), | |
| 397 | + | ])); | |
| 398 | + | assert_eq!(status.volume, None); | |
| 399 | + | assert!(status.repeat && !status.random); | |
| 400 | + | assert_eq!(status.single, OnOff::Oneshot); | |
| 401 | + | assert_eq!(status.playlist_version, 12); | |
| 402 | + | assert_eq!(status.state, PlayState::Pause); | |
| 403 | + | assert_eq!(status.elapsed, Duration::from_millis(1250)); | |
| 404 | + | assert_eq!(status.bitrate, None); | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | #[test] | |
| 408 | + | fn parses_grouped_albums() { | |
| 409 | + | let albums = parse_albums(&pairs(&[ | |
| 410 | + | ("Date", "2016"), | |
| 411 | + | ("Album", "B"), | |
| 412 | + | ("Date", "2001"), | |
| 413 | + | ("Album", "A"), | |
| 414 | + | ("Album", "C"), | |
| 415 | + | ])); | |
| 416 | + | let names: Vec<_> = albums.iter().map(|a| a.name.as_str()).collect(); | |
| 417 | + | assert_eq!(names, ["A", "C", "B"]); | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | #[test] | |
| 421 | + | fn parses_subsystems() { | |
| 422 | + | let subs = parse_subsystems(&pairs(&[("changed", "player"), ("changed", "mixer")])); | |
| 423 | + | assert_eq!(subs, [Subsystem::Player, Subsystem::Mixer]); | |
| 424 | + | } | |
| 425 | + | } | |
| @@ -0,0 +1,477 @@ | |||
| 1 | + | //! Background tasks that own the MPD connections. | |
| 2 | + | //! | |
| 3 | + | //! * The **idle** task blocks on `idle` and reports changed subsystems. | |
| 4 | + | //! * The **command** task executes [`Request`]s sent through an [`MpdHandle`] | |
| 5 | + | //! and reports query results as [`Reply`]s. | |
| 6 | + | //! | |
| 7 | + | //! The UI never awaits the server: it sends requests and reacts to events. | |
| 8 | + | ||
| 9 | + | use std::{path::PathBuf, time::Duration}; | |
| 10 | + | ||
| 11 | + | use tokio::{sync::mpsc, time::sleep}; | |
| 12 | + | use tracing::{debug, warn}; | |
| 13 | + | ||
| 14 | + | use super::{ | |
| 15 | + | commands, | |
| 16 | + | connection::{Address, Connection}, | |
| 17 | + | error::{MpdError, MpdResult}, | |
| 18 | + | protocol::{Command, Pair}, | |
| 19 | + | types::{ | |
| 20 | + | Album, DirEntry, PlaylistInfo, Song, Status, Subsystem, parse_albums, parse_entries, | |
| 21 | + | parse_playlists, parse_songs, parse_subsystems, parse_tag_values, | |
| 22 | + | }, | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | /// Data the UI asks for. | |
| 26 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 27 | + | pub enum Query { | |
| 28 | + | /// Status and current song. | |
| 29 | + | Status, | |
| 30 | + | /// Queue changes since the given queue version (`0` for everything). | |
| 31 | + | QueueChanges { | |
| 32 | + | since: u32, | |
| 33 | + | }, | |
| 34 | + | Playlists, | |
| 35 | + | Directory(String), | |
| 36 | + | PlaylistSongs(String), | |
| 37 | + | /// All values of a tag, e.g. every `AlbumArtist`. | |
| 38 | + | TagValues(String), | |
| 39 | + | /// Albums whose `tag` equals `value`, grouped by date. | |
| 40 | + | Albums { | |
| 41 | + | tag: String, | |
| 42 | + | value: String, | |
| 43 | + | }, | |
| 44 | + | /// Songs matching a filter expression. | |
| 45 | + | Find(String), | |
| 46 | + | /// The server's music directory (only answered on local connections). | |
| 47 | + | MusicDirectory, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// The answer to a [`Query`]. | |
| 51 | + | #[derive(Clone, Debug)] | |
| 52 | + | pub enum Reply { | |
| 53 | + | Status { | |
| 54 | + | status: Status, | |
| 55 | + | current: Option<Song>, | |
| 56 | + | }, | |
| 57 | + | QueueChanges { | |
| 58 | + | status: Status, | |
| 59 | + | since: u32, | |
| 60 | + | changes: Vec<Song>, | |
| 61 | + | }, | |
| 62 | + | Playlists(Vec<PlaylistInfo>), | |
| 63 | + | Directory { | |
| 64 | + | path: String, | |
| 65 | + | entries: Vec<DirEntry>, | |
| 66 | + | }, | |
| 67 | + | PlaylistSongs { | |
| 68 | + | name: String, | |
| 69 | + | songs: Vec<Song>, | |
| 70 | + | }, | |
| 71 | + | TagValues { | |
| 72 | + | tag: String, | |
| 73 | + | values: Vec<String>, | |
| 74 | + | }, | |
| 75 | + | Albums { | |
| 76 | + | tag: String, | |
| 77 | + | value: String, | |
| 78 | + | albums: Vec<Album>, | |
| 79 | + | }, | |
| 80 | + | Find { | |
| 81 | + | filter: String, | |
| 82 | + | songs: Vec<Song>, | |
| 83 | + | }, | |
| 84 | + | MusicDirectory(Option<PathBuf>), | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 88 | + | pub enum Request { | |
| 89 | + | /// Fire-and-forget commands, executed atomically as one command list. | |
| 90 | + | Exec(Vec<Command>), | |
| 91 | + | Query(Query), | |
| 92 | + | /// Appends songs and starts playing the first one. | |
| 93 | + | AddAndPlay(Vec<String>), | |
| 94 | + | /// Appends every song matching a filter and plays the first one. | |
| 95 | + | FindAddAndPlay(String), | |
| 96 | + | /// Appends a stored playlist and plays its song at `offset`. | |
| 97 | + | LoadAndPlay { | |
| 98 | + | name: String, | |
| 99 | + | offset: u32, | |
| 100 | + | }, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Notifications from the background tasks. | |
| 104 | + | #[derive(Clone, Debug)] | |
| 105 | + | pub enum MpdEvent { | |
| 106 | + | Connected { version: String }, | |
| 107 | + | Disconnected(String), | |
| 108 | + | Changed(Vec<Subsystem>), | |
| 109 | + | Reply(Reply), | |
| 110 | + | Error(String), | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// Cheap, cloneable sender for requests. | |
| 114 | + | #[derive(Clone, Debug)] | |
| 115 | + | pub struct MpdHandle { | |
| 116 | + | tx: mpsc::UnboundedSender<Request>, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | impl MpdHandle { | |
| 120 | + | pub fn request(&self, request: Request) { | |
| 121 | + | // The worker lives as long as the application, so a closed channel | |
| 122 | + | // only happens during shutdown. | |
| 123 | + | let _ = self.tx.send(request); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | pub fn exec(&self, command: Command) { | |
| 127 | + | self.request(Request::Exec(vec![command])); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | pub fn exec_all(&self, commands: Vec<Command>) { | |
| 131 | + | if !commands.is_empty() { | |
| 132 | + | self.request(Request::Exec(commands)); | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | pub fn query(&self, query: Query) { | |
| 137 | + | self.request(Request::Query(query)); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// A handle whose requests go to `tx`, for tests. | |
| 141 | + | pub fn from_sender(tx: mpsc::UnboundedSender<Request>) -> Self { | |
| 142 | + | Self { tx } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// Starts both workers and returns the request handle. | |
| 147 | + | pub fn spawn<E>( | |
| 148 | + | address: Address, | |
| 149 | + | password: Option<String>, | |
| 150 | + | events: mpsc::UnboundedSender<E>, | |
| 151 | + | ) -> MpdHandle | |
| 152 | + | where | |
| 153 | + | E: From<MpdEvent> + Send + 'static, | |
| 154 | + | { | |
| 155 | + | let (tx, rx) = mpsc::unbounded_channel(); | |
| 156 | + | let emit = Emitter(events); | |
| 157 | + | tokio::spawn(idle_task(address.clone(), password.clone(), emit.clone())); | |
| 158 | + | tokio::spawn(command_task(address, password, rx, emit)); | |
| 159 | + | MpdHandle { tx } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | struct Emitter<E>(mpsc::UnboundedSender<E>); | |
| 163 | + | ||
| 164 | + | impl<E> Clone for Emitter<E> { | |
| 165 | + | fn clone(&self) -> Self { | |
| 166 | + | Self(self.0.clone()) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | impl<E: From<MpdEvent>> Emitter<E> { | |
| 171 | + | /// Returns `false` once the application stopped listening. | |
| 172 | + | fn emit(&self, event: MpdEvent) -> bool { | |
| 173 | + | self.0.send(event.into()).is_ok() | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | const MAX_BACKOFF: Duration = Duration::from_secs(10); | |
| 178 | + | ||
| 179 | + | async fn idle_task<E: From<MpdEvent>>( | |
| 180 | + | address: Address, | |
| 181 | + | password: Option<String>, | |
| 182 | + | emit: Emitter<E>, | |
| 183 | + | ) { | |
| 184 | + | let mut backoff = Duration::from_millis(500); | |
| 185 | + | let mut reported_down = false; | |
| 186 | + | loop { | |
| 187 | + | match Connection::connect(&address, password.as_deref()).await { | |
| 188 | + | Ok(mut conn) => { | |
| 189 | + | backoff = Duration::from_millis(500); | |
| 190 | + | if !emit.emit(MpdEvent::Connected { | |
| 191 | + | version: conn.version().to_owned(), | |
| 192 | + | }) { | |
| 193 | + | return; | |
| 194 | + | } | |
| 195 | + | let error = loop { | |
| 196 | + | match conn.execute(&Command::new("idle")).await { | |
| 197 | + | Ok(pairs) => { | |
| 198 | + | if !emit.emit(MpdEvent::Changed(parse_subsystems(&pairs))) { | |
| 199 | + | return; | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | Err(error) => break error, | |
| 203 | + | } | |
| 204 | + | }; | |
| 205 | + | warn!("idle connection lost: {error}"); | |
| 206 | + | if !emit.emit(MpdEvent::Disconnected(error.to_string())) { | |
| 207 | + | return; | |
| 208 | + | } | |
| 209 | + | reported_down = true; | |
| 210 | + | } | |
| 211 | + | Err(error) => { | |
| 212 | + | debug!("connecting to {address} failed: {error}"); | |
| 213 | + | if !reported_down { | |
| 214 | + | reported_down = true; | |
| 215 | + | if !emit.emit(MpdEvent::Disconnected(format!( | |
| 216 | + | "cannot connect to {address}: {error}" | |
| 217 | + | ))) { | |
| 218 | + | return; | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | sleep(backoff).await; | |
| 224 | + | backoff = (backoff * 2).min(MAX_BACKOFF); | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | async fn command_task<E: From<MpdEvent>>( | |
| 229 | + | address: Address, | |
| 230 | + | password: Option<String>, | |
| 231 | + | mut requests: mpsc::UnboundedReceiver<Request>, | |
| 232 | + | emit: Emitter<E>, | |
| 233 | + | ) { | |
| 234 | + | let mut conn: Option<Connection> = None; | |
| 235 | + | while let Some(request) = requests.recv().await { | |
| 236 | + | // MPD drops connections that stay silent for `connection_timeout`, so a | |
| 237 | + | // failure on a reused connection is retried once on a fresh one. | |
| 238 | + | let mut attempts = if conn.is_some() { 2 } else { 1 }; | |
| 239 | + | let result = loop { | |
| 240 | + | let c = match conn.as_mut() { | |
| 241 | + | Some(c) => c, | |
| 242 | + | None => match Connection::connect(&address, password.as_deref()).await { | |
| 243 | + | Ok(c) => conn.insert(c), | |
| 244 | + | Err(error) => break Err(error), | |
| 245 | + | }, | |
| 246 | + | }; | |
| 247 | + | attempts -= 1; | |
| 248 | + | match execute(c, &request).await { | |
| 249 | + | Err(error) if error.is_fatal() => { | |
| 250 | + | conn = None; | |
| 251 | + | if attempts == 0 { | |
| 252 | + | break Err(error); | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | other => break other, | |
| 256 | + | } | |
| 257 | + | }; | |
| 258 | + | let event = match result { | |
| 259 | + | Ok(Some(reply)) => MpdEvent::Reply(reply), | |
| 260 | + | Ok(None) => continue, | |
| 261 | + | Err(error) => { | |
| 262 | + | warn!("request {request:?} failed: {error}"); | |
| 263 | + | MpdEvent::Error(error.to_string()) | |
| 264 | + | } | |
| 265 | + | }; | |
| 266 | + | if !emit.emit(event) { | |
| 267 | + | return; | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | async fn execute(conn: &mut Connection, request: &Request) -> MpdResult<Option<Reply>> { | |
| 273 | + | match request { | |
| 274 | + | Request::Exec(commands) => conn.execute_all(commands).await.map(|()| None), | |
| 275 | + | Request::Query(query) => run_query(conn, query).await.map(Some), | |
| 276 | + | Request::AddAndPlay(uris) => { | |
| 277 | + | let Some((first, rest)) = uris.split_first() else { | |
| 278 | + | return Ok(None); | |
| 279 | + | }; | |
| 280 | + | let id = conn | |
| 281 | + | .execute(&commands::add_id(first)) | |
| 282 | + | .await? | |
| 283 | + | .into_iter() | |
| 284 | + | .find(|(key, _)| key == "Id") | |
| 285 | + | .and_then(|(_, id)| id.parse().ok()) | |
| 286 | + | .ok_or_else(|| MpdError::Protocol("addid returned no id".into()))?; | |
| 287 | + | let mut batch: Vec<Command> = rest.iter().map(|uri| commands::add(uri)).collect(); | |
| 288 | + | batch.push(commands::play_id(id)); | |
| 289 | + | conn.execute_all(&batch).await.map(|()| None) | |
| 290 | + | } | |
| 291 | + | Request::FindAddAndPlay(filter) => { | |
| 292 | + | let start = queue_length(conn).await?; | |
| 293 | + | conn.execute_all(&[commands::find_add(filter), commands::play_pos(start)]) | |
| 294 | + | .await | |
| 295 | + | .map(|()| None) | |
| 296 | + | } | |
| 297 | + | Request::LoadAndPlay { name, offset } => { | |
| 298 | + | let start = queue_length(conn).await?; | |
| 299 | + | let entries = conn | |
| 300 | + | .execute(&Command::new("listplaylist").arg(name)) | |
| 301 | + | .await?; | |
| 302 | + | conn.execute(&commands::load(name)).await?; | |
| 303 | + | // `load` silently skips entries the server cannot resolve, so the | |
| 304 | + | // playlist index is not necessarily the queue offset. | |
| 305 | + | let added = conn | |
| 306 | + | .execute(&Command::new("playlistinfo").arg(format!("{start}:"))) | |
| 307 | + | .await?; | |
| 308 | + | let entries: Vec<&str> = files(&entries).collect(); | |
| 309 | + | let added: Vec<&str> = files(&added).collect(); | |
| 310 | + | let Some(pos) = queued_offset(&entries, &added, *offset as usize) else { | |
| 311 | + | return Ok(None); | |
| 312 | + | }; | |
| 313 | + | conn.execute(&commands::play_pos(start + pos as u32)) | |
| 314 | + | .await | |
| 315 | + | .map(|_| None) | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | fn files(pairs: &[Pair]) -> impl Iterator<Item = &str> { | |
| 321 | + | pairs | |
| 322 | + | .iter() | |
| 323 | + | .filter(|(key, _)| key == "file") | |
| 324 | + | .map(|(_, value)| value.as_str()) | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /// Where playlist entry `index` ended up among the songs `load` appended, | |
| 328 | + | /// matching entries in order and skipping the ones that were dropped. | |
| 329 | + | fn queued_offset(entries: &[&str], added: &[&str], index: usize) -> Option<usize> { | |
| 330 | + | if entries.len() == added.len() { | |
| 331 | + | return (index < added.len()).then_some(index); | |
| 332 | + | } | |
| 333 | + | let mut pos = 0; | |
| 334 | + | for (i, entry) in entries.iter().enumerate() { | |
| 335 | + | let matched = added.get(pos).is_some_and(|song| same_song(entry, song)); | |
| 336 | + | if i == index { | |
| 337 | + | // The entry was skipped: play whatever followed it instead. | |
| 338 | + | return (pos < added.len()).then_some(pos); | |
| 339 | + | } | |
| 340 | + | if matched { | |
| 341 | + | pos += 1; | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | None | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | /// Playlists may store absolute paths, which `load` turns into paths | |
| 348 | + | /// relative to the music directory. | |
| 349 | + | fn same_song(entry: &str, queued: &str) -> bool { | |
| 350 | + | entry == queued | |
| 351 | + | || entry | |
| 352 | + | .strip_suffix(queued) | |
| 353 | + | .is_some_and(|prefix| prefix.ends_with('/')) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | async fn queue_length(conn: &mut Connection) -> MpdResult<u32> { | |
| 357 | + | let status = conn.execute(&Command::new("status")).await?; | |
| 358 | + | Ok(Status::from_pairs(&status).playlist_length) | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | async fn run_query(conn: &mut Connection, query: &Query) -> MpdResult<Reply> { | |
| 362 | + | Ok(match query { | |
| 363 | + | Query::Status => { | |
| 364 | + | let responses = conn | |
| 365 | + | .execute_list(&[Command::new("status"), Command::new("currentsong")]) | |
| 366 | + | .await?; | |
| 367 | + | let [status, current] = <[_; 2]>::try_from(responses) | |
| 368 | + | .map_err(|_| MpdError::Protocol("short command list response".into()))?; | |
| 369 | + | let current = (!current.is_empty()).then(|| Song::from_pairs(¤t)); | |
| 370 | + | Reply::Status { | |
| 371 | + | status: Status::from_pairs(&status), | |
| 372 | + | current, | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | Query::QueueChanges { since } => { | |
| 376 | + | let responses = conn | |
| 377 | + | .execute_list(&[ | |
| 378 | + | Command::new("status"), | |
| 379 | + | Command::new("plchanges").arg(since.to_string()), | |
| 380 | + | ]) | |
| 381 | + | .await?; | |
| 382 | + | let [status, changes] = <[_; 2]>::try_from(responses) | |
| 383 | + | .map_err(|_| MpdError::Protocol("short command list response".into()))?; | |
| 384 | + | Reply::QueueChanges { | |
| 385 | + | status: Status::from_pairs(&status), | |
| 386 | + | since: *since, | |
| 387 | + | changes: parse_songs(&changes), | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | Query::Playlists => Reply::Playlists(parse_playlists( | |
| 391 | + | &conn.execute(&Command::new("listplaylists")).await?, | |
| 392 | + | )), | |
| 393 | + | Query::Directory(path) => { | |
| 394 | + | let entries = parse_entries(&conn.execute(&Command::new("lsinfo").arg(path)).await?); | |
| 395 | + | Reply::Directory { | |
| 396 | + | path: path.clone(), | |
| 397 | + | entries, | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | Query::PlaylistSongs(name) => { | |
| 401 | + | let pairs = conn | |
| 402 | + | .execute(&Command::new("listplaylistinfo").arg(name)) | |
| 403 | + | .await?; | |
| 404 | + | Reply::PlaylistSongs { | |
| 405 | + | name: name.clone(), | |
| 406 | + | songs: parse_songs(&pairs), | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | Query::TagValues(tag) => { | |
| 410 | + | let pairs = conn.execute(&Command::new("list").arg(tag)).await?; | |
| 411 | + | Reply::TagValues { | |
| 412 | + | tag: tag.clone(), | |
| 413 | + | values: parse_tag_values(&pairs, tag), | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | Query::Albums { tag, value } => { | |
| 417 | + | let pairs = conn | |
| 418 | + | .execute( | |
| 419 | + | &Command::new("list") | |
| 420 | + | .arg("Album") | |
| 421 | + | .arg(super::protocol::filter_eq(tag, value)) | |
| 422 | + | .arg("group") | |
| 423 | + | .arg("Date"), | |
| 424 | + | ) | |
| 425 | + | .await?; | |
| 426 | + | Reply::Albums { | |
| 427 | + | tag: tag.clone(), | |
| 428 | + | value: value.clone(), | |
| 429 | + | albums: parse_albums(&pairs), | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | Query::Find(filter) => { | |
| 433 | + | let mut songs = parse_songs(&conn.execute(&Command::new("find").arg(filter)).await?); | |
| 434 | + | songs.sort_by_key(Song::disc_track); | |
| 435 | + | Reply::Find { | |
| 436 | + | filter: filter.clone(), | |
| 437 | + | songs, | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | Query::MusicDirectory => { | |
| 441 | + | let dir = match conn.execute(&Command::new("config")).await { | |
| 442 | + | Ok(pairs) => pairs | |
| 443 | + | .into_iter() | |
| 444 | + | .find(|(key, _)| key == "music_directory") | |
| 445 | + | .map(|(_, dir)| PathBuf::from(dir)), | |
| 446 | + | // `config` is only permitted on local (Unix socket) connections. | |
| 447 | + | Err(MpdError::Ack(_)) => None, | |
| 448 | + | Err(error) => return Err(error), | |
| 449 | + | }; | |
| 450 | + | Reply::MusicDirectory(dir) | |
| 451 | + | } | |
| 452 | + | }) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | #[cfg(test)] | |
| 456 | + | mod tests { | |
| 457 | + | use super::queued_offset; | |
| 458 | + | ||
| 459 | + | #[test] | |
| 460 | + | fn queued_offset_skips_dropped_entries() { | |
| 461 | + | let entries = ["gone.mp3", "a.mp3", "b.mp3", "missing.mp3", "c.mp3"]; | |
| 462 | + | let added = ["a.mp3", "b.mp3", "c.mp3"]; | |
| 463 | + | assert_eq!(queued_offset(&entries, &added, 1), Some(0)); | |
| 464 | + | assert_eq!(queued_offset(&entries, &added, 2), Some(1)); | |
| 465 | + | assert_eq!(queued_offset(&entries, &added, 4), Some(2)); | |
| 466 | + | // A dropped entry plays the song after it. | |
| 467 | + | assert_eq!(queued_offset(&entries, &added, 0), Some(0)); | |
| 468 | + | assert_eq!(queued_offset(&entries, &added, 3), Some(2)); | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | #[test] | |
| 472 | + | fn queued_offset_matches_absolute_paths() { | |
| 473 | + | let entries = ["/music/x.mp3", "gone.mp3", "/music/y.mp3"]; | |
| 474 | + | let added = ["x.mp3", "y.mp3"]; | |
| 475 | + | assert_eq!(queued_offset(&entries, &added, 2), Some(1)); | |
| 476 | + | } | |
| 477 | + | } | |
| @@ -0,0 +1,114 @@ | |||
| 1 | + | use ratatui::{ | |
| 2 | + | Frame, | |
| 3 | + | layout::{Constraint, Rect}, | |
| 4 | + | text::Span, | |
| 5 | + | widgets::Row, | |
| 6 | + | }; | |
| 7 | + | ||
| 8 | + | use super::{InputPopup, InputPurpose, Popup, PopupState, frame_popup}; | |
| 9 | + | use crate::{ | |
| 10 | + | action::{Action, Context}, | |
| 11 | + | config::KeyCombo, | |
| 12 | + | mpd::Selection, | |
| 13 | + | screens::Ctx, | |
| 14 | + | ui::list::ListView, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | enum Choice { | |
| 18 | + | Queue, | |
| 19 | + | NewPlaylist, | |
| 20 | + | Playlist(String), | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Chooses where to add a selection: the queue, a new or an existing playlist. | |
| 24 | + | pub struct AddToPlaylistPopup { | |
| 25 | + | selection: Selection, | |
| 26 | + | choices: Vec<Choice>, | |
| 27 | + | list: ListView, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl AddToPlaylistPopup { | |
| 31 | + | pub fn new(selection: Selection, ctx: &Ctx) -> Self { | |
| 32 | + | let mut choices = vec![Choice::Queue, Choice::NewPlaylist]; | |
| 33 | + | choices.extend( | |
| 34 | + | ctx.mpd | |
| 35 | + | .playlists | |
| 36 | + | .iter() | |
| 37 | + | .map(|p| Choice::Playlist(p.name.clone())), | |
| 38 | + | ); | |
| 39 | + | Self { | |
| 40 | + | selection, | |
| 41 | + | choices, | |
| 42 | + | list: ListView::default(), | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | fn choose(&mut self, ctx: &mut Ctx) -> PopupState { | |
| 47 | + | let Some(choice) = self | |
| 48 | + | .list | |
| 49 | + | .selected(self.choices.len()) | |
| 50 | + | .and_then(|i| self.choices.get(i)) | |
| 51 | + | else { | |
| 52 | + | return PopupState::Open; | |
| 53 | + | }; | |
| 54 | + | match choice { | |
| 55 | + | Choice::Queue => { | |
| 56 | + | ctx.client.exec_all(self.selection.add_to_queue()); | |
| 57 | + | ctx.notify("Added to the queue"); | |
| 58 | + | } | |
| 59 | + | Choice::NewPlaylist => { | |
| 60 | + | ctx.popup(InputPopup::new( | |
| 61 | + | "New playlist", | |
| 62 | + | "", | |
| 63 | + | InputPurpose::NewPlaylist(self.selection.clone()), | |
| 64 | + | )); | |
| 65 | + | } | |
| 66 | + | Choice::Playlist(name) => { | |
| 67 | + | ctx.client.exec_all(self.selection.add_to_playlist(name)); | |
| 68 | + | ctx.notify(format!("Added to playlist “{name}”")); | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | PopupState::Closed | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | impl Popup for AddToPlaylistPopup { | |
| 76 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 77 | + | let height = self.choices.len().min(15) as u16 + 2; | |
| 78 | + | let inner = frame_popup(frame, area, 44, height, "Add to", ctx); | |
| 79 | + | let theme = ctx.theme(); | |
| 80 | + | let choices = &self.choices; | |
| 81 | + | self.list.render( | |
| 82 | + | frame, | |
| 83 | + | inner, | |
| 84 | + | None, | |
| 85 | + | &[Constraint::Fill(1)], | |
| 86 | + | choices.len(), | |
| 87 | + | true, | |
| 88 | + | theme, | |
| 89 | + | |i| { | |
| 90 | + | Row::new([match &choices[i] { | |
| 91 | + | Choice::Queue => Span::styled("Current queue", theme.header()), | |
| 92 | + | Choice::NewPlaylist => Span::styled("New playlist…", theme.header()), | |
| 93 | + | Choice::Playlist(name) => Span::styled(name.clone(), theme.text()), | |
| 94 | + | }]) | |
| 95 | + | }, | |
| 96 | + | ); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState { | |
| 100 | + | match ctx | |
| 101 | + | .config | |
| 102 | + | .keymap | |
| 103 | + | .resolve(&[Context::Popup, Context::Navigation], key) | |
| 104 | + | { | |
| 105 | + | Some(Action::Close) => PopupState::Closed, | |
| 106 | + | Some(Action::Activate | Action::Right) => self.choose(ctx), | |
| 107 | + | Some(action) => { | |
| 108 | + | self.list.navigate(action, self.choices.len()); | |
| 109 | + | PopupState::Open | |
| 110 | + | } | |
| 111 | + | None => PopupState::Open, | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| @@ -0,0 +1,69 @@ | |||
| 1 | + | use crossterm::event::KeyCode; | |
| 2 | + | use ratatui::{ | |
| 3 | + | Frame, | |
| 4 | + | layout::{Alignment, Rect}, | |
| 5 | + | text::{Line, Span}, | |
| 6 | + | widgets::{Paragraph, Wrap}, | |
| 7 | + | }; | |
| 8 | + | ||
| 9 | + | use super::{Popup, PopupState, frame_popup}; | |
| 10 | + | use crate::{ | |
| 11 | + | action::{Action, Context}, | |
| 12 | + | config::KeyCombo, | |
| 13 | + | mpd::Command, | |
| 14 | + | screens::Ctx, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | /// Asks before running destructive commands. | |
| 18 | + | pub struct ConfirmPopup { | |
| 19 | + | message: String, | |
| 20 | + | commands: Vec<Command>, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | impl ConfirmPopup { | |
| 24 | + | pub fn new(message: impl Into<String>, commands: Vec<Command>) -> Self { | |
| 25 | + | Self { | |
| 26 | + | message: message.into(), | |
| 27 | + | commands, | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl Popup for ConfirmPopup { | |
| 33 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 34 | + | let inner = frame_popup(frame, area, 50, 5, "Confirm", ctx); | |
| 35 | + | let theme = ctx.theme(); | |
| 36 | + | let text = vec![ | |
| 37 | + | Line::from(self.message.as_str()), | |
| 38 | + | Line::default(), | |
| 39 | + | Line::from(vec![ | |
| 40 | + | Span::styled("[y]", theme.header()), | |
| 41 | + | Span::raw("es "), | |
| 42 | + | Span::styled("[n]", theme.header()), | |
| 43 | + | Span::raw("o"), | |
| 44 | + | ]), | |
| 45 | + | ]; | |
| 46 | + | frame.render_widget( | |
| 47 | + | Paragraph::new(text) | |
| 48 | + | .alignment(Alignment::Center) | |
| 49 | + | .wrap(Wrap { trim: true }), | |
| 50 | + | inner, | |
| 51 | + | ); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState { | |
| 55 | + | let confirmed = matches!(key.code, KeyCode::Char('y' | 'Y')) | |
| 56 | + | || ctx.config.keymap.resolve(&[Context::Navigation], key) == Some(Action::Activate); | |
| 57 | + | if confirmed { | |
| 58 | + | ctx.client.exec_all(std::mem::take(&mut self.commands)); | |
| 59 | + | return PopupState::Closed; | |
| 60 | + | } | |
| 61 | + | let declined = matches!(key.code, KeyCode::Char('n' | 'N')) | |
| 62 | + | || ctx.config.keymap.resolve(&[Context::Popup], key) == Some(Action::Close); | |
| 63 | + | if declined { | |
| 64 | + | PopupState::Closed | |
| 65 | + | } else { | |
| 66 | + | PopupState::Open | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,133 @@ | |||
| 1 | + | use ratatui::{ | |
| 2 | + | Frame, | |
| 3 | + | layout::{Constraint, Rect}, | |
| 4 | + | text::Span, | |
| 5 | + | widgets::Row, | |
| 6 | + | }; | |
| 7 | + | ||
| 8 | + | use super::{Popup, PopupState, frame_popup}; | |
| 9 | + | use crate::{ | |
| 10 | + | action::{Action, Context}, | |
| 11 | + | config::KeyCombo, | |
| 12 | + | screens::Ctx, | |
| 13 | + | ui::list::ListView, | |
| 14 | + | }; | |
| 15 | + | ||
| 16 | + | enum HelpRow { | |
| 17 | + | Heading(String), | |
| 18 | + | Binding { | |
| 19 | + | keys: String, | |
| 20 | + | description: &'static str, | |
| 21 | + | }, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// Lists the key bindings, starting with the active screen's. | |
| 25 | + | pub struct HelpPopup { | |
| 26 | + | rows: Vec<HelpRow>, | |
| 27 | + | list: ListView, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl HelpPopup { | |
| 31 | + | pub fn new(screen: Context, ctx: &Ctx) -> Self { | |
| 32 | + | let mut order = vec![screen, Context::Navigation, Context::Global]; | |
| 33 | + | let rest: Vec<Context> = Context::ALL | |
| 34 | + | .iter() | |
| 35 | + | .copied() | |
| 36 | + | .filter(|c| !order.contains(c) && !is_screen_context(*c)) | |
| 37 | + | .collect(); | |
| 38 | + | order.extend(rest); | |
| 39 | + | let mut rows = Vec::new(); | |
| 40 | + | for context in order { | |
| 41 | + | let Some(actions) = ctx.config.keymap.bindings().get(&context) else { | |
| 42 | + | continue; | |
| 43 | + | }; | |
| 44 | + | let bound: Vec<_> = actions | |
| 45 | + | .iter() | |
| 46 | + | .filter(|(_, keys)| !keys.is_empty()) | |
| 47 | + | .collect(); | |
| 48 | + | if bound.is_empty() { | |
| 49 | + | continue; | |
| 50 | + | } | |
| 51 | + | if !rows.is_empty() { | |
| 52 | + | rows.push(HelpRow::Heading(String::new())); | |
| 53 | + | } | |
| 54 | + | rows.push(HelpRow::Heading(heading(context))); | |
| 55 | + | for (action, keys) in bound { | |
| 56 | + | rows.push(HelpRow::Binding { | |
| 57 | + | keys: keys | |
| 58 | + | .iter() | |
| 59 | + | .map(ToString::to_string) | |
| 60 | + | .collect::<Vec<_>>() | |
| 61 | + | .join(", "), | |
| 62 | + | description: action.description(), | |
| 63 | + | }); | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | Self { | |
| 67 | + | rows, | |
| 68 | + | list: ListView::default(), | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | fn is_screen_context(context: Context) -> bool { | |
| 74 | + | !matches!( | |
| 75 | + | context, | |
| 76 | + | Context::Global | Context::Navigation | Context::Popup | Context::Input | |
| 77 | + | ) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | fn heading(context: Context) -> String { | |
| 81 | + | let name = context.name().replace('_', " "); | |
| 82 | + | let mut chars = name.chars(); | |
| 83 | + | chars | |
| 84 | + | .next() | |
| 85 | + | .map(|c| c.to_uppercase().chain(chars).collect()) | |
| 86 | + | .unwrap_or_default() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | impl Popup for HelpPopup { | |
| 90 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 91 | + | let inner = frame_popup( | |
| 92 | + | frame, | |
| 93 | + | area, | |
| 94 | + | 70, | |
| 95 | + | area.height.saturating_sub(4), | |
| 96 | + | "Key bindings", | |
| 97 | + | ctx, | |
| 98 | + | ); | |
| 99 | + | let theme = ctx.theme(); | |
| 100 | + | let rows = &self.rows; | |
| 101 | + | self.list.render( | |
| 102 | + | frame, | |
| 103 | + | inner, | |
| 104 | + | None, | |
| 105 | + | &[Constraint::Length(24), Constraint::Fill(1)], | |
| 106 | + | rows.len(), | |
| 107 | + | true, | |
| 108 | + | theme, | |
| 109 | + | |i| match &rows[i] { | |
| 110 | + | HelpRow::Heading(title) => Row::new([Span::styled(title.clone(), theme.header())]), | |
| 111 | + | HelpRow::Binding { keys, description } => Row::new([ | |
| 112 | + | Span::styled(format!(" {keys}"), theme.text()), | |
| 113 | + | Span::styled(*description, theme.dim()), | |
| 114 | + | ]), | |
| 115 | + | }, | |
| 116 | + | ); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState { | |
| 120 | + | let keymap = &ctx.config.keymap; | |
| 121 | + | match keymap.resolve(&[Context::Popup, Context::Navigation], key) { | |
| 122 | + | Some(Action::Close) => PopupState::Closed, | |
| 123 | + | Some(action) => { | |
| 124 | + | self.list.navigate(action, self.rows.len()); | |
| 125 | + | PopupState::Open | |
| 126 | + | } | |
| 127 | + | None if keymap.resolve(&[Context::Global], key) == Some(Action::Help) => { | |
| 128 | + | PopupState::Closed | |
| 129 | + | } | |
| 130 | + | None => PopupState::Open, | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | } | |
| @@ -0,0 +1,99 @@ | |||
| 1 | + | use ratatui::{Frame, layout::Rect}; | |
| 2 | + | ||
| 3 | + | use super::{Popup, PopupState, frame_popup}; | |
| 4 | + | use crate::{ | |
| 5 | + | action::{Context, ScreenId}, | |
| 6 | + | config::KeyCombo, | |
| 7 | + | mpd::{Selection, commands}, | |
| 8 | + | screens::{Ctx, Effect, ScreenMessage}, | |
| 9 | + | tags::TagField, | |
| 10 | + | ui::input::{InputEvent, LineInput}, | |
| 11 | + | }; | |
| 12 | + | ||
| 13 | + | /// What the entered text is for. | |
| 14 | + | #[derive(Clone, Debug)] | |
| 15 | + | pub enum InputPurpose { | |
| 16 | + | NewPlaylist(Selection), | |
| 17 | + | RenamePlaylist(String), | |
| 18 | + | SaveQueue, | |
| 19 | + | TagValue(TagField), | |
| 20 | + | FileName, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | pub struct InputPopup { | |
| 24 | + | title: String, | |
| 25 | + | input: LineInput, | |
| 26 | + | purpose: InputPurpose, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | impl InputPopup { | |
| 30 | + | pub fn new(title: impl Into<String>, initial: &str, purpose: InputPurpose) -> Self { | |
| 31 | + | Self { | |
| 32 | + | title: title.into(), | |
| 33 | + | input: LineInput::new(initial), | |
| 34 | + | purpose, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | fn submit(&self, ctx: &mut Ctx) -> PopupState { | |
| 39 | + | let value = self.input.text().trim().to_owned(); | |
| 40 | + | if value.is_empty() && !matches!(self.purpose, InputPurpose::TagValue(_)) { | |
| 41 | + | return PopupState::Open; | |
| 42 | + | } | |
| 43 | + | match &self.purpose { | |
| 44 | + | InputPurpose::NewPlaylist(selection) => { | |
| 45 | + | if ctx.mpd.playlists.iter().any(|p| p.name == value) { | |
| 46 | + | ctx.notify(format!("Added to existing playlist “{value}”")); | |
| 47 | + | } else { | |
| 48 | + | ctx.notify(format!("Created playlist “{value}”")); | |
| 49 | + | } | |
| 50 | + | ctx.client.exec_all(selection.add_to_playlist(&value)); | |
| 51 | + | } | |
| 52 | + | InputPurpose::RenamePlaylist(from) => { | |
| 53 | + | if *from != value { | |
| 54 | + | ctx.client.exec(commands::rename(from, &value)); | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | InputPurpose::SaveQueue => { | |
| 58 | + | ctx.client.exec(commands::save(&value)); | |
| 59 | + | ctx.notify(format!("Saved the queue as “{value}”")); | |
| 60 | + | } | |
| 61 | + | InputPurpose::FileName => ctx.effects.push(Effect::Message( | |
| 62 | + | ScreenId::TagEditor, | |
| 63 | + | ScreenMessage::FileName(value), | |
| 64 | + | )), | |
| 65 | + | InputPurpose::TagValue(field) => ctx.effects.push(Effect::Message( | |
| 66 | + | ScreenId::TagEditor, | |
| 67 | + | ScreenMessage::TagValue { | |
| 68 | + | field: *field, | |
| 69 | + | value, | |
| 70 | + | }, | |
| 71 | + | )), | |
| 72 | + | } | |
| 73 | + | PopupState::Closed | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | impl Popup for InputPopup { | |
| 78 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 79 | + | let width = (area.width * 3 / 5).max(40); | |
| 80 | + | let inner = frame_popup(frame, area, width, 3, &self.title, ctx); | |
| 81 | + | self.input.render(frame, inner, "", ctx.theme()); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState { | |
| 85 | + | match ctx.config.keymap.resolve(&[Context::Input], key) { | |
| 86 | + | Some(action) => match self.input.apply(action) { | |
| 87 | + | InputEvent::Submit => self.submit(ctx), | |
| 88 | + | InputEvent::Cancel => PopupState::Closed, | |
| 89 | + | _ => PopupState::Open, | |
| 90 | + | }, | |
| 91 | + | None => { | |
| 92 | + | if let Some(ch) = key.as_text() { | |
| 93 | + | self.input.insert(ch); | |
| 94 | + | } | |
| 95 | + | PopupState::Open | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| @@ -0,0 +1,60 @@ | |||
| 1 | + | //! Modal popups drawn above the screens. The topmost popup receives all | |
| 2 | + | //! keys until it closes. | |
| 3 | + | ||
| 4 | + | mod add_to_playlist; | |
| 5 | + | mod confirm; | |
| 6 | + | mod help; | |
| 7 | + | mod input; | |
| 8 | + | ||
| 9 | + | use ratatui::{ | |
| 10 | + | Frame, | |
| 11 | + | layout::{Constraint, Flex, Layout, Rect}, | |
| 12 | + | text::Line, | |
| 13 | + | widgets::{Block, BorderType, Clear}, | |
| 14 | + | }; | |
| 15 | + | ||
| 16 | + | pub use add_to_playlist::AddToPlaylistPopup; | |
| 17 | + | pub use confirm::ConfirmPopup; | |
| 18 | + | pub use help::HelpPopup; | |
| 19 | + | pub use input::{InputPopup, InputPurpose}; | |
| 20 | + | ||
| 21 | + | use crate::{config::KeyCombo, screens::Ctx}; | |
| 22 | + | ||
| 23 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 24 | + | pub enum PopupState { | |
| 25 | + | Open, | |
| 26 | + | Closed, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | pub trait Popup { | |
| 30 | + | /// Draws the popup; `area` is the whole terminal. | |
| 31 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx); | |
| 32 | + | ||
| 33 | + | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Clears a centered rectangle and draws a titled frame around it, | |
| 37 | + | /// returning the inner area. | |
| 38 | + | fn frame_popup( | |
| 39 | + | frame: &mut Frame, | |
| 40 | + | area: Rect, | |
| 41 | + | width: u16, | |
| 42 | + | height: u16, | |
| 43 | + | title: &str, | |
| 44 | + | ctx: &Ctx, | |
| 45 | + | ) -> Rect { | |
| 46 | + | let [area] = Layout::horizontal([Constraint::Length(width.min(area.width))]) | |
| 47 | + | .flex(Flex::Center) | |
| 48 | + | .areas(area); | |
| 49 | + | let [area] = Layout::vertical([Constraint::Length(height.min(area.height))]) | |
| 50 | + | .flex(Flex::Center) | |
| 51 | + | .areas(area); | |
| 52 | + | let block = Block::bordered() | |
| 53 | + | .border_type(BorderType::Plain) | |
| 54 | + | .border_style(ctx.theme().border()) | |
| 55 | + | .title(Line::from(format!(" {title} ")).style(ctx.theme().header())); | |
| 56 | + | let inner = block.inner(area); | |
| 57 | + | frame.render_widget(Clear, area); | |
| 58 | + | frame.render_widget(block, area); | |
| 59 | + | inner | |
| 60 | + | } | |
[Diff too large] View file
[Diff too large] View file
| @@ -0,0 +1,138 @@ | |||
| 1 | + | //! Screen 7: lyrics of the current song. Also drawn as a pane on the queue. | |
| 2 | + | ||
| 3 | + | use crossterm::event::{MouseEvent, MouseEventKind}; | |
| 4 | + | use ratatui::{ | |
| 5 | + | Frame, | |
| 6 | + | layout::{Alignment, Constraint, Layout, Rect}, | |
| 7 | + | text::{Line, Span}, | |
| 8 | + | widgets::Paragraph, | |
| 9 | + | }; | |
| 10 | + | ||
| 11 | + | use super::{Ctx, Effect, Screen}; | |
| 12 | + | use crate::{ | |
| 13 | + | action::Action, | |
| 14 | + | lyrics::{Lyrics, LyricsStatus}, | |
| 15 | + | ui, | |
| 16 | + | }; | |
| 17 | + | ||
| 18 | + | #[derive(Debug, Default)] | |
| 19 | + | pub struct LyricsScreen { | |
| 20 | + | /// Scroll offset for plain (unsynced) lyrics. | |
| 21 | + | scroll: usize, | |
| 22 | + | height: usize, | |
| 23 | + | file: Option<String>, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Draws the lyrics pane next to the queue (no manual scrolling). | |
| 27 | + | pub fn draw_lyrics(frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 28 | + | let theme = ctx.theme(); | |
| 29 | + | let [header, line, body] = Layout::vertical([ | |
| 30 | + | Constraint::Length(1), | |
| 31 | + | Constraint::Length(1), | |
| 32 | + | Constraint::Fill(1), | |
| 33 | + | ]) | |
| 34 | + | .areas(area); | |
| 35 | + | frame.render_widget(Line::styled("Lyrics", theme.header()), header); | |
| 36 | + | ui::draw_hline(frame, line, theme); | |
| 37 | + | render(frame, body, ctx, 0); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | fn render(frame: &mut Frame, inner: Rect, ctx: &Ctx, scroll: usize) -> usize { | |
| 41 | + | let theme = ctx.theme(); | |
| 42 | + | let height = usize::from(inner.height); | |
| 43 | + | ||
| 44 | + | let lyrics = match (&ctx.mpd.current, &ctx.lyrics.status) { | |
| 45 | + | (None, _) => { | |
| 46 | + | ui::draw_placeholder(frame, inner, &["Nothing is playing"], theme); | |
| 47 | + | return height; | |
| 48 | + | } | |
| 49 | + | (Some(_), LyricsStatus::Found(lyrics)) => lyrics, | |
| 50 | + | (Some(_), LyricsStatus::Idle | LyricsStatus::Loading) => { | |
| 51 | + | ui::draw_placeholder(frame, inner, &["Looking for lyrics…"], theme); | |
| 52 | + | return height; | |
| 53 | + | } | |
| 54 | + | (Some(_), LyricsStatus::NotFound) => { | |
| 55 | + | ui::draw_placeholder(frame, inner, &["No lyrics found"], theme); | |
| 56 | + | return height; | |
| 57 | + | } | |
| 58 | + | (Some(_), LyricsStatus::Failed(error)) => { | |
| 59 | + | ui::draw_placeholder(frame, inner, &["Could not fetch lyrics", error], theme); | |
| 60 | + | return height; | |
| 61 | + | } | |
| 62 | + | }; | |
| 63 | + | ||
| 64 | + | let current = lyrics.current_line(ctx.mpd.elapsed()); | |
| 65 | + | let top = match (lyrics, current) { | |
| 66 | + | // Keep the current line in the middle. | |
| 67 | + | (Lyrics::Synced(_), Some(line)) => line.saturating_sub(height / 2), | |
| 68 | + | (Lyrics::Synced(_), None) => 0, | |
| 69 | + | (Lyrics::Plain(_), _) => scroll.min(lyrics.len().saturating_sub(1)), | |
| 70 | + | }; | |
| 71 | + | let lines: Vec<Line> = (top..lyrics.len().min(top + height)) | |
| 72 | + | .map(|i| { | |
| 73 | + | let text = lyrics.line(i); | |
| 74 | + | let style = match (lyrics, current) { | |
| 75 | + | (Lyrics::Synced(_), Some(c)) if i == c => theme.header(), | |
| 76 | + | (Lyrics::Synced(_), Some(c)) if i < c => theme.dim(), | |
| 77 | + | (Lyrics::Synced(_), None) => theme.dim(), | |
| 78 | + | _ => theme.text(), | |
| 79 | + | }; | |
| 80 | + | Line::from(Span::styled(text.to_owned(), style)) | |
| 81 | + | }) | |
| 82 | + | .collect(); | |
| 83 | + | frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), inner); | |
| 84 | + | height | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | impl Screen for LyricsScreen { | |
| 88 | + | fn title(&self, ctx: &Ctx) -> Line<'static> { | |
| 89 | + | match &ctx.mpd.current { | |
| 90 | + | Some(song) => Line::from(format!("Lyrics: {}", ui::song_label(song))), | |
| 91 | + | None => Line::from("Lyrics"), | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 96 | + | // Plain lyrics start from the top for every song. | |
| 97 | + | let file = ctx.mpd.current.as_ref().map(|s| s.file.clone()); | |
| 98 | + | if file != self.file { | |
| 99 | + | self.file = file; | |
| 100 | + | self.scroll = 0; | |
| 101 | + | } | |
| 102 | + | self.height = render(frame, area, ctx, self.scroll); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| 106 | + | let len = ctx.lyrics.lyrics().map_or(0, Lyrics::len); | |
| 107 | + | let page = self.height.max(1); | |
| 108 | + | let max = len.saturating_sub(1); | |
| 109 | + | self.scroll = match action { | |
| 110 | + | Action::Up => self.scroll.saturating_sub(1), | |
| 111 | + | Action::Down => (self.scroll + 1).min(max), | |
| 112 | + | Action::PageUp => self.scroll.saturating_sub(page), | |
| 113 | + | Action::PageDown => (self.scroll + page).min(max), | |
| 114 | + | Action::HalfPageUp => self.scroll.saturating_sub(page / 2), | |
| 115 | + | Action::HalfPageDown => (self.scroll + page / 2).min(max), | |
| 116 | + | Action::Top => 0, | |
| 117 | + | Action::Bottom => max, | |
| 118 | + | Action::ReloadLyrics => { | |
| 119 | + | ctx.effects.push(Effect::ReloadLyrics); | |
| 120 | + | ctx.notify("Fetching lyrics again…"); | |
| 121 | + | return true; | |
| 122 | + | } | |
| 123 | + | _ => return false, | |
| 124 | + | }; | |
| 125 | + | true | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) { | |
| 129 | + | let action = match event.kind { | |
| 130 | + | MouseEventKind::ScrollDown => Action::Down, | |
| 131 | + | MouseEventKind::ScrollUp => Action::Up, | |
| 132 | + | _ => return, | |
| 133 | + | }; | |
| 134 | + | for _ in 0..3 { | |
| 135 | + | self.handle_action(action, ctx); | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
[Diff too large] View file
[Diff too large] View file
[Diff too large] View file
[Diff too large] View file
[Diff too large] View file
[Diff too large] View file
[Diff too large] View file
| @@ -1,62 +0,0 @@ | |||
| 1 | − | use crate::ui; | |
| 2 | − | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; | |
| 3 | − | use crossterm::terminal::{self, *}; | |
| 4 | − | use std::io; | |
| 5 | − | use std::panic; | |
| 6 | − | ||
| 7 | − | use crate::{app::App, AppResult}; | |
| 8 | − | ||
| 9 | − | pub type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>; | |
| 10 | − | ||
| 11 | − | #[derive(Debug)] | |
| 12 | − | pub struct Tui { | |
| 13 | − | terminal: CrosstermTerminal, | |
| 14 | − | } | |
| 15 | − | ||
| 16 | − | impl Tui { | |
| 17 | − | pub fn new(terminal: CrosstermTerminal) -> Self { | |
| 18 | − | Self { terminal } | |
| 19 | − | } | |
| 20 | − | ||
| 21 | − | pub fn init(&mut self) -> AppResult<()> { | |
| 22 | − | terminal::enable_raw_mode()?; | |
| 23 | − | crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?; | |
| 24 | − | ||
| 25 | − | let panic_hook = panic::take_hook(); | |
| 26 | − | panic::set_hook(Box::new(move |panic| { | |
| 27 | − | if let Err(error) = Self::reset() { | |
| 28 | − | eprintln!("failed to reset the terminal: {error}"); | |
| 29 | − | } | |
| 30 | − | panic_hook(panic); | |
| 31 | − | })); | |
| 32 | − | Ok(()) | |
| 33 | − | } | |
| 34 | − | ||
| 35 | − | /// [`Draw`] the terminal interface by [`rendering`] the widgets. | |
| 36 | − | /// | |
| 37 | − | /// [`Draw`]: ratatui::Terminal::draw | |
| 38 | − | /// [`rendering`]: crate::ui::render | |
| 39 | − | pub fn draw(&mut self, app: &mut App) -> AppResult<()> { | |
| 40 | − | self.terminal.draw(|frame| ui::render(app, frame))?; | |
| 41 | − | Ok(()) | |
| 42 | − | } | |
| 43 | − | ||
| 44 | − | /// Resets the terminal interface. | |
| 45 | − | /// | |
| 46 | − | /// This function is also used for the panic hook to revert | |
| 47 | − | /// the terminal properties if unexpected errors occur. | |
| 48 | − | fn reset() -> AppResult<()> { | |
| 49 | − | terminal::disable_raw_mode()?; | |
| 50 | − | crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?; | |
| 51 | − | Ok(()) | |
| 52 | − | } | |
| 53 | − | ||
| 54 | − | /// Exits the terminal interface. | |
| 55 | − | /// | |
| 56 | − | /// It disables the raw mode and reverts back the terminal properties. | |
| 57 | − | pub fn exit(&mut self) -> AppResult<()> { | |
| 58 | − | Self::reset()?; | |
| 59 | − | self.terminal.show_cursor()?; | |
| 60 | − | Ok(()) | |
| 61 | − | } | |
| 62 | − | } | |
[Diff too large] View file
[Diff too large] View file
[Diff too large] View file
[Diff too large] View file
[Diff too large] View file
| @@ -1,27 +0,0 @@ | |||
| 1 | − | use std::ffi::OsStr; | |
| 2 | − | use std::path::Path; | |
| 3 | − | ||
| 4 | − | /// Checks if a file has a given extension | |
| 5 | − | // https://stackoverflow.com/questions/72392835/check-if-a-file-is-of-a-given-type | |
| 6 | − | pub trait FileExtension { | |
| 7 | − | fn has_extension<S: AsRef<str>>(&self, extensions: &[S]) -> bool; | |
| 8 | − | } | |
| 9 | − | ||
| 10 | − | impl<P: AsRef<Path>> FileExtension for P { | |
| 11 | − | fn has_extension<S: AsRef<str>>(&self, extensions: &[S]) -> bool { | |
| 12 | − | if let Some(extension) = self.as_ref().extension().and_then(OsStr::to_str) { | |
| 13 | − | return extensions | |
| 14 | − | .iter() | |
| 15 | − | .any(|x| x.as_ref().eq_ignore_ascii_case(extension)); | |
| 16 | − | } | |
| 17 | − | ||
| 18 | − | false | |
| 19 | − | } | |
| 20 | − | } | |
| 21 | − | ||
| 22 | − | pub fn is_audio_file(path: &str) -> bool { | |
| 23 | − | Path::new(path).has_extension(&[ | |
| 24 | − | "mp3", "ogg", "flac", "m4a", "wav", "aac", "opus", "ape", "wma", "mpc", "aiff", "dff", | |
| 25 | − | "mp2", "mka", | |
| 26 | − | ]) | |
| 27 | − | } | |
[Diff too large] View file
[Diff too large] View file
[Diff too large] View file