rmptui7f77734
Release rmptui v0.1.7
+3626 −1800
23 files changed
- A .github/copilot-instructions.md +30 −0
- M CHANGELOG.md +20 −0
- M Cargo.lock +642 −196
- M Cargo.toml +17 −11
- M README.md +227 −55
- A install.sh +86 −0
- M src/app.rs +373 −329
- M src/browser.rs +78 −101
- A src/config.rs +669 −0
- M src/connection.rs +700 −160
- D src/event_handler/event.rs +0 −79
- M src/event_handler/handler.rs +435 −290
- M src/event_handler/mod.rs +0 −5
- D src/event_handler/new_pl_keys.rs +0 −47
- D src/event_handler/pl_append_keys.rs +0 −115
- D src/event_handler/pl_rename_keys.rs +0 −43
- D src/event_handler/search_keys.rs +0 −110
- M src/lib.rs +10 −19
- M src/list.rs +4 −18
- M src/main.rs +155 −32
- M src/tui.rs +8 −8
- M src/ui.rs +166 −171
- M src/utils.rs +6 −11
| @@ -0,0 +1,30 @@ | |||
| 1 | + | # Copilot instructions for rmptui | |
| 2 | + | ||
| 3 | + | ## Build, test, and lint | |
| 4 | + | ||
| 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. | |
| 11 | + | ||
| 12 | + | ## Architecture | |
| 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`. The config contains MPD and playback settings plus configurable keybindings; `MPD_HOST` and `MPD_PORT` override its connection defaults. | |
| 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. | |
| 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 audio-extension check used by the browser and directory operations. | |
| 20 | + | ||
| 21 | + | ## Repository conventions | |
| 22 | + | ||
| 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 | + | - 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. | |
| 27 | + | - Preserve async boundaries through event handling and app operations; do not introduce blocking socket reads, polling threads, or synchronous terminal polling. | |
| 28 | + | - 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. | |
| 29 | + | - When changing key behavior or features, keep the controls documented in `README.md` consistent with `event_handler/handler.rs`. | |
| 30 | + | - `AppResult<T>` is `Send + Sync` because application errors cross Tokio task boundaries. | |
| @@ -1,3 +1,23 @@ | |||
| 1 | + | ## rmptui-v0.1.7 | |
| 2 | + | ||
| 3 | + | - Replace the outdated MPD crate with an asynchronous native MPD TCP protocol | |
| 4 | + | client and `idle`-based server updates. | |
| 5 | + | - Preserve and improve browser search with fuzzy result cycling using `n`/`N`, | |
| 6 | + | and render directory names across the full browser row. | |
| 7 | + | - Add a first-run TOML configuration for MPD connection details, playback | |
| 8 | + | adjustments, and configurable keybindings, with documented defaults and | |
| 9 | + | environment-variable overrides. | |
| 10 | + | - Add playlist management in the Playlists view: focus playlist contents, | |
| 11 | + | load playlists, play individual tracks, remove/reorder songs, and rename or | |
| 12 | + | delete playlists. | |
| 13 | + | - Add a local install/uninstall script for the release executable. | |
| 14 | + | - Remove dmenu support and document the async architecture, configuration, | |
| 15 | + | keybindings, installation, and playlist controls. | |
| 16 | + | - Refactor application state and browser entries, remove unused helpers, and | |
| 17 | + | improve terminal restoration and MPD address handling. | |
| 18 | + | - Add and update tests for MPD protocol commands, configuration, search, | |
| 19 | + | browser rendering, and address formatting. | |
| 20 | + | ||
| 1 | 21 | ## rmptui-v0.1.6 | |
| 2 | 22 | - Add songs to new playlist feature | |
| 3 | 23 | - Code refactoring | |
| @@ -1,94 +1,98 @@ | |||
| 1 | 1 | # This file is automatically @generated by Cargo. | |
| 2 | 2 | # It is not intended for manual editing. | |
| 3 | − | version = 3 | |
| 4 | − | ||
| 5 | − | [[package]] | |
| 6 | − | name = "ahash" | |
| 7 | − | version = "0.8.11" | |
| 8 | − | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | − | checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" | |
| 10 | − | dependencies = [ | |
| 11 | − | "cfg-if", | |
| 12 | − | "once_cell", | |
| 13 | − | "version_check", | |
| 14 | − | "zerocopy", | |
| 15 | − | ] | |
| 3 | + | version = 4 | |
| 16 | 4 | ||
| 17 | 5 | [[package]] | |
| 18 | 6 | name = "allocator-api2" | |
| 19 | − | version = "0.2.18" | |
| 7 | + | version = "0.2.21" | |
| 20 | 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 21 | − | checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" | |
| 9 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 22 | 10 | ||
| 23 | 11 | [[package]] | |
| 24 | − | name = "autocfg" | |
| 25 | − | version = "1.2.0" | |
| 12 | + | name = "approx" | |
| 13 | + | version = "0.5.1" | |
| 26 | 14 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 27 | − | checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" | |
| 15 | + | checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" | |
| 16 | + | dependencies = [ | |
| 17 | + | "num-traits", | |
| 18 | + | ] | |
| 28 | 19 | ||
| 29 | 20 | [[package]] | |
| 30 | − | name = "bitflags" | |
| 31 | − | version = "1.3.2" | |
| 21 | + | name = "autocfg" | |
| 22 | + | version = "1.5.1" | |
| 32 | 23 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 33 | − | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" | |
| 24 | + | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 34 | 25 | ||
| 35 | 26 | [[package]] | |
| 36 | 27 | name = "bitflags" | |
| 37 | − | version = "2.5.0" | |
| 28 | + | version = "2.13.2" | |
| 38 | 29 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 39 | − | checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" | |
| 30 | + | checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" | |
| 40 | 31 | ||
| 41 | 32 | [[package]] | |
| 42 | − | name = "bufstream" | |
| 43 | − | version = "0.1.4" | |
| 33 | + | name = "by_address" | |
| 34 | + | version = "1.2.1" | |
| 44 | 35 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 45 | − | checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8" | |
| 36 | + | checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" | |
| 46 | 37 | ||
| 47 | 38 | [[package]] | |
| 48 | − | name = "cassowary" | |
| 49 | − | version = "0.3.0" | |
| 39 | + | name = "bytes" | |
| 40 | + | version = "1.12.1" | |
| 50 | 41 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 51 | − | checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" | |
| 42 | + | checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" | |
| 52 | 43 | ||
| 53 | 44 | [[package]] | |
| 54 | 45 | name = "castaway" | |
| 55 | − | version = "0.2.2" | |
| 46 | + | version = "0.2.4" | |
| 56 | 47 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 57 | − | checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" | |
| 48 | + | checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" | |
| 58 | 49 | dependencies = [ | |
| 59 | 50 | "rustversion", | |
| 60 | 51 | ] | |
| 61 | 52 | ||
| 62 | 53 | [[package]] | |
| 63 | 54 | name = "cfg-if" | |
| 64 | − | version = "1.0.0" | |
| 55 | + | version = "1.0.5" | |
| 65 | 56 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 66 | − | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" | |
| 57 | + | checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" | |
| 67 | 58 | ||
| 68 | 59 | [[package]] | |
| 69 | 60 | name = "compact_str" | |
| 70 | − | version = "0.7.1" | |
| 61 | + | version = "0.9.1" | |
| 71 | 62 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 72 | − | checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" | |
| 63 | + | checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" | |
| 73 | 64 | dependencies = [ | |
| 74 | 65 | "castaway", | |
| 75 | 66 | "cfg-if", | |
| 76 | 67 | "itoa", | |
| 68 | + | "rustversion", | |
| 77 | 69 | "ryu", | |
| 78 | 70 | "static_assertions", | |
| 79 | 71 | ] | |
| 80 | 72 | ||
| 73 | + | [[package]] | |
| 74 | + | name = "convert_case" | |
| 75 | + | version = "0.10.0" | |
| 76 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 77 | + | checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" | |
| 78 | + | dependencies = [ | |
| 79 | + | "unicode-segmentation", | |
| 80 | + | ] | |
| 81 | + | ||
| 81 | 82 | [[package]] | |
| 82 | 83 | name = "crossterm" | |
| 83 | − | version = "0.27.0" | |
| 84 | + | version = "0.29.0" | |
| 84 | 85 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 85 | − | checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" | |
| 86 | + | checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" | |
| 86 | 87 | dependencies = [ | |
| 87 | − | "bitflags 2.5.0", | |
| 88 | + | "bitflags", | |
| 88 | 89 | "crossterm_winapi", | |
| 89 | − | "libc", | |
| 90 | + | "derive_more", | |
| 91 | + | "document-features", | |
| 92 | + | "futures-core", | |
| 90 | 93 | "mio", | |
| 91 | 94 | "parking_lot", | |
| 95 | + | "rustix", | |
| 92 | 96 | "signal-hook", | |
| 93 | 97 | "signal-hook-mio", | |
| 94 | 98 | "winapi", | |
| @@ -103,85 +107,295 @@ dependencies = [ | |||
| 103 | 107 | "winapi", | |
| 104 | 108 | ] | |
| 105 | 109 | ||
| 110 | + | [[package]] | |
| 111 | + | name = "darling" | |
| 112 | + | version = "0.24.1" | |
| 113 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 114 | + | checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" | |
| 115 | + | dependencies = [ | |
| 116 | + | "darling_core", | |
| 117 | + | "darling_macro", | |
| 118 | + | ] | |
| 119 | + | ||
| 120 | + | [[package]] | |
| 121 | + | name = "darling_core" | |
| 122 | + | version = "0.24.1" | |
| 123 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 124 | + | checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" | |
| 125 | + | dependencies = [ | |
| 126 | + | "ident_case", | |
| 127 | + | "proc-macro2", | |
| 128 | + | "quote", | |
| 129 | + | "strsim", | |
| 130 | + | "syn 3.0.9", | |
| 131 | + | ] | |
| 132 | + | ||
| 133 | + | [[package]] | |
| 134 | + | name = "darling_macro" | |
| 135 | + | version = "0.24.1" | |
| 136 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 137 | + | checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" | |
| 138 | + | dependencies = [ | |
| 139 | + | "darling_core", | |
| 140 | + | "quote", | |
| 141 | + | "syn 3.0.9", | |
| 142 | + | ] | |
| 143 | + | ||
| 144 | + | [[package]] | |
| 145 | + | name = "deranged" | |
| 146 | + | version = "0.5.9" | |
| 147 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 148 | + | checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" | |
| 149 | + | ||
| 150 | + | [[package]] | |
| 151 | + | name = "derive_more" | |
| 152 | + | version = "2.1.1" | |
| 153 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 154 | + | checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" | |
| 155 | + | dependencies = [ | |
| 156 | + | "derive_more-impl", | |
| 157 | + | ] | |
| 158 | + | ||
| 159 | + | [[package]] | |
| 160 | + | name = "derive_more-impl" | |
| 161 | + | version = "2.1.1" | |
| 162 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 163 | + | checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" | |
| 164 | + | dependencies = [ | |
| 165 | + | "convert_case", | |
| 166 | + | "proc-macro2", | |
| 167 | + | "quote", | |
| 168 | + | "rustc_version", | |
| 169 | + | "syn 2.0.119", | |
| 170 | + | ] | |
| 171 | + | ||
| 172 | + | [[package]] | |
| 173 | + | name = "document-features" | |
| 174 | + | version = "0.2.12" | |
| 175 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 176 | + | checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" | |
| 177 | + | dependencies = [ | |
| 178 | + | "litrs", | |
| 179 | + | ] | |
| 180 | + | ||
| 106 | 181 | [[package]] | |
| 107 | 182 | name = "either" | |
| 108 | − | version = "1.11.0" | |
| 183 | + | version = "1.19.0" | |
| 109 | 184 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 110 | − | checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2" | |
| 185 | + | checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" | |
| 186 | + | ||
| 187 | + | [[package]] | |
| 188 | + | name = "equivalent" | |
| 189 | + | version = "1.0.3" | |
| 190 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 191 | + | checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" | |
| 192 | + | ||
| 193 | + | [[package]] | |
| 194 | + | name = "errno" | |
| 195 | + | version = "0.3.14" | |
| 196 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 197 | + | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 198 | + | dependencies = [ | |
| 199 | + | "libc", | |
| 200 | + | "windows-sys", | |
| 201 | + | ] | |
| 202 | + | ||
| 203 | + | [[package]] | |
| 204 | + | name = "foldhash" | |
| 205 | + | version = "0.2.0" | |
| 206 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 207 | + | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 208 | + | ||
| 209 | + | [[package]] | |
| 210 | + | name = "futures-core" | |
| 211 | + | version = "0.3.34" | |
| 212 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 213 | + | checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" | |
| 214 | + | ||
| 215 | + | [[package]] | |
| 216 | + | name = "futures-macro" | |
| 217 | + | version = "0.3.34" | |
| 218 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 219 | + | checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" | |
| 220 | + | dependencies = [ | |
| 221 | + | "proc-macro2", | |
| 222 | + | "quote", | |
| 223 | + | "syn 3.0.9", | |
| 224 | + | ] | |
| 225 | + | ||
| 226 | + | [[package]] | |
| 227 | + | name = "futures-task" | |
| 228 | + | version = "0.3.34" | |
| 229 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 230 | + | checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" | |
| 231 | + | ||
| 232 | + | [[package]] | |
| 233 | + | name = "futures-util" | |
| 234 | + | version = "0.3.34" | |
| 235 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 236 | + | checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" | |
| 237 | + | dependencies = [ | |
| 238 | + | "futures-core", | |
| 239 | + | "futures-macro", | |
| 240 | + | "futures-task", | |
| 241 | + | "pin-project-lite", | |
| 242 | + | "slab", | |
| 243 | + | ] | |
| 244 | + | ||
| 245 | + | [[package]] | |
| 246 | + | name = "hashbrown" | |
| 247 | + | version = "0.16.1" | |
| 248 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 249 | + | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" | |
| 250 | + | dependencies = [ | |
| 251 | + | "allocator-api2", | |
| 252 | + | "equivalent", | |
| 253 | + | "foldhash", | |
| 254 | + | ] | |
| 111 | 255 | ||
| 112 | 256 | [[package]] | |
| 113 | 257 | name = "hashbrown" | |
| 114 | − | version = "0.14.3" | |
| 258 | + | version = "0.17.1" | |
| 115 | 259 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 116 | − | checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" | |
| 260 | + | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" | |
| 117 | 261 | dependencies = [ | |
| 118 | − | "ahash", | |
| 119 | 262 | "allocator-api2", | |
| 263 | + | "equivalent", | |
| 264 | + | "foldhash", | |
| 120 | 265 | ] | |
| 121 | 266 | ||
| 122 | 267 | [[package]] | |
| 123 | 268 | name = "heck" | |
| 124 | − | version = "0.4.1" | |
| 269 | + | version = "0.5.0" | |
| 125 | 270 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 126 | − | checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" | |
| 271 | + | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 272 | + | ||
| 273 | + | [[package]] | |
| 274 | + | name = "ident_case" | |
| 275 | + | version = "1.0.1" | |
| 276 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 277 | + | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" | |
| 278 | + | ||
| 279 | + | [[package]] | |
| 280 | + | name = "indexmap" | |
| 281 | + | version = "2.14.2" | |
| 282 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 283 | + | checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" | |
| 284 | + | dependencies = [ | |
| 285 | + | "equivalent", | |
| 286 | + | "hashbrown 0.17.1", | |
| 287 | + | ] | |
| 127 | 288 | ||
| 128 | 289 | [[package]] | |
| 129 | 290 | name = "indoc" | |
| 130 | − | version = "2.0.5" | |
| 291 | + | version = "2.0.8" | |
| 292 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 293 | + | checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" | |
| 294 | + | dependencies = [ | |
| 295 | + | "rustversion", | |
| 296 | + | ] | |
| 297 | + | ||
| 298 | + | [[package]] | |
| 299 | + | name = "instability" | |
| 300 | + | version = "0.3.14" | |
| 131 | 301 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 132 | − | checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" | |
| 302 | + | checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" | |
| 303 | + | dependencies = [ | |
| 304 | + | "darling", | |
| 305 | + | "indoc", | |
| 306 | + | "proc-macro2", | |
| 307 | + | "quote", | |
| 308 | + | "syn 3.0.9", | |
| 309 | + | ] | |
| 133 | 310 | ||
| 134 | 311 | [[package]] | |
| 135 | 312 | name = "itertools" | |
| 136 | − | version = "0.12.1" | |
| 313 | + | version = "0.14.0" | |
| 137 | 314 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 138 | − | checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" | |
| 315 | + | checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" | |
| 139 | 316 | dependencies = [ | |
| 140 | 317 | "either", | |
| 141 | 318 | ] | |
| 142 | 319 | ||
| 143 | 320 | [[package]] | |
| 144 | 321 | name = "itoa" | |
| 145 | − | version = "1.0.11" | |
| 322 | + | version = "1.0.18" | |
| 146 | 323 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 147 | − | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" | |
| 324 | + | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 325 | + | ||
| 326 | + | [[package]] | |
| 327 | + | name = "kasuari" | |
| 328 | + | version = "0.4.12" | |
| 329 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 330 | + | checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" | |
| 331 | + | dependencies = [ | |
| 332 | + | "hashbrown 0.16.1", | |
| 333 | + | "portable-atomic", | |
| 334 | + | "thiserror", | |
| 335 | + | ] | |
| 148 | 336 | ||
| 149 | 337 | [[package]] | |
| 150 | 338 | name = "libc" | |
| 151 | − | version = "0.2.153" | |
| 339 | + | version = "0.2.190" | |
| 340 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 341 | + | checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" | |
| 342 | + | ||
| 343 | + | [[package]] | |
| 344 | + | name = "libm" | |
| 345 | + | version = "0.2.16" | |
| 346 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 347 | + | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" | |
| 348 | + | ||
| 349 | + | [[package]] | |
| 350 | + | name = "line-clipping" | |
| 351 | + | version = "0.3.8" | |
| 352 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 353 | + | checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" | |
| 354 | + | dependencies = [ | |
| 355 | + | "bitflags", | |
| 356 | + | ] | |
| 357 | + | ||
| 358 | + | [[package]] | |
| 359 | + | name = "linux-raw-sys" | |
| 360 | + | version = "0.12.1" | |
| 361 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 362 | + | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" | |
| 363 | + | ||
| 364 | + | [[package]] | |
| 365 | + | name = "litrs" | |
| 366 | + | version = "1.0.1" | |
| 152 | 367 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 153 | − | checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" | |
| 368 | + | checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" | |
| 154 | 369 | ||
| 155 | 370 | [[package]] | |
| 156 | 371 | name = "lock_api" | |
| 157 | − | version = "0.4.11" | |
| 372 | + | version = "0.4.14" | |
| 158 | 373 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 159 | − | checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" | |
| 374 | + | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" | |
| 160 | 375 | dependencies = [ | |
| 161 | − | "autocfg", | |
| 162 | 376 | "scopeguard", | |
| 163 | 377 | ] | |
| 164 | 378 | ||
| 165 | 379 | [[package]] | |
| 166 | 380 | name = "log" | |
| 167 | − | version = "0.4.21" | |
| 381 | + | version = "0.4.34" | |
| 168 | 382 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 169 | − | checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" | |
| 383 | + | checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" | |
| 170 | 384 | ||
| 171 | 385 | [[package]] | |
| 172 | 386 | name = "lru" | |
| 173 | − | version = "0.12.3" | |
| 387 | + | version = "0.18.5" | |
| 174 | 388 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 175 | − | checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" | |
| 389 | + | checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" | |
| 176 | 390 | dependencies = [ | |
| 177 | − | "hashbrown", | |
| 391 | + | "hashbrown 0.17.1", | |
| 178 | 392 | ] | |
| 179 | 393 | ||
| 180 | 394 | [[package]] | |
| 181 | 395 | name = "mio" | |
| 182 | − | version = "0.8.11" | |
| 396 | + | version = "1.2.4" | |
| 183 | 397 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 184 | − | checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" | |
| 398 | + | checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" | |
| 185 | 399 | dependencies = [ | |
| 186 | 400 | "libc", | |
| 187 | 401 | "log", | |
| @@ -190,25 +404,67 @@ dependencies = [ | |||
| 190 | 404 | ] | |
| 191 | 405 | ||
| 192 | 406 | [[package]] | |
| 193 | − | name = "mpd" | |
| 194 | − | version = "0.1.0" | |
| 407 | + | name = "num-conv" | |
| 408 | + | version = "0.2.2" | |
| 409 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 410 | + | checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" | |
| 411 | + | ||
| 412 | + | [[package]] | |
| 413 | + | name = "num-traits" | |
| 414 | + | version = "0.2.19" | |
| 195 | 415 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 196 | − | checksum = "2e12cace5746cb0aa78faa6cd2caec9f9c01882fc0e6b54d34685a2d3303ea34" | |
| 416 | + | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | |
| 197 | 417 | dependencies = [ | |
| 198 | − | "bufstream", | |
| 418 | + | "autocfg", | |
| 199 | 419 | ] | |
| 200 | 420 | ||
| 201 | 421 | [[package]] | |
| 202 | − | name = "once_cell" | |
| 203 | − | version = "1.19.0" | |
| 422 | + | name = "num_threads" | |
| 423 | + | version = "0.1.7" | |
| 424 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 425 | + | checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" | |
| 426 | + | dependencies = [ | |
| 427 | + | "libc", | |
| 428 | + | ] | |
| 429 | + | ||
| 430 | + | [[package]] | |
| 431 | + | name = "palette" | |
| 432 | + | version = "0.7.7" | |
| 204 | 433 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 205 | − | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" | |
| 434 | + | checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" | |
| 435 | + | dependencies = [ | |
| 436 | + | "approx", | |
| 437 | + | "libm", | |
| 438 | + | "palette_derive", | |
| 439 | + | "palette_math", | |
| 440 | + | ] | |
| 441 | + | ||
| 442 | + | [[package]] | |
| 443 | + | name = "palette_derive" | |
| 444 | + | version = "0.7.7" | |
| 445 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 446 | + | checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" | |
| 447 | + | dependencies = [ | |
| 448 | + | "by_address", | |
| 449 | + | "proc-macro2", | |
| 450 | + | "quote", | |
| 451 | + | "syn 2.0.119", | |
| 452 | + | ] | |
| 453 | + | ||
| 454 | + | [[package]] | |
| 455 | + | name = "palette_math" | |
| 456 | + | version = "0.7.7" | |
| 457 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 458 | + | checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" | |
| 459 | + | dependencies = [ | |
| 460 | + | "libm", | |
| 461 | + | ] | |
| 206 | 462 | ||
| 207 | 463 | [[package]] | |
| 208 | 464 | name = "parking_lot" | |
| 209 | − | version = "0.12.1" | |
| 465 | + | version = "0.12.5" | |
| 210 | 466 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 211 | − | checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" | |
| 467 | + | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" | |
| 212 | 468 | dependencies = [ | |
| 213 | 469 | "lock_api", | |
| 214 | 470 | "parking_lot_core", | |
| @@ -216,68 +472,126 @@ dependencies = [ | |||
| 216 | 472 | ||
| 217 | 473 | [[package]] | |
| 218 | 474 | name = "parking_lot_core" | |
| 219 | − | version = "0.9.9" | |
| 475 | + | version = "0.9.12" | |
| 220 | 476 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 221 | − | checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" | |
| 477 | + | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" | |
| 222 | 478 | dependencies = [ | |
| 223 | 479 | "cfg-if", | |
| 224 | 480 | "libc", | |
| 225 | 481 | "redox_syscall", | |
| 226 | 482 | "smallvec", | |
| 227 | − | "windows-targets", | |
| 483 | + | "windows-link", | |
| 228 | 484 | ] | |
| 229 | 485 | ||
| 230 | 486 | [[package]] | |
| 231 | − | name = "paste" | |
| 232 | − | version = "1.0.14" | |
| 487 | + | name = "pin-project-lite" | |
| 488 | + | version = "0.2.17" | |
| 489 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 490 | + | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 491 | + | ||
| 492 | + | [[package]] | |
| 493 | + | name = "portable-atomic" | |
| 494 | + | version = "1.15.0" | |
| 233 | 495 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 234 | − | checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" | |
| 496 | + | checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" | |
| 497 | + | ||
| 498 | + | [[package]] | |
| 499 | + | name = "powerfmt" | |
| 500 | + | version = "0.2.1" | |
| 501 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 502 | + | checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" | |
| 235 | 503 | ||
| 236 | 504 | [[package]] | |
| 237 | 505 | name = "proc-macro2" | |
| 238 | − | version = "1.0.81" | |
| 506 | + | version = "1.0.107" | |
| 239 | 507 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 240 | − | checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" | |
| 508 | + | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 241 | 509 | dependencies = [ | |
| 242 | 510 | "unicode-ident", | |
| 243 | 511 | ] | |
| 244 | 512 | ||
| 245 | 513 | [[package]] | |
| 246 | 514 | name = "quote" | |
| 247 | − | version = "1.0.36" | |
| 515 | + | version = "1.0.47" | |
| 248 | 516 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 249 | − | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" | |
| 517 | + | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 250 | 518 | dependencies = [ | |
| 251 | 519 | "proc-macro2", | |
| 252 | 520 | ] | |
| 253 | 521 | ||
| 254 | 522 | [[package]] | |
| 255 | 523 | name = "ratatui" | |
| 256 | − | version = "0.26.2" | |
| 524 | + | version = "0.30.2" | |
| 525 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 526 | + | checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" | |
| 527 | + | dependencies = [ | |
| 528 | + | "instability", | |
| 529 | + | "ratatui-core", | |
| 530 | + | "ratatui-crossterm", | |
| 531 | + | "ratatui-widgets", | |
| 532 | + | "serde", | |
| 533 | + | ] | |
| 534 | + | ||
| 535 | + | [[package]] | |
| 536 | + | name = "ratatui-core" | |
| 537 | + | version = "0.1.2" | |
| 257 | 538 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 258 | − | checksum = "a564a852040e82671dc50a37d88f3aa83bbc690dfc6844cfe7a2591620206a80" | |
| 539 | + | checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" | |
| 259 | 540 | dependencies = [ | |
| 260 | − | "bitflags 2.5.0", | |
| 261 | − | "cassowary", | |
| 541 | + | "bitflags", | |
| 262 | 542 | "compact_str", | |
| 543 | + | "hashbrown 0.17.1", | |
| 544 | + | "itertools", | |
| 545 | + | "kasuari", | |
| 546 | + | "lru", | |
| 547 | + | "palette", | |
| 548 | + | "serde", | |
| 549 | + | "strum", | |
| 550 | + | "thiserror", | |
| 551 | + | "unicode-segmentation", | |
| 552 | + | "unicode-truncate", | |
| 553 | + | "unicode-width", | |
| 554 | + | ] | |
| 555 | + | ||
| 556 | + | [[package]] | |
| 557 | + | name = "ratatui-crossterm" | |
| 558 | + | version = "0.1.2" | |
| 559 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 560 | + | checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" | |
| 561 | + | dependencies = [ | |
| 562 | + | "cfg-if", | |
| 263 | 563 | "crossterm", | |
| 564 | + | "instability", | |
| 565 | + | "ratatui-core", | |
| 566 | + | ] | |
| 567 | + | ||
| 568 | + | [[package]] | |
| 569 | + | name = "ratatui-widgets" | |
| 570 | + | version = "0.3.2" | |
| 571 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 572 | + | checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" | |
| 573 | + | dependencies = [ | |
| 574 | + | "bitflags", | |
| 575 | + | "hashbrown 0.17.1", | |
| 264 | 576 | "indoc", | |
| 577 | + | "instability", | |
| 265 | 578 | "itertools", | |
| 266 | − | "lru", | |
| 267 | − | "paste", | |
| 268 | − | "stability", | |
| 579 | + | "line-clipping", | |
| 580 | + | "ratatui-core", | |
| 581 | + | "serde", | |
| 269 | 582 | "strum", | |
| 583 | + | "time", | |
| 270 | 584 | "unicode-segmentation", | |
| 271 | 585 | "unicode-width", | |
| 272 | 586 | ] | |
| 273 | 587 | ||
| 274 | 588 | [[package]] | |
| 275 | 589 | name = "redox_syscall" | |
| 276 | − | version = "0.4.1" | |
| 590 | + | version = "0.5.18" | |
| 277 | 591 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 278 | − | checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" | |
| 592 | + | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" | |
| 279 | 593 | dependencies = [ | |
| 280 | − | "bitflags 1.3.2", | |
| 594 | + | "bitflags", | |
| 281 | 595 | ] | |
| 282 | 596 | ||
| 283 | 597 | [[package]] | |
| @@ -285,10 +599,12 @@ name = "rmptui" | |||
| 285 | 599 | version = "0.1.6" | |
| 286 | 600 | dependencies = [ | |
| 287 | 601 | "crossterm", | |
| 288 | − | "mpd", | |
| 602 | + | "futures-util", | |
| 289 | 603 | "ratatui", | |
| 290 | 604 | "rust-fuzzy-search", | |
| 291 | − | "simple-dmenu", | |
| 605 | + | "serde", | |
| 606 | + | "tokio", | |
| 607 | + | "toml", | |
| 292 | 608 | ] | |
| 293 | 609 | ||
| 294 | 610 | [[package]] | |
| @@ -297,17 +613,39 @@ version = "0.1.1" | |||
| 297 | 613 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 298 | 614 | checksum = "a157657054ffe556d8858504af8a672a054a6e0bd9e8ee531059100c0fa11bb2" | |
| 299 | 615 | ||
| 616 | + | [[package]] | |
| 617 | + | name = "rustc_version" | |
| 618 | + | version = "0.4.1" | |
| 619 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 620 | + | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" | |
| 621 | + | dependencies = [ | |
| 622 | + | "semver", | |
| 623 | + | ] | |
| 624 | + | ||
| 625 | + | [[package]] | |
| 626 | + | name = "rustix" | |
| 627 | + | version = "1.1.5" | |
| 628 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 629 | + | checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" | |
| 630 | + | dependencies = [ | |
| 631 | + | "bitflags", | |
| 632 | + | "errno", | |
| 633 | + | "libc", | |
| 634 | + | "linux-raw-sys", | |
| 635 | + | "windows-sys", | |
| 636 | + | ] | |
| 637 | + | ||
| 300 | 638 | [[package]] | |
| 301 | 639 | name = "rustversion" | |
| 302 | − | version = "1.0.15" | |
| 640 | + | version = "1.0.23" | |
| 303 | 641 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 304 | − | checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" | |
| 642 | + | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 305 | 643 | ||
| 306 | 644 | [[package]] | |
| 307 | 645 | name = "ryu" | |
| 308 | − | version = "1.0.17" | |
| 646 | + | version = "1.0.23" | |
| 309 | 647 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 310 | − | checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" | |
| 648 | + | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | |
| 311 | 649 | ||
| 312 | 650 | [[package]] | |
| 313 | 651 | name = "scopeguard" | |
| @@ -315,11 +653,56 @@ version = "1.2.0" | |||
| 315 | 653 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 316 | 654 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | |
| 317 | 655 | ||
| 656 | + | [[package]] | |
| 657 | + | name = "semver" | |
| 658 | + | version = "1.0.28" | |
| 659 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 660 | + | checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" | |
| 661 | + | ||
| 662 | + | [[package]] | |
| 663 | + | name = "serde" | |
| 664 | + | version = "1.0.229" | |
| 665 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 666 | + | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" | |
| 667 | + | dependencies = [ | |
| 668 | + | "serde_core", | |
| 669 | + | "serde_derive", | |
| 670 | + | ] | |
| 671 | + | ||
| 672 | + | [[package]] | |
| 673 | + | name = "serde_core" | |
| 674 | + | version = "1.0.229" | |
| 675 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 676 | + | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" | |
| 677 | + | dependencies = [ | |
| 678 | + | "serde_derive", | |
| 679 | + | ] | |
| 680 | + | ||
| 681 | + | [[package]] | |
| 682 | + | name = "serde_derive" | |
| 683 | + | version = "1.0.229" | |
| 684 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 685 | + | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" | |
| 686 | + | dependencies = [ | |
| 687 | + | "proc-macro2", | |
| 688 | + | "quote", | |
| 689 | + | "syn 3.0.9", | |
| 690 | + | ] | |
| 691 | + | ||
| 692 | + | [[package]] | |
| 693 | + | name = "serde_spanned" | |
| 694 | + | version = "1.1.2" | |
| 695 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 696 | + | checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" | |
| 697 | + | dependencies = [ | |
| 698 | + | "serde_core", | |
| 699 | + | ] | |
| 700 | + | ||
| 318 | 701 | [[package]] | |
| 319 | 702 | name = "signal-hook" | |
| 320 | − | version = "0.3.17" | |
| 703 | + | version = "0.3.18" | |
| 321 | 704 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 322 | − | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" | |
| 705 | + | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" | |
| 323 | 706 | dependencies = [ | |
| 324 | 707 | "libc", | |
| 325 | 708 | "signal-hook-registry", | |
| @@ -327,9 +710,9 @@ dependencies = [ | |||
| 327 | 710 | ||
| 328 | 711 | [[package]] | |
| 329 | 712 | name = "signal-hook-mio" | |
| 330 | − | version = "0.2.3" | |
| 713 | + | version = "0.2.5" | |
| 331 | 714 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 332 | − | checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" | |
| 715 | + | checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" | |
| 333 | 716 | dependencies = [ | |
| 334 | 717 | "libc", | |
| 335 | 718 | "mio", | |
| @@ -338,33 +721,34 @@ dependencies = [ | |||
| 338 | 721 | ||
| 339 | 722 | [[package]] | |
| 340 | 723 | name = "signal-hook-registry" | |
| 341 | − | version = "1.4.2" | |
| 724 | + | version = "1.4.8" | |
| 342 | 725 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 343 | − | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" | |
| 726 | + | checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" | |
| 344 | 727 | dependencies = [ | |
| 728 | + | "errno", | |
| 345 | 729 | "libc", | |
| 346 | 730 | ] | |
| 347 | 731 | ||
| 348 | 732 | [[package]] | |
| 349 | − | name = "simple-dmenu" | |
| 350 | − | version = "0.1.0" | |
| 733 | + | name = "slab" | |
| 734 | + | version = "0.4.12" | |
| 351 | 735 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 352 | − | checksum = "2f392360283341235b298e79f0360bd924db6e6ad1e0964cbf4c43ea79c237eb" | |
| 736 | + | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | |
| 353 | 737 | ||
| 354 | 738 | [[package]] | |
| 355 | 739 | name = "smallvec" | |
| 356 | − | version = "1.13.2" | |
| 740 | + | version = "1.16.3" | |
| 357 | 741 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 358 | − | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" | |
| 742 | + | checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" | |
| 359 | 743 | ||
| 360 | 744 | [[package]] | |
| 361 | − | name = "stability" | |
| 362 | − | version = "0.2.0" | |
| 745 | + | name = "socket2" | |
| 746 | + | version = "0.6.5" | |
| 363 | 747 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 364 | − | checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a" | |
| 748 | + | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 365 | 749 | dependencies = [ | |
| 366 | − | "quote", | |
| 367 | − | "syn", | |
| 750 | + | "libc", | |
| 751 | + | "windows-sys", | |
| 368 | 752 | ] | |
| 369 | 753 | ||
| 370 | 754 | [[package]] | |
| @@ -373,33 +757,38 @@ version = "1.1.0" | |||
| 373 | 757 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 374 | 758 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" | |
| 375 | 759 | ||
| 760 | + | [[package]] | |
| 761 | + | name = "strsim" | |
| 762 | + | version = "0.11.1" | |
| 763 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 764 | + | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" | |
| 765 | + | ||
| 376 | 766 | [[package]] | |
| 377 | 767 | name = "strum" | |
| 378 | − | version = "0.26.2" | |
| 768 | + | version = "0.28.0" | |
| 379 | 769 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 380 | − | checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" | |
| 770 | + | checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" | |
| 381 | 771 | dependencies = [ | |
| 382 | 772 | "strum_macros", | |
| 383 | 773 | ] | |
| 384 | 774 | ||
| 385 | 775 | [[package]] | |
| 386 | 776 | name = "strum_macros" | |
| 387 | − | version = "0.26.2" | |
| 777 | + | version = "0.28.0" | |
| 388 | 778 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 389 | − | checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" | |
| 779 | + | checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" | |
| 390 | 780 | dependencies = [ | |
| 391 | 781 | "heck", | |
| 392 | 782 | "proc-macro2", | |
| 393 | 783 | "quote", | |
| 394 | − | "rustversion", | |
| 395 | − | "syn", | |
| 784 | + | "syn 2.0.119", | |
| 396 | 785 | ] | |
| 397 | 786 | ||
| 398 | 787 | [[package]] | |
| 399 | 788 | name = "syn" | |
| 400 | − | version = "2.0.60" | |
| 789 | + | version = "2.0.119" | |
| 401 | 790 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 402 | − | checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" | |
| 791 | + | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 403 | 792 | dependencies = [ | |
| 404 | 793 | "proc-macro2", | |
| 405 | 794 | "quote", | |
| @@ -407,139 +796,196 @@ dependencies = [ | |||
| 407 | 796 | ] | |
| 408 | 797 | ||
| 409 | 798 | [[package]] | |
| 410 | − | name = "unicode-ident" | |
| 411 | − | version = "1.0.12" | |
| 799 | + | name = "syn" | |
| 800 | + | version = "3.0.9" | |
| 412 | 801 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 413 | − | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" | |
| 802 | + | checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" | |
| 803 | + | dependencies = [ | |
| 804 | + | "proc-macro2", | |
| 805 | + | "quote", | |
| 806 | + | "unicode-ident", | |
| 807 | + | ] | |
| 414 | 808 | ||
| 415 | 809 | [[package]] | |
| 416 | − | name = "unicode-segmentation" | |
| 417 | − | version = "1.11.0" | |
| 810 | + | name = "thiserror" | |
| 811 | + | version = "2.0.21" | |
| 418 | 812 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 419 | − | checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" | |
| 813 | + | checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" | |
| 814 | + | dependencies = [ | |
| 815 | + | "thiserror-impl", | |
| 816 | + | ] | |
| 420 | 817 | ||
| 421 | 818 | [[package]] | |
| 422 | − | name = "unicode-width" | |
| 423 | − | version = "0.1.11" | |
| 819 | + | name = "thiserror-impl" | |
| 820 | + | version = "2.0.21" | |
| 424 | 821 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 425 | − | checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" | |
| 822 | + | checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" | |
| 823 | + | dependencies = [ | |
| 824 | + | "proc-macro2", | |
| 825 | + | "quote", | |
| 826 | + | "syn 3.0.9", | |
| 827 | + | ] | |
| 426 | 828 | ||
| 427 | 829 | [[package]] | |
| 428 | − | name = "version_check" | |
| 429 | − | version = "0.9.4" | |
| 830 | + | name = "time" | |
| 831 | + | version = "0.3.55" | |
| 430 | 832 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 431 | − | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" | |
| 833 | + | checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" | |
| 834 | + | dependencies = [ | |
| 835 | + | "deranged", | |
| 836 | + | "libc", | |
| 837 | + | "num-conv", | |
| 838 | + | "num_threads", | |
| 839 | + | "powerfmt", | |
| 840 | + | "serde_core", | |
| 841 | + | "time-core", | |
| 842 | + | ] | |
| 432 | 843 | ||
| 433 | 844 | [[package]] | |
| 434 | − | name = "wasi" | |
| 435 | − | version = "0.11.0+wasi-snapshot-preview1" | |
| 845 | + | name = "time-core" | |
| 846 | + | version = "0.1.9" | |
| 436 | 847 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 437 | − | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" | |
| 848 | + | checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" | |
| 438 | 849 | ||
| 439 | 850 | [[package]] | |
| 440 | − | name = "winapi" | |
| 441 | − | version = "0.3.9" | |
| 851 | + | name = "tokio" | |
| 852 | + | version = "1.53.3" | |
| 442 | 853 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 443 | − | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | |
| 854 | + | checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" | |
| 444 | 855 | dependencies = [ | |
| 445 | − | "winapi-i686-pc-windows-gnu", | |
| 446 | − | "winapi-x86_64-pc-windows-gnu", | |
| 856 | + | "bytes", | |
| 857 | + | "libc", | |
| 858 | + | "mio", | |
| 859 | + | "pin-project-lite", | |
| 860 | + | "socket2", | |
| 861 | + | "tokio-macros", | |
| 862 | + | "windows-sys", | |
| 447 | 863 | ] | |
| 448 | 864 | ||
| 449 | 865 | [[package]] | |
| 450 | − | name = "winapi-i686-pc-windows-gnu" | |
| 451 | − | version = "0.4.0" | |
| 866 | + | name = "tokio-macros" | |
| 867 | + | version = "2.7.2" | |
| 452 | 868 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 453 | − | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | |
| 869 | + | checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" | |
| 870 | + | dependencies = [ | |
| 871 | + | "proc-macro2", | |
| 872 | + | "quote", | |
| 873 | + | "syn 3.0.9", | |
| 874 | + | ] | |
| 454 | 875 | ||
| 455 | 876 | [[package]] | |
| 456 | − | name = "winapi-x86_64-pc-windows-gnu" | |
| 457 | − | version = "0.4.0" | |
| 877 | + | name = "toml" | |
| 878 | + | version = "1.1.8+spec-1.1.0" | |
| 458 | 879 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 459 | − | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | |
| 880 | + | checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" | |
| 881 | + | dependencies = [ | |
| 882 | + | "indexmap", | |
| 883 | + | "serde_core", | |
| 884 | + | "serde_spanned", | |
| 885 | + | "toml_datetime", | |
| 886 | + | "toml_parser", | |
| 887 | + | "toml_writer", | |
| 888 | + | "winnow", | |
| 889 | + | ] | |
| 460 | 890 | ||
| 461 | 891 | [[package]] | |
| 462 | − | name = "windows-sys" | |
| 463 | − | version = "0.48.0" | |
| 892 | + | name = "toml_datetime" | |
| 893 | + | version = "1.1.2+spec-1.1.0" | |
| 464 | 894 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 465 | − | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" | |
| 895 | + | checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" | |
| 466 | 896 | dependencies = [ | |
| 467 | − | "windows-targets", | |
| 897 | + | "serde_core", | |
| 468 | 898 | ] | |
| 469 | 899 | ||
| 470 | 900 | [[package]] | |
| 471 | − | name = "windows-targets" | |
| 472 | − | version = "0.48.5" | |
| 901 | + | name = "toml_parser" | |
| 902 | + | version = "1.1.5+spec-1.1.0" | |
| 473 | 903 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 474 | − | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" | |
| 904 | + | checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" | |
| 475 | 905 | dependencies = [ | |
| 476 | − | "windows_aarch64_gnullvm", | |
| 477 | − | "windows_aarch64_msvc", | |
| 478 | − | "windows_i686_gnu", | |
| 479 | − | "windows_i686_msvc", | |
| 480 | − | "windows_x86_64_gnu", | |
| 481 | − | "windows_x86_64_gnullvm", | |
| 482 | − | "windows_x86_64_msvc", | |
| 906 | + | "winnow", | |
| 483 | 907 | ] | |
| 484 | 908 | ||
| 485 | 909 | [[package]] | |
| 486 | − | name = "windows_aarch64_gnullvm" | |
| 487 | − | version = "0.48.5" | |
| 910 | + | name = "toml_writer" | |
| 911 | + | version = "1.1.3+spec-1.1.0" | |
| 912 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 913 | + | checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" | |
| 914 | + | ||
| 915 | + | [[package]] | |
| 916 | + | name = "unicode-ident" | |
| 917 | + | version = "1.0.27" | |
| 918 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 919 | + | checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" | |
| 920 | + | ||
| 921 | + | [[package]] | |
| 922 | + | name = "unicode-segmentation" | |
| 923 | + | version = "1.13.3" | |
| 488 | 924 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 489 | − | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" | |
| 925 | + | checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" | |
| 926 | + | ||
| 927 | + | [[package]] | |
| 928 | + | name = "unicode-truncate" | |
| 929 | + | version = "2.0.1" | |
| 930 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 931 | + | checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" | |
| 932 | + | dependencies = [ | |
| 933 | + | "itertools", | |
| 934 | + | "unicode-segmentation", | |
| 935 | + | "unicode-width", | |
| 936 | + | ] | |
| 490 | 937 | ||
| 491 | 938 | [[package]] | |
| 492 | − | name = "windows_aarch64_msvc" | |
| 493 | − | version = "0.48.5" | |
| 939 | + | name = "unicode-width" | |
| 940 | + | version = "0.2.2" | |
| 494 | 941 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 495 | − | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" | |
| 942 | + | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" | |
| 496 | 943 | ||
| 497 | 944 | [[package]] | |
| 498 | − | name = "windows_i686_gnu" | |
| 499 | − | version = "0.48.5" | |
| 945 | + | name = "wasi" | |
| 946 | + | version = "0.11.1+wasi-snapshot-preview1" | |
| 500 | 947 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 501 | − | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" | |
| 948 | + | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" | |
| 502 | 949 | ||
| 503 | 950 | [[package]] | |
| 504 | − | name = "windows_i686_msvc" | |
| 505 | − | version = "0.48.5" | |
| 951 | + | name = "winapi" | |
| 952 | + | version = "0.3.9" | |
| 506 | 953 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 507 | − | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" | |
| 954 | + | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | |
| 955 | + | dependencies = [ | |
| 956 | + | "winapi-i686-pc-windows-gnu", | |
| 957 | + | "winapi-x86_64-pc-windows-gnu", | |
| 958 | + | ] | |
| 508 | 959 | ||
| 509 | 960 | [[package]] | |
| 510 | − | name = "windows_x86_64_gnu" | |
| 511 | − | version = "0.48.5" | |
| 961 | + | name = "winapi-i686-pc-windows-gnu" | |
| 962 | + | version = "0.4.0" | |
| 512 | 963 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 513 | − | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" | |
| 964 | + | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | |
| 514 | 965 | ||
| 515 | 966 | [[package]] | |
| 516 | − | name = "windows_x86_64_gnullvm" | |
| 517 | − | version = "0.48.5" | |
| 967 | + | name = "winapi-x86_64-pc-windows-gnu" | |
| 968 | + | version = "0.4.0" | |
| 518 | 969 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 519 | − | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" | |
| 970 | + | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | |
| 520 | 971 | ||
| 521 | 972 | [[package]] | |
| 522 | − | name = "windows_x86_64_msvc" | |
| 523 | − | version = "0.48.5" | |
| 973 | + | name = "windows-link" | |
| 974 | + | version = "0.2.1" | |
| 524 | 975 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 525 | − | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" | |
| 976 | + | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 526 | 977 | ||
| 527 | 978 | [[package]] | |
| 528 | − | name = "zerocopy" | |
| 529 | − | version = "0.7.32" | |
| 979 | + | name = "windows-sys" | |
| 980 | + | version = "0.61.2" | |
| 530 | 981 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 531 | − | checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" | |
| 982 | + | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | |
| 532 | 983 | dependencies = [ | |
| 533 | − | "zerocopy-derive", | |
| 984 | + | "windows-link", | |
| 534 | 985 | ] | |
| 535 | 986 | ||
| 536 | 987 | [[package]] | |
| 537 | − | name = "zerocopy-derive" | |
| 538 | − | version = "0.7.32" | |
| 988 | + | name = "winnow" | |
| 989 | + | version = "1.0.4" | |
| 539 | 990 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 540 | − | checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" | |
| 541 | − | dependencies = [ | |
| 542 | − | "proc-macro2", | |
| 543 | − | "quote", | |
| 544 | − | "syn", | |
| 545 | − | ] | |
| 991 | + | checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" | |
| @@ -1,22 +1,28 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "rmptui" | |
| 3 | 3 | authors = ["krolyxon"] | |
| 4 | − | description = """ | |
| 5 | − | a fast and minimal tui mpd client | |
| 6 | − | """ | |
| 4 | + | description = "A fast and minimal terminal MPD client" | |
| 7 | 5 | readme = "README.md" | |
| 8 | 6 | version = "0.1.6" | |
| 9 | 7 | edition = "2021" | |
| 8 | + | license = "AGPL-3.0-only" | |
| 10 | 9 | repository = "https://github.com/krolyxon/rmptui" | |
| 11 | − | keywords = ["rmptui", "mpd", "music", "cli", "tui", "client"] | |
| 12 | − | ||
| 13 | − | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | |
| 10 | + | keywords = ["mpd", "music", "cli", "tui", "client"] | |
| 14 | 11 | ||
| 15 | 12 | [dependencies] | |
| 16 | − | mpd = "0.1.0" | |
| 17 | − | simple-dmenu = "0.1.0" | |
| 18 | − | ratatui = { version = "0.26.2", default-features = false, features = [ | |
| 19 | − | 'crossterm', | |
| 13 | + | ratatui = { version = "0.30.2", default-features = false, features = [ | |
| 14 | + | "crossterm_0_29", | |
| 20 | 15 | ] } | |
| 21 | − | crossterm = "0.27.0" | |
| 16 | + | crossterm = { version = "0.29.0", features = ["event-stream"] } | |
| 22 | 17 | rust-fuzzy-search = "0.1.1" | |
| 18 | + | futures-util = "0.3.34" | |
| 19 | + | serde = { version = "1.0.229", features = ["derive"] } | |
| 20 | + | 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 | + | ] } | |
| @@ -1,59 +1,231 @@ | |||
| 1 | − | ## rmptui - A MPD client in Rust | |
| 1 | + | # rmptui | |
| 2 | + | ||
| 2 | 3 |  | |
| 3 | 4 |  | |
| 4 | 5 | [](https://github.com/krolyxon/rmptui/releases) | |
| 5 | 6 | ||
| 6 | − | rmptui is a minimal tui mpd client made with rust. | |
| 7 | − | ||
| 8 | − | ## rmptui in action | |
| 9 | − |  | |
| 10 | − | ||
| 11 | − | ### Keys | |
| 12 | − | | Key | Action | | |
| 13 | − | | --- | --- | | |
| 14 | − | | `q`/`Ctr+C` | Quit | | |
| 15 | − | | `p` | Toggle pause | | |
| 16 | − | | `+`/`=` | Increase volume | | |
| 17 | − | | `-` | Decrease volume | | |
| 18 | − | | `m` | Toggle Mute | | |
| 19 | − | | `D` | Get dmenu prompt | | |
| 20 | − | | `j`/`Down` | Scroll down | | |
| 21 | − | | `k`/`Up` | Scroll up | | |
| 22 | − | | `J` | Swap highlighted song with next one | | |
| 23 | − | | `K` | Swap highlighted song with previous one | | |
| 24 | − | | `l`/`Right` | Add song to playlist or go inside the directory | | |
| 25 | − | | `h`/`Left` | Go back to previous directory | | |
| 26 | − | | `Tab` | Cycle through tabs | | |
| 27 | − | | `1` | Go to queue | | |
| 28 | − | | `2` | Go to directory browser | | |
| 29 | − | | `3` | Go to playlists view | | |
| 30 | − | | `Enter`/`l`/`Right` | Add song/playlist to current playlist | | |
| 31 | − | | `a` | Append the song to current playing queue | | |
| 32 | − | | `Space` | Delete the highlighted song from queue | | |
| 33 | − | | `f` | Go forwards | | |
| 34 | − | | `b` | Go backwards | | |
| 35 | − | | `>` | Play next song from queue | | |
| 36 | − | | `<` | Play previous song from queue | | |
| 37 | − | | `U` | Update the MPD database | | |
| 38 | − | | `r` | Toggle repeat | | |
| 39 | − | | `z` | Toggle random | | |
| 40 | − | | `/` | Search | | |
| 41 | − | | `R` | Rename Playlist | | |
| 42 | − | | `g` | Go to top of list | | |
| 43 | − | | `G` | Go to bottom of list | | |
| 44 | − | ||
| 45 | − | ### Prerequisites | |
| 46 | − | - [MPD](https://wiki.archlinux.org/title/Music_Player_Daemon) installed and configured. | |
| 47 | − | - [dmenu](https://tools.suckless.org/dmenu/) (optional) | |
| 48 | − | ||
| 49 | − | ### TODO | |
| 50 | − | - [x] fix performance issues | |
| 51 | − | - [x] improvements on queue control | |
| 52 | − | - [x] add to playlists | |
| 53 | − | - [x] search for songs | |
| 54 | − | - [x] Human readable time format | |
| 55 | − | - [x] metadata based tree view | |
| 56 | − | - [x] view playlist | |
| 57 | − | - [x] change playlist name | |
| 58 | − | - [x] add to new playlist | |
| 59 | − | - [ ] add lyrics fetcher | |
| 7 | + | **A fast, keyboard-driven MPD client for your terminal.** | |
| 8 | + | ||
| 9 | + | rmptui is a lightweight music player interface for [Music Player Daemon | |
| 10 | + | (MPD)](https://www.musicpd.org/). Browse your library, manage the play queue, | |
| 11 | + | work with playlists, and control playback without leaving the terminal. | |
| 12 | + | ||
| 13 | + |  | |
| 14 | + | ||
| 15 | + | ## Highlights | |
| 16 | + | ||
| 17 | + | - **Three focused views:** browse your music library, control the current queue, | |
| 18 | + | and inspect saved playlists. | |
| 19 | + | - **Metadata at a glance:** see artist, track, title, album, and duration in | |
| 20 | + | the browser and queue. | |
| 21 | + | - **Fuzzy, repeatable search:** search the current view with `/`; in the song | |
| 22 | + | browser, keep the query and cycle matching folders and songs with `n` and | |
| 23 | + | `N`. | |
| 24 | + | - **Playlist tools:** load playlists, add selected tracks or folders to a | |
| 25 | + | playlist, create playlists, and rename them. | |
| 26 | + | - **Queue control:** play, remove, reorder, and append tracks. | |
| 27 | + | - **Responsive MPD updates:** the client uses MPD's asynchronous `idle` | |
| 28 | + | notifications instead of regularly polling the server. | |
| 29 | + | - **Direct MPD connection:** speaks MPD's standard text protocol over | |
| 30 | + | asynchronous TCP; no `mpd` crate or WebSocket proxy is required. | |
| 31 | + | ||
| 32 | + | ## Requirements | |
| 33 | + | ||
| 34 | + | - A running MPD server with its music library configured. | |
| 35 | + | - Rust and Cargo to build from source. | |
| 36 | + | ||
| 37 | + | ## Install | |
| 38 | + | ||
| 39 | + | From a local checkout, run the installer to build the release executable and | |
| 40 | + | copy it to `/usr/local/bin/rmptui`: | |
| 41 | + | ||
| 42 | + | ```sh | |
| 43 | + | ./install.sh | |
| 44 | + | ``` | |
| 45 | + | ||
| 46 | + | The script uses `sudo` for the system-directory operations when needed. It | |
| 47 | + | requires Rust and Cargo to build from source. To use a different binary | |
| 48 | + | directory or uninstall rmptui: | |
| 49 | + | ||
| 50 | + | ```sh | |
| 51 | + | ./install.sh --bin-dir "$HOME/.local/bin" | |
| 52 | + | ./install.sh --uninstall | |
| 53 | + | ``` | |
| 54 | + | ||
| 55 | + | Uninstall from a custom directory with | |
| 56 | + | `./install.sh --uninstall --bin-dir "$HOME/.local/bin"`. Run | |
| 57 | + | `./install.sh --help` for usage. | |
| 58 | + | ||
| 59 | + | ## Build from source | |
| 60 | + | ||
| 61 | + | ```sh | |
| 62 | + | git clone https://github.com/krolyxon/rmptui.git | |
| 63 | + | cd rmptui | |
| 64 | + | cargo run --release | |
| 65 | + | ``` | |
| 66 | + | ||
| 67 | + | To build and run without installing, use `cargo run --release`. | |
| 68 | + | ||
| 69 | + | On first launch, rmptui creates a default configuration file at | |
| 70 | + | `$CONFIG_HOME/rmptui/config.toml`. If `CONFIG_HOME` is not set, it uses | |
| 71 | + | `$XDG_CONFIG_HOME/rmptui/config.toml`, then `~/.config/rmptui/config.toml`. | |
| 72 | + | Edit the file and restart rmptui to apply changes: | |
| 73 | + | ||
| 74 | + | ```toml | |
| 75 | + | [mpd] | |
| 76 | + | host = "127.0.0.1" | |
| 77 | + | port = 6600 | |
| 78 | + | password = "" # Set this only if your MPD server requires a password. | |
| 79 | + | ||
| 80 | + | [playback] | |
| 81 | + | seek_seconds = 2 | |
| 82 | + | volume_step = 2 | |
| 83 | + | ||
| 84 | + | [keybindings] | |
| 85 | + | quit = ["q", "Ctrl+c"] | |
| 86 | + | clear_queue = ["c", "C"] | |
| 87 | + | toggle_pause = ["p"] | |
| 88 | + | pause = ["s"] | |
| 89 | + | toggle_repeat = ["r"] | |
| 90 | + | toggle_random = ["z"] | |
| 91 | + | open_playlist_menu = ["a"] | |
| 92 | + | seek_forward = ["f"] | |
| 93 | + | seek_backward = ["b"] | |
| 94 | + | cycle_tabs = ["Tab"] | |
| 95 | + | select_queue = ["1"] | |
| 96 | + | select_browser = ["2"] | |
| 97 | + | select_playlists = ["3"] | |
| 98 | + | next_track = [">"] | |
| 99 | + | previous_track = ["<"] | |
| 100 | + | volume_up = ["=", "+"] | |
| 101 | + | volume_down = ["-"] | |
| 102 | + | toggle_mute = ["m"] | |
| 103 | + | update_database = ["U"] | |
| 104 | + | search = ["/"] | |
| 105 | + | toggle_selected_item = ["Space"] | |
| 106 | + | search_next = ["n"] | |
| 107 | + | search_previous = ["N"] | |
| 108 | + | move_down = ["j", "Down"] | |
| 109 | + | move_up = ["k", "Up"] | |
| 110 | + | activate = ["Enter", "l", "Right"] | |
| 111 | + | go_back = ["h", "Left"] | |
| 112 | + | go_to_top = ["g"] | |
| 113 | + | go_to_bottom = ["G"] | |
| 114 | + | queue_delete = ["d"] | |
| 115 | + | queue_move_down = ["J"] | |
| 116 | + | queue_move_up = ["K"] | |
| 117 | + | playlist_rename = ["R"] | |
| 118 | + | playlist_focus_contents = ["Right", "l"] | |
| 119 | + | playlist_focus_list = ["Left", "h"] | |
| 120 | + | playlist_delete_song = ["d"] | |
| 121 | + | playlist_move_song_down = ["J"] | |
| 122 | + | playlist_move_song_up = ["K"] | |
| 123 | + | playlist_delete = ["D"] | |
| 124 | + | playlist_load = ["Space", "Enter"] | |
| 125 | + | popup_close = ["q", "Esc"] | |
| 126 | + | popup_select = ["Enter"] | |
| 127 | + | input_cancel = ["Esc"] | |
| 128 | + | input_backspace = ["Backspace"] | |
| 129 | + | input_cursor_left = ["Left"] | |
| 130 | + | input_cursor_right = ["Right"] | |
| 131 | + | input_submit = ["Enter"] | |
| 132 | + | ``` | |
| 133 | + | ||
| 134 | + | Every listed action is configurable in `[keybindings]`; each value is an array | |
| 135 | + | so actions can have multiple aliases. Remove an alias or set an action to `[]` | |
| 136 | + | to disable it. A key can be a single character or a named key such as `Enter`, | |
| 137 | + | `Esc`, `Tab`, `Space`, `Up`, `Down`, `Left`, `Right`, or `Backspace`. Add | |
| 138 | + | modifiers with `Ctrl+`, `Alt+`, or `Shift+`, for example `Ctrl+x` or | |
| 139 | + | `Alt+Enter`. Bindings are matched in the current UI context, so avoid assigning | |
| 140 | + | the same key to different actions that can be active in the same context. | |
| 141 | + | Characters typed into search and playlist-name fields remain text unless | |
| 142 | + | configured as an input action. | |
| 143 | + | ||
| 144 | + | `playback.seek_seconds` sets the `seek_forward`/`seek_backward` distance, and | |
| 145 | + | `playback.volume_step` sets the `volume_up`/`volume_down` amount. Supported | |
| 146 | + | audio formats remain built in. The config stores an MPD password as plain text; | |
| 147 | + | newly created config files are restricted to the current user. | |
| 148 | + | ||
| 149 | + | The MPD connection settings can also be overridden with environment variables: | |
| 150 | + | ||
| 151 | + | | Variable | Default | Description | | |
| 152 | + | | --- | --- | --- | | |
| 153 | + | | `MPD_HOST` | Config file | MPD hostname or address. Use `password@host` to set both host and password. | | |
| 154 | + | | `MPD_PORT` | Config file | MPD port; an explicit port in `MPD_HOST` takes precedence. | | |
| 155 | + | ||
| 156 | + | For example: | |
| 157 | + | ||
| 158 | + | ```sh | |
| 159 | + | MPD_HOST=192.168.1.20 MPD_PORT=6600 ./target/release/rmptui | |
| 160 | + | ``` | |
| 161 | + | ||
| 162 | + | Or, for a password-protected local server: | |
| 163 | + | ||
| 164 | + | ```sh | |
| 165 | + | MPD_HOST='secret@localhost' ./target/release/rmptui | |
| 166 | + | ``` | |
| 167 | + | ||
| 168 | + | ## Controls | |
| 169 | + | ||
| 170 | + | The keys below are the defaults; edit the `[keybindings]` table above to change | |
| 171 | + | them. | |
| 172 | + | ||
| 173 | + | ### Navigation | |
| 174 | + | ||
| 175 | + | | Key | Action | | |
| 176 | + | | --- | --- | | |
| 177 | + | | `Tab` | Cycle between Queue, Browser, and Playlists | | |
| 178 | + | | `1` / `2` / `3` | Open Queue / Browser / Playlists | | |
| 179 | + | | `j` / `Down` | Move selection down | | |
| 180 | + | | `k` / `Up` | Move selection up | | |
| 181 | + | | `g` / `G` | Jump to the top / bottom of the current list | | |
| 182 | + | | `Enter` / `l` / `Right` | Activate the selected item | | |
| 183 | + | | `h` / `Left` | Go to the parent directory in the browser | | |
| 184 | + | | `q` / `Ctrl+C` | Quit | | |
| 185 | + | ||
| 186 | + | ### Search | |
| 187 | + | ||
| 188 | + | 1. Press `/` and type a query. | |
| 189 | + | 2. In the browser, press `Enter` to keep the query and return to the list. | |
| 190 | + | 3. Press `n` for the next fuzzy match or `N` for the previous match. Results | |
| 191 | + | wrap around when you reach either end. | |
| 192 | + | 4. Press `/` to start a new search, or `Esc` to cancel search input. | |
| 193 | + | ||
| 194 | + | Search matches the names shown in the current view. Browser search includes | |
| 195 | + | both folders and songs. | |
| 196 | + | ||
| 197 | + | ### Playback and queue | |
| 198 | + | ||
| 199 | + | | Key | Action | | |
| 200 | + | | --- | --- | | |
| 201 | + | | `p` | Toggle play/pause | | |
| 202 | + | | `s` | Pause | | |
| 203 | + | | `>` / `<` | Play next / previous track | | |
| 204 | + | | `f` / `b` | Seek forward / backward by two seconds | | |
| 205 | + | | `+` / `=` / `-` | Increase / decrease volume | | |
| 206 | + | | `m` | Toggle mute | | |
| 207 | + | | `r` / `z` | Toggle repeat / random | | |
| 208 | + | | `d` | Remove the selected queue item | | |
| 209 | + | | `Space` | Remove a queue item; toggle a browser song in the queue | | |
| 210 | + | | `J` / `K` | Move the selected queue item down / up | | |
| 211 | + | | `c` | Clear the queue | | |
| 212 | + | | `a` | Open the add-to-playlist menu | | |
| 213 | + | | `U` | Rescan the MPD music database | | |
| 214 | + | ||
| 215 | + | ### Playlists | |
| 216 | + | ||
| 217 | + | The Playlists view has a playlist pane and a contents pane. Select a playlist | |
| 218 | + | with `j`/`k` and press `Space` or `Enter` to load it into the queue and start | |
| 219 | + | playback. Press `Right` or `l` to focus its songs and `Left` or `h` to return | |
| 220 | + | to the playlist pane. In the contents pane, `j`/`k` selects a song, `Enter` | |
| 221 | + | plays it, `d` removes it from the saved playlist, and `J`/`K` moves it | |
| 222 | + | down/up. `D` deletes the selected saved playlist. `R` renames a playlist. | |
| 223 | + | ||
| 224 | + | Press `a` to open the add-to-playlist menu; choose **Current Playlist**, **New | |
| 225 | + | Playlist**, or a saved playlist. When creating a playlist, type its name and | |
| 226 | + | press `Enter` to save. Playlist contents update after each edit. | |
| 227 | + | ||
| 228 | + | ## License | |
| 229 | + | ||
| 230 | + | rmptui is distributed under the | |
| 231 | + | [GNU Affero General Public License v3.0](LICENSE). | |
| @@ -0,0 +1,86 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | ||
| 3 | + | set -eu | |
| 4 | + | ||
| 5 | + | BIN_DIR=/usr/local/bin | |
| 6 | + | ACTION=install | |
| 7 | + | ||
| 8 | + | usage() { | |
| 9 | + | cat <<'EOF' | |
| 10 | + | Build and install rmptui from this checkout, or uninstall it. | |
| 11 | + | ||
| 12 | + | Usage: | |
| 13 | + | ./install.sh [--bin-dir DIR] | |
| 14 | + | ./install.sh --uninstall [--bin-dir DIR] | |
| 15 | + | ||
| 16 | + | Options: | |
| 17 | + | --bin-dir DIR Install/remove rmptui in DIR (default: /usr/local/bin) | |
| 18 | + | --uninstall Remove the installed rmptui executable | |
| 19 | + | -h, --help Show this help | |
| 20 | + | EOF | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | fail() { | |
| 24 | + | printf 'install.sh: %s\n' "$1" >&2 | |
| 25 | + | exit 1 | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | while [ "$#" -gt 0 ]; do | |
| 29 | + | case "$1" in | |
| 30 | + | --bin-dir) | |
| 31 | + | [ "$#" -ge 2 ] || fail "--bin-dir requires a directory" | |
| 32 | + | [ -n "$2" ] || fail "--bin-dir cannot be empty" | |
| 33 | + | BIN_DIR=$2 | |
| 34 | + | shift 2 | |
| 35 | + | ;; | |
| 36 | + | --uninstall) | |
| 37 | + | [ "$ACTION" = install ] || fail "--uninstall cannot be specified more than once" | |
| 38 | + | ACTION=uninstall | |
| 39 | + | shift | |
| 40 | + | ;; | |
| 41 | + | -h|--help) | |
| 42 | + | usage | |
| 43 | + | exit 0 | |
| 44 | + | ;; | |
| 45 | + | *) | |
| 46 | + | fail "unknown option: $1 (use --help for usage)" | |
| 47 | + | ;; | |
| 48 | + | esac | |
| 49 | + | done | |
| 50 | + | ||
| 51 | + | run_privileged() { | |
| 52 | + | if [ "$(id -u)" -eq 0 ]; then | |
| 53 | + | "$@" | |
| 54 | + | elif command -v sudo >/dev/null 2>&1; then | |
| 55 | + | sudo "$@" | |
| 56 | + | else | |
| 57 | + | fail "sudo is required to write to $BIN_DIR; install sudo or run this script as root" | |
| 58 | + | fi | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | SCRIPT_DIR=$(CDPATH= cd "$(dirname "$0")" && pwd) | |
| 62 | + | DESTINATION=$BIN_DIR/rmptui | |
| 63 | + | ||
| 64 | + | if [ "$ACTION" = uninstall ]; then | |
| 65 | + | if [ ! -e "$DESTINATION" ]; then | |
| 66 | + | printf 'rmptui is not installed at %s\n' "$DESTINATION" | |
| 67 | + | exit 0 | |
| 68 | + | fi | |
| 69 | + | run_privileged rm -f "$DESTINATION" | |
| 70 | + | printf 'Removed %s\n' "$DESTINATION" | |
| 71 | + | exit 0 | |
| 72 | + | fi | |
| 73 | + | ||
| 74 | + | if ! command -v cargo >/dev/null 2>&1; then | |
| 75 | + | fail "Cargo was not found. Install Rust and Cargo from https://rustup.rs/ and try again." | |
| 76 | + | fi | |
| 77 | + | ||
| 78 | + | printf 'Building rmptui from %s\n' "$SCRIPT_DIR" | |
| 79 | + | cargo build --release --locked --manifest-path "$SCRIPT_DIR/Cargo.toml" | |
| 80 | + | ||
| 81 | + | EXECUTABLE=$SCRIPT_DIR/target/release/rmptui | |
| 82 | + | [ -x "$EXECUTABLE" ] || fail "build succeeded but executable was not found at $EXECUTABLE" | |
| 83 | + | ||
| 84 | + | run_privileged install -d "$BIN_DIR" | |
| 85 | + | run_privileged install -m 0755 "$EXECUTABLE" "$DESTINATION" | |
| 86 | + | printf 'Installed rmptui to %s\n' "$DESTINATION" | |
| @@ -1,121 +1,130 @@ | |||
| 1 | − | use std::{path::Path, time::Duration}; | |
| 2 | − | ||
| 3 | − | use crate::browser::FileBrowser; | |
| 4 | − | use crate::connection::Connection; | |
| 5 | − | use crate::list::ContentList; | |
| 6 | − | use crate::ui::InputMode; | |
| 7 | − | use crate::utils::FileExtension; | |
| 8 | − | use mpd::{Client, Song}; | |
| 1 | + | use std::time::Duration; | |
| 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, | |
| 11 | + | }; | |
| 9 | 12 | use ratatui::widgets::{ListState, TableState}; | |
| 10 | 13 | ||
| 11 | − | // Application result type | |
| 12 | − | pub type AppResult<T> = std::result::Result<T, Box<dyn std::error::Error>>; | |
| 13 | − | ||
| 14 | − | /// Application | |
| 15 | 14 | #[derive(Debug)] | |
| 16 | 15 | pub struct App { | |
| 17 | − | pub running: bool, // Check if app is running | |
| 18 | − | pub conn: Connection, // Connection | |
| 19 | − | pub browser: FileBrowser, // Directory browser | |
| 20 | − | pub queue_list: ContentList<Song>, // Stores the current playing queue | |
| 21 | − | pub pl_list: ContentList<String>, // Stores list of playlists | |
| 22 | − | pub selected_tab: SelectedTab, // Used to switch between tabs | |
| 23 | − | ||
| 24 | − | // Search | |
| 25 | − | pub inputmode: InputMode, // Defines input mode, Normal or Search | |
| 26 | − | pub search_input: String, // Stores the userinput to be searched | |
| 27 | − | pub search_cursor_pos: usize, // Stores the cursor position for searching | |
| 28 | − | ||
| 29 | − | pub pl_newname_input: String, // Stores the new name of the playlist | |
| 30 | − | pub pl_cursor_pos: usize, // Stores the cursor position for renaming playlist | |
| 31 | − | ||
| 32 | − | pub pl_new_pl_input: String, // Stores the name of new playlist to be created | |
| 33 | − | pub pl_new_pl_cursor_pos: usize, // Stores the cursor position of new playlist to be created | |
| 34 | − | pub pl_new_pl_songs_buffer: Vec<Song>, // Buffer for songs that need to be added to the newly created playlist | |
| 35 | − | ||
| 36 | − | // playlist variables | |
| 37 | − | // used to show playlist popup | |
| 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>, | |
| 38 | 35 | pub playlist_popup: bool, | |
| 39 | 36 | pub append_list: ContentList<String>, | |
| 40 | − | ||
| 41 | − | // Determines if the database should be updated or not | |
| 42 | 37 | pub should_update_song_list: bool, | |
| 43 | − | ||
| 44 | − | // States | |
| 45 | 38 | pub queue_state: TableState, | |
| 46 | 39 | pub browser_state: TableState, | |
| 47 | 40 | pub playlists_state: ListState, | |
| 41 | + | pub playlist_songs_state: TableState, | |
| 42 | + | pub playlist_contents: Vec<Song>, | |
| 48 | 43 | } | |
| 49 | 44 | ||
| 50 | − | #[derive(Debug, PartialEq, Clone)] | |
| 45 | + | #[derive(Debug, PartialEq, Clone, Copy)] | |
| 51 | 46 | pub enum SelectedTab { | |
| 52 | 47 | DirectoryBrowser, | |
| 53 | 48 | Queue, | |
| 54 | 49 | Playlists, | |
| 55 | 50 | } | |
| 56 | 51 | ||
| 52 | + | #[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] | |
| 53 | + | pub enum PlaylistFocus { | |
| 54 | + | #[default] | |
| 55 | + | List, | |
| 56 | + | Songs, | |
| 57 | + | } | |
| 58 | + | ||
| 57 | 59 | impl App { | |
| 58 | − | pub fn builder(addrs: &str) -> AppResult<Self> { | |
| 59 | − | let mut conn = Connection::builder(addrs)?; | |
| 60 | + | pub async fn new(address: &str, password: Option<&str>, config: Config) -> AppResult<Self> { | |
| 61 | + | let mut conn = Connection::connect(address, password).await?; | |
| 60 | 62 | let mut queue_list = ContentList::new(); | |
| 63 | + | queue_list.list = conn.client.queue().await?; | |
| 61 | 64 | let mut pl_list = ContentList::new(); | |
| 62 | − | ||
| 63 | − | pl_list.list = Self::get_playlist(&mut conn.conn)?; | |
| 65 | + | pl_list.list = conn.client.playlists().await?; | |
| 64 | 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?; | |
| 65 | 70 | ||
| 66 | − | let append_list = Self::get_append_list(&mut conn.conn)?; | |
| 67 | − | Self::get_queue(&mut conn, &mut queue_list.list); | |
| 68 | − | ||
| 69 | − | let browser = FileBrowser::new(); | |
| 70 | − | ||
| 71 | − | let queue_state = TableState::new(); | |
| 72 | − | let browser_state = TableState::new(); | |
| 73 | − | let playlists_state = ListState::default(); | |
| 71 | + | if let Some(position) = conn.current_song.place { | |
| 72 | + | queue_list.index = position.pos as usize; | |
| 73 | + | } | |
| 74 | 74 | ||
| 75 | − | Ok(Self { | |
| 75 | + | let mut app = Self { | |
| 76 | 76 | running: true, | |
| 77 | 77 | conn, | |
| 78 | + | config, | |
| 79 | + | browser, | |
| 78 | 80 | queue_list, | |
| 79 | 81 | pl_list, | |
| 82 | + | playlist_focus: PlaylistFocus::List, | |
| 83 | + | playlist_song_index: 0, | |
| 80 | 84 | selected_tab: SelectedTab::Queue, | |
| 81 | − | browser, | |
| 82 | 85 | inputmode: InputMode::Normal, | |
| 83 | 86 | search_input: String::new(), | |
| 84 | − | pl_newname_input: String::new(), | |
| 85 | 87 | search_cursor_pos: 0, | |
| 88 | + | browser_search_results: Vec::new(), | |
| 89 | + | browser_search_index: 0, | |
| 90 | + | pl_newname_input: String::new(), | |
| 86 | 91 | pl_cursor_pos: 0, | |
| 87 | − | playlist_popup: false, | |
| 88 | 92 | pl_new_pl_input: String::new(), | |
| 89 | 93 | pl_new_pl_cursor_pos: 0, | |
| 90 | 94 | pl_new_pl_songs_buffer: Vec::new(), | |
| 95 | + | playlist_popup: false, | |
| 91 | 96 | append_list, | |
| 92 | 97 | should_update_song_list: false, | |
| 93 | − | queue_state, | |
| 94 | − | browser_state, | |
| 95 | − | playlists_state, | |
| 96 | − | }) | |
| 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(), | |
| 103 | + | }; | |
| 104 | + | app.refresh_playlist_view().await?; | |
| 105 | + | Ok(app) | |
| 97 | 106 | } | |
| 98 | 107 | ||
| 99 | − | pub fn tick(&mut self) -> AppResult<()> { | |
| 100 | − | self.conn.update_status(); | |
| 101 | − | self.update_queue(); | |
| 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 | + | } | |
| 102 | 122 | ||
| 103 | − | // Deals with database update | |
| 104 | 123 | if self.should_update_song_list && self.conn.status.updating_db.is_none() { | |
| 105 | − | // Update the songs list | |
| 106 | − | self.conn.songs_filenames = self | |
| 107 | − | .conn | |
| 108 | − | .conn | |
| 109 | − | .listall()? | |
| 110 | − | .into_iter() | |
| 111 | − | .map(|x| x.file) | |
| 112 | − | .collect(); | |
| 113 | − | ||
| 114 | − | self.browser.update_directory(&mut self.conn)?; | |
| 115 | − | ||
| 116 | − | self.should_update_song_list = false; | |
| 124 | + | self.conn.refresh_song_list().await?; | |
| 125 | + | self.browser.update_directory(&mut self.conn).await?; | |
| 126 | + | self.should_update_song_list = false; | |
| 117 | 127 | } | |
| 118 | − | ||
| 119 | 128 | Ok(()) | |
| 120 | 129 | } | |
| 121 | 130 | ||
| @@ -123,340 +132,375 @@ impl App { | |||
| 123 | 132 | self.running = false; | |
| 124 | 133 | } | |
| 125 | 134 | ||
| 126 | − | pub fn get_queue(conn: &mut Connection, vec: &mut Vec<Song>) { | |
| 127 | − | conn.conn.queue().unwrap().into_iter().for_each(|x| { | |
| 128 | − | vec.push(x); | |
| 129 | − | }); | |
| 135 | + | pub async fn update_queue(&mut self) -> AppResult<()> { | |
| 136 | + | self.queue_list.list = self.conn.client.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); | |
| 141 | + | } | |
| 142 | + | Ok(()) | |
| 130 | 143 | } | |
| 131 | 144 | ||
| 132 | − | // Rescan the queue into queue_list | |
| 133 | − | pub fn update_queue(&mut self) { | |
| 134 | − | self.queue_list.list.clear(); | |
| 135 | − | Self::get_queue(&mut self.conn, &mut self.queue_list.list); | |
| 145 | + | pub async fn refresh_playlists(&mut self) -> AppResult<()> { | |
| 146 | + | self.pl_list.list = self.conn.client.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 | |
| 136 | 154 | } | |
| 137 | 155 | ||
| 138 | − | pub fn get_playlist(conn: &mut Client) -> AppResult<Vec<String>> { | |
| 139 | − | let list: Vec<String> = conn.playlists()?.iter().map(|p| p.clone().name).collect(); | |
| 140 | − | Ok(list) | |
| 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.client.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(()) | |
| 141 | 165 | } | |
| 142 | 166 | ||
| 143 | − | pub fn get_append_list(conn: &mut Client) -> AppResult<ContentList<String>> { | |
| 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(()); | |
| 173 | + | } | |
| 174 | + | self.conn | |
| 175 | + | .client | |
| 176 | + | .delete_playlist_song(playlist, self.playlist_song_index) | |
| 177 | + | .await?; | |
| 178 | + | self.refresh_playlist_view().await | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | pub async fn move_selected_playlist_song(&mut self, down: bool) -> AppResult<()> { | |
| 182 | + | let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else { | |
| 183 | + | return Ok(()); | |
| 184 | + | }; | |
| 185 | + | let target = if down { | |
| 186 | + | self.playlist_song_index.checked_add(1) | |
| 187 | + | } else { | |
| 188 | + | self.playlist_song_index.checked_sub(1) | |
| 189 | + | }; | |
| 190 | + | let Some(target) = target.filter(|target| *target < self.playlist_contents.len()) else { | |
| 191 | + | return Ok(()); | |
| 192 | + | }; | |
| 193 | + | self.conn | |
| 194 | + | .client | |
| 195 | + | .move_playlist_song(playlist, self.playlist_song_index, target) | |
| 196 | + | .await?; | |
| 197 | + | self.playlist_song_index = target; | |
| 198 | + | self.refresh_playlist_view().await | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | pub async fn delete_selected_playlist(&mut self) -> AppResult<()> { | |
| 202 | + | let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else { | |
| 203 | + | return Ok(()); | |
| 204 | + | }; | |
| 205 | + | self.conn.client.delete_playlist(playlist).await?; | |
| 206 | + | self.refresh_playlists().await?; | |
| 207 | + | Ok(()) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | pub async fn play_selected_playlist_song(&mut self) -> AppResult<()> { | |
| 211 | + | let Some(playlist) = self.pl_list.list.get(self.pl_list.index) else { | |
| 212 | + | return Ok(()); | |
| 213 | + | }; | |
| 214 | + | if self.playlist_song_index >= self.playlist_contents.len() { | |
| 215 | + | return Ok(()); | |
| 216 | + | } | |
| 217 | + | let queue_position = self.conn.client.queue().await?.len(); | |
| 218 | + | self.conn.client.append_playlist(playlist).await?; | |
| 219 | + | self.conn | |
| 220 | + | .client | |
| 221 | + | .play(Some((queue_position + self.playlist_song_index) as u32)) | |
| 222 | + | .await?; | |
| 223 | + | self.update_queue().await?; | |
| 224 | + | self.conn.update_status().await | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | pub async fn get_append_list(conn: &mut Connection) -> AppResult<ContentList<String>> { | |
| 144 | 228 | let mut list = ContentList::new(); | |
| 145 | 229 | list.list.push("Current Playlist".to_string()); | |
| 146 | 230 | list.list.push("New Playlist".to_string()); | |
| 147 | − | for item in Self::get_playlist(conn)? { | |
| 148 | − | list.list.push(item.to_string()); | |
| 149 | − | } | |
| 150 | − | ||
| 231 | + | list.list.extend(conn.client.playlists().await?); | |
| 151 | 232 | Ok(list) | |
| 152 | 233 | } | |
| 153 | 234 | ||
| 154 | − | /// Handles the <Space> event key | |
| 155 | − | pub fn handle_add_or_remove_from_current_playlist(&mut self) -> AppResult<()> { | |
| 235 | + | pub async fn toggle_selected_item(&mut self) -> AppResult<()> { | |
| 156 | 236 | match self.selected_tab { | |
| 157 | 237 | SelectedTab::DirectoryBrowser => { | |
| 158 | − | let (content_type, content) = | |
| 159 | − | self.browser.filetree.get(self.browser.selected).unwrap(); | |
| 160 | − | if content_type == "directory" { | |
| 161 | − | let file = format!("{}/{}", self.browser.path, content); | |
| 162 | − | let songs = self.conn.conn.listfiles(&file).unwrap_or_default(); | |
| 163 | − | for (t, f) in songs.iter() { | |
| 164 | − | if t == "file" | |
| 165 | − | && Path::new(&f).has_extension(&[ | |
| 166 | − | "mp3", "ogg", "flac", "m4a", "wav", "aac", "opus", "ape", "wma", | |
| 167 | − | "mpc", "aiff", "dff", "mp2", "mka", | |
| 168 | − | ]) | |
| 169 | − | { | |
| 170 | − | let path = file.clone() + "/" + f; | |
| 171 | − | let full_path = path.strip_prefix("./").unwrap_or(""); | |
| 172 | − | let song = self.conn.get_song_with_only_filename(full_path); | |
| 173 | − | self.conn.conn.push(&song)?; | |
| 238 | + | let Some(entry) = self.browser.entries.get(self.browser.selected).cloned() else { | |
| 239 | + | return Ok(()); | |
| 240 | + | }; | |
| 241 | + | match entry { | |
| 242 | + | BrowserEntry::Directory(name) => { | |
| 243 | + | let path = join_library_path(&self.browser.path, &name); | |
| 244 | + | for (entry_type, file) in self.conn.client.list_files(&path).await? { | |
| 245 | + | if entry_type == "file" && is_audio_file(&file) { | |
| 246 | + | self.conn.client.add(&file).await?; | |
| 247 | + | } | |
| 174 | 248 | } | |
| 175 | 249 | } | |
| 176 | − | } else if content_type == "file" { | |
| 177 | − | let mut status = false; | |
| 178 | − | for (i, song) in self.queue_list.list.clone().iter().enumerate() { | |
| 179 | − | let song_path = song.file.split('/').last().unwrap_or_default(); | |
| 180 | − | if song_path.eq(content) { | |
| 181 | − | self.conn.conn.delete(i as u32).unwrap(); | |
| 182 | − | status = true; | |
| 250 | + | BrowserEntry::Song(song) => { | |
| 251 | + | let positions: Vec<u32> = self | |
| 252 | + | .queue_list | |
| 253 | + | .list | |
| 254 | + | .iter() | |
| 255 | + | .enumerate() | |
| 256 | + | .filter_map(|(index, queued)| { | |
| 257 | + | (queued.file == song.file).then_some(index as u32) | |
| 258 | + | }) | |
| 259 | + | .collect(); | |
| 260 | + | if positions.is_empty() { | |
| 261 | + | self.conn.client.add(&song.file).await?; | |
| 262 | + | } else { | |
| 263 | + | for position in positions.into_iter().rev() { | |
| 264 | + | self.conn.client.delete(position).await?; | |
| 265 | + | } | |
| 183 | 266 | } | |
| 184 | 267 | } | |
| 185 | − | ||
| 186 | − | if !status { | |
| 187 | − | let mut filename = format!("{}/{}", self.browser.path, content); | |
| 188 | − | ||
| 189 | − | // Remove "./" from the beginning of filename | |
| 190 | − | filename.remove(0); | |
| 191 | − | filename.remove(0); | |
| 192 | − | ||
| 193 | − | let song = self.conn.get_song_with_only_filename(&filename); | |
| 194 | − | self.conn.conn.push(&song)?; | |
| 195 | − | ||
| 196 | − | // updating queue, to avoid multiple pushes of the same songs if we enter multiple times before the queue gets updated | |
| 197 | − | self.update_queue(); | |
| 198 | − | } | |
| 199 | 268 | } | |
| 200 | − | ||
| 201 | − | // Highlight next row if possible | |
| 202 | − | if self.browser.selected != self.browser.filetree.len() - 1 { | |
| 269 | + | if self.browser.selected < self.browser.entries.len().saturating_sub(1) { | |
| 203 | 270 | self.browser.selected += 1; | |
| 204 | 271 | } | |
| 205 | 272 | } | |
| 206 | − | ||
| 207 | 273 | SelectedTab::Queue => { | |
| 208 | − | if self.queue_list.list.is_empty() { | |
| 209 | − | return Ok(()); | |
| 210 | − | } | |
| 211 | − | let file = self | |
| 212 | − | .queue_list | |
| 213 | − | .list | |
| 214 | − | .get(self.queue_list.index) | |
| 215 | − | .unwrap() | |
| 216 | − | .file | |
| 217 | − | .to_string(); | |
| 218 | − | ||
| 219 | − | for (i, song) in self.queue_list.list.clone().iter().enumerate() { | |
| 220 | − | if song.file.eq(&file) { | |
| 221 | − | self.conn.conn.delete(i as u32).unwrap(); | |
| 222 | − | if self.queue_list.index == self.queue_list.list.len() - 1 | |
| 223 | − | && self.queue_list.index != 0 | |
| 224 | − | { | |
| 225 | − | self.queue_list.index -= 1; | |
| 226 | − | } | |
| 227 | − | } | |
| 274 | + | if !self.queue_list.list.is_empty() { | |
| 275 | + | self.conn | |
| 276 | + | .client | |
| 277 | + | .delete(self.queue_list.index as u32) | |
| 278 | + | .await?; | |
| 228 | 279 | } | |
| 229 | 280 | } | |
| 230 | − | ||
| 231 | − | _ => {} | |
| 281 | + | SelectedTab::Playlists => {} | |
| 232 | 282 | } | |
| 233 | − | ||
| 234 | − | self.update_queue(); | |
| 235 | − | self.conn.update_status(); | |
| 283 | + | self.update_queue().await?; | |
| 284 | + | self.conn.update_status().await?; | |
| 236 | 285 | Ok(()) | |
| 237 | 286 | } | |
| 238 | 287 | ||
| 239 | − | /// Cycle through tabs | |
| 240 | − | pub fn cycle_tabls(&mut self) { | |
| 288 | + | pub fn cycle_tabs(&mut self) { | |
| 241 | 289 | self.selected_tab = match self.selected_tab { | |
| 242 | 290 | SelectedTab::Queue => SelectedTab::DirectoryBrowser, | |
| 243 | 291 | SelectedTab::DirectoryBrowser => SelectedTab::Playlists, | |
| 244 | − | SelectedTab::Playlists => SelectedTab::DirectoryBrowser, | |
| 292 | + | SelectedTab::Playlists => SelectedTab::Queue, | |
| 245 | 293 | }; | |
| 246 | 294 | } | |
| 247 | 295 | ||
| 248 | − | /// handles the Enter event on the directory browser | |
| 249 | − | pub fn handle_enter(&mut self) -> AppResult<()> { | |
| 250 | − | let browser = &mut self.browser; | |
| 251 | − | let (t, path) = browser.filetree.get(browser.selected).unwrap(); | |
| 252 | − | if t == "directory" { | |
| 253 | − | if path != "." { | |
| 254 | − | browser.prev_path.clone_from(&browser.path); | |
| 255 | − | browser.path = browser.prev_path.clone() + "/" + path; | |
| 256 | − | browser.update_directory(&mut self.conn)?; | |
| 257 | − | browser.prev_selected = browser.selected; | |
| 258 | − | browser.selected = 0; | |
| 296 | + | pub async fn handle_enter(&mut self) -> AppResult<()> { | |
| 297 | + | let Some(entry) = self.browser.entries.get(self.browser.selected).cloned() else { | |
| 298 | + | return Ok(()); | |
| 299 | + | }; | |
| 300 | + | match entry { | |
| 301 | + | BrowserEntry::Directory(name) => { | |
| 302 | + | self.browser.prev_selected = self.browser.selected; | |
| 303 | + | self.browser.path = join_library_path(&self.browser.path, &name); | |
| 304 | + | self.browser.selected = 0; | |
| 305 | + | self.browser.update_directory(&mut self.conn).await?; | |
| 259 | 306 | } | |
| 260 | − | } else { | |
| 261 | − | let index = self.queue_list.list.iter().position(|x| { | |
| 262 | − | let file = x.file.split('/').last().unwrap_or_default(); | |
| 263 | − | file.eq(path) | |
| 264 | − | }); | |
| 265 | − | ||
| 266 | − | if index.is_some() { | |
| 267 | − | self.conn.conn.switch(index.unwrap() as u32)?; | |
| 268 | − | } else { | |
| 269 | − | let mut filename = format!("{}/{}", browser.path, path); | |
| 270 | − | ||
| 271 | − | // Remove "./" from the beginning of filename | |
| 272 | − | filename.remove(0); | |
| 273 | − | filename.remove(0); | |
| 274 | − | ||
| 275 | − | let song = self.conn.get_song_with_only_filename(&filename); | |
| 276 | − | self.conn.push(&song)?; | |
| 277 | − | ||
| 278 | − | // updating queue, to avoid multiple pushes of the same songs if we enter multiple times before the queue gets updated | |
| 279 | − | self.update_queue(); | |
| 307 | + | BrowserEntry::Song(song) => { | |
| 308 | + | if let Some(index) = self | |
| 309 | + | .queue_list | |
| 310 | + | .list | |
| 311 | + | .iter() | |
| 312 | + | .position(|queued| queued.file == song.file) | |
| 313 | + | { | |
| 314 | + | self.conn.client.play(Some(index as u32)).await?; | |
| 315 | + | } else { | |
| 316 | + | self.conn.push(&song).await?; | |
| 317 | + | self.update_queue().await?; | |
| 318 | + | } | |
| 319 | + | self.conn.update_status().await?; | |
| 280 | 320 | } | |
| 281 | 321 | } | |
| 282 | 322 | Ok(()) | |
| 283 | 323 | } | |
| 284 | 324 | ||
| 285 | − | // Cursor movements | |
| 286 | − | pub fn move_cursor_left(&mut self) { | |
| 287 | − | match self.inputmode { | |
| 288 | − | InputMode::PlaylistRename => { | |
| 289 | − | let cursor_moved_left = self.pl_cursor_pos.saturating_sub(1); | |
| 290 | − | self.pl_cursor_pos = self.clamp_cursor(cursor_moved_left); | |
| 325 | + | pub async fn append_selected_to(&mut self) -> AppResult<()> { | |
| 326 | + | let Some(playlist) = self.append_list.list.get(self.append_list.index).cloned() else { | |
| 327 | + | return Ok(()); | |
| 328 | + | }; | |
| 329 | + | let songs = match self.selected_tab { | |
| 330 | + | SelectedTab::Queue => self | |
| 331 | + | .queue_list | |
| 332 | + | .list | |
| 333 | + | .get(self.queue_list.index) | |
| 334 | + | .cloned() | |
| 335 | + | .into_iter() | |
| 336 | + | .collect(), | |
| 337 | + | SelectedTab::DirectoryBrowser => { | |
| 338 | + | let Some(entry) = self.browser.entries.get(self.browser.selected).cloned() else { | |
| 339 | + | return Ok(()); | |
| 340 | + | }; | |
| 341 | + | match entry { | |
| 342 | + | BrowserEntry::Song(song) => vec![song], | |
| 343 | + | BrowserEntry::Directory(name) => { | |
| 344 | + | let path = join_library_path(&self.browser.path, &name); | |
| 345 | + | self.conn | |
| 346 | + | .client | |
| 347 | + | .list_files(&path) | |
| 348 | + | .await? | |
| 349 | + | .into_iter() | |
| 350 | + | .filter_map(|(kind, file)| { | |
| 351 | + | (kind == "file" && is_audio_file(&file)) | |
| 352 | + | .then(|| Connection::song_from_file(&file)) | |
| 353 | + | }) | |
| 354 | + | .collect() | |
| 355 | + | } | |
| 356 | + | } | |
| 291 | 357 | } | |
| 292 | − | InputMode::Editing => { | |
| 293 | − | let cursor_moved_left = self.search_cursor_pos.saturating_sub(1); | |
| 294 | − | self.search_cursor_pos = self.clamp_cursor(cursor_moved_left); | |
| 358 | + | SelectedTab::Playlists => { | |
| 359 | + | let Some(name) = self.pl_list.list.get(self.pl_list.index) else { | |
| 360 | + | return Ok(()); | |
| 361 | + | }; | |
| 362 | + | self.conn.client.playlist(name).await? | |
| 295 | 363 | } | |
| 296 | − | InputMode::NewPlaylist => { | |
| 297 | − | let cursor_moved_left = self.pl_new_pl_cursor_pos.saturating_sub(1); | |
| 298 | − | self.pl_new_pl_cursor_pos = self.clamp_cursor(cursor_moved_left); | |
| 364 | + | }; | |
| 365 | + | ||
| 366 | + | if playlist == "Current Playlist" { | |
| 367 | + | if self.selected_tab == SelectedTab::Playlists { | |
| 368 | + | if let Some(name) = self.pl_list.list.get(self.pl_list.index) { | |
| 369 | + | self.conn.load_playlist(name).await?; | |
| 370 | + | self.update_queue().await?; | |
| 371 | + | } | |
| 372 | + | } else { | |
| 373 | + | for song in songs { | |
| 374 | + | self.conn.client.add(&song.file).await?; | |
| 375 | + | } | |
| 376 | + | self.update_queue().await?; | |
| 377 | + | } | |
| 378 | + | } else if playlist == "New Playlist" { | |
| 379 | + | self.pl_new_pl_songs_buffer = songs; | |
| 380 | + | self.inputmode = InputMode::NewPlaylist; | |
| 381 | + | } else { | |
| 382 | + | for song in songs { | |
| 383 | + | self.conn.add_to_playlist(&playlist, &song).await?; | |
| 299 | 384 | } | |
| 300 | − | _ => {} | |
| 385 | + | self.refresh_playlists().await?; | |
| 301 | 386 | } | |
| 387 | + | self.playlist_popup = false; | |
| 388 | + | self.append_list.index = 0; | |
| 389 | + | Ok(()) | |
| 302 | 390 | } | |
| 303 | 391 | ||
| 304 | − | pub fn move_cursor_right(&mut self) { | |
| 305 | − | match self.inputmode { | |
| 306 | − | InputMode::PlaylistRename => { | |
| 307 | − | let cursor_moved_right = self.pl_cursor_pos.saturating_add(1); | |
| 308 | − | self.pl_cursor_pos = self.clamp_cursor(cursor_moved_right); | |
| 309 | − | } | |
| 310 | − | InputMode::Editing => { | |
| 311 | − | let cursor_moved_right = self.search_cursor_pos.saturating_add(1); | |
| 312 | − | self.search_cursor_pos = self.clamp_cursor(cursor_moved_right); | |
| 313 | − | } | |
| 392 | + | pub fn move_cursor_left(&mut self) { | |
| 393 | + | let cursor = self.cursor_position().saturating_sub(1); | |
| 394 | + | self.set_cursor_position(cursor); | |
| 395 | + | } | |
| 314 | 396 | ||
| 315 | − | InputMode::NewPlaylist => { | |
| 316 | − | let cursor_moved_right = self.pl_new_pl_cursor_pos.saturating_add(1); | |
| 317 | − | self.pl_new_pl_cursor_pos = self.clamp_cursor(cursor_moved_right); | |
| 318 | − | } | |
| 397 | + | pub fn move_cursor_right(&mut self) { | |
| 398 | + | let cursor = self.cursor_position().saturating_add(1); | |
| 399 | + | self.set_cursor_position(cursor); | |
| 400 | + | } | |
| 319 | 401 | ||
| 320 | − | _ => {} | |
| 402 | + | fn cursor_position(&self) -> usize { | |
| 403 | + | match self.inputmode { | |
| 404 | + | InputMode::PlaylistRename => self.pl_cursor_pos, | |
| 405 | + | InputMode::Editing => self.search_cursor_pos, | |
| 406 | + | InputMode::NewPlaylist => self.pl_new_pl_cursor_pos, | |
| 407 | + | InputMode::Normal => 0, | |
| 321 | 408 | } | |
| 322 | 409 | } | |
| 323 | 410 | ||
| 324 | − | pub fn enter_char(&mut self, new_char: char) { | |
| 411 | + | fn set_cursor_position(&mut self, position: usize) { | |
| 412 | + | let length = match self.inputmode { | |
| 413 | + | InputMode::PlaylistRename => self.pl_newname_input.chars().count(), | |
| 414 | + | InputMode::Editing => self.search_input.chars().count(), | |
| 415 | + | InputMode::NewPlaylist => self.pl_new_pl_input.chars().count(), | |
| 416 | + | InputMode::Normal => 0, | |
| 417 | + | }; | |
| 418 | + | let position = position.min(length); | |
| 325 | 419 | match self.inputmode { | |
| 326 | − | InputMode::PlaylistRename => { | |
| 327 | − | self.pl_newname_input.insert(self.pl_cursor_pos, new_char); | |
| 328 | − | } | |
| 329 | − | InputMode::NewPlaylist => { | |
| 330 | − | self.pl_new_pl_input | |
| 331 | − | .insert(self.pl_new_pl_cursor_pos, new_char); | |
| 332 | − | } | |
| 333 | − | InputMode::Editing => { | |
| 334 | − | self.search_input.insert(self.search_cursor_pos, new_char); | |
| 335 | − | } | |
| 336 | − | _ => {} | |
| 420 | + | InputMode::PlaylistRename => self.pl_cursor_pos = position, | |
| 421 | + | InputMode::Editing => self.search_cursor_pos = position, | |
| 422 | + | InputMode::NewPlaylist => self.pl_new_pl_cursor_pos = position, | |
| 423 | + | InputMode::Normal => {} | |
| 337 | 424 | } | |
| 425 | + | } | |
| 338 | 426 | ||
| 427 | + | pub fn enter_char(&mut self, character: char) { | |
| 428 | + | let (text, cursor) = match self.inputmode { | |
| 429 | + | InputMode::PlaylistRename => (&mut self.pl_newname_input, self.pl_cursor_pos), | |
| 430 | + | InputMode::Editing => (&mut self.search_input, self.search_cursor_pos), | |
| 431 | + | InputMode::NewPlaylist => (&mut self.pl_new_pl_input, self.pl_new_pl_cursor_pos), | |
| 432 | + | InputMode::Normal => return, | |
| 433 | + | }; | |
| 434 | + | let byte_index = text | |
| 435 | + | .char_indices() | |
| 436 | + | .nth(cursor) | |
| 437 | + | .map(|(index, _)| index) | |
| 438 | + | .unwrap_or(text.len()); | |
| 439 | + | text.insert(byte_index, character); | |
| 339 | 440 | self.move_cursor_right(); | |
| 340 | 441 | } | |
| 341 | 442 | ||
| 342 | 443 | pub fn delete_char(&mut self) { | |
| 343 | − | let is_not_cursor_leftmost = match self.inputmode { | |
| 344 | − | InputMode::PlaylistRename => self.pl_cursor_pos != 0, | |
| 345 | − | InputMode::NewPlaylist => self.pl_new_pl_cursor_pos != 0, | |
| 346 | − | InputMode::Editing => self.search_cursor_pos != 0, | |
| 347 | − | _ => false, | |
| 348 | − | }; | |
| 349 | − | ||
| 350 | − | if is_not_cursor_leftmost { | |
| 351 | − | // Method "remove" is not used on the saved text for deleting the selected char. | |
| 352 | − | // Reason: Using remove on String works on bytes instead of the chars. | |
| 353 | − | // Using remove would require special care because of char boundaries. | |
| 354 | − | ||
| 355 | − | let current_index = match self.inputmode { | |
| 356 | − | InputMode::Editing => self.search_cursor_pos, | |
| 357 | − | InputMode::PlaylistRename => self.pl_cursor_pos, | |
| 358 | − | InputMode::NewPlaylist => self.pl_new_pl_cursor_pos, | |
| 359 | − | _ => 0, | |
| 360 | − | }; | |
| 361 | − | ||
| 362 | − | let from_left_to_current_index = current_index - 1; | |
| 363 | − | ||
| 364 | − | if self.inputmode == InputMode::PlaylistRename { | |
| 365 | − | let before_char_to_delete = self | |
| 366 | − | .pl_newname_input | |
| 367 | − | .chars() | |
| 368 | − | .take(from_left_to_current_index); | |
| 369 | − | let after_char_to_delete = self.pl_newname_input.chars().skip(current_index); | |
| 370 | − | ||
| 371 | − | self.pl_newname_input = before_char_to_delete.chain(after_char_to_delete).collect(); | |
| 372 | − | self.move_cursor_left(); | |
| 373 | − | } else if self.inputmode == InputMode::NewPlaylist { | |
| 374 | − | let before_char_to_delete = self | |
| 375 | − | .pl_new_pl_input | |
| 376 | − | .chars() | |
| 377 | − | .take(from_left_to_current_index); | |
| 378 | − | let after_char_to_delete = self.pl_new_pl_input.chars().skip(current_index); | |
| 379 | − | ||
| 380 | − | self.pl_new_pl_input = before_char_to_delete.chain(after_char_to_delete).collect(); | |
| 381 | − | self.move_cursor_left(); | |
| 382 | − | } else if self.inputmode == InputMode::Editing { | |
| 383 | − | let before_char_to_delete = | |
| 384 | − | self.search_input.chars().take(from_left_to_current_index); | |
| 385 | − | let after_char_to_delete = self.search_input.chars().skip(current_index); | |
| 386 | − | ||
| 387 | − | self.search_input = before_char_to_delete.chain(after_char_to_delete).collect(); | |
| 388 | − | self.move_cursor_left(); | |
| 389 | − | } | |
| 444 | + | let cursor = self.cursor_position(); | |
| 445 | + | if cursor == 0 { | |
| 446 | + | return; | |
| 390 | 447 | } | |
| 391 | − | } | |
| 392 | − | ||
| 393 | − | pub fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { | |
| 394 | − | match self.inputmode { | |
| 395 | − | InputMode::PlaylistRename => new_cursor_pos.clamp(0, self.pl_newname_input.len()), | |
| 396 | − | InputMode::NewPlaylist => new_cursor_pos.clamp(0, self.pl_new_pl_input.len()), | |
| 397 | − | InputMode::Editing => new_cursor_pos.clamp(0, self.search_input.len()), | |
| 398 | − | _ => 0, | |
| 448 | + | let text = match self.inputmode { | |
| 449 | + | InputMode::PlaylistRename => &mut self.pl_newname_input, | |
| 450 | + | InputMode::Editing => &mut self.search_input, | |
| 451 | + | InputMode::NewPlaylist => &mut self.pl_new_pl_input, | |
| 452 | + | InputMode::Normal => return, | |
| 453 | + | }; | |
| 454 | + | let start = text.char_indices().nth(cursor - 1).map(|(i, _)| i); | |
| 455 | + | let end = text | |
| 456 | + | .char_indices() | |
| 457 | + | .nth(cursor) | |
| 458 | + | .map(|(i, _)| i) | |
| 459 | + | .unwrap_or(text.len()); | |
| 460 | + | if let Some(start) = start { | |
| 461 | + | text.replace_range(start..end, ""); | |
| 462 | + | self.move_cursor_left(); | |
| 399 | 463 | } | |
| 400 | 464 | } | |
| 401 | 465 | ||
| 402 | 466 | pub fn reset_cursor(&mut self) { | |
| 403 | − | match self.inputmode { | |
| 404 | − | InputMode::Editing => { | |
| 405 | − | self.search_cursor_pos = 0; | |
| 406 | − | } | |
| 407 | − | InputMode::PlaylistRename => { | |
| 408 | − | self.pl_cursor_pos = 0; | |
| 409 | − | } | |
| 410 | − | InputMode::NewPlaylist => { | |
| 411 | − | self.pl_new_pl_cursor_pos = 0; | |
| 412 | − | } | |
| 413 | − | _ => {} | |
| 414 | − | } | |
| 467 | + | self.set_cursor_position(0); | |
| 415 | 468 | } | |
| 416 | 469 | ||
| 417 | − | /// Given time in seconds, convert it to hh:mm:ss | |
| 418 | 470 | pub fn format_time(time: Duration) -> String { | |
| 419 | − | let time = time.as_secs(); | |
| 420 | − | let h = time / 3600; | |
| 421 | − | let m = (time % 3600) / 60; | |
| 422 | − | let s = (time % 3600) % 60; | |
| 423 | − | if h == 0 { | |
| 424 | − | format!("{:02}:{:02}", m, s) | |
| 471 | + | let seconds = time.as_secs(); | |
| 472 | + | let hours = seconds / 3600; | |
| 473 | + | let minutes = (seconds % 3600) / 60; | |
| 474 | + | let seconds = seconds % 60; | |
| 475 | + | if hours == 0 { | |
| 476 | + | format!("{minutes:02}:{seconds:02}") | |
| 425 | 477 | } else { | |
| 426 | − | format!("{:02}:{:02}:{:02}", h, m, s) | |
| 478 | + | format!("{hours:02}:{minutes:02}:{seconds:02}") | |
| 427 | 479 | } | |
| 428 | 480 | } | |
| 429 | 481 | ||
| 430 | − | // Mouse event handlers | |
| 431 | 482 | pub fn handle_scroll_up(&mut self) { | |
| 432 | 483 | match self.selected_tab { | |
| 433 | − | SelectedTab::Queue => { | |
| 434 | − | self.queue_list.prev(); | |
| 435 | − | } | |
| 436 | − | SelectedTab::DirectoryBrowser => { | |
| 437 | − | self.browser.prev(); | |
| 438 | − | } | |
| 439 | − | SelectedTab::Playlists => { | |
| 440 | − | self.pl_list.prev(); | |
| 441 | − | } | |
| 484 | + | SelectedTab::Queue => self.queue_list.prev(), | |
| 485 | + | SelectedTab::DirectoryBrowser => self.browser.prev(), | |
| 486 | + | SelectedTab::Playlists => self.pl_list.prev(), | |
| 442 | 487 | } | |
| 443 | 488 | } | |
| 444 | 489 | ||
| 445 | 490 | pub fn handle_scroll_down(&mut self) { | |
| 446 | 491 | match self.selected_tab { | |
| 447 | − | SelectedTab::Queue => { | |
| 448 | − | self.queue_list.next(); | |
| 449 | − | } | |
| 450 | − | SelectedTab::DirectoryBrowser => { | |
| 451 | − | self.browser.next(); | |
| 452 | − | } | |
| 453 | − | SelectedTab::Playlists => { | |
| 454 | − | self.pl_list.next(); | |
| 455 | − | } | |
| 492 | + | SelectedTab::Queue => self.queue_list.next(), | |
| 493 | + | SelectedTab::DirectoryBrowser => self.browser.next(), | |
| 494 | + | SelectedTab::Playlists => self.pl_list.next(), | |
| 456 | 495 | } | |
| 457 | 496 | } | |
| 497 | + | } | |
| 458 | 498 | ||
| 459 | − | pub fn handle_mouse_left_click(&mut self, _x: u16, _y: u16) -> AppResult<()> { | |
| 460 | − | Ok(()) | |
| 499 | + | pub fn join_library_path(parent: &str, child: &str) -> String { | |
| 500 | + | let parent = parent.trim_matches('/').trim_start_matches("./"); | |
| 501 | + | if parent.is_empty() || parent == "." { | |
| 502 | + | child.trim_start_matches("./").to_string() | |
| 503 | + | } else { | |
| 504 | + | format!("{parent}/{}", child.trim_start_matches("./")) | |
| 461 | 505 | } | |
| 462 | 506 | } | |
| @@ -1,137 +1,114 @@ | |||
| 1 | 1 | use std::path::Path; | |
| 2 | 2 | ||
| 3 | − | use mpd::Song; | |
| 3 | + | use crate::{ | |
| 4 | + | connection::{Connection, Song}, | |
| 5 | + | utils::is_audio_file, | |
| 6 | + | AppResult, | |
| 7 | + | }; | |
| 4 | 8 | ||
| 5 | − | use crate::{app::AppResult, connection::Connection, utils::FileExtension}; | |
| 6 | − | ||
| 7 | − | #[derive(Debug)] | |
| 8 | − | /// struct for working with directory browser tab in rmptui | |
| 9 | + | #[derive(Debug, Default)] | |
| 9 | 10 | pub struct FileBrowser { | |
| 10 | − | pub filetree: Vec<(String, String)>, | |
| 11 | + | pub entries: Vec<BrowserEntry>, | |
| 11 | 12 | pub selected: usize, | |
| 12 | 13 | pub prev_selected: usize, | |
| 13 | 14 | pub path: String, | |
| 14 | − | pub prev_path: String, | |
| 15 | − | pub songs: Vec<Song>, | |
| 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(name) => name, | |
| 27 | + | Self::Song(song) => Path::new(&song.file) | |
| 28 | + | .file_name() | |
| 29 | + | .and_then(|name| name.to_str()) | |
| 30 | + | .unwrap_or(&song.file), | |
| 31 | + | } | |
| 32 | + | } | |
| 16 | 33 | } | |
| 17 | 34 | ||
| 18 | 35 | impl FileBrowser { | |
| 19 | − | pub fn new() -> FileBrowser { | |
| 20 | − | FileBrowser { | |
| 21 | − | filetree: Vec::new(), | |
| 22 | − | selected: 0, | |
| 23 | − | prev_selected: 0, | |
| 36 | + | pub fn new() -> Self { | |
| 37 | + | Self { | |
| 24 | 38 | path: ".".to_string(), | |
| 25 | − | prev_path: ".".to_string(), | |
| 26 | − | songs: vec![], | |
| 39 | + | ..Self::default() | |
| 27 | 40 | } | |
| 28 | 41 | } | |
| 29 | 42 | ||
| 30 | − | pub fn update_directory(&mut self, conn: &mut Connection) -> AppResult<()> { | |
| 31 | − | self.filetree.clear(); | |
| 32 | − | let mut file_vec: Vec<(String, String)> = vec![]; | |
| 33 | − | let mut dir_vec: Vec<(String, String)> = vec![]; | |
| 34 | − | for (t, f) in conn.conn.listfiles(self.path.as_str())?.into_iter() { | |
| 35 | − | if t == "directory" && !f.starts_with('.') { | |
| 36 | − | dir_vec.push((t, f)); | |
| 37 | − | } else if t == "file" | |
| 38 | − | && Path::new(&f).has_extension(&[ | |
| 39 | − | "mp3", "ogg", "flac", "m4a", "wav", "aac", "opus", "ape", "wma", "mpc", "aiff", | |
| 40 | − | "dff", "mp2", "mka", | |
| 41 | − | ]) | |
| 43 | + | pub async fn update_directory(&mut self, conn: &mut Connection) -> AppResult<()> { | |
| 44 | + | let query_path = self.path.trim_start_matches("./").trim_matches('/'); | |
| 45 | + | let (directories, songs) = conn.client.ls_info(query_path).await?; | |
| 46 | + | let mut entries = Vec::new(); | |
| 47 | + | for directory in directories { | |
| 48 | + | if let Some(name) = Path::new(&directory) | |
| 49 | + | .file_name() | |
| 50 | + | .and_then(|name| name.to_str()) | |
| 42 | 51 | { | |
| 43 | − | file_vec.push((t, f)); | |
| 52 | + | if !name.starts_with('.') { | |
| 53 | + | entries.push(BrowserEntry::Directory(name.to_string())); | |
| 54 | + | } | |
| 44 | 55 | } | |
| 45 | 56 | } | |
| 46 | 57 | ||
| 47 | − | // dir_vec.sort_by(|a, b| a.1.cmp(&b.1)); | |
| 48 | − | dir_vec.sort_by(|a, b| { | |
| 49 | − | let num_a = a.1.parse::<u32>().unwrap_or(u32::MAX); | |
| 50 | − | let num_b = b.1.parse::<u32>().unwrap_or(u32::MAX); | |
| 51 | − | num_a | |
| 52 | − | .cmp(&num_b) | |
| 53 | − | .then_with(|| a.1.to_lowercase().cmp(&b.1.to_lowercase())) | |
| 54 | − | }); | |
| 55 | − | ||
| 56 | − | file_vec.sort_by(|a, b| { | |
| 57 | − | let num_a = a.1.parse::<u32>().unwrap_or(u32::MAX); | |
| 58 | − | let num_b = b.1.parse::<u32>().unwrap_or(u32::MAX); | |
| 59 | − | num_a | |
| 60 | − | .cmp(&num_b) | |
| 61 | − | .then_with(|| a.1.to_lowercase().cmp(&b.1.to_lowercase())) | |
| 62 | − | }); | |
| 63 | − | ||
| 64 | − | dir_vec.extend(file_vec); | |
| 65 | − | self.filetree = dir_vec; | |
| 66 | − | ||
| 67 | − | // Add metadata | |
| 68 | − | self.songs.clear(); | |
| 69 | − | for (t, song) in self.filetree.iter() { | |
| 70 | − | if t == "file" { | |
| 71 | − | let v = conn | |
| 72 | − | .conn | |
| 73 | − | .lsinfo(Song { | |
| 74 | − | file: (self.path.clone() + "/" + song) | |
| 75 | − | .strip_prefix("./") | |
| 76 | − | .unwrap_or("") | |
| 77 | − | .to_string(), | |
| 78 | − | ..Default::default() | |
| 79 | − | }) | |
| 80 | − | .unwrap_or_else(|_| { | |
| 81 | − | vec![Song { | |
| 82 | − | file: "Not a song".to_string(), | |
| 83 | − | ..Default::default() | |
| 84 | − | }] | |
| 85 | − | }); | |
| 86 | − | ||
| 87 | − | self.songs.push(v.first().unwrap().clone()); | |
| 88 | − | } else { | |
| 89 | − | let v = Song { | |
| 90 | − | file: "".to_string(), | |
| 91 | − | ..Default::default() | |
| 92 | − | }; | |
| 93 | − | ||
| 94 | − | self.songs.push(v); | |
| 58 | + | for song in songs { | |
| 59 | + | let Some(name) = Path::new(&song.file) | |
| 60 | + | .file_name() | |
| 61 | + | .and_then(|name| name.to_str()) | |
| 62 | + | else { | |
| 63 | + | continue; | |
| 64 | + | }; | |
| 65 | + | if is_audio_file(name) { | |
| 66 | + | entries.push(BrowserEntry::Song(song)); | |
| 95 | 67 | } | |
| 96 | 68 | } | |
| 97 | 69 | ||
| 70 | + | entries.sort_by(|a, b| { | |
| 71 | + | let kind_order = |entry: &BrowserEntry| match entry { | |
| 72 | + | BrowserEntry::Directory(_) => 0, | |
| 73 | + | BrowserEntry::Song(_) => 1, | |
| 74 | + | }; | |
| 75 | + | kind_order(a).cmp(&kind_order(b)).then_with(|| { | |
| 76 | + | let name_a = a.name(); | |
| 77 | + | let name_b = b.name(); | |
| 78 | + | let number_a = name_a.parse::<u32>().unwrap_or(u32::MAX); | |
| 79 | + | let number_b = name_b.parse::<u32>().unwrap_or(u32::MAX); | |
| 80 | + | number_a | |
| 81 | + | .cmp(&number_b) | |
| 82 | + | .then_with(|| name_a.to_lowercase().cmp(&name_b.to_lowercase())) | |
| 83 | + | }) | |
| 84 | + | }); | |
| 85 | + | ||
| 86 | + | self.entries = entries; | |
| 87 | + | self.selected = self.selected.min(self.entries.len().saturating_sub(1)); | |
| 98 | 88 | Ok(()) | |
| 99 | 89 | } | |
| 100 | 90 | ||
| 101 | − | // Go to next item in filetree | |
| 102 | 91 | pub fn next(&mut self) { | |
| 103 | − | if self.selected < self.filetree.len() - 1 { | |
| 92 | + | if self.selected < self.entries.len().saturating_sub(1) { | |
| 104 | 93 | self.selected += 1; | |
| 105 | 94 | } | |
| 106 | 95 | } | |
| 107 | 96 | ||
| 108 | − | /// Go to previous item in filetree | |
| 109 | 97 | pub fn prev(&mut self) { | |
| 110 | − | if self.selected != 0 { | |
| 111 | − | self.selected -= 1; | |
| 112 | − | } | |
| 98 | + | self.selected = self.selected.saturating_sub(1); | |
| 113 | 99 | } | |
| 114 | 100 | ||
| 115 | − | /// handles going back event | |
| 116 | − | pub fn handle_go_back(&mut self, conn: &mut Connection) -> AppResult<()> { | |
| 117 | − | if self.prev_path != "." { | |
| 118 | − | let r = self.path.rfind('/'); | |
| 119 | − | if let Some(r) = r { | |
| 120 | − | self.path = self.path.as_str()[..r].to_string(); | |
| 121 | − | self.update_directory(conn)?; | |
| 122 | − | } | |
| 123 | − | } else { | |
| 124 | − | self.path.clone_from(&self.prev_path); | |
| 125 | − | self.update_directory(conn)?; | |
| 101 | + | pub async fn handle_go_back(&mut self, conn: &mut Connection) -> AppResult<()> { | |
| 102 | + | if self.path != "." { | |
| 103 | + | self.path = Path::new(&self.path) | |
| 104 | + | .parent() | |
| 105 | + | .and_then(Path::to_str) | |
| 106 | + | .filter(|path| !path.is_empty()) | |
| 107 | + | .unwrap_or(".") | |
| 108 | + | .to_string(); | |
| 109 | + | self.update_directory(conn).await?; | |
| 126 | 110 | } | |
| 127 | − | ||
| 128 | − | self.selected = self.prev_selected; | |
| 111 | + | self.selected = self.prev_selected.min(self.entries.len().saturating_sub(1)); | |
| 129 | 112 | Ok(()) | |
| 130 | 113 | } | |
| 131 | 114 | } | |
| 132 | − | ||
| 133 | − | impl Default for FileBrowser { | |
| 134 | − | fn default() -> Self { | |
| 135 | − | Self::new() | |
| 136 | − | } | |
| 137 | − | } | |
| @@ -0,0 +1,669 @@ | |||
| 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 | + | } | |
| @@ -1,235 +1,775 @@ | |||
| 1 | − | use crate::app::AppResult; | |
| 2 | − | use crate::utils::is_installed; | |
| 3 | − | use mpd::song::Song; | |
| 4 | − | use mpd::{Client, State}; | |
| 5 | − | use simple_dmenu::dmenu; | |
| 6 | − | use std::time::Duration; | |
| 7 | − | ||
| 8 | − | /// Defines the current status of volume (Muted or UnMuted) | |
| 9 | − | #[derive(Debug)] | |
| 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)] | |
| 10 | 56 | pub enum VolumeStatus { | |
| 11 | 57 | Muted(i8), | |
| 12 | 58 | Unmuted, | |
| 13 | 59 | } | |
| 14 | 60 | ||
| 15 | − | #[derive(Debug)] | |
| 16 | − | /// struct storing the mpd Client related stuff | |
| 17 | − | pub struct Connection { | |
| 18 | − | pub conn: Client, | |
| 19 | − | pub songs_filenames: Vec<String>, | |
| 20 | − | pub state: String, | |
| 21 | − | pub elapsed: Duration, | |
| 22 | − | pub total_duration: Duration, | |
| 61 | + | #[derive(Clone, Debug)] | |
| 62 | + | pub struct MpdSnapshot { | |
| 63 | + | pub status: Status, | |
| 64 | + | pub stats: Stats, | |
| 23 | 65 | pub current_song: Song, | |
| 24 | − | pub stats: mpd::Stats, | |
| 25 | − | pub status: mpd::Status, | |
| 26 | − | pub volume_status: VolumeStatus, | |
| 66 | + | pub queue: Vec<Song>, | |
| 27 | 67 | } | |
| 28 | 68 | ||
| 29 | − | impl Connection { | |
| 30 | − | /// Create a new connection | |
| 31 | − | pub fn builder(addrs: &str) -> AppResult<Self> { | |
| 32 | − | let mut conn = Client::connect(addrs).unwrap_or_else(|_| { | |
| 33 | − | eprintln!("Error connecting to mpd server, Make sure mpd is running"); | |
| 34 | − | std::process::exit(1); | |
| 35 | − | }); | |
| 69 | + | #[derive(Debug)] | |
| 70 | + | struct MpdError(String); | |
| 36 | 71 | ||
| 37 | − | let empty_song = Song { | |
| 38 | − | file: "No Song playing or in Queue".to_string(), | |
| 39 | − | ..Default::default() | |
| 40 | − | }; | |
| 72 | + | impl fmt::Display for MpdError { | |
| 73 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 74 | + | f.write_str(&self.0) | |
| 75 | + | } | |
| 76 | + | } | |
| 41 | 77 | ||
| 42 | − | let songs_filenames: Vec<String> = conn.listall()?.into_iter().map(|x| x.file).collect(); | |
| 78 | + | impl Error for MpdError {} | |
| 43 | 79 | ||
| 44 | − | let status = conn.status().unwrap(); | |
| 45 | − | let (elapsed, total) = status.time.unwrap_or_default(); | |
| 46 | − | let stats = conn.stats().unwrap_or_default(); | |
| 80 | + | #[derive(Debug)] | |
| 81 | + | pub struct MpdClient { | |
| 82 | + | stream: BufReader<TcpStream>, | |
| 83 | + | } | |
| 47 | 84 | ||
| 48 | − | let current_song = conn | |
| 49 | − | .currentsong() | |
| 50 | − | .unwrap_or_else(|_| Some(empty_song.clone())) | |
| 51 | − | .unwrap_or(empty_song); | |
| 85 | + | impl MpdClient { | |
| 86 | + | pub async fn connect(address: &str, password: Option<&str>) -> AppResult<Self> { | |
| 87 | + | let stream = TcpStream::connect(address).await?; | |
| 88 | + | let mut client = Self { | |
| 89 | + | stream: BufReader::new(stream), | |
| 90 | + | }; | |
| 91 | + | let mut greeting = String::new(); | |
| 92 | + | client.stream.read_line(&mut greeting).await?; | |
| 93 | + | if !greeting.starts_with("OK MPD ") { | |
| 94 | + | return Err(Box::new(MpdError(format!( | |
| 95 | + | "unexpected MPD greeting: {}", | |
| 96 | + | greeting.trim_end() | |
| 97 | + | )))); | |
| 98 | + | } | |
| 99 | + | if let Some(password) = password { | |
| 100 | + | client.command("password", &[password]).await?; | |
| 101 | + | } | |
| 102 | + | Ok(client) | |
| 103 | + | } | |
| 52 | 104 | ||
| 53 | − | let volume_status = VolumeStatus::Unmuted; | |
| 105 | + | async fn command(&mut self, name: &str, args: &[&str]) -> AppResult<Vec<String>> { | |
| 106 | + | let mut request = name.to_string(); | |
| 107 | + | for arg in args { | |
| 108 | + | request.push(' '); | |
| 109 | + | request.push_str("e(arg)); | |
| 110 | + | } | |
| 111 | + | request.push('\n'); | |
| 112 | + | self.stream.get_mut().write_all(request.as_bytes()).await?; | |
| 113 | + | self.stream.get_mut().flush().await?; | |
| 114 | + | ||
| 115 | + | let mut lines = Vec::new(); | |
| 116 | + | loop { | |
| 117 | + | let mut line = String::new(); | |
| 118 | + | let read = self.stream.read_line(&mut line).await?; | |
| 119 | + | if read == 0 { | |
| 120 | + | return Err(Box::new(MpdError( | |
| 121 | + | "MPD closed the connection while replying to a command".to_string(), | |
| 122 | + | ))); | |
| 123 | + | } | |
| 124 | + | let line = line.trim_end_matches(['\r', '\n']); | |
| 125 | + | if line == "OK" { | |
| 126 | + | return Ok(lines); | |
| 127 | + | } | |
| 128 | + | if line.starts_with("ACK ") { | |
| 129 | + | return Err(Box::new(MpdError(format!("MPD rejected `{name}`: {line}")))); | |
| 130 | + | } | |
| 131 | + | lines.push(line.to_string()); | |
| 132 | + | } | |
| 133 | + | } | |
| 54 | 134 | ||
| 55 | − | Ok(Self { | |
| 56 | − | conn, | |
| 57 | − | songs_filenames, | |
| 58 | − | state: "Stopped".to_string(), | |
| 135 | + | pub async fn status(&mut self) -> AppResult<Status> { | |
| 136 | + | let fields = fields(self.command("status", &[]).await?); | |
| 137 | + | let time = fields.get("time").and_then(|s| { | |
| 138 | + | let (elapsed, total) = s.split_once(':')?; | |
| 139 | + | Some((parse_duration(elapsed), parse_duration(total))) | |
| 140 | + | }); | |
| 141 | + | let elapsed = fields | |
| 142 | + | .get("elapsed") | |
| 143 | + | .map(|s| parse_duration(s)) | |
| 144 | + | .or_else(|| time.map(|(elapsed, _)| elapsed)) | |
| 145 | + | .unwrap_or_default(); | |
| 146 | + | let total = fields | |
| 147 | + | .get("duration") | |
| 148 | + | .map(|s| parse_duration(s)) | |
| 149 | + | .or_else(|| time.map(|(_, total)| total)) | |
| 150 | + | .unwrap_or_default(); | |
| 151 | + | ||
| 152 | + | Ok(Status { | |
| 153 | + | state: match fields.get("state").map(String::as_str) { | |
| 154 | + | Some("play") => PlaybackState::Playing, | |
| 155 | + | Some("pause") => PlaybackState::Paused, | |
| 156 | + | _ => PlaybackState::Stopped, | |
| 157 | + | }, | |
| 158 | + | volume: fields | |
| 159 | + | .get("volume") | |
| 160 | + | .and_then(|s| s.parse().ok()) | |
| 161 | + | .unwrap_or(-1), | |
| 162 | + | repeat: bool_field(&fields, "repeat"), | |
| 163 | + | random: bool_field(&fields, "random"), | |
| 164 | + | song: fields | |
| 165 | + | .get("song") | |
| 166 | + | .and_then(|s| s.parse().ok()) | |
| 167 | + | .map(|pos| Place { pos }), | |
| 59 | 168 | elapsed, | |
| 60 | − | total_duration: total, | |
| 61 | − | current_song, | |
| 62 | − | stats, | |
| 169 | + | total, | |
| 170 | + | updating_db: fields.get("updating_db").and_then(|s| s.parse().ok()), | |
| 171 | + | }) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | pub async fn stats(&mut self) -> AppResult<Stats> { | |
| 175 | + | let fields = fields(self.command("stats", &[]).await?); | |
| 176 | + | Ok(Stats { | |
| 177 | + | songs: fields | |
| 178 | + | .get("songs") | |
| 179 | + | .and_then(|s| s.parse().ok()) | |
| 180 | + | .unwrap_or_default(), | |
| 181 | + | }) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | pub async fn current_song(&mut self) -> AppResult<Song> { | |
| 185 | + | Ok(parse_songs(self.command("currentsong", &[]).await?) | |
| 186 | + | .into_iter() | |
| 187 | + | .next() | |
| 188 | + | .unwrap_or_default()) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | pub async fn queue(&mut self) -> AppResult<Vec<Song>> { | |
| 192 | + | Ok(parse_songs(self.command("playlistinfo", &[]).await?)) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | pub async fn snapshot(&mut self) -> AppResult<MpdSnapshot> { | |
| 196 | + | let status = self.status().await?; | |
| 197 | + | let stats = self.stats().await?; | |
| 198 | + | let current_song = self.current_song().await?; | |
| 199 | + | let queue = self.queue().await?; | |
| 200 | + | Ok(MpdSnapshot { | |
| 63 | 201 | status, | |
| 64 | − | volume_status, | |
| 202 | + | stats, | |
| 203 | + | current_song, | |
| 204 | + | queue, | |
| 65 | 205 | }) | |
| 66 | 206 | } | |
| 67 | 207 | ||
| 68 | − | /// Dmenu prompt for selecting songs | |
| 69 | − | pub fn play_dmenu(&mut self) -> AppResult<()> { | |
| 70 | − | if is_installed("dmenu") { | |
| 71 | − | let ss: Vec<&str> = self.songs_filenames.iter().map(|x| x.as_str()).collect(); | |
| 72 | − | let op = dmenu!(iter &ss; args "-p", "Choose a song: ", "-l", "30"); | |
| 73 | − | let index = ss.iter().position(|s| s == &op); | |
| 74 | − | if let Some(i) = index { | |
| 75 | − | let song = self.get_song_with_only_filename(ss.get(i).unwrap()); | |
| 76 | − | self.push(&song)?; | |
| 208 | + | pub async fn list_files(&mut self, path: &str) -> AppResult<Vec<(String, String)>> { | |
| 209 | + | let lines = if path.is_empty() || path == "." { | |
| 210 | + | self.command("listfiles", &[]).await? | |
| 211 | + | } else { | |
| 212 | + | self.command("listfiles", &[path]).await? | |
| 213 | + | }; | |
| 214 | + | Ok(lines | |
| 215 | + | .into_iter() | |
| 216 | + | .filter_map(|line| { | |
| 217 | + | let (kind, value) = parse_field(&line)?; | |
| 218 | + | matches!(kind.as_str(), "file" | "directory").then_some((kind, value)) | |
| 219 | + | }) | |
| 220 | + | .collect()) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | pub async fn ls_info(&mut self, path: &str) -> AppResult<(Vec<String>, Vec<Song>)> { | |
| 224 | + | let lines = if path.is_empty() || path == "." { | |
| 225 | + | self.command("lsinfo", &[]).await? | |
| 226 | + | } else { | |
| 227 | + | self.command("lsinfo", &[path]).await? | |
| 228 | + | }; | |
| 229 | + | let mut directories = Vec::new(); | |
| 230 | + | let mut songs = Vec::new(); | |
| 231 | + | let mut current: Option<Song> = None; | |
| 232 | + | for line in lines { | |
| 233 | + | let Some((key, value)) = parse_field(&line) else { | |
| 234 | + | continue; | |
| 235 | + | }; | |
| 236 | + | match key.as_str() { | |
| 237 | + | "directory" => directories.push(value), | |
| 238 | + | "file" => { | |
| 239 | + | if let Some(song) = current.take() { | |
| 240 | + | songs.push(song); | |
| 241 | + | } | |
| 242 | + | current = Some(Song { | |
| 243 | + | file: value, | |
| 244 | + | ..Song::default() | |
| 245 | + | }); | |
| 246 | + | } | |
| 247 | + | "playlist" => {} | |
| 248 | + | _ => { | |
| 249 | + | if let Some(song) = current.as_mut() { | |
| 250 | + | add_metadata(song, key, value); | |
| 251 | + | } | |
| 252 | + | } | |
| 77 | 253 | } | |
| 78 | 254 | } | |
| 255 | + | if let Some(song) = current { | |
| 256 | + | songs.push(song); | |
| 257 | + | } | |
| 258 | + | Ok((directories, songs)) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | pub async fn playlists(&mut self) -> AppResult<Vec<String>> { | |
| 262 | + | Ok(self | |
| 263 | + | .command("listplaylists", &[]) | |
| 264 | + | .await? | |
| 265 | + | .into_iter() | |
| 266 | + | .filter_map(|line| parse_field(&line)) | |
| 267 | + | .filter_map(|(key, value)| (key == "playlist").then_some(value)) | |
| 268 | + | .collect()) | |
| 269 | + | } | |
| 79 | 270 | ||
| 271 | + | pub async fn playlist(&mut self, name: &str) -> AppResult<Vec<Song>> { | |
| 272 | + | Ok(parse_songs( | |
| 273 | + | self.command("listplaylistinfo", &[name]).await?, | |
| 274 | + | )) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | pub async fn add(&mut self, file: &str) -> AppResult<()> { | |
| 278 | + | self.command("add", &[file]).await?; | |
| 80 | 279 | Ok(()) | |
| 81 | 280 | } | |
| 82 | 281 | ||
| 83 | − | /// Update status | |
| 84 | − | pub fn update_status(&mut self) { | |
| 85 | − | let status = self.conn.status().unwrap(); | |
| 86 | − | let stats = self.conn.stats().unwrap_or_default(); | |
| 282 | + | pub async fn play(&mut self, position: Option<u32>) -> AppResult<()> { | |
| 283 | + | match position { | |
| 284 | + | Some(position) => self.command("play", &[&position.to_string()]).await?, | |
| 285 | + | None => self.command("play", &[]).await?, | |
| 286 | + | }; | |
| 287 | + | Ok(()) | |
| 288 | + | } | |
| 87 | 289 | ||
| 88 | − | let empty_song = self.get_song_with_only_filename("No Song playing or in Queue"); | |
| 290 | + | pub async fn pause(&mut self, paused: bool) -> AppResult<()> { | |
| 291 | + | self.command("pause", &[if paused { "1" } else { "0" }]) | |
| 292 | + | .await?; | |
| 293 | + | Ok(()) | |
| 294 | + | } | |
| 89 | 295 | ||
| 90 | − | let current_song = self | |
| 91 | − | .conn | |
| 92 | − | .currentsong() | |
| 93 | − | .unwrap_or_else(|_| Some(empty_song.clone())) | |
| 94 | − | .unwrap_or(empty_song); | |
| 296 | + | pub async fn toggle_pause(&mut self) -> AppResult<()> { | |
| 297 | + | self.command("pause", &[]).await?; | |
| 298 | + | Ok(()) | |
| 299 | + | } | |
| 95 | 300 | ||
| 96 | − | // Status | |
| 97 | − | self.status = status.clone(); | |
| 301 | + | pub async fn set_repeat(&mut self, enabled: bool) -> AppResult<()> { | |
| 302 | + | self.command("repeat", &[if enabled { "1" } else { "0" }]) | |
| 303 | + | .await?; | |
| 304 | + | Ok(()) | |
| 305 | + | } | |
| 98 | 306 | ||
| 99 | − | // Playback State | |
| 100 | − | self.state = match status.state { | |
| 101 | − | State::Stop => "Stopped".to_string(), | |
| 102 | − | State::Play => "Playing".to_string(), | |
| 103 | − | State::Pause => "Paused".to_string(), | |
| 104 | − | }; | |
| 307 | + | pub async fn set_random(&mut self, enabled: bool) -> AppResult<()> { | |
| 308 | + | self.command("random", &[if enabled { "1" } else { "0" }]) | |
| 309 | + | .await?; | |
| 310 | + | Ok(()) | |
| 311 | + | } | |
| 105 | 312 | ||
| 106 | − | // Progress | |
| 107 | − | let (elapsed, total) = status.time.unwrap_or_default(); | |
| 108 | − | self.elapsed = elapsed; | |
| 109 | − | self.total_duration = total; | |
| 313 | + | pub async fn set_volume(&mut self, volume: i8) -> AppResult<()> { | |
| 314 | + | self.command("setvol", &[&volume.clamp(0, 100).to_string()]) | |
| 315 | + | .await?; | |
| 316 | + | Ok(()) | |
| 317 | + | } | |
| 110 | 318 | ||
| 111 | − | // Current song | |
| 112 | − | self.current_song = current_song; | |
| 319 | + | pub async fn seek(&mut self, position: u32, elapsed: Duration) -> AppResult<()> { | |
| 320 | + | self.command( | |
| 321 | + | "seek", | |
| 322 | + | &[&position.to_string(), &elapsed.as_secs().to_string()], | |
| 323 | + | ) | |
| 324 | + | .await?; | |
| 325 | + | Ok(()) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | pub async fn next(&mut self) -> AppResult<()> { | |
| 329 | + | self.command("next", &[]).await?; | |
| 330 | + | Ok(()) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | pub async fn previous(&mut self) -> AppResult<()> { | |
| 334 | + | self.command("previous", &[]).await?; | |
| 335 | + | Ok(()) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | pub async fn clear(&mut self) -> AppResult<()> { | |
| 339 | + | self.command("clear", &[]).await?; | |
| 340 | + | Ok(()) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | pub async fn delete(&mut self, position: u32) -> AppResult<()> { | |
| 344 | + | self.command("delete", &[&position.to_string()]).await?; | |
| 345 | + | Ok(()) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | pub async fn swap(&mut self, first: u32, second: u32) -> AppResult<()> { | |
| 349 | + | self.command("swap", &[&first.to_string(), &second.to_string()]) | |
| 350 | + | .await?; | |
| 351 | + | Ok(()) | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | pub async fn load_playlist(&mut self, name: &str) -> AppResult<()> { | |
| 355 | + | self.append_playlist(name).await?; | |
| 356 | + | self.play(None).await | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | pub async fn append_playlist(&mut self, name: &str) -> AppResult<()> { | |
| 360 | + | self.command("load", &[name]).await?; | |
| 361 | + | Ok(()) | |
| 362 | + | } | |
| 113 | 363 | ||
| 114 | − | // Stats | |
| 364 | + | pub async fn add_to_playlist(&mut self, playlist: &str, file: &str) -> AppResult<()> { | |
| 365 | + | self.command("playlistadd", &[playlist, file]).await?; | |
| 366 | + | Ok(()) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | pub async fn delete_playlist_song(&mut self, playlist: &str, position: usize) -> AppResult<()> { | |
| 370 | + | self.command("playlistdelete", &[playlist, &position.to_string()]) | |
| 371 | + | .await?; | |
| 372 | + | Ok(()) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | pub async fn move_playlist_song( | |
| 376 | + | &mut self, | |
| 377 | + | playlist: &str, | |
| 378 | + | from: usize, | |
| 379 | + | to: usize, | |
| 380 | + | ) -> AppResult<()> { | |
| 381 | + | self.command( | |
| 382 | + | "playlistmove", | |
| 383 | + | &[playlist, &from.to_string(), &to.to_string()], | |
| 384 | + | ) | |
| 385 | + | .await?; | |
| 386 | + | Ok(()) | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | pub async fn delete_playlist(&mut self, playlist: &str) -> AppResult<()> { | |
| 390 | + | self.command("rm", &[playlist]).await?; | |
| 391 | + | Ok(()) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | pub async fn rename_playlist(&mut self, from: &str, to: &str) -> AppResult<()> { | |
| 395 | + | self.command("rename", &[from, to]).await?; | |
| 396 | + | Ok(()) | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | pub async fn update_database(&mut self) -> AppResult<()> { | |
| 400 | + | self.command("rescan", &[]).await?; | |
| 401 | + | Ok(()) | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | pub async fn idle(&mut self) -> AppResult<()> { | |
| 405 | + | self.command( | |
| 406 | + | "idle", | |
| 407 | + | &[ | |
| 408 | + | "database", | |
| 409 | + | "stored_playlist", | |
| 410 | + | "player", | |
| 411 | + | "playlist", | |
| 412 | + | "mixer", | |
| 413 | + | "options", | |
| 414 | + | ], | |
| 415 | + | ) | |
| 416 | + | .await?; | |
| 417 | + | Ok(()) | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | #[derive(Debug)] | |
| 422 | + | pub struct Connection { | |
| 423 | + | pub client: MpdClient, | |
| 424 | + | pub current_song: Song, | |
| 425 | + | pub stats: Stats, | |
| 426 | + | pub status: Status, | |
| 427 | + | pub volume_status: VolumeStatus, | |
| 428 | + | last_status_update: Instant, | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | impl Connection { | |
| 432 | + | pub async fn connect(address: &str, password: Option<&str>) -> AppResult<Self> { | |
| 433 | + | let mut client = MpdClient::connect(address, password).await?; | |
| 434 | + | let status = client.status().await?; | |
| 435 | + | let stats = client.stats().await?; | |
| 436 | + | let current_song = client.current_song().await?; | |
| 437 | + | Ok(Self { | |
| 438 | + | client, | |
| 439 | + | current_song, | |
| 440 | + | stats, | |
| 441 | + | status, | |
| 442 | + | volume_status: VolumeStatus::Unmuted, | |
| 443 | + | last_status_update: Instant::now(), | |
| 444 | + | }) | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | pub fn apply_snapshot(&mut self, status: Status, stats: Stats, current_song: Song) { | |
| 448 | + | self.status = status; | |
| 115 | 449 | self.stats = stats; | |
| 450 | + | self.current_song = current_song; | |
| 451 | + | self.last_status_update = Instant::now(); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | pub async fn update_status(&mut self) -> AppResult<()> { | |
| 455 | + | self.status = self.client.status().await?; | |
| 456 | + | self.stats = self.client.stats().await?; | |
| 457 | + | self.current_song = self.client.current_song().await?; | |
| 458 | + | self.last_status_update = Instant::now(); | |
| 459 | + | Ok(()) | |
| 116 | 460 | } | |
| 117 | 461 | ||
| 118 | − | /// Get progress ratio of current playing song | |
| 119 | 462 | pub fn get_progress_ratio(&self) -> f64 { | |
| 120 | − | let total = self.total_duration.as_secs_f64(); | |
| 121 | − | if total == 0.0 { | |
| 463 | + | let total = self.status.total.as_secs_f64(); | |
| 464 | + | if total <= 0.0 { | |
| 122 | 465 | 0.0 | |
| 123 | 466 | } else { | |
| 124 | − | let ratio = self.elapsed.as_secs_f64() / self.total_duration.as_secs_f64(); | |
| 125 | − | if ratio > 1.0 || ratio == 0.0 { | |
| 126 | − | 0.0 | |
| 127 | − | } else { | |
| 128 | − | ratio | |
| 129 | − | } | |
| 467 | + | (self.current_elapsed().as_secs_f64() / total).clamp(0.0, 1.0) | |
| 130 | 468 | } | |
| 131 | 469 | } | |
| 132 | 470 | ||
| 133 | − | /// push the given song to queue | |
| 134 | − | pub fn push(&mut self, song: &Song) -> AppResult<()> { | |
| 135 | − | if self.conn.queue().unwrap().is_empty() { | |
| 136 | − | self.conn.push(song).unwrap(); | |
| 137 | − | self.conn.play().unwrap(); | |
| 471 | + | pub fn current_elapsed(&self) -> Duration { | |
| 472 | + | let elapsed = if self.status.state == PlaybackState::Playing { | |
| 473 | + | self.status | |
| 474 | + | .elapsed | |
| 475 | + | .saturating_add(self.last_status_update.elapsed()) | |
| 138 | 476 | } else { | |
| 139 | − | self.conn.push(song)?; | |
| 140 | − | let len: u32 = (self.conn.queue().unwrap().len() - 1).try_into().unwrap(); | |
| 141 | − | self.conn.switch(len)?; | |
| 142 | − | if self.conn.status()?.state == State::Stop { | |
| 143 | − | self.conn.play()?; | |
| 144 | − | } | |
| 477 | + | self.status.elapsed | |
| 478 | + | }; | |
| 479 | + | if self.status.total.is_zero() { | |
| 480 | + | elapsed | |
| 481 | + | } else { | |
| 482 | + | elapsed.min(self.status.total) | |
| 145 | 483 | } | |
| 484 | + | } | |
| 146 | 485 | ||
| 147 | − | Ok(()) | |
| 486 | + | pub async fn push(&mut self, song: &Song) -> AppResult<()> { | |
| 487 | + | self.client.add(&song.file).await?; | |
| 488 | + | let queue = self.client.queue().await?; | |
| 489 | + | if !queue.is_empty() { | |
| 490 | + | self.client.play(Some(queue.len() as u32 - 1)).await?; | |
| 491 | + | } | |
| 492 | + | self.update_status().await | |
| 148 | 493 | } | |
| 149 | 494 | ||
| 150 | − | /// Push all songs of a playlist into queue | |
| 151 | − | pub fn load_playlist(&mut self, playlist: &str) -> AppResult<()> { | |
| 152 | − | self.conn.load(playlist, ..)?; | |
| 153 | − | self.conn.play()?; | |
| 154 | − | Ok(()) | |
| 495 | + | pub async fn load_playlist(&mut self, playlist: &str) -> AppResult<()> { | |
| 496 | + | self.client.load_playlist(playlist).await?; | |
| 497 | + | self.update_status().await | |
| 155 | 498 | } | |
| 156 | 499 | ||
| 157 | − | /// Add given song to playlist | |
| 158 | − | pub fn add_to_playlist(&mut self, playlist: &str, song: &Song) -> AppResult<()> { | |
| 159 | − | self.conn.pl_push(playlist, song)?; | |
| 160 | − | Ok(()) | |
| 500 | + | pub async fn add_to_playlist(&mut self, playlist: &str, song: &Song) -> AppResult<()> { | |
| 501 | + | self.client.add_to_playlist(playlist, &song.file).await | |
| 161 | 502 | } | |
| 162 | 503 | ||
| 163 | − | /// Given a filename, get instance of Song with only filename | |
| 164 | − | pub fn get_song_with_only_filename(&self, filename: &str) -> Song { | |
| 504 | + | pub fn song_from_file(filename: &str) -> Song { | |
| 165 | 505 | Song { | |
| 166 | 506 | file: filename.to_string(), | |
| 167 | − | ..Default::default() | |
| 507 | + | ..Song::default() | |
| 168 | 508 | } | |
| 169 | 509 | } | |
| 170 | 510 | ||
| 171 | − | /// Given a song name from a directory, it returns the full path of the song in the database | |
| 172 | − | pub fn get_full_path(&self, short_path: &str) -> Option<&str> { | |
| 173 | − | for (i, f) in self.songs_filenames.iter().enumerate() { | |
| 174 | − | if f.contains(short_path) { | |
| 175 | − | return Some(self.songs_filenames.get(i).unwrap()); | |
| 176 | − | } | |
| 511 | + | pub fn now_playing(&self) -> String { | |
| 512 | + | match (&self.current_song.title, &self.current_song.artist) { | |
| 513 | + | (Some(title), Some(artist)) => format!("\"{title}\" By {artist}"), | |
| 514 | + | (Some(title), None) => title.clone(), | |
| 515 | + | _ if self.current_song.file.is_empty() => "No Song playing or in Queue".to_string(), | |
| 516 | + | _ => self.current_song.file.clone(), | |
| 177 | 517 | } | |
| 178 | − | None | |
| 179 | 518 | } | |
| 180 | 519 | ||
| 181 | − | /// Gives title of current playing song | |
| 182 | − | pub fn now_playing(&mut self) -> AppResult<Option<String>> { | |
| 183 | − | if let Some(s) = &self.current_song.title { | |
| 184 | − | if let Some(a) = &self.current_song.artist { | |
| 185 | − | Ok(Some(format!("\"{}\" By {}", s, a))) | |
| 520 | + | pub async fn toggle_pause(&mut self) -> AppResult<()> { | |
| 521 | + | self.client.toggle_pause().await?; | |
| 522 | + | self.update_status().await | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | pub async fn pause(&mut self) -> AppResult<()> { | |
| 526 | + | self.client.pause(true).await?; | |
| 527 | + | self.update_status().await | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | pub async fn toggle_repeat(&mut self) -> AppResult<()> { | |
| 531 | + | self.client.set_repeat(!self.status.repeat).await?; | |
| 532 | + | self.update_status().await | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | pub async fn toggle_random(&mut self) -> AppResult<()> { | |
| 536 | + | self.client.set_random(!self.status.random).await?; | |
| 537 | + | self.update_status().await | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | pub async fn inc_volume(&mut self, amount: i8) -> AppResult<()> { | |
| 541 | + | let volume = self.status.volume.saturating_add(amount).min(100); | |
| 542 | + | self.volume_status = VolumeStatus::Unmuted; | |
| 543 | + | self.client.set_volume(volume).await?; | |
| 544 | + | self.update_status().await | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | pub async fn dec_volume(&mut self, amount: i8) -> AppResult<()> { | |
| 548 | + | let volume = self.status.volume.saturating_sub(amount).max(0); | |
| 549 | + | self.volume_status = VolumeStatus::Unmuted; | |
| 550 | + | self.client.set_volume(volume).await?; | |
| 551 | + | self.update_status().await | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | pub async fn refresh_song_list(&mut self) -> AppResult<()> { | |
| 555 | + | self.stats = self.client.stats().await?; | |
| 556 | + | Ok(()) | |
| 557 | + | } | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | fn fields(lines: Vec<String>) -> HashMap<String, String> { | |
| 561 | + | lines | |
| 562 | + | .into_iter() | |
| 563 | + | .filter_map(|line| parse_field(&line)) | |
| 564 | + | .collect() | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | fn parse_songs(lines: Vec<String>) -> Vec<Song> { | |
| 568 | + | let mut songs = Vec::new(); | |
| 569 | + | let mut current: Option<Song> = None; | |
| 570 | + | for line in lines { | |
| 571 | + | let Some((key, value)) = parse_field(&line) else { | |
| 572 | + | continue; | |
| 573 | + | }; | |
| 574 | + | if key == "file" { | |
| 575 | + | if let Some(song) = current.take() { | |
| 576 | + | songs.push(song); | |
| 577 | + | } | |
| 578 | + | current = Some(Song { | |
| 579 | + | file: value, | |
| 580 | + | ..Song::default() | |
| 581 | + | }); | |
| 582 | + | } else if let Some(song) = current.as_mut() { | |
| 583 | + | if key.eq_ignore_ascii_case("pos") { | |
| 584 | + | song.place = value.parse().ok().map(|pos| Place { pos }); | |
| 585 | + | } else if key == "duration" || key == "Time" { | |
| 586 | + | song.duration = Some(parse_duration(&value)); | |
| 186 | 587 | } else { | |
| 187 | − | Ok(Some(s.to_string())) | |
| 588 | + | add_metadata(song, key, value); | |
| 188 | 589 | } | |
| 189 | − | } else { | |
| 190 | − | Ok(Some(self.current_song.file.clone())) | |
| 191 | 590 | } | |
| 192 | 591 | } | |
| 592 | + | if let Some(song) = current { | |
| 593 | + | songs.push(song); | |
| 594 | + | } | |
| 595 | + | songs | |
| 596 | + | } | |
| 193 | 597 | ||
| 194 | − | // Playback controls | |
| 195 | − | /// Pause playback | |
| 196 | − | pub fn pause(&mut self) { | |
| 197 | − | self.conn.pause(true).unwrap(); | |
| 598 | + | fn add_metadata(song: &mut Song, key: String, value: String) { | |
| 599 | + | match key.to_ascii_lowercase().as_str() { | |
| 600 | + | "title" => song.title = Some(value), | |
| 601 | + | "artist" => song.artist = Some(value), | |
| 602 | + | "duration" | "time" => song.duration = Some(parse_duration(&value)), | |
| 603 | + | _ => song.tags.push((key, value)), | |
| 198 | 604 | } | |
| 605 | + | } | |
| 199 | 606 | ||
| 200 | − | /// Toggles playback | |
| 201 | − | pub fn toggle_pause(&mut self) { | |
| 202 | − | self.conn.toggle_pause().unwrap(); | |
| 607 | + | fn parse_field(line: &str) -> Option<(String, String)> { | |
| 608 | + | let (key, value) = line.split_once(": ")?; | |
| 609 | + | Some((key.to_string(), unquote(value))) | |
| 610 | + | } | |
| 611 | + | ||
| 612 | + | fn unquote(value: &str) -> String { | |
| 613 | + | let value = value | |
| 614 | + | .strip_prefix('"') | |
| 615 | + | .and_then(|v| v.strip_suffix('"')) | |
| 616 | + | .unwrap_or(value); | |
| 617 | + | let mut result = String::with_capacity(value.len()); | |
| 618 | + | let mut escaped = false; | |
| 619 | + | for character in value.chars() { | |
| 620 | + | if escaped { | |
| 621 | + | result.push(character); | |
| 622 | + | escaped = false; | |
| 623 | + | } else if character == '\\' { | |
| 624 | + | escaped = true; | |
| 625 | + | } else { | |
| 626 | + | result.push(character); | |
| 627 | + | } | |
| 628 | + | } | |
| 629 | + | if escaped { | |
| 630 | + | result.push('\\'); | |
| 631 | + | } | |
| 632 | + | result | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | fn quote(value: &str) -> String { | |
| 636 | + | format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | fn bool_field(fields: &HashMap<String, String>, key: &str) -> bool { | |
| 640 | + | fields.get(key).is_some_and(|value| value == "1") | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | fn parse_duration(value: &str) -> Duration { | |
| 644 | + | value | |
| 645 | + | .parse::<f64>() | |
| 646 | + | .ok() | |
| 647 | + | .filter(|seconds| seconds.is_finite() && *seconds >= 0.0) | |
| 648 | + | .and_then(|seconds| Duration::try_from_secs_f64(seconds).ok()) | |
| 649 | + | .unwrap_or_default() | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | #[cfg(test)] | |
| 653 | + | mod tests { | |
| 654 | + | use super::*; | |
| 655 | + | use tokio::{ | |
| 656 | + | io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, | |
| 657 | + | net::TcpListener, | |
| 658 | + | }; | |
| 659 | + | ||
| 660 | + | #[test] | |
| 661 | + | fn parses_quoted_mpd_values_and_escapes() { | |
| 662 | + | assert_eq!( | |
| 663 | + | parse_field(r#"Title: "A \"quoted\" song\\file""#), | |
| 664 | + | Some(("Title".to_string(), "A \"quoted\" song\\file".to_string())) | |
| 665 | + | ); | |
| 666 | + | assert_eq!(quote(r#"A "song"\path"#), r#""A \"song\"\\path""#); | |
| 203 | 667 | } | |
| 204 | 668 | ||
| 205 | − | /// Toggle Repeat mode | |
| 206 | − | pub fn toggle_repeat(&mut self) { | |
| 207 | − | let mode = self.status.repeat; | |
| 208 | − | self.conn.repeat(!mode).unwrap(); | |
| 669 | + | #[test] | |
| 670 | + | fn parses_songs_with_tags_and_queue_positions() { | |
| 671 | + | let songs = parse_songs(vec![ | |
| 672 | + | "file: music/example.flac".to_string(), | |
| 673 | + | "Title: Example".to_string(), | |
| 674 | + | "Artist: Someone".to_string(), | |
| 675 | + | "duration: 124.5".to_string(), | |
| 676 | + | "Pos: 3".to_string(), | |
| 677 | + | "file: music/another.ogg".to_string(), | |
| 678 | + | ]); | |
| 679 | + | assert_eq!(songs.len(), 2); | |
| 680 | + | assert_eq!(songs[0].title.as_deref(), Some("Example")); | |
| 681 | + | assert_eq!(songs[0].artist.as_deref(), Some("Someone")); | |
| 682 | + | assert_eq!(songs[0].duration, Some(Duration::from_secs_f64(124.5))); | |
| 683 | + | assert_eq!(songs[0].place, Some(Place { pos: 3 })); | |
| 684 | + | assert_eq!(songs[1].file, "music/another.ogg"); | |
| 209 | 685 | } | |
| 210 | 686 | ||
| 211 | − | /// Toggle random mode | |
| 212 | − | pub fn toggle_random(&mut self) { | |
| 213 | − | let mode = self.status.random; | |
| 214 | − | self.conn.random(!mode).unwrap(); | |
| 687 | + | #[tokio::test] | |
| 688 | + | async fn sends_quoted_commands_over_the_mpd_tcp_protocol() { | |
| 689 | + | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 690 | + | let address = listener.local_addr().unwrap(); | |
| 691 | + | let server = tokio::spawn(async move { | |
| 692 | + | let (stream, _) = listener.accept().await.unwrap(); | |
| 693 | + | let mut stream = BufReader::new(stream); | |
| 694 | + | stream | |
| 695 | + | .get_mut() | |
| 696 | + | .write_all(b"OK MPD 0.23.0\n") | |
| 697 | + | .await | |
| 698 | + | .unwrap(); | |
| 699 | + | let mut command = String::new(); | |
| 700 | + | stream.read_line(&mut command).await.unwrap(); | |
| 701 | + | assert_eq!(command, "play \"0\"\n"); | |
| 702 | + | stream.get_mut().write_all(b"OK\n").await.unwrap(); | |
| 703 | + | }); | |
| 704 | + | ||
| 705 | + | let mut client = MpdClient::connect(&address.to_string(), None) | |
| 706 | + | .await | |
| 707 | + | .unwrap(); | |
| 708 | + | client.command("play", &["0"]).await.unwrap(); | |
| 709 | + | server.await.unwrap(); | |
| 215 | 710 | } | |
| 216 | 711 | ||
| 217 | − | // Volume controls | |
| 218 | − | /// Increase Volume | |
| 219 | − | pub fn inc_volume(&mut self, v: i8) { | |
| 220 | − | let cur = self.status.volume; | |
| 221 | − | if cur + v <= 100 { | |
| 222 | − | self.volume_status = VolumeStatus::Unmuted; | |
| 223 | − | self.conn.volume(cur + v).unwrap(); | |
| 224 | − | } | |
| 712 | + | #[tokio::test] | |
| 713 | + | async fn omits_empty_root_paths_from_mpd_commands() { | |
| 714 | + | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 715 | + | let address = listener.local_addr().unwrap(); | |
| 716 | + | let server = tokio::spawn(async move { | |
| 717 | + | let (stream, _) = listener.accept().await.unwrap(); | |
| 718 | + | let mut stream = BufReader::new(stream); | |
| 719 | + | stream | |
| 720 | + | .get_mut() | |
| 721 | + | .write_all(b"OK MPD 0.23.0\n") | |
| 722 | + | .await | |
| 723 | + | .unwrap(); | |
| 724 | + | for expected in ["lsinfo\n", "listfiles\n"] { | |
| 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 = MpdClient::connect(&address.to_string(), None) | |
| 733 | + | .await | |
| 734 | + | .unwrap(); | |
| 735 | + | client.ls_info("").await.unwrap(); | |
| 736 | + | client.list_files("").await.unwrap(); | |
| 737 | + | server.await.unwrap(); | |
| 225 | 738 | } | |
| 226 | 739 | ||
| 227 | − | /// Decrease volume | |
| 228 | − | pub fn dec_volume(&mut self, v: i8) { | |
| 229 | − | let cur = self.status.volume; | |
| 230 | − | if cur - v >= 0 { | |
| 231 | − | self.volume_status = VolumeStatus::Unmuted; | |
| 232 | − | self.conn.volume(cur - v).unwrap(); | |
| 233 | − | } | |
| 740 | + | #[tokio::test] | |
| 741 | + | async fn sends_stored_playlist_edit_commands() { | |
| 742 | + | let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | |
| 743 | + | let address = listener.local_addr().unwrap(); | |
| 744 | + | let server = tokio::spawn(async move { | |
| 745 | + | let (stream, _) = listener.accept().await.unwrap(); | |
| 746 | + | let mut stream = BufReader::new(stream); | |
| 747 | + | stream | |
| 748 | + | .get_mut() | |
| 749 | + | .write_all(b"OK MPD 0.23.0\n") | |
| 750 | + | .await | |
| 751 | + | .unwrap(); | |
| 752 | + | for expected in [ | |
| 753 | + | "playlistdelete \"mix \\\"one\\\"\" \"2\"\n", | |
| 754 | + | "playlistmove \"mix \\\"one\\\"\" \"1\" \"2\"\n", | |
| 755 | + | "rm \"mix \\\"one\\\"\"\n", | |
| 756 | + | ] { | |
| 757 | + | let mut command = String::new(); | |
| 758 | + | stream.read_line(&mut command).await.unwrap(); | |
| 759 | + | assert_eq!(command, expected); | |
| 760 | + | stream.get_mut().write_all(b"OK\n").await.unwrap(); | |
| 761 | + | } | |
| 762 | + | }); | |
| 763 | + | ||
| 764 | + | let mut client = MpdClient::connect(&address.to_string(), None) | |
| 765 | + | .await | |
| 766 | + | .unwrap(); | |
| 767 | + | client.delete_playlist_song("mix \"one\"", 2).await.unwrap(); | |
| 768 | + | client | |
| 769 | + | .move_playlist_song("mix \"one\"", 1, 2) | |
| 770 | + | .await | |
| 771 | + | .unwrap(); | |
| 772 | + | client.delete_playlist("mix \"one\"").await.unwrap(); | |
| 773 | + | server.await.unwrap(); | |
| 234 | 774 | } | |
| 235 | 775 | } | |
| @@ -1,79 +0,0 @@ | |||
| 1 | − | use crate::app::AppResult; | |
| 2 | − | use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; | |
| 3 | − | use std::sync::mpsc; | |
| 4 | − | use std::thread; | |
| 5 | − | use std::time::{Duration, Instant}; | |
| 6 | − | ||
| 7 | − | /// Terminal events. | |
| 8 | − | #[derive(Clone, Copy, Debug)] | |
| 9 | − | pub enum Event { | |
| 10 | − | /// Terminal tick. | |
| 11 | − | Tick, | |
| 12 | − | /// Key press. | |
| 13 | − | Key(KeyEvent), | |
| 14 | − | /// Mouse click/scroll. | |
| 15 | − | Mouse(MouseEvent), | |
| 16 | − | /// Terminal resize. | |
| 17 | − | Resize(u16, u16), | |
| 18 | − | } | |
| 19 | − | ||
| 20 | − | /// Terminal event handler. | |
| 21 | − | #[allow(dead_code)] | |
| 22 | − | #[derive(Debug)] | |
| 23 | − | pub struct EventHandler { | |
| 24 | − | /// Event sender channel. | |
| 25 | − | sender: mpsc::Sender<Event>, | |
| 26 | − | /// Event receiver channel. | |
| 27 | − | receiver: mpsc::Receiver<Event>, | |
| 28 | − | /// Event handler thread. | |
| 29 | − | handler: thread::JoinHandle<()>, | |
| 30 | − | } | |
| 31 | − | ||
| 32 | − | impl EventHandler { | |
| 33 | − | /// Constructs a new instance of [`EventHandler`]. | |
| 34 | − | pub fn new(tick_rate: u64) -> Self { | |
| 35 | − | let tick_rate = Duration::from_millis(tick_rate); | |
| 36 | − | let (sender, receiver) = mpsc::channel(); | |
| 37 | − | let handler = { | |
| 38 | − | let sender = sender.clone(); | |
| 39 | − | thread::spawn(move || { | |
| 40 | − | let mut last_tick = Instant::now(); | |
| 41 | − | loop { | |
| 42 | − | let timeout = tick_rate | |
| 43 | − | .checked_sub(last_tick.elapsed()) | |
| 44 | − | .unwrap_or(tick_rate); | |
| 45 | − | ||
| 46 | − | if event::poll(timeout).expect("failed to poll new events") { | |
| 47 | − | match event::read().expect("unable to read event") { | |
| 48 | − | CrosstermEvent::Key(e) => sender.send(Event::Key(e)), | |
| 49 | − | CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)), | |
| 50 | − | CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h)), | |
| 51 | − | CrosstermEvent::FocusGained => Ok(()), | |
| 52 | − | CrosstermEvent::FocusLost => Ok(()), | |
| 53 | − | CrosstermEvent::Paste(_) => unimplemented!(), | |
| 54 | − | } | |
| 55 | − | .expect("failed to send terminal event") | |
| 56 | − | } | |
| 57 | − | ||
| 58 | − | if last_tick.elapsed() >= tick_rate { | |
| 59 | − | sender.send(Event::Tick).expect("failed to send tick event"); | |
| 60 | − | last_tick = Instant::now(); | |
| 61 | − | } | |
| 62 | − | } | |
| 63 | − | }) | |
| 64 | − | }; | |
| 65 | − | Self { | |
| 66 | − | sender, | |
| 67 | − | receiver, | |
| 68 | − | handler, | |
| 69 | − | } | |
| 70 | − | } | |
| 71 | − | ||
| 72 | − | /// Receive the next event from the handler thread. | |
| 73 | − | /// | |
| 74 | − | /// This function will always block the current thread if | |
| 75 | − | /// there is no data available and it's possible for more data to be sent. | |
| 76 | − | pub fn next(&self) -> AppResult<Event> { | |
| 77 | − | Ok(self.receiver.recv()?) | |
| 78 | − | } | |
| 79 | − | } | |
| @@ -1,335 +1,480 @@ | |||
| 1 | + | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; | |
| 2 | + | use rust_fuzzy_search::fuzzy_search_sorted; | |
| 3 | + | ||
| 1 | 4 | use crate::{ | |
| 2 | − | app::{App, AppResult, SelectedTab}, | |
| 5 | + | app::{App, PlaylistFocus, SelectedTab}, | |
| 3 | 6 | connection::VolumeStatus, | |
| 4 | 7 | ui::InputMode, | |
| 8 | + | AppResult, | |
| 5 | 9 | }; | |
| 6 | − | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; | |
| 7 | − | use std::time::Duration; | |
| 8 | − | ||
| 9 | − | use super::{new_pl_keys, pl_append_keys, pl_rename_keys, search_keys}; | |
| 10 | 10 | ||
| 11 | − | pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 12 | − | // searching, playlist renaming, playlist appending | |
| 13 | − | if app.inputmode == InputMode::Editing { | |
| 14 | − | search_keys::handle_search_keys(key_event, app)?; | |
| 15 | − | } else if app.inputmode == InputMode::PlaylistRename { | |
| 16 | − | pl_rename_keys::handle_pl_rename_keys(key_event, app)?; | |
| 17 | − | } else if app.inputmode == InputMode::NewPlaylist { | |
| 18 | − | new_pl_keys::handle_new_pl_keys(key_event, app)?; | |
| 19 | − | } else if app.playlist_popup { | |
| 20 | − | pl_append_keys::hande_pl_append_keys(key_event, app)?; | |
| 21 | − | } else { | |
| 22 | − | // General KeyMaps | |
| 23 | − | match key_event.code { | |
| 24 | − | // Quit | |
| 25 | − | KeyCode::Char('q') => app.quit(), | |
| 26 | − | KeyCode::Char('c') | KeyCode::Char('C') => { | |
| 27 | − | if key_event.modifiers == KeyModifiers::CONTROL { | |
| 28 | − | app.quit(); | |
| 29 | − | } else { | |
| 30 | − | app.conn.conn.clear()?; | |
| 31 | − | app.conn.update_status(); | |
| 32 | − | app.queue_list.list.clear(); | |
| 33 | − | app.queue_list.reset_index(); | |
| 34 | − | } | |
| 35 | − | } | |
| 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 | + | } | |
| 36 | 44 | ||
| 37 | − | // Playback controls | |
| 38 | − | // Toggle Pause | |
| 39 | − | KeyCode::Char('p') => { | |
| 40 | − | app.conn.toggle_pause(); | |
| 41 | − | app.conn.update_status(); | |
| 45 | + | if is_bound(app, "quit", key) { | |
| 46 | + | app.quit(); | |
| 47 | + | } else if is_bound(app, "clear_queue", key) { | |
| 48 | + | app.conn.client.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 | + | .client | |
| 71 | + | .seek(position.pos, std::time::Duration::from_secs(target)) | |
| 72 | + | .await?; | |
| 73 | + | app.conn.update_status().await?; | |
| 74 | + | } | |
| 75 | + | } else if is_bound(app, "cycle_tabs", key) { | |
| 76 | + | app.cycle_tabs(); | |
| 77 | + | if app.selected_tab == SelectedTab::Playlists { | |
| 78 | + | app.refresh_playlist_view().await?; | |
| 79 | + | } | |
| 80 | + | } else if is_bound(app, "select_queue", key) { | |
| 81 | + | app.selected_tab = SelectedTab::Queue; | |
| 82 | + | } else if is_bound(app, "select_browser", key) { | |
| 83 | + | app.selected_tab = SelectedTab::DirectoryBrowser; | |
| 84 | + | } else if is_bound(app, "select_playlists", key) { | |
| 85 | + | app.selected_tab = SelectedTab::Playlists; | |
| 86 | + | app.refresh_playlist_view().await?; | |
| 87 | + | } else if is_bound(app, "next_track", key) { | |
| 88 | + | if !app.queue_list.list.is_empty() { | |
| 89 | + | app.conn.client.next().await?; | |
| 90 | + | app.update_queue().await?; | |
| 91 | + | app.conn.update_status().await?; | |
| 92 | + | } | |
| 93 | + | } else if is_bound(app, "previous_track", key) { | |
| 94 | + | if !app.queue_list.list.is_empty() { | |
| 95 | + | app.conn.client.previous().await?; | |
| 96 | + | app.update_queue().await?; | |
| 97 | + | app.conn.update_status().await?; | |
| 98 | + | } | |
| 99 | + | } else if is_bound(app, "volume_up", key) { | |
| 100 | + | app.conn | |
| 101 | + | .inc_volume(app.config.playback.volume_step as i8) | |
| 102 | + | .await?; | |
| 103 | + | } else if is_bound(app, "volume_down", key) { | |
| 104 | + | app.conn | |
| 105 | + | .dec_volume(app.config.playback.volume_step as i8) | |
| 106 | + | .await?; | |
| 107 | + | } else if is_bound(app, "toggle_mute", key) { | |
| 108 | + | match app.conn.volume_status { | |
| 109 | + | VolumeStatus::Muted(volume) => { | |
| 110 | + | app.conn.client.set_volume(volume).await?; | |
| 111 | + | app.conn.volume_status = VolumeStatus::Unmuted; | |
| 42 | 112 | } | |
| 43 | − | ||
| 44 | − | // Pause | |
| 45 | − | KeyCode::Char('s') => { | |
| 46 | − | app.conn.pause(); | |
| 47 | − | app.conn.update_status(); | |
| 113 | + | VolumeStatus::Unmuted => { | |
| 114 | + | app.conn.client.set_volume(0).await?; | |
| 115 | + | app.conn.volume_status = VolumeStatus::Muted(app.conn.status.volume); | |
| 48 | 116 | } | |
| 117 | + | } | |
| 118 | + | app.conn.update_status().await?; | |
| 119 | + | } else if is_bound(app, "update_database", key) { | |
| 120 | + | app.conn.client.update_database().await?; | |
| 121 | + | app.should_update_song_list = true; | |
| 122 | + | app.conn.update_status().await?; | |
| 123 | + | } else if is_bound(app, "search", key) { | |
| 124 | + | app.inputmode = InputMode::Editing; | |
| 125 | + | app.search_input.clear(); | |
| 126 | + | app.search_cursor_pos = 0; | |
| 127 | + | app.browser_search_results.clear(); | |
| 128 | + | app.browser_search_index = 0; | |
| 129 | + | } else if is_bound(app, "toggle_selected_item", key) { | |
| 130 | + | app.toggle_selected_item().await?; | |
| 131 | + | } | |
| 49 | 132 | ||
| 50 | − | // Toggle rpeat | |
| 51 | − | KeyCode::Char('r') => { | |
| 52 | − | app.conn.toggle_repeat(); | |
| 53 | − | app.conn.update_status(); | |
| 54 | − | } | |
| 133 | + | match app.selected_tab { | |
| 134 | + | SelectedTab::Queue => handle_queue_key(key, app).await?, | |
| 135 | + | SelectedTab::DirectoryBrowser => handle_browser_key(key, app).await?, | |
| 136 | + | SelectedTab::Playlists => handle_playlists_key(key, app).await?, | |
| 137 | + | } | |
| 138 | + | Ok(()) | |
| 139 | + | } | |
| 55 | 140 | ||
| 56 | − | // Toggle random | |
| 57 | − | KeyCode::Char('z') => { | |
| 58 | − | app.conn.toggle_random(); | |
| 59 | − | app.conn.update_status(); | |
| 60 | − | } | |
| 141 | + | fn is_bound(app: &App, action: &str, key: KeyEvent) -> bool { | |
| 142 | + | app.config.keybindings.matches(action, key) | |
| 143 | + | } | |
| 61 | 144 | ||
| 62 | − | // Dmenu prompt | |
| 63 | − | KeyCode::Char('D') => { | |
| 64 | − | app.conn.play_dmenu()?; | |
| 65 | − | app.conn.update_status(); | |
| 66 | − | } | |
| 145 | + | async fn handle_queue_key(key: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 146 | + | if is_bound(app, "move_down", key) { | |
| 147 | + | app.queue_list.next(); | |
| 148 | + | } else if is_bound(app, "move_up", key) { | |
| 149 | + | app.queue_list.prev(); | |
| 150 | + | } else if is_bound(app, "activate", key) { | |
| 151 | + | if app.queue_list.index < app.queue_list.list.len() { | |
| 152 | + | app.conn | |
| 153 | + | .client | |
| 154 | + | .play(Some(app.queue_list.index as u32)) | |
| 155 | + | .await?; | |
| 156 | + | app.conn.update_status().await?; | |
| 157 | + | } | |
| 158 | + | } else if is_bound(app, "queue_delete", key) { | |
| 159 | + | if app.queue_list.index < app.queue_list.list.len() { | |
| 160 | + | app.conn.client.delete(app.queue_list.index as u32).await?; | |
| 161 | + | app.update_queue().await?; | |
| 162 | + | app.conn.update_status().await?; | |
| 163 | + | } | |
| 164 | + | } else if is_bound(app, "queue_move_down", key) { | |
| 165 | + | let position = app.queue_list.index; | |
| 166 | + | if position + 1 < app.queue_list.list.len() { | |
| 167 | + | app.conn | |
| 168 | + | .client | |
| 169 | + | .swap(position as u32, (position + 1) as u32) | |
| 170 | + | .await?; | |
| 171 | + | app.queue_list.index += 1; | |
| 172 | + | app.update_queue().await?; | |
| 173 | + | } | |
| 174 | + | } else if is_bound(app, "queue_move_up", key) { | |
| 175 | + | let position = app.queue_list.index; | |
| 176 | + | if position > 0 && position < app.queue_list.list.len() { | |
| 177 | + | app.conn | |
| 178 | + | .client | |
| 179 | + | .swap(position as u32, (position - 1) as u32) | |
| 180 | + | .await?; | |
| 181 | + | app.queue_list.index -= 1; | |
| 182 | + | app.update_queue().await?; | |
| 183 | + | } | |
| 184 | + | } else if is_bound(app, "go_to_top", key) { | |
| 185 | + | app.queue_list.index = 0; | |
| 186 | + | } else if is_bound(app, "go_to_bottom", key) { | |
| 187 | + | app.queue_list.index = app.queue_list.list.len().saturating_sub(1); | |
| 188 | + | } | |
| 189 | + | Ok(()) | |
| 190 | + | } | |
| 67 | 191 | ||
| 68 | − | // add to queue | |
| 69 | − | KeyCode::Char('a') => app.playlist_popup = true, | |
| 192 | + | async fn handle_browser_key(key: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 193 | + | if is_bound(app, "move_down", key) { | |
| 194 | + | app.browser.next(); | |
| 195 | + | } else if is_bound(app, "move_up", key) { | |
| 196 | + | app.browser.prev(); | |
| 197 | + | } else if is_bound(app, "activate", key) { | |
| 198 | + | let previous_path = app.browser.path.clone(); | |
| 199 | + | app.handle_enter().await?; | |
| 200 | + | if app.browser.path != previous_path { | |
| 201 | + | app.browser_search_results.clear(); | |
| 202 | + | app.search_input.clear(); | |
| 203 | + | } | |
| 204 | + | } else if is_bound(app, "go_back", key) { | |
| 205 | + | app.browser.handle_go_back(&mut app.conn).await?; | |
| 206 | + | app.browser_search_results.clear(); | |
| 207 | + | app.search_input.clear(); | |
| 208 | + | } else if is_bound(app, "go_to_top", key) { | |
| 209 | + | app.browser.selected = 0; | |
| 210 | + | } else if is_bound(app, "go_to_bottom", key) { | |
| 211 | + | app.browser.selected = app.browser.entries.len().saturating_sub(1); | |
| 212 | + | } | |
| 213 | + | Ok(()) | |
| 214 | + | } | |
| 70 | 215 | ||
| 71 | − | // Fast forward | |
| 72 | − | KeyCode::Char('f') => { | |
| 73 | − | if !app.queue_list.list.is_empty() { | |
| 74 | − | let status = app.conn.conn.status().unwrap_or_default(); | |
| 75 | − | let place = status.song.unwrap_or_default().pos; | |
| 76 | − | let (pos, _) = status.time.unwrap_or_default(); | |
| 77 | − | let pos = Duration::from_secs(pos.as_secs().wrapping_add(2)); | |
| 78 | − | app.conn.conn.seek(place, pos)?; | |
| 79 | − | app.conn.update_status(); | |
| 80 | − | } | |
| 81 | − | } | |
| 216 | + | async fn handle_playlists_key(key: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 217 | + | if is_bound(app, "playlist_focus_contents", key) { | |
| 218 | + | app.playlist_focus = PlaylistFocus::Songs; | |
| 219 | + | return Ok(()); | |
| 220 | + | } | |
| 221 | + | if is_bound(app, "playlist_focus_list", key) { | |
| 222 | + | app.playlist_focus = PlaylistFocus::List; | |
| 223 | + | return Ok(()); | |
| 224 | + | } | |
| 82 | 225 | ||
| 83 | − | // backward | |
| 84 | − | KeyCode::Char('b') => { | |
| 85 | − | if !app.queue_list.list.is_empty() { | |
| 86 | − | let status = app.conn.conn.status().unwrap_or_default(); | |
| 87 | − | let place = status.song.unwrap_or_default().pos; | |
| 88 | − | let (pos, _) = status.time.unwrap_or_default(); | |
| 89 | − | let pos = Duration::from_secs(pos.as_secs().wrapping_sub(2)); | |
| 90 | − | app.conn.conn.seek(place, pos)?; | |
| 91 | − | app.conn.update_status(); | |
| 92 | − | } | |
| 93 | − | } | |
| 226 | + | if app.playlist_focus == PlaylistFocus::Songs { | |
| 227 | + | if is_bound(app, "move_down", key) { | |
| 228 | + | app.playlist_song_index = app | |
| 229 | + | .playlist_song_index | |
| 230 | + | .saturating_add(1) | |
| 231 | + | .min(app.playlist_contents.len().saturating_sub(1)); | |
| 232 | + | } else if is_bound(app, "move_up", key) { | |
| 233 | + | app.playlist_song_index = app.playlist_song_index.saturating_sub(1); | |
| 234 | + | } else if is_bound(app, "go_to_top", key) { | |
| 235 | + | app.playlist_song_index = 0; | |
| 236 | + | } else if is_bound(app, "go_to_bottom", key) { | |
| 237 | + | app.playlist_song_index = app.playlist_contents.len().saturating_sub(1); | |
| 238 | + | } else if is_bound(app, "playlist_delete_song", key) { | |
| 239 | + | app.delete_selected_playlist_song().await?; | |
| 240 | + | } else if is_bound(app, "playlist_move_song_down", key) { | |
| 241 | + | app.move_selected_playlist_song(true).await?; | |
| 242 | + | } else if is_bound(app, "playlist_move_song_up", key) { | |
| 243 | + | app.move_selected_playlist_song(false).await?; | |
| 244 | + | } else if is_bound(app, "activate", key) { | |
| 245 | + | app.play_selected_playlist_song().await?; | |
| 246 | + | } else if is_bound(app, "playlist_delete", key) { | |
| 247 | + | app.delete_selected_playlist().await?; | |
| 248 | + | } | |
| 249 | + | return Ok(()); | |
| 250 | + | } | |
| 94 | 251 | ||
| 95 | − | // Cycle through tabs | |
| 96 | − | KeyCode::Tab => { | |
| 97 | − | app.cycle_tabls(); | |
| 98 | − | } | |
| 252 | + | let previous_index = app.pl_list.index; | |
| 253 | + | if is_bound(app, "move_down", key) { | |
| 254 | + | app.pl_list.next(); | |
| 255 | + | } else if is_bound(app, "move_up", key) { | |
| 256 | + | app.pl_list.prev(); | |
| 257 | + | } else if is_bound(app, "go_to_top", key) { | |
| 258 | + | app.pl_list.index = 0; | |
| 259 | + | } else if is_bound(app, "go_to_bottom", key) { | |
| 260 | + | app.pl_list.index = app.pl_list.list.len().saturating_sub(1); | |
| 261 | + | } else if is_bound(app, "playlist_rename", key) && !app.pl_list.list.is_empty() { | |
| 262 | + | app.inputmode = InputMode::PlaylistRename; | |
| 263 | + | app.pl_newname_input.clear(); | |
| 264 | + | app.pl_cursor_pos = 0; | |
| 265 | + | } else if is_bound(app, "playlist_delete", key) { | |
| 266 | + | app.delete_selected_playlist().await?; | |
| 267 | + | } else if is_bound(app, "playlist_load", key) { | |
| 268 | + | if let Some(playlist) = app.pl_list.list.get(app.pl_list.index) { | |
| 269 | + | app.conn.load_playlist(playlist).await?; | |
| 270 | + | app.update_queue().await?; | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | if previous_index != app.pl_list.index { | |
| 274 | + | app.playlist_song_index = 0; | |
| 275 | + | app.refresh_playlist_view().await?; | |
| 276 | + | } | |
| 277 | + | Ok(()) | |
| 278 | + | } | |
| 99 | 279 | ||
| 100 | − | // Directory browser tab | |
| 101 | − | KeyCode::Char('1') => { | |
| 102 | − | app.selected_tab = SelectedTab::Queue; | |
| 280 | + | async fn handle_input_key(key: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 281 | + | if is_bound(app, "input_cancel", key) { | |
| 282 | + | match app.inputmode { | |
| 283 | + | InputMode::Editing => { | |
| 284 | + | app.search_input.clear(); | |
| 285 | + | app.browser_search_results.clear(); | |
| 286 | + | app.inputmode = InputMode::Normal; | |
| 103 | 287 | } | |
| 104 | − | ||
| 105 | − | // Playing queue tab | |
| 106 | − | KeyCode::Char('2') => { | |
| 107 | − | app.selected_tab = SelectedTab::DirectoryBrowser; | |
| 288 | + | InputMode::PlaylistRename => { | |
| 289 | + | app.pl_newname_input.clear(); | |
| 290 | + | app.inputmode = InputMode::Normal; | |
| 108 | 291 | } | |
| 109 | − | ||
| 110 | − | // Playlists tab | |
| 111 | − | KeyCode::Char('3') => { | |
| 112 | − | app.selected_tab = SelectedTab::Playlists; | |
| 292 | + | InputMode::NewPlaylist => { | |
| 293 | + | app.pl_new_pl_input.clear(); | |
| 294 | + | app.inputmode = InputMode::Normal; | |
| 113 | 295 | } | |
| 114 | − | ||
| 115 | − | // Play next song | |
| 116 | − | KeyCode::Char('>') => { | |
| 117 | − | if !app.queue_list.list.is_empty() { | |
| 118 | − | app.conn.conn.next()?; | |
| 119 | − | app.update_queue(); | |
| 120 | − | app.conn.update_status(); | |
| 296 | + | InputMode::Normal => {} | |
| 297 | + | } | |
| 298 | + | app.reset_cursor(); | |
| 299 | + | } else if is_bound(app, "input_backspace", key) { | |
| 300 | + | app.delete_char(); | |
| 301 | + | if app.inputmode == InputMode::Editing { | |
| 302 | + | update_search_selection(app); | |
| 303 | + | } | |
| 304 | + | } else if is_bound(app, "input_cursor_left", key) { | |
| 305 | + | app.move_cursor_left(); | |
| 306 | + | } else if is_bound(app, "input_cursor_right", key) { | |
| 307 | + | app.move_cursor_right(); | |
| 308 | + | } else if is_bound(app, "input_submit", key) { | |
| 309 | + | match app.inputmode { | |
| 310 | + | InputMode::Editing => { | |
| 311 | + | update_search_selection(app); | |
| 312 | + | if app.selected_tab != SelectedTab::DirectoryBrowser { | |
| 313 | + | app.search_input.clear(); | |
| 121 | 314 | } | |
| 122 | − | } | |
| 123 | − | ||
| 124 | − | // Play previous song | |
| 125 | − | KeyCode::Char('<') => { | |
| 126 | − | if !app.queue_list.list.is_empty() { | |
| 127 | − | app.conn.conn.prev()?; | |
| 128 | − | app.update_queue(); | |
| 129 | − | app.conn.update_status(); | |
| 315 | + | if app.selected_tab == SelectedTab::Playlists { | |
| 316 | + | app.refresh_playlist_view().await?; | |
| 130 | 317 | } | |
| 318 | + | app.reset_cursor(); | |
| 319 | + | app.inputmode = InputMode::Normal; | |
| 131 | 320 | } | |
| 132 | − | ||
| 133 | − | // Volume controls | |
| 134 | − | KeyCode::Char('=') | KeyCode::Char('+') => { | |
| 135 | − | app.conn.inc_volume(2); | |
| 136 | − | app.conn.update_status(); | |
| 137 | − | } | |
| 138 | − | ||
| 139 | − | KeyCode::Char('-') => { | |
| 140 | − | app.conn.dec_volume(2); | |
| 141 | − | app.conn.update_status(); | |
| 142 | − | } | |
| 143 | − | ||
| 144 | − | // Toggle Mute | |
| 145 | − | KeyCode::Char('m') => { | |
| 146 | − | match app.conn.volume_status { | |
| 147 | − | VolumeStatus::Muted(v) => { | |
| 148 | − | app.conn.conn.volume(v)?; | |
| 149 | − | app.conn.volume_status = VolumeStatus::Unmuted; | |
| 150 | − | } | |
| 151 | − | VolumeStatus::Unmuted => { | |
| 152 | − | let current_volume = app.conn.status.volume; | |
| 153 | − | app.conn.conn.volume(0)?; | |
| 154 | − | app.conn.volume_status = VolumeStatus::Muted(current_volume); | |
| 321 | + | InputMode::PlaylistRename => { | |
| 322 | + | if let Some(old_name) = app.pl_list.list.get(app.pl_list.index).cloned() { | |
| 323 | + | if !app.pl_newname_input.trim().is_empty() { | |
| 324 | + | app.conn | |
| 325 | + | .client | |
| 326 | + | .rename_playlist(&old_name, &app.pl_newname_input) | |
| 327 | + | .await?; | |
| 328 | + | app.refresh_playlists().await?; | |
| 155 | 329 | } | |
| 156 | 330 | } | |
| 157 | − | app.conn.update_status(); | |
| 158 | − | } | |
| 159 | − | ||
| 160 | − | // Update MPD database | |
| 161 | − | KeyCode::Char('U') => { | |
| 162 | − | app.conn.conn.rescan()?; | |
| 163 | − | app.should_update_song_list = true; | |
| 331 | + | app.pl_newname_input.clear(); | |
| 332 | + | app.reset_cursor(); | |
| 333 | + | app.inputmode = InputMode::Normal; | |
| 164 | 334 | } | |
| 165 | − | ||
| 166 | − | // Search for songs | |
| 167 | − | KeyCode::Char('/') => { | |
| 168 | − | if app.inputmode == InputMode::Normal { | |
| 169 | − | app.inputmode = InputMode::Editing; | |
| 170 | − | } else { | |
| 171 | − | app.inputmode = InputMode::Normal; | |
| 335 | + | InputMode::NewPlaylist => { | |
| 336 | + | if !app.pl_new_pl_input.trim().is_empty() { | |
| 337 | + | for song in &app.pl_new_pl_songs_buffer { | |
| 338 | + | app.conn.add_to_playlist(&app.pl_new_pl_input, song).await?; | |
| 339 | + | } | |
| 340 | + | app.refresh_playlists().await?; | |
| 172 | 341 | } | |
| 342 | + | app.pl_new_pl_input.clear(); | |
| 343 | + | app.reset_cursor(); | |
| 344 | + | app.inputmode = InputMode::Normal; | |
| 173 | 345 | } | |
| 174 | − | ||
| 175 | − | // Add or Remove from Current Playlist | |
| 176 | − | KeyCode::Char(' ') => { | |
| 177 | − | app.handle_add_or_remove_from_current_playlist()?; | |
| 346 | + | InputMode::Normal => {} | |
| 347 | + | } | |
| 348 | + | } else if let KeyCode::Char(character) = key.code { | |
| 349 | + | if !key | |
| 350 | + | .modifiers | |
| 351 | + | .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) | |
| 352 | + | { | |
| 353 | + | app.enter_char(character); | |
| 354 | + | if app.inputmode == InputMode::Editing { | |
| 355 | + | update_search_selection(app); | |
| 178 | 356 | } | |
| 179 | − | _ => {} | |
| 180 | 357 | } | |
| 358 | + | } | |
| 359 | + | Ok(()) | |
| 360 | + | } | |
| 181 | 361 | ||
| 182 | − | // Tab specific keymaps | |
| 183 | − | match app.selected_tab { | |
| 184 | − | SelectedTab::Queue => { | |
| 185 | − | match key_event.code { | |
| 186 | − | // Go Up | |
| 187 | − | KeyCode::Char('j') | KeyCode::Down => app.queue_list.next(), | |
| 188 | − | ||
| 189 | − | // Go down | |
| 190 | − | KeyCode::Char('k') | KeyCode::Up => app.queue_list.prev(), | |
| 191 | − | ||
| 192 | − | // Next directory | |
| 193 | − | KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { | |
| 194 | − | app.conn.conn.switch(app.queue_list.index as u32)?; | |
| 195 | − | app.conn.update_status(); | |
| 196 | − | } | |
| 197 | − | ||
| 198 | − | // Delete highlighted song from the queue | |
| 199 | − | KeyCode::Char('d') => { | |
| 200 | − | if app.queue_list.index >= app.queue_list.list.len() | |
| 201 | − | && app.queue_list.index != 0 | |
| 202 | − | { | |
| 203 | − | app.queue_list.index -= 1; | |
| 204 | − | } | |
| 205 | − | ||
| 206 | − | app.conn.conn.delete(app.queue_list.index as u32)?; | |
| 207 | − | ||
| 208 | − | if app.queue_list.index >= app.queue_list.list.len().saturating_sub(1) | |
| 209 | − | && app.queue_list.index != 0 | |
| 210 | − | { | |
| 211 | − | app.queue_list.index -= 1; | |
| 212 | − | } | |
| 213 | − | ||
| 214 | − | app.conn.update_status(); | |
| 215 | − | app.update_queue(); | |
| 216 | − | } | |
| 217 | − | ||
| 218 | − | // Swap highlighted song with next one | |
| 219 | − | KeyCode::Char('J') => { | |
| 220 | − | let current: u32 = app.queue_list.index as u32; | |
| 221 | − | let next: u32 = if (current + 1) as usize == app.queue_list.list.len() { | |
| 222 | − | app.queue_list.index as u32 | |
| 223 | − | } else { | |
| 224 | − | app.queue_list.index += 1; | |
| 225 | − | current + 1 | |
| 226 | − | }; | |
| 227 | − | app.conn.conn.swap(current, next)?; | |
| 228 | − | app.update_queue(); | |
| 229 | − | app.conn.update_status(); | |
| 230 | − | } | |
| 231 | − | ||
| 232 | − | // Swap highlighted song with previous one | |
| 233 | − | KeyCode::Char('K') => { | |
| 234 | − | let current: u32 = app.queue_list.index as u32; | |
| 235 | − | let prev: u32 = if current == 0 { | |
| 236 | − | app.queue_list.index as u32 | |
| 237 | − | } else { | |
| 238 | − | app.queue_list.index -= 1; | |
| 239 | − | current - 1 | |
| 240 | − | }; | |
| 241 | − | app.conn.conn.swap(current, prev)?; | |
| 242 | − | app.update_queue(); | |
| 243 | − | app.conn.update_status(); | |
| 244 | − | } | |
| 245 | − | ||
| 246 | − | // go to top of list | |
| 247 | − | KeyCode::Char('g') => app.queue_list.index = 0, | |
| 248 | − | ||
| 249 | − | // go to bottom of list | |
| 250 | − | KeyCode::Char('G') => app.queue_list.index = app.queue_list.list.len() - 1, | |
| 251 | − | ||
| 252 | − | _ => {} | |
| 362 | + | fn update_search_selection(app: &mut App) { | |
| 363 | + | if app.search_input.is_empty() { | |
| 364 | + | return; | |
| 365 | + | } | |
| 366 | + | match app.selected_tab { | |
| 367 | + | SelectedTab::Queue => { | |
| 368 | + | let items: Vec<&str> = app | |
| 369 | + | .queue_list | |
| 370 | + | .list | |
| 371 | + | .iter() | |
| 372 | + | .map(|song| song.file.as_str()) | |
| 373 | + | .collect(); | |
| 374 | + | if let Some((best, _)) = fuzzy_search_sorted(&app.search_input, &items).first() { | |
| 375 | + | if let Some(index) = app | |
| 376 | + | .queue_list | |
| 377 | + | .list | |
| 378 | + | .iter() | |
| 379 | + | .position(|song| song.file == *best) | |
| 380 | + | { | |
| 381 | + | app.queue_list.index = index; | |
| 253 | 382 | } | |
| 254 | 383 | } | |
| 255 | − | ||
| 256 | − | SelectedTab::DirectoryBrowser => { | |
| 257 | − | match key_event.code { | |
| 258 | − | // Go Up | |
| 259 | − | KeyCode::Char('j') | KeyCode::Down => app.browser.next(), | |
| 260 | − | ||
| 261 | − | // Go down | |
| 262 | − | KeyCode::Char('k') | KeyCode::Up => app.browser.prev(), | |
| 263 | − | ||
| 264 | − | // Next directory | |
| 265 | − | KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { | |
| 266 | − | // app.update_queue(); | |
| 267 | − | app.handle_enter()?; | |
| 268 | − | app.conn.update_status(); | |
| 269 | − | } | |
| 270 | − | ||
| 271 | − | // head back to previous directory | |
| 272 | − | KeyCode::Char('h') | KeyCode::Left => { | |
| 273 | − | app.browser.handle_go_back(&mut app.conn)? | |
| 274 | − | } | |
| 275 | − | ||
| 276 | − | // go to top of list | |
| 277 | − | KeyCode::Char('g') => app.browser.selected = 0, | |
| 278 | − | ||
| 279 | − | // go to bottom of list | |
| 280 | − | KeyCode::Char('G') => app.browser.selected = app.browser.filetree.len() - 1, | |
| 281 | − | ||
| 282 | − | _ => {} | |
| 283 | − | } | |
| 384 | + | } | |
| 385 | + | SelectedTab::DirectoryBrowser => { | |
| 386 | + | let items: Vec<String> = app | |
| 387 | + | .browser | |
| 388 | + | .entries | |
| 389 | + | .iter() | |
| 390 | + | .map(|entry| entry.name().to_string()) | |
| 391 | + | .collect(); | |
| 392 | + | app.browser_search_results = fuzzy_indices(&app.search_input, &items); | |
| 393 | + | app.browser_search_index = 0; | |
| 394 | + | if let Some(index) = app.browser_search_results.first() { | |
| 395 | + | app.browser.selected = *index; | |
| 284 | 396 | } | |
| 285 | − | ||
| 286 | − | SelectedTab::Playlists => { | |
| 287 | − | match key_event.code { | |
| 288 | − | // Go Up | |
| 289 | − | KeyCode::Char('j') | KeyCode::Down => app.pl_list.next(), | |
| 290 | − | ||
| 291 | − | // Go down | |
| 292 | − | KeyCode::Char('k') | KeyCode::Up => app.pl_list.prev(), | |
| 293 | − | ||
| 294 | − | // go to top of list | |
| 295 | − | KeyCode::Char('g') => app.pl_list.index = 0, | |
| 296 | − | ||
| 297 | − | // go to bottom of list | |
| 298 | − | KeyCode::Char('G') => app.pl_list.index = app.pl_list.list.len() - 1, | |
| 299 | − | ||
| 300 | − | // Playlist Rename | |
| 301 | − | KeyCode::Char('R') => { | |
| 302 | − | app.inputmode = InputMode::PlaylistRename; | |
| 303 | − | } | |
| 304 | − | ||
| 305 | − | // add to current playlist | |
| 306 | − | KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Char(' ') => { | |
| 307 | − | // app.update_queue(); | |
| 308 | − | if !app.pl_list.list.is_empty() { | |
| 309 | − | app.conn | |
| 310 | − | .load_playlist(app.pl_list.list.get(app.pl_list.index).unwrap())?; | |
| 311 | − | app.conn.update_status(); | |
| 312 | − | } | |
| 313 | − | } | |
| 314 | − | _ => {} | |
| 397 | + | } | |
| 398 | + | SelectedTab::Playlists => { | |
| 399 | + | let items: Vec<&str> = app.pl_list.list.iter().map(String::as_str).collect(); | |
| 400 | + | if let Some((best, _)) = fuzzy_search_sorted(&app.search_input, &items).first() { | |
| 401 | + | if let Some(index) = app.pl_list.list.iter().position(|name| name == *best) { | |
| 402 | + | app.pl_list.index = index; | |
| 315 | 403 | } | |
| 316 | 404 | } | |
| 317 | 405 | } | |
| 318 | 406 | } | |
| 319 | − | Ok(()) | |
| 320 | 407 | } | |
| 321 | 408 | ||
| 322 | − | pub fn handle_mouse_events(mouse_event: MouseEvent, app: &mut App) -> AppResult<()> { | |
| 323 | − | match mouse_event.kind { | |
| 409 | + | fn cycle_browser_search(app: &mut App, forward: bool) { | |
| 410 | + | let len = app.browser_search_results.len(); | |
| 411 | + | if len == 0 { | |
| 412 | + | return; | |
| 413 | + | } | |
| 414 | + | app.browser_search_index = if forward { | |
| 415 | + | (app.browser_search_index + 1) % len | |
| 416 | + | } else { | |
| 417 | + | app.browser_search_index.checked_sub(1).unwrap_or(len - 1) | |
| 418 | + | }; | |
| 419 | + | app.browser.selected = app.browser_search_results[app.browser_search_index]; | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | fn fuzzy_indices(query: &str, items: &[String]) -> Vec<usize> { | |
| 423 | + | if query.is_empty() { | |
| 424 | + | return Vec::new(); | |
| 425 | + | } | |
| 426 | + | let candidates: Vec<&str> = items.iter().map(String::as_str).collect(); | |
| 427 | + | let ranked = fuzzy_search_sorted(query, &candidates); | |
| 428 | + | let mut used = vec![false; items.len()]; | |
| 429 | + | ranked | |
| 430 | + | .into_iter() | |
| 431 | + | .filter(|(_, score)| *score > 0.0) | |
| 432 | + | .filter_map(|(name, _)| { | |
| 433 | + | let index = items | |
| 434 | + | .iter() | |
| 435 | + | .enumerate() | |
| 436 | + | .find(|(index, item)| !used[*index] && item.as_str() == name) | |
| 437 | + | .map(|(index, _)| index)?; | |
| 438 | + | used[index] = true; | |
| 439 | + | Some(index) | |
| 440 | + | }) | |
| 441 | + | .collect() | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | pub async fn handle_mouse_events(mouse: MouseEvent, app: &mut App) -> AppResult<()> { | |
| 445 | + | let previous_playlist = app.pl_list.index; | |
| 446 | + | match mouse.kind { | |
| 324 | 447 | MouseEventKind::ScrollUp => app.handle_scroll_up(), | |
| 325 | 448 | MouseEventKind::ScrollDown => app.handle_scroll_down(), | |
| 326 | − | MouseEventKind::Down(button) => { | |
| 327 | − | let (x, y) = (mouse_event.column, mouse_event.row); | |
| 328 | − | if button == crossterm::event::MouseButton::Left { | |
| 329 | − | app.handle_mouse_left_click(x, y)?; | |
| 330 | − | } | |
| 331 | − | } | |
| 332 | 449 | _ => {} | |
| 333 | 450 | } | |
| 451 | + | if app.selected_tab == SelectedTab::Playlists && app.pl_list.index != previous_playlist { | |
| 452 | + | app.playlist_song_index = 0; | |
| 453 | + | app.refresh_playlist_view().await?; | |
| 454 | + | } | |
| 334 | 455 | Ok(()) | |
| 335 | 456 | } | |
| 457 | + | ||
| 458 | + | #[cfg(test)] | |
| 459 | + | mod tests { | |
| 460 | + | use super::fuzzy_indices; | |
| 461 | + | ||
| 462 | + | #[test] | |
| 463 | + | fn fuzzy_search_returns_all_matches_and_keeps_duplicate_names() { | |
| 464 | + | let items = vec![ | |
| 465 | + | "mr-robot-vol1".to_string(), | |
| 466 | + | "mr-robot-vol2".to_string(), | |
| 467 | + | "mr-robot-vol3".to_string(), | |
| 468 | + | "other-album".to_string(), | |
| 469 | + | ]; | |
| 470 | + | let results = fuzzy_indices("robot", &items); | |
| 471 | + | assert_eq!(results.len(), 3); | |
| 472 | + | assert_eq!( | |
| 473 | + | results | |
| 474 | + | .iter() | |
| 475 | + | .map(|index| items[*index].as_str()) | |
| 476 | + | .collect::<Vec<_>>(), | |
| 477 | + | vec!["mr-robot-vol1", "mr-robot-vol2", "mr-robot-vol3"] | |
| 478 | + | ); | |
| 479 | + | } | |
| 480 | + | } | |
| @@ -1,6 +1 @@ | |||
| 1 | − | pub mod event; | |
| 2 | 1 | pub mod handler; | |
| 3 | − | pub mod search_keys; | |
| 4 | − | pub mod pl_rename_keys; | |
| 5 | − | pub mod pl_append_keys; | |
| 6 | − | pub mod new_pl_keys; | |
| @@ -1,47 +0,0 @@ | |||
| 1 | − | use crate::{ | |
| 2 | − | app::{App, AppResult}, | |
| 3 | − | ui::InputMode, | |
| 4 | − | }; | |
| 5 | − | use crossterm::event::{KeyCode, KeyEvent}; | |
| 6 | − | ||
| 7 | − | pub fn handle_new_pl_keys(key_event: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 8 | − | match key_event.code { | |
| 9 | − | KeyCode::Esc => { | |
| 10 | − | app.pl_new_pl_input.clear(); | |
| 11 | − | app.reset_cursor(); | |
| 12 | − | app.inputmode = InputMode::Normal; | |
| 13 | − | } | |
| 14 | − | KeyCode::Char(to_insert) => { | |
| 15 | − | app.enter_char(to_insert); | |
| 16 | − | } | |
| 17 | − | KeyCode::Enter => { | |
| 18 | − | let pl_name = &app.pl_new_pl_input; | |
| 19 | − | ||
| 20 | − | for song in app.pl_new_pl_songs_buffer.iter() { | |
| 21 | − | app.conn.conn.pl_push(pl_name, song)?; | |
| 22 | − | } | |
| 23 | − | app.pl_new_pl_input.clear(); | |
| 24 | − | ||
| 25 | − | app.pl_list.list = App::get_playlist(&mut app.conn.conn)?; | |
| 26 | − | app.append_list = App::get_append_list(&mut app.conn.conn)?; | |
| 27 | − | ||
| 28 | − | app.reset_cursor(); | |
| 29 | − | app.inputmode = InputMode::Normal; | |
| 30 | − | } | |
| 31 | − | ||
| 32 | − | KeyCode::Backspace => { | |
| 33 | − | app.delete_char(); | |
| 34 | − | } | |
| 35 | − | ||
| 36 | − | KeyCode::Left => { | |
| 37 | − | app.move_cursor_left(); | |
| 38 | − | } | |
| 39 | − | ||
| 40 | − | KeyCode::Right => { | |
| 41 | − | app.move_cursor_right(); | |
| 42 | − | } | |
| 43 | − | ||
| 44 | − | _ => {} | |
| 45 | − | } | |
| 46 | − | Ok(()) | |
| 47 | − | } | |
| @@ -1,115 +0,0 @@ | |||
| 1 | − | use crate::app::{App, AppResult, SelectedTab}; | |
| 2 | − | use crate::ui::InputMode; | |
| 3 | − | use crate::utils::FileExtension; | |
| 4 | − | use crossterm::event::{KeyCode, KeyEvent}; | |
| 5 | − | use std::path::Path; | |
| 6 | − | ||
| 7 | − | pub fn hande_pl_append_keys(key_event: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 8 | − | match key_event.code { | |
| 9 | − | KeyCode::Char('q') | KeyCode::Esc => { | |
| 10 | − | app.playlist_popup = false; | |
| 11 | − | } | |
| 12 | − | ||
| 13 | − | KeyCode::Char('j') | KeyCode::Down => app.append_list.next(), | |
| 14 | − | KeyCode::Char('k') | KeyCode::Up => app.append_list.prev(), | |
| 15 | − | ||
| 16 | − | KeyCode::Enter => { | |
| 17 | − | // name of highlighted playlist in append list | |
| 18 | − | let pl_name = &app.append_list.get_item_at_current_index(); | |
| 19 | − | ||
| 20 | − | match app.selected_tab { | |
| 21 | − | SelectedTab::Queue => { | |
| 22 | − | // Just exit out the menu if no item is selected in the Queue | |
| 23 | − | if app.queue_list.list.is_empty() { | |
| 24 | − | app.playlist_popup = false; | |
| 25 | − | return Ok(()); | |
| 26 | − | } | |
| 27 | − | ||
| 28 | − | if let Ok(songs) = app.conn.conn.songs(app.queue_list.index as u32) { | |
| 29 | − | let option_song = songs.first(); | |
| 30 | − | if let Some(song) = option_song { | |
| 31 | − | if *pl_name == "Current Playlist" { | |
| 32 | − | app.conn.conn.push(song)?; | |
| 33 | − | app.update_queue(); | |
| 34 | − | } else if *pl_name == "New Playlist" { | |
| 35 | − | app.pl_new_pl_songs_buffer.clear(); | |
| 36 | − | app.pl_new_pl_songs_buffer.push(song.clone()); | |
| 37 | − | app.inputmode = InputMode::NewPlaylist; | |
| 38 | − | } else { | |
| 39 | − | app.conn.add_to_playlist(pl_name, song)?; | |
| 40 | − | } | |
| 41 | − | } | |
| 42 | − | } | |
| 43 | − | } | |
| 44 | − | ||
| 45 | − | SelectedTab::DirectoryBrowser => { | |
| 46 | − | let (t, f) = app.browser.filetree.get(app.browser.selected).unwrap(); | |
| 47 | − | if t == "file" { | |
| 48 | − | let short_path = f; | |
| 49 | − | if let Some(full_path) = app.conn.get_full_path(short_path) { | |
| 50 | − | let song = app.conn.get_song_with_only_filename(full_path); | |
| 51 | − | ||
| 52 | − | if *pl_name == "Current Playlist" { | |
| 53 | − | app.conn.conn.push(&song)?; | |
| 54 | − | app.update_queue(); | |
| 55 | − | } else if *pl_name == "New Playlist" { | |
| 56 | − | app.pl_new_pl_songs_buffer.clear(); | |
| 57 | − | app.pl_new_pl_songs_buffer.push(song.clone()); | |
| 58 | − | app.inputmode = InputMode::NewPlaylist; | |
| 59 | − | } else { | |
| 60 | − | app.conn.add_to_playlist(pl_name, &song)?; | |
| 61 | − | } | |
| 62 | − | } | |
| 63 | − | } else if t == "directory" { | |
| 64 | − | let file = format!("{}/{}", app.browser.path, f); | |
| 65 | − | app.pl_new_pl_songs_buffer.clear(); | |
| 66 | − | for (t, f) in app.conn.conn.listfiles(&file)?.iter() { | |
| 67 | − | // dir_vec.push((t, f)); | |
| 68 | − | if t == "file" | |
| 69 | − | && Path::new(&f).has_extension(&[ | |
| 70 | − | "mp3", "ogg", "flac", "m4a", "wav", "aac", "opus", "ape", | |
| 71 | − | "wma", "mpc", "aiff", "dff", "mp2", "mka", | |
| 72 | − | ]) | |
| 73 | − | { | |
| 74 | − | let full_path = app.conn.get_full_path(f).unwrap_or_default(); | |
| 75 | − | let song = app.conn.get_song_with_only_filename(full_path); | |
| 76 | − | if *pl_name == "Current Playlist" { | |
| 77 | − | app.conn.conn.push(&song)?; | |
| 78 | − | } else if *pl_name == "New Playlist" { | |
| 79 | − | app.pl_new_pl_songs_buffer.push(song.clone()); | |
| 80 | − | app.inputmode = InputMode::NewPlaylist; | |
| 81 | − | } else { | |
| 82 | − | app.conn.add_to_playlist(pl_name, &song)?; | |
| 83 | − | } | |
| 84 | − | } | |
| 85 | − | } | |
| 86 | − | } | |
| 87 | − | } | |
| 88 | − | ||
| 89 | − | SelectedTab::Playlists => { | |
| 90 | − | let playlist_name = app.pl_list.get_item_at_current_index(); | |
| 91 | − | if *pl_name == "Current Playlist" { | |
| 92 | − | app.conn.load_playlist(playlist_name)?; | |
| 93 | − | app.update_queue(); | |
| 94 | − | } else if *pl_name == "New Playlist" { | |
| 95 | − | app.inputmode = InputMode::NewPlaylist; | |
| 96 | − | } else { | |
| 97 | − | let songs = app.conn.conn.playlist(playlist_name)?; | |
| 98 | − | for song in songs { | |
| 99 | − | // We ignore the Err() since there could be songs in playlists, which do not exist in the db anymore. | |
| 100 | − | // So instead of panicking, we just ignore if the song does not exists | |
| 101 | − | app.conn.add_to_playlist(pl_name, &song).unwrap_or(()); | |
| 102 | − | } | |
| 103 | − | } | |
| 104 | − | } | |
| 105 | − | } | |
| 106 | − | ||
| 107 | − | // hide the playlist popup | |
| 108 | − | app.playlist_popup = false; | |
| 109 | − | app.append_list.index = 0; | |
| 110 | − | } | |
| 111 | − | _ => {} | |
| 112 | − | } | |
| 113 | − | ||
| 114 | − | Ok(()) | |
| 115 | − | } | |
| @@ -1,43 +0,0 @@ | |||
| 1 | − | use crate::{ | |
| 2 | − | app::{App, AppResult}, | |
| 3 | − | ui::InputMode, | |
| 4 | − | }; | |
| 5 | − | use crossterm::event::{KeyCode, KeyEvent}; | |
| 6 | − | ||
| 7 | − | pub fn handle_pl_rename_keys(key_event: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 8 | − | match key_event.code { | |
| 9 | − | KeyCode::Esc => { | |
| 10 | − | app.pl_newname_input.clear(); | |
| 11 | − | app.reset_cursor(); | |
| 12 | − | app.inputmode = InputMode::Normal; | |
| 13 | − | } | |
| 14 | − | KeyCode::Char(to_insert) => { | |
| 15 | − | app.enter_char(to_insert); | |
| 16 | − | } | |
| 17 | − | KeyCode::Enter => { | |
| 18 | − | app.conn.conn.pl_rename( | |
| 19 | − | app.pl_list.get_item_at_current_index(), | |
| 20 | − | &app.pl_newname_input, | |
| 21 | − | )?; | |
| 22 | − | app.pl_list.list = App::get_playlist(&mut app.conn.conn)?; | |
| 23 | − | app.pl_newname_input.clear(); | |
| 24 | − | app.reset_cursor(); | |
| 25 | − | app.inputmode = InputMode::Normal; | |
| 26 | − | } | |
| 27 | − | ||
| 28 | − | KeyCode::Backspace => { | |
| 29 | − | app.delete_char(); | |
| 30 | − | } | |
| 31 | − | ||
| 32 | − | KeyCode::Left => { | |
| 33 | − | app.move_cursor_left(); | |
| 34 | − | } | |
| 35 | − | ||
| 36 | − | KeyCode::Right => { | |
| 37 | − | app.move_cursor_right(); | |
| 38 | − | } | |
| 39 | − | ||
| 40 | − | _ => {} | |
| 41 | − | } | |
| 42 | − | Ok(()) | |
| 43 | − | } | |
| @@ -1,110 +0,0 @@ | |||
| 1 | − | use crate::{ | |
| 2 | − | app::{App, AppResult, SelectedTab}, | |
| 3 | − | ui::InputMode, | |
| 4 | − | }; | |
| 5 | − | use crossterm::event::{KeyCode, KeyEvent}; | |
| 6 | − | use rust_fuzzy_search::{self, fuzzy_search_sorted}; | |
| 7 | − | ||
| 8 | − | pub fn handle_search_keys(key_event: KeyEvent, app: &mut App) -> AppResult<()> { | |
| 9 | − | match app.selected_tab { | |
| 10 | − | SelectedTab::DirectoryBrowser => { | |
| 11 | − | let list: Vec<&str> = app | |
| 12 | − | .browser | |
| 13 | − | .filetree | |
| 14 | − | .iter() | |
| 15 | − | .map(|(_, f)| f.as_str()) | |
| 16 | − | .collect::<Vec<&str>>(); | |
| 17 | − | ||
| 18 | − | let res: Vec<(&str, f32)> = fuzzy_search_sorted(&app.search_input, &list); | |
| 19 | − | let res = res.iter().map(|(x, _)| *x).collect::<Vec<&str>>(); | |
| 20 | − | ||
| 21 | − | for (i, (_, item)) in app.browser.filetree.iter().enumerate() { | |
| 22 | − | if item.contains(res.first().unwrap()) { | |
| 23 | − | app.browser.selected = i; | |
| 24 | − | } | |
| 25 | − | } | |
| 26 | − | } | |
| 27 | − | ||
| 28 | − | SelectedTab::Queue => { | |
| 29 | − | let list: Vec<&str> = app | |
| 30 | − | .queue_list | |
| 31 | − | .list | |
| 32 | − | .iter() | |
| 33 | − | .map(|f| f.file.as_str()) | |
| 34 | − | .collect::<Vec<&str>>(); | |
| 35 | − | let res: Vec<(&str, f32)> = fuzzy_search_sorted(&app.search_input, &list); | |
| 36 | − | let res = res.iter().map(|(x, _)| *x).collect::<Vec<&str>>(); | |
| 37 | − | ||
| 38 | − | for (i, item) in app.queue_list.list.iter().enumerate() { | |
| 39 | − | if item.file.contains(res.first().unwrap()) { | |
| 40 | − | app.queue_list.index = i; | |
| 41 | − | } | |
| 42 | − | } | |
| 43 | − | } | |
| 44 | − | ||
| 45 | − | SelectedTab::Playlists => { | |
| 46 | − | let list: Vec<&str> = app | |
| 47 | − | .pl_list | |
| 48 | − | .list | |
| 49 | − | .iter() | |
| 50 | − | .map(|f| f.as_str()) | |
| 51 | − | .collect::<Vec<&str>>(); | |
| 52 | − | let res: Vec<(&str, f32)> = fuzzy_search_sorted(&app.search_input, &list); | |
| 53 | − | let res = res.iter().map(|(x, _)| *x).collect::<Vec<&str>>(); | |
| 54 | − | ||
| 55 | − | for (i, item) in app.pl_list.list.iter().enumerate() { | |
| 56 | − | if item.contains(res.first().unwrap()) { | |
| 57 | − | app.pl_list.index = i; | |
| 58 | − | } | |
| 59 | − | } | |
| 60 | − | } | |
| 61 | − | } | |
| 62 | − | ||
| 63 | − | // Keybind for searching | |
| 64 | − | // | |
| 65 | − | // Keybinds for when the search prompt is visible | |
| 66 | − | match key_event.code { | |
| 67 | − | KeyCode::Esc => { | |
| 68 | − | app.inputmode = InputMode::Normal; | |
| 69 | − | } | |
| 70 | − | KeyCode::Char(to_insert) => { | |
| 71 | − | app.enter_char(to_insert); | |
| 72 | − | } | |
| 73 | − | KeyCode::Enter => { | |
| 74 | − | let list: Vec<&str> = app | |
| 75 | − | .browser | |
| 76 | − | .filetree | |
| 77 | − | .iter() | |
| 78 | − | .map(|(_, f)| f.as_str()) | |
| 79 | − | .collect::<Vec<&str>>(); | |
| 80 | − | ||
| 81 | − | let res: Vec<(&str, f32)> = fuzzy_search_sorted(&app.search_input, &list); | |
| 82 | − | let (res, _) = res.first().unwrap(); | |
| 83 | − | ||
| 84 | − | for (i, (_, item)) in app.browser.filetree.iter().enumerate() { | |
| 85 | − | if item.contains(res) { | |
| 86 | − | app.browser.selected = i; | |
| 87 | − | } | |
| 88 | − | } | |
| 89 | − | ||
| 90 | − | app.search_input.clear(); | |
| 91 | − | app.reset_cursor(); | |
| 92 | − | app.inputmode = InputMode::Normal; | |
| 93 | − | } | |
| 94 | − | ||
| 95 | − | KeyCode::Backspace => { | |
| 96 | − | app.delete_char(); | |
| 97 | − | } | |
| 98 | − | ||
| 99 | − | KeyCode::Left => { | |
| 100 | − | app.move_cursor_left(); | |
| 101 | − | } | |
| 102 | − | ||
| 103 | − | KeyCode::Right => { | |
| 104 | − | app.move_cursor_right(); | |
| 105 | − | } | |
| 106 | − | ||
| 107 | − | _ => {} | |
| 108 | − | } | |
| 109 | − | Ok(()) | |
| 110 | − | } | |
| @@ -1,23 +1,14 @@ | |||
| 1 | − | /// Handle mpd connection | |
| 2 | − | pub mod connection; | |
| 3 | − | ||
| 4 | − | /// Widget renderer | |
| 5 | − | pub mod ui; | |
| 6 | − | ||
| 7 | − | /// Terminal user Interface | |
| 8 | − | pub mod tui; | |
| 1 | + | //! An asynchronous terminal client for Music Player Daemon. | |
| 9 | 2 | ||
| 10 | − | /// Content list | |
| 11 | − | pub mod list; | |
| 12 | − | ||
| 13 | − | /// File Browser | |
| 3 | + | pub mod app; | |
| 14 | 4 | pub mod browser; | |
| 15 | − | ||
| 16 | − | /// Event Handler/ keymaps | |
| 5 | + | pub mod config; | |
| 6 | + | pub mod connection; | |
| 17 | 7 | pub mod event_handler; | |
| 18 | − | ||
| 19 | − | /// Application | |
| 20 | − | pub mod app; | |
| 21 | − | ||
| 22 | − | /// Utilities | |
| 8 | + | pub mod list; | |
| 9 | + | pub mod tui; | |
| 10 | + | pub mod ui; | |
| 23 | 11 | pub mod utils; | |
| 12 | + | ||
| 13 | + | /// The application's error type. | |
| 14 | + | pub type AppResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; | |
| @@ -6,34 +6,20 @@ pub struct ContentList<T> { | |||
| 6 | 6 | ||
| 7 | 7 | impl<T> ContentList<T> { | |
| 8 | 8 | pub fn new() -> Self { | |
| 9 | − | ContentList { | |
| 9 | + | Self { | |
| 10 | 10 | list: Vec::new(), | |
| 11 | 11 | index: 0, | |
| 12 | 12 | } | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | − | // Go to next item in list | |
| 16 | 15 | pub fn next(&mut self) { | |
| 17 | − | let len = self.list.len(); | |
| 18 | − | if len != 0 && self.index < len - 1 { | |
| 19 | − | self.index += 1; | |
| 16 | + | if let Some(last) = self.list.len().checked_sub(1) { | |
| 17 | + | self.index = self.index.saturating_add(1).min(last); | |
| 20 | 18 | } | |
| 21 | 19 | } | |
| 22 | 20 | ||
| 23 | − | /// Go to previous item in list | |
| 24 | 21 | pub fn prev(&mut self) { | |
| 25 | − | if self.index != 0 { | |
| 26 | − | self.index -= 1; | |
| 27 | − | } | |
| 28 | − | } | |
| 29 | − | ||
| 30 | − | pub fn reset_index(&mut self) { | |
| 31 | − | self.index = 0; | |
| 32 | − | } | |
| 33 | − | ||
| 34 | − | /// Returns the self.list[self.index] item | |
| 35 | − | pub fn get_item_at_current_index(&mut self) -> &T { | |
| 36 | − | self.list.get(self.index).unwrap() | |
| 22 | + | self.index = self.index.saturating_sub(1); | |
| 37 | 23 | } | |
| 38 | 24 | } | |
| 39 | 25 | ||
| @@ -1,44 +1,167 @@ | |||
| 1 | + | use std::{env, io, net::Ipv6Addr, str::FromStr, time::Duration}; | |
| 2 | + | ||
| 3 | + | use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind}; | |
| 4 | + | use futures_util::StreamExt; | |
| 1 | 5 | use ratatui::prelude::*; | |
| 2 | − | use rmptui::app::App; | |
| 3 | − | use rmptui::app::AppResult; | |
| 4 | − | use rmptui::event_handler::event::Event; | |
| 5 | − | use rmptui::event_handler::event::EventHandler; | |
| 6 | − | use rmptui::event_handler::handler; | |
| 7 | − | use rmptui::tui; | |
| 8 | − | use std::env; | |
| 9 | − | use std::io; | |
| 10 | − | ||
| 11 | − | fn main() -> AppResult<()> { | |
| 12 | − | // Connection | |
| 13 | − | let env_host = env::var("MPD_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); | |
| 14 | − | let env_port = env::var("MPD_PORT").unwrap_or_else(|_| "6600".to_string()); | |
| 15 | − | let url = format!("{}:{}", env_host, env_port); | |
| 16 | − | let mut app = App::builder(&url)?; | |
| 17 | − | ||
| 18 | − | // UI | |
| 6 | + | use rmptui::{ | |
| 7 | + | app::App, | |
| 8 | + | config::Config, | |
| 9 | + | connection::{MpdClient, MpdSnapshot}, | |
| 10 | + | event_handler::handler, | |
| 11 | + | tui, AppResult, | |
| 12 | + | }; | |
| 13 | + | use tokio::{sync::mpsc, time}; | |
| 14 | + | ||
| 15 | + | #[tokio::main] | |
| 16 | + | async fn main() -> AppResult<()> { | |
| 17 | + | let (config, config_path) = Config::load_or_create()?; | |
| 18 | + | eprintln!("Using configuration: {}", config_path.display()); | |
| 19 | + | ||
| 20 | + | let configured_host = env::var("MPD_HOST").unwrap_or_else(|_| config.mpd.host.clone()); | |
| 21 | + | let (host, password) = configured_host | |
| 22 | + | .split_once('@') | |
| 23 | + | .map(|(password, host)| (host, Some(password.to_string()))) | |
| 24 | + | .unwrap_or_else(|| { | |
| 25 | + | ( | |
| 26 | + | configured_host.as_str(), | |
| 27 | + | (!config.mpd.password.is_empty()).then(|| config.mpd.password.clone()), | |
| 28 | + | ) | |
| 29 | + | }); | |
| 30 | + | if host.is_empty() { | |
| 31 | + | return Err(io::Error::new(io::ErrorKind::InvalidInput, "MPD host cannot be empty").into()); | |
| 32 | + | } | |
| 33 | + | let port = env::var("MPD_PORT") | |
| 34 | + | .map(|port| { | |
| 35 | + | port.parse::<u16>().map_err(|error| { | |
| 36 | + | io::Error::new( | |
| 37 | + | std::io::ErrorKind::InvalidInput, | |
| 38 | + | format!("invalid MPD_PORT value `{port}`: {error}"), | |
| 39 | + | ) | |
| 40 | + | }) | |
| 41 | + | }) | |
| 42 | + | .unwrap_or(Ok(config.mpd.port))?; | |
| 43 | + | if port == 0 { | |
| 44 | + | return Err(io::Error::new( | |
| 45 | + | std::io::ErrorKind::InvalidInput, | |
| 46 | + | "MPD port must be between 1 and 65535", | |
| 47 | + | ) | |
| 48 | + | .into()); | |
| 49 | + | } | |
| 50 | + | let address = mpd_address(host, port); | |
| 51 | + | let mut app = App::new(&address, password.as_deref(), config).await?; | |
| 52 | + | ||
| 19 | 53 | let backend = CrosstermBackend::new(io::stderr()); | |
| 20 | 54 | let terminal = Terminal::new(backend)?; | |
| 21 | − | let events = EventHandler::new(1000); | |
| 22 | − | let mut tui = tui::Tui::new(terminal, events); | |
| 55 | + | let mut tui = tui::Tui::new(terminal); | |
| 23 | 56 | tui.init()?; | |
| 24 | 57 | ||
| 25 | − | // initial directory read | |
| 26 | − | app.browser.update_directory(&mut app.conn)?; | |
| 58 | + | let (updates_tx, mut updates_rx) = mpsc::channel(16); | |
| 59 | + | let watcher_address = address.clone(); | |
| 60 | + | let watcher_password = password.clone(); | |
| 61 | + | let watcher = | |
| 62 | + | tokio::spawn(async move { watch_mpd(watcher_address, watcher_password, updates_tx).await }); | |
| 63 | + | let mut terminal_events = EventStream::new(); | |
| 64 | + | let mut redraw = time::interval(Duration::from_millis(50)); | |
| 65 | + | redraw.set_missed_tick_behavior(time::MissedTickBehavior::Skip); | |
| 27 | 66 | ||
| 28 | − | // initially set the queue's highlighted item to the current playing item | |
| 29 | − | if let Ok(item) = app.conn.conn.currentsong() { | |
| 30 | − | app.queue_list.index = item.unwrap_or_default().place.unwrap_or_default().pos as usize; | |
| 67 | + | let run_result: AppResult<()> = async { | |
| 68 | + | while app.running { | |
| 69 | + | tui.draw(&mut app)?; | |
| 70 | + | tokio::select! { | |
| 71 | + | event = terminal_events.next() => { | |
| 72 | + | match event { | |
| 73 | + | Some(Ok(CrosstermEvent::Key(key))) | |
| 74 | + | if key.kind != KeyEventKind::Release => | |
| 75 | + | { | |
| 76 | + | handler::handle_key_events(key, &mut app).await?; | |
| 77 | + | } | |
| 78 | + | Some(Ok(CrosstermEvent::Key(_))) => {} | |
| 79 | + | Some(Ok(CrosstermEvent::Mouse(mouse))) => { | |
| 80 | + | handler::handle_mouse_events(mouse, &mut app).await?; | |
| 81 | + | } | |
| 82 | + | Some(Ok(CrosstermEvent::Resize(_, _))) | |
| 83 | + | | Some(Ok(CrosstermEvent::FocusGained)) | |
| 84 | + | | Some(Ok(CrosstermEvent::FocusLost)) | |
| 85 | + | | Some(Ok(CrosstermEvent::Paste(_))) => {} | |
| 86 | + | Some(Err(error)) => { | |
| 87 | + | return Err(Box::new(error) as Box<dyn std::error::Error + Send + Sync>); | |
| 88 | + | } | |
| 89 | + | None => { | |
| 90 | + | return Err(std::io::Error::other("terminal event stream ended").into()); | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | _ = redraw.tick() => {} | |
| 95 | + | snapshot = updates_rx.recv() => { | |
| 96 | + | match snapshot { | |
| 97 | + | Some(snapshot) => app.apply_snapshot(snapshot).await?, | |
| 98 | + | None => { | |
| 99 | + | return Err(std::io::Error::other( | |
| 100 | + | "MPD change watcher stopped unexpectedly", | |
| 101 | + | ) | |
| 102 | + | .into()); | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | Ok(()) | |
| 31 | 109 | } | |
| 110 | + | .await; | |
| 32 | 111 | ||
| 33 | − | while app.running { | |
| 34 | − | tui.draw(&mut app)?; | |
| 35 | − | match tui.events.next()? { | |
| 36 | − | Event::Tick => app.tick()?, | |
| 37 | − | Event::Key(key_event) => handler::handle_key_events(key_event, &mut app)?, | |
| 38 | − | Event::Mouse(mouse_event) => handler::handle_mouse_events(mouse_event, &mut app)?, | |
| 39 | − | Event::Resize(_, _) => {} | |
| 112 | + | watcher.abort(); | |
| 113 | + | let exit_result = tui.exit(); | |
| 114 | + | run_result?; | |
| 115 | + | exit_result?; | |
| 116 | + | Ok(()) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | async fn watch_mpd( | |
| 120 | + | address: String, | |
| 121 | + | password: Option<String>, | |
| 122 | + | updates: mpsc::Sender<MpdSnapshot>, | |
| 123 | + | ) -> AppResult<()> { | |
| 124 | + | let mut client = MpdClient::connect(&address, password.as_deref()).await?; | |
| 125 | + | loop { | |
| 126 | + | client.idle().await?; | |
| 127 | + | let snapshot = client.snapshot().await?; | |
| 128 | + | if updates.send(snapshot).await.is_err() { | |
| 129 | + | return Ok(()); | |
| 40 | 130 | } | |
| 41 | 131 | } | |
| 132 | + | } | |
| 42 | 133 | ||
| 43 | − | Ok(()) | |
| 134 | + | fn mpd_address(host: &str, port: u16) -> String { | |
| 135 | + | if host.starts_with('[') && host.contains("]:") { | |
| 136 | + | host.to_string() | |
| 137 | + | } else if let Some(ipv6) = host | |
| 138 | + | .strip_prefix('[') | |
| 139 | + | .and_then(|host| host.strip_suffix(']')) | |
| 140 | + | .filter(|host| Ipv6Addr::from_str(host).is_ok()) | |
| 141 | + | { | |
| 142 | + | format!("[{ipv6}]:{port}") | |
| 143 | + | } else if host.contains(':') && Ipv6Addr::from_str(host).is_ok() { | |
| 144 | + | format!("[{host}]:{port}") | |
| 145 | + | } else if host | |
| 146 | + | .rsplit_once(':') | |
| 147 | + | .is_some_and(|(_, suffix)| suffix.parse::<u16>().is_ok()) | |
| 148 | + | { | |
| 149 | + | host.to_string() | |
| 150 | + | } else { | |
| 151 | + | format!("{host}:{port}") | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | #[cfg(test)] | |
| 156 | + | mod tests { | |
| 157 | + | use super::mpd_address; | |
| 158 | + | ||
| 159 | + | #[test] | |
| 160 | + | fn formats_mpd_host_addresses() { | |
| 161 | + | assert_eq!(mpd_address("localhost", 6600), "localhost:6600"); | |
| 162 | + | assert_eq!(mpd_address("localhost:6601", 6600), "localhost:6601"); | |
| 163 | + | assert_eq!(mpd_address("[::1]", 6600), "[::1]:6600"); | |
| 164 | + | assert_eq!(mpd_address("[::1]:6601", 6600), "[::1]:6601"); | |
| 165 | + | assert_eq!(mpd_address("::1", 6600), "[::1]:6600"); | |
| 166 | + | } | |
| 44 | 167 | } | |
| @@ -1,32 +1,32 @@ | |||
| 1 | − | use std::io; | |
| 2 | 1 | use crate::ui; | |
| 3 | 2 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; | |
| 4 | 3 | use crossterm::terminal::{self, *}; | |
| 4 | + | use std::io; | |
| 5 | 5 | use std::panic; | |
| 6 | 6 | ||
| 7 | − | use crate::app::{App, AppResult}; | |
| 8 | − | use crate::event_handler::event::EventHandler; | |
| 7 | + | use crate::{app::App, AppResult}; | |
| 9 | 8 | ||
| 10 | 9 | pub type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>; | |
| 11 | 10 | ||
| 12 | 11 | #[derive(Debug)] | |
| 13 | 12 | pub struct Tui { | |
| 14 | 13 | terminal: CrosstermTerminal, | |
| 15 | − | pub events: EventHandler, | |
| 16 | 14 | } | |
| 17 | 15 | ||
| 18 | 16 | impl Tui { | |
| 19 | − | pub fn new(terminal: CrosstermTerminal, events: EventHandler) -> Self { | |
| 20 | − | Self { terminal, events } | |
| 17 | + | pub fn new(terminal: CrosstermTerminal) -> Self { | |
| 18 | + | Self { terminal } | |
| 21 | 19 | } | |
| 22 | 20 | ||
| 23 | 21 | pub fn init(&mut self) -> AppResult<()> { | |
| 24 | 22 | terminal::enable_raw_mode()?; | |
| 25 | − | crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; | |
| 23 | + | crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?; | |
| 26 | 24 | ||
| 27 | 25 | let panic_hook = panic::take_hook(); | |
| 28 | 26 | panic::set_hook(Box::new(move |panic| { | |
| 29 | − | Self::reset().expect("failed to reset the terminal"); | |
| 27 | + | if let Err(error) = Self::reset() { | |
| 28 | + | eprintln!("failed to reset the terminal: {error}"); | |
| 29 | + | } | |
| 30 | 30 | panic_hook(panic); | |
| 31 | 31 | })); | |
| 32 | 32 | Ok(()) | |
| @@ -1,15 +1,13 @@ | |||
| 1 | 1 | use std::time::Duration; | |
| 2 | 2 | ||
| 3 | 3 | use crate::{ | |
| 4 | − | app::{App, SelectedTab}, | |
| 5 | − | connection::VolumeStatus, | |
| 6 | − | }; | |
| 7 | − | use ratatui::{ | |
| 8 | − | prelude::*, | |
| 9 | − | widgets::{block::Title, *}, | |
| 4 | + | app::{App, PlaylistFocus, SelectedTab}, | |
| 5 | + | browser::BrowserEntry, | |
| 6 | + | connection::{PlaybackState, Song, VolumeStatus}, | |
| 10 | 7 | }; | |
| 8 | + | use ratatui::{prelude::*, widgets::*}; | |
| 11 | 9 | ||
| 12 | − | #[derive(Debug, PartialEq)] | |
| 10 | + | #[derive(Debug, PartialEq, Clone, Copy)] | |
| 13 | 11 | pub enum InputMode { | |
| 14 | 12 | Editing, | |
| 15 | 13 | Normal, | |
| @@ -17,13 +15,49 @@ pub enum InputMode { | |||
| 17 | 15 | NewPlaylist, | |
| 18 | 16 | } | |
| 19 | 17 | ||
| 18 | + | fn song_row(song: &Song, playing: bool, queued: bool) -> Row<'static> { | |
| 19 | + | let mut artist = song.artist.clone().unwrap_or_default(); | |
| 20 | + | let track = tag_value(song, "Track"); | |
| 21 | + | let title = song.title.clone().unwrap_or_else(|| song.file.clone()); | |
| 22 | + | let album = tag_value(song, "Album"); | |
| 23 | + | let time = App::format_time(song.duration.unwrap_or(Duration::ZERO)); | |
| 24 | + | ||
| 25 | + | if playing { | |
| 26 | + | artist.insert_str(0, "> "); | |
| 27 | + | } | |
| 28 | + | let row = Row::new(vec![ | |
| 29 | + | Cell::from(artist.cyan()), | |
| 30 | + | Cell::from(if playing { | |
| 31 | + | format!(" {track}").green() | |
| 32 | + | } else { | |
| 33 | + | track.green() | |
| 34 | + | }), | |
| 35 | + | Cell::from(if playing { format!(" {title}") } else { title }), | |
| 36 | + | Cell::from(album.cyan()), | |
| 37 | + | Cell::from(time.magenta()), | |
| 38 | + | ]); | |
| 39 | + | if queued { | |
| 40 | + | row.bold() | |
| 41 | + | } else { | |
| 42 | + | row | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | fn tag_value(song: &Song, name: &str) -> String { | |
| 47 | + | song.tags | |
| 48 | + | .iter() | |
| 49 | + | .find(|(tag, _)| tag.eq_ignore_ascii_case(name)) | |
| 50 | + | .map(|(_, value)| value.clone()) | |
| 51 | + | .unwrap_or_default() | |
| 52 | + | } | |
| 53 | + | ||
| 20 | 54 | /// Renders the user interface widgets | |
| 21 | 55 | pub fn render(app: &mut App, frame: &mut Frame) { | |
| 22 | 56 | // Layout | |
| 23 | 57 | let layout = Layout::default() | |
| 24 | 58 | .direction(Direction::Vertical) | |
| 25 | 59 | .constraints(vec![Constraint::Percentage(93), Constraint::Min(3)]) | |
| 26 | − | .split(frame.size()); | |
| 60 | + | .split(frame.area()); | |
| 27 | 61 | ||
| 28 | 62 | match app.selected_tab { | |
| 29 | 63 | SelectedTab::Queue => draw_queue(frame, app, layout[0]), | |
| @@ -55,57 +89,15 @@ pub fn render(app: &mut App, frame: &mut Frame) { | |||
| 55 | 89 | fn draw_directory_browser(frame: &mut Frame, app: &mut App, size: Rect) { | |
| 56 | 90 | let total_songs = app.conn.stats.songs.to_string(); | |
| 57 | 91 | ||
| 58 | − | let rows = app.browser.filetree.iter().enumerate().map(|(i, (t, s))| { | |
| 59 | − | if t == "file" { | |
| 60 | − | let song = app.browser.songs.get(i).unwrap().clone(); | |
| 61 | − | ||
| 62 | − | // metadata | |
| 63 | − | let title = song.clone().title.unwrap_or_else(|| song.clone().file); | |
| 64 | − | let artist = song.clone().artist.unwrap_or_default().cyan(); | |
| 65 | − | let album = song | |
| 66 | − | .tags | |
| 67 | − | .iter() | |
| 68 | − | .filter(|(x, _)| x == "Album") | |
| 69 | − | .map(|(_, l)| l.clone()) | |
| 70 | − | .collect::<Vec<String>>() | |
| 71 | − | .join(""); | |
| 72 | − | ||
| 73 | − | let track = song | |
| 74 | − | .tags | |
| 75 | − | .iter() | |
| 76 | − | .filter(|(x, _)| x == "Track") | |
| 77 | − | .map(|(_, l)| l.clone()) | |
| 78 | − | .collect::<Vec<String>>() | |
| 79 | − | .join(""); | |
| 80 | − | ||
| 81 | − | let time = | |
| 82 | − | App::format_time(song.clone().duration.unwrap_or_else(|| Duration::new(0, 0))); | |
| 83 | − | ||
| 84 | − | let mut status: bool = false; | |
| 85 | − | for sn in app.queue_list.list.iter() { | |
| 86 | − | let file = sn.file.split('/').last().unwrap_or_default(); | |
| 87 | − | if file.eq(s) { | |
| 88 | − | status = true; | |
| 89 | − | } | |
| 90 | − | } | |
| 91 | − | ||
| 92 | − | let row = Row::new(vec![ | |
| 93 | − | Cell::from(artist), | |
| 94 | − | Cell::from(track.green()), | |
| 95 | − | Cell::from(title), | |
| 96 | − | Cell::from(album.cyan()), | |
| 97 | − | Cell::from(time.to_string().magenta()), | |
| 98 | − | ]); | |
| 99 | − | ||
| 100 | − | if status { | |
| 101 | − | row.bold() | |
| 102 | − | } else { | |
| 103 | − | row | |
| 104 | − | } | |
| 105 | − | } else { | |
| 106 | − | let row = Row::new(vec![Cell::from(format!("[{}]", *s))]); | |
| 107 | − | row | |
| 92 | + | let rows = app.browser.entries.iter().map(|entry| match entry { | |
| 93 | + | BrowserEntry::Song(song) => { | |
| 94 | + | let queued = | |
| 95 | + | app.queue_list.list.iter().any(|queued| { | |
| 96 | + | queued.file.rsplit('/').next().unwrap_or_default() == entry.name() | |
| 97 | + | }); | |
| 98 | + | song_row(song, false, queued) | |
| 108 | 99 | } | |
| 100 | + | BrowserEntry::Directory(name) => directory_row(name), | |
| 109 | 101 | }); | |
| 110 | 102 | ||
| 111 | 103 | let header = ["Artist", "Track", "Title", "Album", "Time"] | |
| @@ -126,23 +118,21 @@ fn draw_directory_browser(frame: &mut Frame, app: &mut App, size: Rect) { | |||
| 126 | 118 | ) | |
| 127 | 119 | .block( | |
| 128 | 120 | Block::default() | |
| 129 | − | .title(format!("Song Browser: {}", app.browser.path.clone()).bold()) | |
| 121 | + | .title(format!("Song Browser: {}", app.browser.path).bold()) | |
| 130 | 122 | .title( | |
| 131 | − | Title::from(format!("Total Songs: {}", total_songs).green()) | |
| 123 | + | Line::from(format!("Total Songs: {}", total_songs).green()) | |
| 132 | 124 | .alignment(Alignment::Center), | |
| 133 | 125 | ) | |
| 134 | 126 | .title(match app.conn.volume_status { | |
| 135 | 127 | VolumeStatus::Unmuted => { | |
| 136 | − | Title::from(format!("Volume: {}%", app.conn.status.volume).green()) | |
| 128 | + | Line::from(format!("Volume: {}%", app.conn.status.volume).green()) | |
| 137 | 129 | .alignment(Alignment::Right) | |
| 138 | 130 | } | |
| 139 | − | VolumeStatus::Muted(_v) => { | |
| 140 | − | Title::from("Muted".red()).alignment(Alignment::Right) | |
| 141 | − | } | |
| 131 | + | VolumeStatus::Muted(_v) => Line::from("Muted".red()).alignment(Alignment::Right), | |
| 142 | 132 | }) | |
| 143 | 133 | .borders(Borders::ALL), | |
| 144 | 134 | ) | |
| 145 | − | .highlight_style( | |
| 135 | + | .row_highlight_style( | |
| 146 | 136 | Style::default() | |
| 147 | 137 | .add_modifier(Modifier::REVERSED) | |
| 148 | 138 | .fg(Color::Cyan) | |
| @@ -158,53 +148,12 @@ fn draw_directory_browser(frame: &mut Frame, app: &mut App, size: Rect) { | |||
| 158 | 148 | /// draws playing queue | |
| 159 | 149 | fn draw_queue(frame: &mut Frame, app: &mut App, size: Rect) { | |
| 160 | 150 | let rows = app.queue_list.list.iter().enumerate().map(|(i, song)| { | |
| 161 | − | // metadata | |
| 162 | − | let title = song.clone().title.unwrap_or_else(|| song.clone().file); | |
| 163 | − | let artist = song.clone().artist.unwrap_or_default().cyan(); | |
| 164 | − | let album = song | |
| 165 | − | .tags | |
| 166 | − | .iter() | |
| 167 | − | .filter(|(x, _)| x == "Album") | |
| 168 | − | .map(|(_, l)| l.clone()) | |
| 169 | − | .collect::<Vec<String>>() | |
| 170 | − | .join(""); | |
| 171 | − | ||
| 172 | − | let track = song | |
| 173 | − | .tags | |
| 174 | − | .iter() | |
| 175 | − | .filter(|(x, _)| x == "Track") | |
| 176 | − | .map(|(_, l)| l.clone()) | |
| 177 | − | .collect::<Vec<String>>() | |
| 178 | − | .join(""); | |
| 179 | − | ||
| 180 | − | let time = App::format_time(song.clone().duration.unwrap_or_else(|| Duration::new(0, 0))); | |
| 181 | − | ||
| 182 | − | let row = Row::new(vec![ | |
| 183 | − | Cell::from(artist.clone()), | |
| 184 | − | Cell::from(track.clone().green()), | |
| 185 | − | Cell::from(title.clone()), | |
| 186 | − | Cell::from(album.clone().cyan()), | |
| 187 | − | Cell::from(time.to_string().magenta()), | |
| 188 | − | ]); | |
| 189 | − | ||
| 190 | − | let place = app.conn.current_song.place; | |
| 191 | − | if let Some(pos) = place { | |
| 192 | − | if i == pos.pos as usize { | |
| 193 | − | let row = Row::new(vec![ | |
| 194 | − | Cell::from(format!("> {}", artist)), | |
| 195 | − | Cell::from(format!(" {}", track).green()), | |
| 196 | − | Cell::from(format!(" {}", title)), | |
| 197 | − | Cell::from(album.cyan()), | |
| 198 | − | Cell::from(time.to_string().magenta()), | |
| 199 | − | ]); | |
| 200 | − | ||
| 201 | − | row | |
| 202 | − | } else { | |
| 203 | − | row | |
| 204 | − | } | |
| 205 | − | } else { | |
| 206 | − | row | |
| 207 | − | } | |
| 151 | + | let playing = app | |
| 152 | + | .conn | |
| 153 | + | .current_song | |
| 154 | + | .place | |
| 155 | + | .is_some_and(|position| i == position.pos as usize); | |
| 156 | + | song_row(song, playing, false) | |
| 208 | 157 | }); | |
| 209 | 158 | ||
| 210 | 159 | let header = ["Artist", "Track", "Title", "Album", "Time"] | |
| @@ -225,22 +174,18 @@ fn draw_queue(frame: &mut Frame, app: &mut App, size: Rect) { | |||
| 225 | 174 | ) | |
| 226 | 175 | .block( | |
| 227 | 176 | Block::default() | |
| 228 | − | .title(Title::from("Play Queue".green().bold())) | |
| 229 | − | .title(Title::from( | |
| 230 | − | format!("({} items)", app.queue_list.list.len()).bold(), | |
| 231 | − | )) | |
| 177 | + | .title("Play Queue".green().bold()) | |
| 178 | + | .title(format!("({} items)", app.queue_list.list.len()).bold()) | |
| 232 | 179 | .title(match app.conn.volume_status { | |
| 233 | 180 | VolumeStatus::Unmuted => { | |
| 234 | − | Title::from(format!("Volume: {}%", app.conn.status.volume).green()) | |
| 181 | + | Line::from(format!("Volume: {}%", app.conn.status.volume).green()) | |
| 235 | 182 | .alignment(Alignment::Right) | |
| 236 | 183 | } | |
| 237 | − | VolumeStatus::Muted(_v) => { | |
| 238 | − | Title::from("Muted".red()).alignment(Alignment::Right) | |
| 239 | − | } | |
| 184 | + | VolumeStatus::Muted(_v) => Line::from("Muted".red()).alignment(Alignment::Right), | |
| 240 | 185 | }) | |
| 241 | 186 | .borders(Borders::ALL), | |
| 242 | 187 | ) | |
| 243 | − | .highlight_style( | |
| 188 | + | .row_highlight_style( | |
| 244 | 189 | Style::default() | |
| 245 | 190 | .add_modifier(Modifier::REVERSED) | |
| 246 | 191 | .fg(Color::Cyan) | |
| @@ -258,37 +203,36 @@ fn draw_search_bar(frame: &mut Frame, app: &mut App, size: Rect) { | |||
| 258 | 203 | // Make the cursor visible and ask ratatui to put it at the specified coordinates after | |
| 259 | 204 | // rendering | |
| 260 | 205 | #[allow(clippy::cast_possible_truncation)] | |
| 261 | − | frame.set_cursor( | |
| 206 | + | frame.set_cursor_position(( | |
| 262 | 207 | // Draw the cursor at the current position in the input field. | |
| 263 | 208 | // This position is can be controlled via the left and right arrow key | |
| 264 | 209 | size.x + app.search_cursor_pos as u16 + 2, | |
| 265 | 210 | // Move one line down, from the border to the input line | |
| 266 | 211 | size.y + 1, | |
| 267 | − | ); | |
| 212 | + | )); | |
| 268 | 213 | ||
| 269 | 214 | let input = Paragraph::new("/".to_string() + &app.search_input) | |
| 270 | 215 | .style(Style::default()) | |
| 271 | 216 | .block( | |
| 272 | 217 | Block::default() | |
| 273 | 218 | .borders(Borders::ALL) | |
| 274 | − | .title("Search Forward: ".bold().green()), | |
| 219 | + | .title("Search: Enter to keep, n/N next/previous ".bold().green()), | |
| 275 | 220 | ); | |
| 276 | 221 | frame.render_widget(input, size); | |
| 277 | 222 | } | |
| 278 | 223 | ||
| 279 | 224 | /// Draws Progress Bar | |
| 280 | 225 | fn draw_progress_bar(frame: &mut Frame, app: &mut App, size: Rect) { | |
| 281 | − | // Get the current playing song | |
| 282 | − | let song = app | |
| 283 | − | .conn | |
| 284 | − | .now_playing() | |
| 285 | − | .unwrap() | |
| 286 | − | .unwrap_or_else(|| "No Title Found".to_string()); | |
| 226 | + | let song = app.conn.now_playing(); | |
| 287 | 227 | ||
| 288 | 228 | // Get the current playing state | |
| 289 | − | let mut state: String = String::new(); | |
| 229 | + | let mut state = String::new(); | |
| 290 | 230 | if !app.queue_list.list.is_empty() { | |
| 291 | − | state.clone_from(&app.conn.state); | |
| 231 | + | state.push_str(match app.conn.status.state { | |
| 232 | + | PlaybackState::Playing => "Playing", | |
| 233 | + | PlaybackState::Paused => "Paused", | |
| 234 | + | PlaybackState::Stopped => "Stopped", | |
| 235 | + | }); | |
| 292 | 236 | state.push(':'); | |
| 293 | 237 | } | |
| 294 | 238 | ||
| @@ -312,11 +256,11 @@ fn draw_progress_bar(frame: &mut Frame, app: &mut App, size: Rect) { | |||
| 312 | 256 | }; | |
| 313 | 257 | ||
| 314 | 258 | // get the duration | |
| 315 | − | let duration = if app.conn.total_duration.as_secs() != 0 { | |
| 259 | + | let duration = if !app.conn.status.total.is_zero() { | |
| 316 | 260 | format!( | |
| 317 | 261 | "[{}/{}]", | |
| 318 | − | App::format_time(app.conn.elapsed), | |
| 319 | − | App::format_time(app.conn.total_duration) | |
| 262 | + | App::format_time(app.conn.current_elapsed()), | |
| 263 | + | App::format_time(app.conn.status.total) | |
| 320 | 264 | ) | |
| 321 | 265 | } else { | |
| 322 | 266 | "".to_string() | |
| @@ -324,21 +268,26 @@ fn draw_progress_bar(frame: &mut Frame, app: &mut App, size: Rect) { | |||
| 324 | 268 | ||
| 325 | 269 | // Define the title | |
| 326 | 270 | let title = Block::default() | |
| 327 | − | .title(Title::from(state.red().bold())) | |
| 328 | − | .title(Title::from(song.green())) | |
| 329 | − | .title(Title::from(duration.cyan().bold()).alignment(Alignment::Right)) | |
| 330 | − | .title(Title::from(modes_bottom).position(block::Position::Bottom)) | |
| 271 | + | .title(state.red().bold()) | |
| 272 | + | .title(song.green()) | |
| 273 | + | .title(Line::from(duration.cyan().bold()).alignment(Alignment::Right)) | |
| 274 | + | .title_bottom(modes_bottom) | |
| 331 | 275 | .borders(Borders::ALL); | |
| 332 | 276 | ||
| 333 | 277 | let progress_bar = LineGauge::default() | |
| 334 | 278 | .block(title.borders(Borders::ALL)) | |
| 335 | − | .gauge_style( | |
| 279 | + | .filled_style( | |
| 336 | 280 | Style::default() | |
| 337 | 281 | .fg(Color::Blue) | |
| 338 | − | .bg(Color::Black) | |
| 339 | 282 | .add_modifier(Modifier::BOLD), | |
| 340 | 283 | ) | |
| 341 | − | .line_set(symbols::line::THICK) | |
| 284 | + | .unfilled_style( | |
| 285 | + | Style::default() | |
| 286 | + | .fg(Color::Black) | |
| 287 | + | .add_modifier(Modifier::BOLD), | |
| 288 | + | ) | |
| 289 | + | .filled_symbol(symbols::line::THICK_HORIZONTAL) | |
| 290 | + | .unfilled_symbol(symbols::line::THICK_HORIZONTAL) | |
| 342 | 291 | .ratio(app.conn.get_progress_ratio()); | |
| 343 | 292 | ||
| 344 | 293 | frame.render_widget(progress_bar, size); | |
| @@ -351,16 +300,24 @@ fn draw_playlist_viewer(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 351 | 300 | .split(area); | |
| 352 | 301 | ||
| 353 | 302 | // Draw list of playlists | |
| 354 | − | let title = Block::default().title(Title::from("Playlist".green().bold())); | |
| 355 | − | let list = List::new(app.pl_list.list.clone()) | |
| 356 | − | .block(title.borders(Borders::ALL)) | |
| 357 | − | .highlight_style( | |
| 358 | − | Style::new() | |
| 359 | − | .fg(Color::Cyan) | |
| 360 | − | .bg(Color::Black) | |
| 361 | − | .add_modifier(Modifier::BOLD) | |
| 362 | − | .add_modifier(Modifier::REVERSED), | |
| 363 | − | ) | |
| 303 | + | let list_focused = app.playlist_focus == PlaylistFocus::List; | |
| 304 | + | let title = Block::default() | |
| 305 | + | .title("Playlists".green().bold()) | |
| 306 | + | .borders(Borders::ALL) | |
| 307 | + | .border_style(if list_focused { | |
| 308 | + | Style::default().fg(Color::Cyan) | |
| 309 | + | } else { | |
| 310 | + | Style::default() | |
| 311 | + | }); | |
| 312 | + | let list = List::new(app.pl_list.list.iter().map(String::as_str)) | |
| 313 | + | .block(title) | |
| 314 | + | .highlight_style(Style::new().fg(Color::Cyan).bg(Color::Black).add_modifier( | |
| 315 | + | if list_focused { | |
| 316 | + | Modifier::BOLD | Modifier::REVERSED | |
| 317 | + | } else { | |
| 318 | + | Modifier::BOLD | |
| 319 | + | }, | |
| 320 | + | )) | |
| 364 | 321 | .repeat_highlight_symbol(true); | |
| 365 | 322 | app.playlists_state.select(Some(app.pl_list.index)); | |
| 366 | 323 | frame.render_stateful_widget(list, layouts[0], &mut app.playlists_state); | |
| @@ -370,7 +327,7 @@ fn draw_playlist_viewer(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 370 | 327 | // Handle if there are no playlists in the mpd database | |
| 371 | 328 | if app.pl_list.list.is_empty() { | |
| 372 | 329 | let title = Block::default() | |
| 373 | − | .title(Title::from("No Playlists Found".red().bold())) | |
| 330 | + | .title("No Playlists Found".red().bold()) | |
| 374 | 331 | .title_alignment(Alignment::Center) | |
| 375 | 332 | .borders(Borders::ALL); | |
| 376 | 333 | frame.render_widget(Clear, area); //this clears out the background | |
| @@ -378,12 +335,11 @@ fn draw_playlist_viewer(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 378 | 335 | return; | |
| 379 | 336 | } | |
| 380 | 337 | ||
| 381 | − | let pl_name = app.pl_list.get_item_at_current_index(); | |
| 382 | − | let songs = app.conn.conn.playlist(pl_name).unwrap(); | |
| 338 | + | let songs = &app.playlist_contents; | |
| 383 | 339 | let rows = songs.iter().map(|song| { | |
| 384 | − | let title = song.clone().title.unwrap_or_default().cyan(); | |
| 385 | − | let artist = song.clone().artist.unwrap_or_else(|| song.clone().file); | |
| 386 | − | let time = App::format_time(song.clone().duration.unwrap_or_else(|| Duration::new(0, 0))); | |
| 340 | + | let title = song.title.as_deref().unwrap_or_default().cyan(); | |
| 341 | + | let artist = song.artist.as_deref().unwrap_or(&song.file); | |
| 342 | + | let time = App::format_time(song.duration.unwrap_or(Duration::ZERO)); | |
| 387 | 343 | ||
| 388 | 344 | let row = Row::new(vec![ | |
| 389 | 345 | Cell::from(artist), | |
| @@ -395,33 +351,40 @@ fn draw_playlist_viewer(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 395 | 351 | ||
| 396 | 352 | let title = Block::default() | |
| 397 | 353 | .title(format!("Content: ({} items)", songs.len()).bold()) | |
| 398 | − | .borders(Borders::ALL); | |
| 354 | + | .borders(Borders::ALL) | |
| 355 | + | .border_style(if app.playlist_focus == PlaylistFocus::Songs { | |
| 356 | + | Style::default().fg(Color::Cyan) | |
| 357 | + | } else { | |
| 358 | + | Style::default() | |
| 359 | + | }); | |
| 399 | 360 | let table = Table::new( | |
| 400 | 361 | rows, | |
| 401 | 362 | vec![ | |
| 402 | − | Constraint::Min(48), | |
| 403 | − | Constraint::Percentage(48), | |
| 404 | − | Constraint::Percentage(4), | |
| 363 | + | Constraint::Percentage(40), | |
| 364 | + | Constraint::Percentage(50), | |
| 365 | + | Constraint::Percentage(10), | |
| 405 | 366 | ], | |
| 406 | 367 | ) | |
| 407 | 368 | .block(title) | |
| 408 | − | .highlight_style( | |
| 369 | + | .row_highlight_style( | |
| 409 | 370 | Style::default() | |
| 410 | 371 | .add_modifier(Modifier::REVERSED) | |
| 411 | 372 | .fg(Color::Cyan) | |
| 412 | 373 | .bg(Color::Black), | |
| 413 | 374 | ) | |
| 414 | 375 | .flex(layout::Flex::SpaceBetween); | |
| 415 | − | frame.render_widget(table, layouts[1]); | |
| 376 | + | app.playlist_songs_state | |
| 377 | + | .select(Some(app.playlist_song_index)); | |
| 378 | + | frame.render_stateful_widget(table, layouts[1], &mut app.playlist_songs_state); | |
| 416 | 379 | } | |
| 417 | 380 | ||
| 418 | 381 | fn draw_add_to_playlist(frame: &mut Frame, app: &mut App, area: Rect) { | |
| 419 | 382 | let area = centered_rect(40, 50, area); | |
| 420 | 383 | let mut state = ListState::default(); | |
| 421 | 384 | let title = Block::default() | |
| 422 | − | .title(Title::from("Add Selected Item to: ")) | |
| 423 | − | .title(Title::from("<Esc> to Cancel".green().bold()).alignment(Alignment::Right)); | |
| 424 | − | let list = List::new(app.append_list.list.clone()) | |
| 385 | + | .title("Add Selected Item to: ") | |
| 386 | + | .title(Line::from("<Esc> to Cancel".green().bold()).alignment(Alignment::Right)); | |
| 387 | + | let list = List::new(app.append_list.list.iter().map(String::as_str)) | |
| 425 | 388 | .block(title.borders(Borders::ALL)) | |
| 426 | 389 | .highlight_style( | |
| 427 | 390 | Style::new() | |
| @@ -439,13 +402,13 @@ fn draw_add_to_playlist(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 439 | 402 | ||
| 440 | 403 | fn draw_rename_playlist(frame: &mut Frame, app: &mut App, area: Rect) { | |
| 441 | 404 | #[allow(clippy::cast_possible_truncation)] | |
| 442 | − | frame.set_cursor( | |
| 405 | + | frame.set_cursor_position(( | |
| 443 | 406 | // Draw the cursor at the current position in the input field. | |
| 444 | 407 | // This position is can be controlled via the left and right arrow key | |
| 445 | 408 | area.x + app.pl_cursor_pos as u16 + 2, | |
| 446 | 409 | // Move one line down, from the border to the input line | |
| 447 | 410 | area.y + 1, | |
| 448 | − | ); | |
| 411 | + | )); | |
| 449 | 412 | ||
| 450 | 413 | let input = Paragraph::new("/".to_string() + &app.pl_newname_input) | |
| 451 | 414 | .style(Style::default()) | |
| @@ -459,13 +422,13 @@ fn draw_rename_playlist(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 459 | 422 | ||
| 460 | 423 | fn draw_new_playlist(frame: &mut Frame, app: &mut App, area: Rect) { | |
| 461 | 424 | #[allow(clippy::cast_possible_truncation)] | |
| 462 | − | frame.set_cursor( | |
| 425 | + | frame.set_cursor_position(( | |
| 463 | 426 | // Draw the cursor at the current position in the input field. | |
| 464 | 427 | // This position is can be controlled via the left and right arrow key | |
| 465 | 428 | area.x + app.pl_new_pl_cursor_pos as u16 + 2, | |
| 466 | 429 | // Move one line down, from the border to the input line | |
| 467 | 430 | area.y + 1, | |
| 468 | − | ); | |
| 431 | + | )); | |
| 469 | 432 | ||
| 470 | 433 | let input = Paragraph::new("/".to_string() + &app.pl_new_pl_input) | |
| 471 | 434 | .style(Style::default()) | |
| @@ -492,3 +455,35 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { | |||
| 492 | 455 | ]) | |
| 493 | 456 | .split(popup_layout[1])[1] | |
| 494 | 457 | } | |
| 458 | + | ||
| 459 | + | fn directory_row(name: &str) -> Row<'static> { | |
| 460 | + | Row::new(vec![Cell::from(format!("[{name}]")).column_span(5)]) | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | #[cfg(test)] | |
| 464 | + | mod tests { | |
| 465 | + | use super::*; | |
| 466 | + | ||
| 467 | + | #[test] | |
| 468 | + | fn directory_names_use_the_full_browser_row_width() { | |
| 469 | + | let name = "mr-robot-vol1-long-name"; | |
| 470 | + | let area = Rect::new(0, 0, 80, 1); | |
| 471 | + | let mut buffer = Buffer::empty(area); | |
| 472 | + | let table = Table::new( | |
| 473 | + | [directory_row(name)], | |
| 474 | + | [ | |
| 475 | + | Constraint::Length(6), | |
| 476 | + | Constraint::Length(2), | |
| 477 | + | Constraint::Length(10), | |
| 478 | + | Constraint::Length(10), | |
| 479 | + | Constraint::Length(6), | |
| 480 | + | ], | |
| 481 | + | ); | |
| 482 | + | Widget::render(table, area, &mut buffer); | |
| 483 | + | ||
| 484 | + | let rendered = (0..area.width) | |
| 485 | + | .map(|x| buffer[(x, 0)].symbol()) | |
| 486 | + | .collect::<String>(); | |
| 487 | + | assert!(rendered.contains(&format!("[{name}]"))); | |
| 488 | + | } | |
| 489 | + | } | |
| @@ -1,17 +1,6 @@ | |||
| 1 | − | use std::process::Command; | |
| 2 | 1 | use std::ffi::OsStr; | |
| 3 | 2 | use std::path::Path; | |
| 4 | 3 | ||
| 5 | − | /// Checks if given program is installed in your system | |
| 6 | − | pub fn is_installed(ss: &str) -> bool { | |
| 7 | − | let output = Command::new("which") | |
| 8 | − | .arg(ss) | |
| 9 | − | .output() | |
| 10 | − | .expect("Failed to execute command"); | |
| 11 | − | ||
| 12 | − | output.status.success() | |
| 13 | − | } | |
| 14 | − | ||
| 15 | 4 | /// Checks if a file has a given extension | |
| 16 | 5 | // https://stackoverflow.com/questions/72392835/check-if-a-file-is-of-a-given-type | |
| 17 | 6 | pub trait FileExtension { | |
| @@ -30,3 +19,9 @@ impl<P: AsRef<Path>> FileExtension for P { | |||
| 30 | 19 | } | |
| 31 | 20 | } | |
| 32 | 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 | + | } | |