Krolyxon
Commit 7f77734

rmptui7f77734

Release rmptui v0.1.7

Author
krolyxon
Date
2026-10-15 22:09 +0530
Parent
529e5bd
Commit
7f777344b5984f98bcfdb339d689fbe81673b905

+3626 −1800

23 files changed

  1. A .github/copilot-instructions.md +30 −0
  2. M CHANGELOG.md +20 −0
  3. M Cargo.lock +642 −196
  4. M Cargo.toml +17 −11
  5. M README.md +227 −55
  6. A install.sh +86 −0
  7. M src/app.rs +373 −329
  8. M src/browser.rs +78 −101
  9. A src/config.rs +669 −0
  10. M src/connection.rs +700 −160
  11. D src/event_handler/event.rs +0 −79
  12. M src/event_handler/handler.rs +435 −290
  13. M src/event_handler/mod.rs +0 −5
  14. D src/event_handler/new_pl_keys.rs +0 −47
  15. D src/event_handler/pl_append_keys.rs +0 −115
  16. D src/event_handler/pl_rename_keys.rs +0 −43
  17. D src/event_handler/search_keys.rs +0 −110
  18. M src/lib.rs +10 −19
  19. M src/list.rs +4 −18
  20. M src/main.rs +155 −32
  21. M src/tui.rs +8 −8
  22. M src/ui.rs +166 −171
  23. M src/utils.rs +6 −11
A .github/copilot-instructions.md View
@@ -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.
M CHANGELOG.md View
@@ -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+
121 ## rmptui-v0.1.6
222 - Add songs to new playlist feature
323 - Code refactoring
M Cargo.lock View
@@ -1,94 +1,98 @@
11 # This file is automatically @generated by Cargo.
22 # 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
164
175 [[package]]
186 name = "allocator-api2"
19−version = "0.2.18"
7+version = "0.2.21"
208 source = "registry+https://github.com/rust-lang/crates.io-index"
21−checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f"
9+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
2210
2311 [[package]]
24−name = "autocfg"
25−version = "1.2.0"
12+name = "approx"
13+version = "0.5.1"
2614 source = "registry+https://github.com/rust-lang/crates.io-index"
27−checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80"
15+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
16+dependencies = [
17+ "num-traits",
18+]
2819
2920 [[package]]
30−name = "bitflags"
31−version = "1.3.2"
21+name = "autocfg"
22+version = "1.5.1"
3223 source = "registry+https://github.com/rust-lang/crates.io-index"
33−checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
24+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
3425
3526 [[package]]
3627 name = "bitflags"
37−version = "2.5.0"
28+version = "2.13.2"
3829 source = "registry+https://github.com/rust-lang/crates.io-index"
39−checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1"
30+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
4031
4132 [[package]]
42−name = "bufstream"
43−version = "0.1.4"
33+name = "by_address"
34+version = "1.2.1"
4435 source = "registry+https://github.com/rust-lang/crates.io-index"
45−checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8"
36+checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
4637
4738 [[package]]
48−name = "cassowary"
49−version = "0.3.0"
39+name = "bytes"
40+version = "1.12.1"
5041 source = "registry+https://github.com/rust-lang/crates.io-index"
51−checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
42+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
5243
5344 [[package]]
5445 name = "castaway"
55−version = "0.2.2"
46+version = "0.2.4"
5647 source = "registry+https://github.com/rust-lang/crates.io-index"
57−checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc"
48+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
5849 dependencies = [
5950 "rustversion",
6051 ]
6152
6253 [[package]]
6354 name = "cfg-if"
64−version = "1.0.0"
55+version = "1.0.5"
6556 source = "registry+https://github.com/rust-lang/crates.io-index"
66−checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
57+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
6758
6859 [[package]]
6960 name = "compact_str"
70−version = "0.7.1"
61+version = "0.9.1"
7162 source = "registry+https://github.com/rust-lang/crates.io-index"
72−checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
63+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
7364 dependencies = [
7465 "castaway",
7566 "cfg-if",
7667 "itoa",
68+ "rustversion",
7769 "ryu",
7870 "static_assertions",
7971 ]
8072
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+
8182 [[package]]
8283 name = "crossterm"
83−version = "0.27.0"
84+version = "0.29.0"
8485 source = "registry+https://github.com/rust-lang/crates.io-index"
85−checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
86+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
8687 dependencies = [
87− "bitflags 2.5.0",
88+ "bitflags",
8889 "crossterm_winapi",
89− "libc",
90+ "derive_more",
91+ "document-features",
92+ "futures-core",
9093 "mio",
9194 "parking_lot",
95+ "rustix",
9296 "signal-hook",
9397 "signal-hook-mio",
9498 "winapi",
@@ -103,85 +107,295 @@ dependencies = [
103107 "winapi",
104108 ]
105109
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+
106181 [[package]]
107182 name = "either"
108−version = "1.11.0"
183+version = "1.19.0"
109184 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+]
111255
112256 [[package]]
113257 name = "hashbrown"
114−version = "0.14.3"
258+version = "0.17.1"
115259 source = "registry+https://github.com/rust-lang/crates.io-index"
116−checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
260+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
117261 dependencies = [
118− "ahash",
119262 "allocator-api2",
263+ "equivalent",
264+ "foldhash",
120265 ]
121266
122267 [[package]]
123268 name = "heck"
124−version = "0.4.1"
269+version = "0.5.0"
125270 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+]
127288
128289 [[package]]
129290 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"
131301 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+]
133310
134311 [[package]]
135312 name = "itertools"
136−version = "0.12.1"
313+version = "0.14.0"
137314 source = "registry+https://github.com/rust-lang/crates.io-index"
138−checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
315+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
139316 dependencies = [
140317 "either",
141318 ]
142319
143320 [[package]]
144321 name = "itoa"
145−version = "1.0.11"
322+version = "1.0.18"
146323 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+]
148336
149337 [[package]]
150338 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"
152367 source = "registry+https://github.com/rust-lang/crates.io-index"
153−checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
368+checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
154369
155370 [[package]]
156371 name = "lock_api"
157−version = "0.4.11"
372+version = "0.4.14"
158373 source = "registry+https://github.com/rust-lang/crates.io-index"
159−checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
374+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
160375 dependencies = [
161− "autocfg",
162376 "scopeguard",
163377 ]
164378
165379 [[package]]
166380 name = "log"
167−version = "0.4.21"
381+version = "0.4.34"
168382 source = "registry+https://github.com/rust-lang/crates.io-index"
169−checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
383+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
170384
171385 [[package]]
172386 name = "lru"
173−version = "0.12.3"
387+version = "0.18.5"
174388 source = "registry+https://github.com/rust-lang/crates.io-index"
175−checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc"
389+checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
176390 dependencies = [
177− "hashbrown",
391+ "hashbrown 0.17.1",
178392 ]
179393
180394 [[package]]
181395 name = "mio"
182−version = "0.8.11"
396+version = "1.2.4"
183397 source = "registry+https://github.com/rust-lang/crates.io-index"
184−checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
398+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
185399 dependencies = [
186400 "libc",
187401 "log",
@@ -190,25 +404,67 @@ dependencies = [
190404 ]
191405
192406 [[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"
195415 source = "registry+https://github.com/rust-lang/crates.io-index"
196−checksum = "2e12cace5746cb0aa78faa6cd2caec9f9c01882fc0e6b54d34685a2d3303ea34"
416+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
197417 dependencies = [
198− "bufstream",
418+ "autocfg",
199419 ]
200420
201421 [[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"
204433 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+]
206462
207463 [[package]]
208464 name = "parking_lot"
209−version = "0.12.1"
465+version = "0.12.5"
210466 source = "registry+https://github.com/rust-lang/crates.io-index"
211−checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
467+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
212468 dependencies = [
213469 "lock_api",
214470 "parking_lot_core",
@@ -216,68 +472,126 @@ dependencies = [
216472
217473 [[package]]
218474 name = "parking_lot_core"
219−version = "0.9.9"
475+version = "0.9.12"
220476 source = "registry+https://github.com/rust-lang/crates.io-index"
221−checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e"
477+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
222478 dependencies = [
223479 "cfg-if",
224480 "libc",
225481 "redox_syscall",
226482 "smallvec",
227− "windows-targets",
483+ "windows-link",
228484 ]
229485
230486 [[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"
233495 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"
235503
236504 [[package]]
237505 name = "proc-macro2"
238−version = "1.0.81"
506+version = "1.0.107"
239507 source = "registry+https://github.com/rust-lang/crates.io-index"
240−checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba"
508+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
241509 dependencies = [
242510 "unicode-ident",
243511 ]
244512
245513 [[package]]
246514 name = "quote"
247−version = "1.0.36"
515+version = "1.0.47"
248516 source = "registry+https://github.com/rust-lang/crates.io-index"
249−checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
517+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
250518 dependencies = [
251519 "proc-macro2",
252520 ]
253521
254522 [[package]]
255523 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"
257538 source = "registry+https://github.com/rust-lang/crates.io-index"
258−checksum = "a564a852040e82671dc50a37d88f3aa83bbc690dfc6844cfe7a2591620206a80"
539+checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
259540 dependencies = [
260− "bitflags 2.5.0",
261− "cassowary",
541+ "bitflags",
262542 "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",
263563 "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",
264576 "indoc",
577+ "instability",
265578 "itertools",
266− "lru",
267− "paste",
268− "stability",
579+ "line-clipping",
580+ "ratatui-core",
581+ "serde",
269582 "strum",
583+ "time",
270584 "unicode-segmentation",
271585 "unicode-width",
272586 ]
273587
274588 [[package]]
275589 name = "redox_syscall"
276−version = "0.4.1"
590+version = "0.5.18"
277591 source = "registry+https://github.com/rust-lang/crates.io-index"
278−checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
592+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
279593 dependencies = [
280− "bitflags 1.3.2",
594+ "bitflags",
281595 ]
282596
283597 [[package]]
@@ -285,10 +599,12 @@ name = "rmptui"
285599 version = "0.1.6"
286600 dependencies = [
287601 "crossterm",
288− "mpd",
602+ "futures-util",
289603 "ratatui",
290604 "rust-fuzzy-search",
291− "simple-dmenu",
605+ "serde",
606+ "tokio",
607+ "toml",
292608 ]
293609
294610 [[package]]
@@ -297,17 +613,39 @@ version = "0.1.1"
297613 source = "registry+https://github.com/rust-lang/crates.io-index"
298614 checksum = "a157657054ffe556d8858504af8a672a054a6e0bd9e8ee531059100c0fa11bb2"
299615
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+
300638 [[package]]
301639 name = "rustversion"
302−version = "1.0.15"
640+version = "1.0.23"
303641 source = "registry+https://github.com/rust-lang/crates.io-index"
304−checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47"
642+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
305643
306644 [[package]]
307645 name = "ryu"
308−version = "1.0.17"
646+version = "1.0.23"
309647 source = "registry+https://github.com/rust-lang/crates.io-index"
310−checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
648+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
311649
312650 [[package]]
313651 name = "scopeguard"
@@ -315,11 +653,56 @@ version = "1.2.0"
315653 source = "registry+https://github.com/rust-lang/crates.io-index"
316654 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
317655
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+
318701 [[package]]
319702 name = "signal-hook"
320−version = "0.3.17"
703+version = "0.3.18"
321704 source = "registry+https://github.com/rust-lang/crates.io-index"
322−checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
705+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
323706 dependencies = [
324707 "libc",
325708 "signal-hook-registry",
@@ -327,9 +710,9 @@ dependencies = [
327710
328711 [[package]]
329712 name = "signal-hook-mio"
330−version = "0.2.3"
713+version = "0.2.5"
331714 source = "registry+https://github.com/rust-lang/crates.io-index"
332−checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
715+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
333716 dependencies = [
334717 "libc",
335718 "mio",
@@ -338,33 +721,34 @@ dependencies = [
338721
339722 [[package]]
340723 name = "signal-hook-registry"
341−version = "1.4.2"
724+version = "1.4.8"
342725 source = "registry+https://github.com/rust-lang/crates.io-index"
343−checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
726+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
344727 dependencies = [
728+ "errno",
345729 "libc",
346730 ]
347731
348732 [[package]]
349−name = "simple-dmenu"
350−version = "0.1.0"
733+name = "slab"
734+version = "0.4.12"
351735 source = "registry+https://github.com/rust-lang/crates.io-index"
352−checksum = "2f392360283341235b298e79f0360bd924db6e6ad1e0964cbf4c43ea79c237eb"
736+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
353737
354738 [[package]]
355739 name = "smallvec"
356−version = "1.13.2"
740+version = "1.16.3"
357741 source = "registry+https://github.com/rust-lang/crates.io-index"
358−checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
742+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
359743
360744 [[package]]
361−name = "stability"
362−version = "0.2.0"
745+name = "socket2"
746+version = "0.6.5"
363747 source = "registry+https://github.com/rust-lang/crates.io-index"
364−checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a"
748+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
365749 dependencies = [
366− "quote",
367− "syn",
750+ "libc",
751+ "windows-sys",
368752 ]
369753
370754 [[package]]
@@ -373,33 +757,38 @@ version = "1.1.0"
373757 source = "registry+https://github.com/rust-lang/crates.io-index"
374758 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
375759
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+
376766 [[package]]
377767 name = "strum"
378−version = "0.26.2"
768+version = "0.28.0"
379769 source = "registry+https://github.com/rust-lang/crates.io-index"
380−checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29"
770+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
381771 dependencies = [
382772 "strum_macros",
383773 ]
384774
385775 [[package]]
386776 name = "strum_macros"
387−version = "0.26.2"
777+version = "0.28.0"
388778 source = "registry+https://github.com/rust-lang/crates.io-index"
389−checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946"
779+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
390780 dependencies = [
391781 "heck",
392782 "proc-macro2",
393783 "quote",
394− "rustversion",
395− "syn",
784+ "syn 2.0.119",
396785 ]
397786
398787 [[package]]
399788 name = "syn"
400−version = "2.0.60"
789+version = "2.0.119"
401790 source = "registry+https://github.com/rust-lang/crates.io-index"
402−checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3"
791+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
403792 dependencies = [
404793 "proc-macro2",
405794 "quote",
@@ -407,139 +796,196 @@ dependencies = [
407796 ]
408797
409798 [[package]]
410−name = "unicode-ident"
411−version = "1.0.12"
799+name = "syn"
800+version = "3.0.9"
412801 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+]
414808
415809 [[package]]
416−name = "unicode-segmentation"
417−version = "1.11.0"
810+name = "thiserror"
811+version = "2.0.21"
418812 source = "registry+https://github.com/rust-lang/crates.io-index"
419−checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
813+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
814+dependencies = [
815+ "thiserror-impl",
816+]
420817
421818 [[package]]
422−name = "unicode-width"
423−version = "0.1.11"
819+name = "thiserror-impl"
820+version = "2.0.21"
424821 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+]
426828
427829 [[package]]
428−name = "version_check"
429−version = "0.9.4"
830+name = "time"
831+version = "0.3.55"
430832 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+]
432843
433844 [[package]]
434−name = "wasi"
435−version = "0.11.0+wasi-snapshot-preview1"
845+name = "time-core"
846+version = "0.1.9"
436847 source = "registry+https://github.com/rust-lang/crates.io-index"
437−checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
848+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
438849
439850 [[package]]
440−name = "winapi"
441−version = "0.3.9"
851+name = "tokio"
852+version = "1.53.3"
442853 source = "registry+https://github.com/rust-lang/crates.io-index"
443−checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
854+checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
444855 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",
447863 ]
448864
449865 [[package]]
450−name = "winapi-i686-pc-windows-gnu"
451−version = "0.4.0"
866+name = "tokio-macros"
867+version = "2.7.2"
452868 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+]
454875
455876 [[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"
458879 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+]
460890
461891 [[package]]
462−name = "windows-sys"
463−version = "0.48.0"
892+name = "toml_datetime"
893+version = "1.1.2+spec-1.1.0"
464894 source = "registry+https://github.com/rust-lang/crates.io-index"
465−checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
895+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
466896 dependencies = [
467− "windows-targets",
897+ "serde_core",
468898 ]
469899
470900 [[package]]
471−name = "windows-targets"
472−version = "0.48.5"
901+name = "toml_parser"
902+version = "1.1.5+spec-1.1.0"
473903 source = "registry+https://github.com/rust-lang/crates.io-index"
474−checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
904+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
475905 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",
483907 ]
484908
485909 [[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"
488924 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+]
490937
491938 [[package]]
492−name = "windows_aarch64_msvc"
493−version = "0.48.5"
939+name = "unicode-width"
940+version = "0.2.2"
494941 source = "registry+https://github.com/rust-lang/crates.io-index"
495−checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
942+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
496943
497944 [[package]]
498−name = "windows_i686_gnu"
499−version = "0.48.5"
945+name = "wasi"
946+version = "0.11.1+wasi-snapshot-preview1"
500947 source = "registry+https://github.com/rust-lang/crates.io-index"
501−checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
948+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
502949
503950 [[package]]
504−name = "windows_i686_msvc"
505−version = "0.48.5"
951+name = "winapi"
952+version = "0.3.9"
506953 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+]
508959
509960 [[package]]
510−name = "windows_x86_64_gnu"
511−version = "0.48.5"
961+name = "winapi-i686-pc-windows-gnu"
962+version = "0.4.0"
512963 source = "registry+https://github.com/rust-lang/crates.io-index"
513−checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
964+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
514965
515966 [[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"
518969 source = "registry+https://github.com/rust-lang/crates.io-index"
519−checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
970+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
520971
521972 [[package]]
522−name = "windows_x86_64_msvc"
523−version = "0.48.5"
973+name = "windows-link"
974+version = "0.2.1"
524975 source = "registry+https://github.com/rust-lang/crates.io-index"
525−checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
976+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
526977
527978 [[package]]
528−name = "zerocopy"
529−version = "0.7.32"
979+name = "windows-sys"
980+version = "0.61.2"
530981 source = "registry+https://github.com/rust-lang/crates.io-index"
531−checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be"
982+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
532983 dependencies = [
533− "zerocopy-derive",
984+ "windows-link",
534985 ]
535986
536987 [[package]]
537−name = "zerocopy-derive"
538−version = "0.7.32"
988+name = "winnow"
989+version = "1.0.4"
539990 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"
M Cargo.toml View
@@ -1,22 +1,28 @@
11 [package]
22 name = "rmptui"
33 authors = ["krolyxon"]
4−description = """
5−a fast and minimal tui mpd client
6−"""
4+description = "A fast and minimal terminal MPD client"
75 readme = "README.md"
86 version = "0.1.6"
97 edition = "2021"
8+license = "AGPL-3.0-only"
109 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"]
1411
1512 [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",
2015 ] }
21−crossterm = "0.27.0"
16+crossterm = { version = "0.29.0", features = ["event-stream"] }
2217 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+] }
M README.md View
@@ -1,59 +1,231 @@
1−## rmptui - A MPD client in Rust
1+# rmptui
2+
23 ![LOC](https://tokei.rs/b1/github/krolyxon/rmptui?category=code)
34 ![Release](https://img.shields.io/github/v/release/krolyxon/rmptui?color=%23c694ff)
45 [![GitHub Downloads](https://img.shields.io/github/downloads/krolyxon/rmptui/total.svg?label=GitHub%20downloads)](https://github.com/krolyxon/rmptui/releases)
56
6−rmptui is a minimal tui mpd client made with rust.
7−
8−## rmptui in action
9−![](https://raw.githubusercontent.com/krolyxon/rmptui/master/assets/ss.png)
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+![rmptui screenshot](https://raw.githubusercontent.com/krolyxon/rmptui/master/assets/ss.png)
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).
A install.sh View
@@ -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"
M src/app.rs View
@@ -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+};
912 use ratatui::widgets::{ListState, TableState};
1013
11−// Application result type
12−pub type AppResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
13−
14−/// Application
1514 #[derive(Debug)]
1615 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>,
3835 pub playlist_popup: bool,
3936 pub append_list: ContentList<String>,
40−
41− // Determines if the database should be updated or not
4237 pub should_update_song_list: bool,
43−
44− // States
4538 pub queue_state: TableState,
4639 pub browser_state: TableState,
4740 pub playlists_state: ListState,
41+ pub playlist_songs_state: TableState,
42+ pub playlist_contents: Vec<Song>,
4843 }
4944
50−#[derive(Debug, PartialEq, Clone)]
45+#[derive(Debug, PartialEq, Clone, Copy)]
5146 pub enum SelectedTab {
5247 DirectoryBrowser,
5348 Queue,
5449 Playlists,
5550 }
5651
52+#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
53+pub enum PlaylistFocus {
54+ #[default]
55+ List,
56+ Songs,
57+}
58+
5759 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?;
6062 let mut queue_list = ContentList::new();
63+ queue_list.list = conn.client.queue().await?;
6164 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?;
6466 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?;
6570
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+ }
7474
75− Ok(Self {
75+ let mut app = Self {
7676 running: true,
7777 conn,
78+ config,
79+ browser,
7880 queue_list,
7981 pl_list,
82+ playlist_focus: PlaylistFocus::List,
83+ playlist_song_index: 0,
8084 selected_tab: SelectedTab::Queue,
81− browser,
8285 inputmode: InputMode::Normal,
8386 search_input: String::new(),
84− pl_newname_input: String::new(),
8587 search_cursor_pos: 0,
88+ browser_search_results: Vec::new(),
89+ browser_search_index: 0,
90+ pl_newname_input: String::new(),
8691 pl_cursor_pos: 0,
87− playlist_popup: false,
8892 pl_new_pl_input: String::new(),
8993 pl_new_pl_cursor_pos: 0,
9094 pl_new_pl_songs_buffer: Vec::new(),
95+ playlist_popup: false,
9196 append_list,
9297 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)
97106 }
98107
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+ }
102122
103− // Deals with database update
104123 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;
117127 }
118−
119128 Ok(())
120129 }
121130
@@ -123,340 +132,375 @@ impl App {
123132 self.running = false;
124133 }
125134
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(())
130143 }
131144
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
136154 }
137155
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(())
141165 }
142166
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>> {
144228 let mut list = ContentList::new();
145229 list.list.push("Current Playlist".to_string());
146230 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?);
151232 Ok(list)
152233 }
153234
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<()> {
156236 match self.selected_tab {
157237 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+ }
174248 }
175249 }
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+ }
183266 }
184267 }
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− }
199268 }
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) {
203270 self.browser.selected += 1;
204271 }
205272 }
206−
207273 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?;
228279 }
229280 }
230−
231− _ => {}
281+ SelectedTab::Playlists => {}
232282 }
233−
234− self.update_queue();
235− self.conn.update_status();
283+ self.update_queue().await?;
284+ self.conn.update_status().await?;
236285 Ok(())
237286 }
238287
239− /// Cycle through tabs
240− pub fn cycle_tabls(&mut self) {
288+ pub fn cycle_tabs(&mut self) {
241289 self.selected_tab = match self.selected_tab {
242290 SelectedTab::Queue => SelectedTab::DirectoryBrowser,
243291 SelectedTab::DirectoryBrowser => SelectedTab::Playlists,
244− SelectedTab::Playlists => SelectedTab::DirectoryBrowser,
292+ SelectedTab::Playlists => SelectedTab::Queue,
245293 };
246294 }
247295
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?;
259306 }
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?;
280320 }
281321 }
282322 Ok(())
283323 }
284324
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+ }
291357 }
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?
295363 }
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?;
299384 }
300− _ => {}
385+ self.refresh_playlists().await?;
301386 }
387+ self.playlist_popup = false;
388+ self.append_list.index = 0;
389+ Ok(())
302390 }
303391
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+ }
314396
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+ }
319401
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,
321408 }
322409 }
323410
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);
325419 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 => {}
337424 }
425+ }
338426
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);
339440 self.move_cursor_right();
340441 }
341442
342443 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;
390447 }
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();
399463 }
400464 }
401465
402466 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);
415468 }
416469
417− /// Given time in seconds, convert it to hh:mm:ss
418470 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}")
425477 } else {
426− format!("{:02}:{:02}:{:02}", h, m, s)
478+ format!("{hours:02}:{minutes:02}:{seconds:02}")
427479 }
428480 }
429481
430− // Mouse event handlers
431482 pub fn handle_scroll_up(&mut self) {
432483 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(),
442487 }
443488 }
444489
445490 pub fn handle_scroll_down(&mut self) {
446491 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(),
456495 }
457496 }
497+}
458498
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("./"))
461505 }
462506 }
M src/browser.rs View
@@ -1,137 +1,114 @@
11 use std::path::Path;
22
3−use mpd::Song;
3+use crate::{
4+ connection::{Connection, Song},
5+ utils::is_audio_file,
6+ AppResult,
7+};
48
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)]
910 pub struct FileBrowser {
10− pub filetree: Vec<(String, String)>,
11+ pub entries: Vec<BrowserEntry>,
1112 pub selected: usize,
1213 pub prev_selected: usize,
1314 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+ }
1633 }
1734
1835 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 {
2438 path: ".".to_string(),
25− prev_path: ".".to_string(),
26− songs: vec![],
39+ ..Self::default()
2740 }
2841 }
2942
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())
4251 {
43− file_vec.push((t, f));
52+ if !name.starts_with('.') {
53+ entries.push(BrowserEntry::Directory(name.to_string()));
54+ }
4455 }
4556 }
4657
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));
9567 }
9668 }
9769
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));
9888 Ok(())
9989 }
10090
101− // Go to next item in filetree
10291 pub fn next(&mut self) {
103− if self.selected < self.filetree.len() - 1 {
92+ if self.selected < self.entries.len().saturating_sub(1) {
10493 self.selected += 1;
10594 }
10695 }
10796
108− /// Go to previous item in filetree
10997 pub fn prev(&mut self) {
110− if self.selected != 0 {
111− self.selected -= 1;
112− }
98+ self.selected = self.selected.saturating_sub(1);
11399 }
114100
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?;
126110 }
127−
128− self.selected = self.prev_selected;
111+ self.selected = self.prev_selected.min(self.entries.len().saturating_sub(1));
129112 Ok(())
130113 }
131114 }
132−
133−impl Default for FileBrowser {
134− fn default() -> Self {
135− Self::new()
136− }
137−}
A src/config.rs View
@@ -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+}
M src/connection.rs View
@@ -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)]
1056 pub enum VolumeStatus {
1157 Muted(i8),
1258 Unmuted,
1359 }
1460
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,
2365 pub current_song: Song,
24− pub stats: mpd::Stats,
25− pub status: mpd::Status,
26− pub volume_status: VolumeStatus,
66+ pub queue: Vec<Song>,
2767 }
2868
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);
3671
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+}
4177
42− let songs_filenames: Vec<String> = conn.listall()?.into_iter().map(|x| x.file).collect();
78+impl Error for MpdError {}
4379
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+}
4784
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+ }
52104
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(&quote(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+ }
54134
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 }),
59168 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 {
63201 status,
64− volume_status,
202+ stats,
203+ current_song,
204+ queue,
65205 })
66206 }
67207
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+ }
77253 }
78254 }
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+ }
79270
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?;
80279 Ok(())
81280 }
82281
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+ }
87289
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+ }
89295
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+ }
95300
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+ }
98306
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+ }
105312
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+ }
110318
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+ }
113363
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;
115449 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(())
116460 }
117461
118− /// Get progress ratio of current playing song
119462 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 {
122465 0.0
123466 } 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)
130468 }
131469 }
132470
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())
138476 } 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)
145483 }
484+ }
146485
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
148493 }
149494
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
155498 }
156499
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
161502 }
162503
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 {
165505 Song {
166506 file: filename.to_string(),
167− ..Default::default()
507+ ..Song::default()
168508 }
169509 }
170510
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(),
177517 }
178− None
179518 }
180519
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));
186587 } else {
187− Ok(Some(s.to_string()))
588+ add_metadata(song, key, value);
188589 }
189− } else {
190− Ok(Some(self.current_song.file.clone()))
191590 }
192591 }
592+ if let Some(song) = current {
593+ songs.push(song);
594+ }
595+ songs
596+}
193597
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)),
198604 }
605+}
199606
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""#);
203667 }
204668
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");
209685 }
210686
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();
215710 }
216711
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();
225738 }
226739
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();
234774 }
235775 }
D src/event_handler/event.rs View
@@ -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−}
M src/event_handler/handler.rs View
@@ -1,335 +1,480 @@
1+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
2+use rust_fuzzy_search::fuzzy_search_sorted;
3+
14 use crate::{
2− app::{App, AppResult, SelectedTab},
5+ app::{App, PlaylistFocus, SelectedTab},
36 connection::VolumeStatus,
47 ui::InputMode,
8+ AppResult,
59 };
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};
1010
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+ }
3644
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;
42112 }
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);
48116 }
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+ }
49132
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+}
55140
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+}
61144
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+}
67191
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+}
70215
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+ }
82225
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+ }
94251
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+}
99279
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;
103287 }
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;
108291 }
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;
113295 }
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();
121314 }
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?;
130317 }
318+ app.reset_cursor();
319+ app.inputmode = InputMode::Normal;
131320 }
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?;
155329 }
156330 }
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;
164334 }
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?;
172341 }
342+ app.pl_new_pl_input.clear();
343+ app.reset_cursor();
344+ app.inputmode = InputMode::Normal;
173345 }
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);
178356 }
179− _ => {}
180357 }
358+ }
359+ Ok(())
360+}
181361
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;
253382 }
254383 }
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;
284396 }
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;
315403 }
316404 }
317405 }
318406 }
319− Ok(())
320407 }
321408
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 {
324447 MouseEventKind::ScrollUp => app.handle_scroll_up(),
325448 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− }
332449 _ => {}
333450 }
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+ }
334455 Ok(())
335456 }
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+}
M src/event_handler/mod.rs View
@@ -1,6 +1 @@
1−pub mod event;
21 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;
D src/event_handler/new_pl_keys.rs View
@@ -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−}
D src/event_handler/pl_append_keys.rs View
@@ -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−}
D src/event_handler/pl_rename_keys.rs View
@@ -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−}
D src/event_handler/search_keys.rs View
@@ -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−}
M src/lib.rs View
@@ -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.
92
10−/// Content list
11−pub mod list;
12−
13−/// File Browser
3+pub mod app;
144 pub mod browser;
15−
16−/// Event Handler/ keymaps
5+pub mod config;
6+pub mod connection;
177 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;
2311 pub mod utils;
12+
13+/// The application's error type.
14+pub type AppResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
M src/list.rs View
@@ -6,34 +6,20 @@ pub struct ContentList<T> {
66
77 impl<T> ContentList<T> {
88 pub fn new() -> Self {
9− ContentList {
9+ Self {
1010 list: Vec::new(),
1111 index: 0,
1212 }
1313 }
1414
15− // Go to next item in list
1615 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);
2018 }
2119 }
2220
23− /// Go to previous item in list
2421 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);
3723 }
3824 }
3925
M src/main.rs View
@@ -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;
15 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+
1953 let backend = CrosstermBackend::new(io::stderr());
2054 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);
2356 tui.init()?;
2457
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);
2766
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(())
31109 }
110+ .await;
32111
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(());
40130 }
41131 }
132+}
42133
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+ }
44167 }
M src/tui.rs View
@@ -1,32 +1,32 @@
1−use std::io;
21 use crate::ui;
32 use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
43 use crossterm::terminal::{self, *};
4+use std::io;
55 use std::panic;
66
7−use crate::app::{App, AppResult};
8−use crate::event_handler::event::EventHandler;
7+use crate::{app::App, AppResult};
98
109 pub type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>;
1110
1211 #[derive(Debug)]
1312 pub struct Tui {
1413 terminal: CrosstermTerminal,
15− pub events: EventHandler,
1614 }
1715
1816 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 }
2119 }
2220
2321 pub fn init(&mut self) -> AppResult<()> {
2422 terminal::enable_raw_mode()?;
25− crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
23+ crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;
2624
2725 let panic_hook = panic::take_hook();
2826 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+ }
3030 panic_hook(panic);
3131 }));
3232 Ok(())
M src/ui.rs View
@@ -1,15 +1,13 @@
11 use std::time::Duration;
22
33 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},
107 };
8+use ratatui::{prelude::*, widgets::*};
119
12−#[derive(Debug, PartialEq)]
10+#[derive(Debug, PartialEq, Clone, Copy)]
1311 pub enum InputMode {
1412 Editing,
1513 Normal,
@@ -17,13 +15,49 @@ pub enum InputMode {
1715 NewPlaylist,
1816 }
1917
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+
2054 /// Renders the user interface widgets
2155 pub fn render(app: &mut App, frame: &mut Frame) {
2256 // Layout
2357 let layout = Layout::default()
2458 .direction(Direction::Vertical)
2559 .constraints(vec![Constraint::Percentage(93), Constraint::Min(3)])
26− .split(frame.size());
60+ .split(frame.area());
2761
2862 match app.selected_tab {
2963 SelectedTab::Queue => draw_queue(frame, app, layout[0]),
@@ -55,57 +89,15 @@ pub fn render(app: &mut App, frame: &mut Frame) {
5589 fn draw_directory_browser(frame: &mut Frame, app: &mut App, size: Rect) {
5690 let total_songs = app.conn.stats.songs.to_string();
5791
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)
10899 }
100+ BrowserEntry::Directory(name) => directory_row(name),
109101 });
110102
111103 let header = ["Artist", "Track", "Title", "Album", "Time"]
@@ -126,23 +118,21 @@ fn draw_directory_browser(frame: &mut Frame, app: &mut App, size: Rect) {
126118 )
127119 .block(
128120 Block::default()
129− .title(format!("Song Browser: {}", app.browser.path.clone()).bold())
121+ .title(format!("Song Browser: {}", app.browser.path).bold())
130122 .title(
131− Title::from(format!("Total Songs: {}", total_songs).green())
123+ Line::from(format!("Total Songs: {}", total_songs).green())
132124 .alignment(Alignment::Center),
133125 )
134126 .title(match app.conn.volume_status {
135127 VolumeStatus::Unmuted => {
136− Title::from(format!("Volume: {}%", app.conn.status.volume).green())
128+ Line::from(format!("Volume: {}%", app.conn.status.volume).green())
137129 .alignment(Alignment::Right)
138130 }
139− VolumeStatus::Muted(_v) => {
140− Title::from("Muted".red()).alignment(Alignment::Right)
141− }
131+ VolumeStatus::Muted(_v) => Line::from("Muted".red()).alignment(Alignment::Right),
142132 })
143133 .borders(Borders::ALL),
144134 )
145− .highlight_style(
135+ .row_highlight_style(
146136 Style::default()
147137 .add_modifier(Modifier::REVERSED)
148138 .fg(Color::Cyan)
@@ -158,53 +148,12 @@ fn draw_directory_browser(frame: &mut Frame, app: &mut App, size: Rect) {
158148 /// draws playing queue
159149 fn draw_queue(frame: &mut Frame, app: &mut App, size: Rect) {
160150 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)
208157 });
209158
210159 let header = ["Artist", "Track", "Title", "Album", "Time"]
@@ -225,22 +174,18 @@ fn draw_queue(frame: &mut Frame, app: &mut App, size: Rect) {
225174 )
226175 .block(
227176 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())
232179 .title(match app.conn.volume_status {
233180 VolumeStatus::Unmuted => {
234− Title::from(format!("Volume: {}%", app.conn.status.volume).green())
181+ Line::from(format!("Volume: {}%", app.conn.status.volume).green())
235182 .alignment(Alignment::Right)
236183 }
237− VolumeStatus::Muted(_v) => {
238− Title::from("Muted".red()).alignment(Alignment::Right)
239− }
184+ VolumeStatus::Muted(_v) => Line::from("Muted".red()).alignment(Alignment::Right),
240185 })
241186 .borders(Borders::ALL),
242187 )
243− .highlight_style(
188+ .row_highlight_style(
244189 Style::default()
245190 .add_modifier(Modifier::REVERSED)
246191 .fg(Color::Cyan)
@@ -258,37 +203,36 @@ fn draw_search_bar(frame: &mut Frame, app: &mut App, size: Rect) {
258203 // Make the cursor visible and ask ratatui to put it at the specified coordinates after
259204 // rendering
260205 #[allow(clippy::cast_possible_truncation)]
261− frame.set_cursor(
206+ frame.set_cursor_position((
262207 // Draw the cursor at the current position in the input field.
263208 // This position is can be controlled via the left and right arrow key
264209 size.x + app.search_cursor_pos as u16 + 2,
265210 // Move one line down, from the border to the input line
266211 size.y + 1,
267− );
212+ ));
268213
269214 let input = Paragraph::new("/".to_string() + &app.search_input)
270215 .style(Style::default())
271216 .block(
272217 Block::default()
273218 .borders(Borders::ALL)
274− .title("Search Forward: ".bold().green()),
219+ .title("Search: Enter to keep, n/N next/previous ".bold().green()),
275220 );
276221 frame.render_widget(input, size);
277222 }
278223
279224 /// Draws Progress Bar
280225 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();
287227
288228 // Get the current playing state
289− let mut state: String = String::new();
229+ let mut state = String::new();
290230 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+ });
292236 state.push(':');
293237 }
294238
@@ -312,11 +256,11 @@ fn draw_progress_bar(frame: &mut Frame, app: &mut App, size: Rect) {
312256 };
313257
314258 // get the duration
315− let duration = if app.conn.total_duration.as_secs() != 0 {
259+ let duration = if !app.conn.status.total.is_zero() {
316260 format!(
317261 "[{}/{}]",
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)
320264 )
321265 } else {
322266 "".to_string()
@@ -324,21 +268,26 @@ fn draw_progress_bar(frame: &mut Frame, app: &mut App, size: Rect) {
324268
325269 // Define the title
326270 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)
331275 .borders(Borders::ALL);
332276
333277 let progress_bar = LineGauge::default()
334278 .block(title.borders(Borders::ALL))
335− .gauge_style(
279+ .filled_style(
336280 Style::default()
337281 .fg(Color::Blue)
338− .bg(Color::Black)
339282 .add_modifier(Modifier::BOLD),
340283 )
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)
342291 .ratio(app.conn.get_progress_ratio());
343292
344293 frame.render_widget(progress_bar, size);
@@ -351,16 +300,24 @@ fn draw_playlist_viewer(frame: &mut Frame, app: &mut App, area: Rect) {
351300 .split(area);
352301
353302 // 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+ ))
364321 .repeat_highlight_symbol(true);
365322 app.playlists_state.select(Some(app.pl_list.index));
366323 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) {
370327 // Handle if there are no playlists in the mpd database
371328 if app.pl_list.list.is_empty() {
372329 let title = Block::default()
373− .title(Title::from("No Playlists Found".red().bold()))
330+ .title("No Playlists Found".red().bold())
374331 .title_alignment(Alignment::Center)
375332 .borders(Borders::ALL);
376333 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) {
378335 return;
379336 }
380337
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;
383339 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));
387343
388344 let row = Row::new(vec![
389345 Cell::from(artist),
@@ -395,33 +351,40 @@ fn draw_playlist_viewer(frame: &mut Frame, app: &mut App, area: Rect) {
395351
396352 let title = Block::default()
397353 .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+ });
399360 let table = Table::new(
400361 rows,
401362 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),
405366 ],
406367 )
407368 .block(title)
408− .highlight_style(
369+ .row_highlight_style(
409370 Style::default()
410371 .add_modifier(Modifier::REVERSED)
411372 .fg(Color::Cyan)
412373 .bg(Color::Black),
413374 )
414375 .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);
416379 }
417380
418381 fn draw_add_to_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
419382 let area = centered_rect(40, 50, area);
420383 let mut state = ListState::default();
421384 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))
425388 .block(title.borders(Borders::ALL))
426389 .highlight_style(
427390 Style::new()
@@ -439,13 +402,13 @@ fn draw_add_to_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
439402
440403 fn draw_rename_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
441404 #[allow(clippy::cast_possible_truncation)]
442− frame.set_cursor(
405+ frame.set_cursor_position((
443406 // Draw the cursor at the current position in the input field.
444407 // This position is can be controlled via the left and right arrow key
445408 area.x + app.pl_cursor_pos as u16 + 2,
446409 // Move one line down, from the border to the input line
447410 area.y + 1,
448− );
411+ ));
449412
450413 let input = Paragraph::new("/".to_string() + &app.pl_newname_input)
451414 .style(Style::default())
@@ -459,13 +422,13 @@ fn draw_rename_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
459422
460423 fn draw_new_playlist(frame: &mut Frame, app: &mut App, area: Rect) {
461424 #[allow(clippy::cast_possible_truncation)]
462− frame.set_cursor(
425+ frame.set_cursor_position((
463426 // Draw the cursor at the current position in the input field.
464427 // This position is can be controlled via the left and right arrow key
465428 area.x + app.pl_new_pl_cursor_pos as u16 + 2,
466429 // Move one line down, from the border to the input line
467430 area.y + 1,
468− );
431+ ));
469432
470433 let input = Paragraph::new("/".to_string() + &app.pl_new_pl_input)
471434 .style(Style::default())
@@ -492,3 +455,35 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
492455 ])
493456 .split(popup_layout[1])[1]
494457 }
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+}
M src/utils.rs View
@@ -1,17 +1,6 @@
1−use std::process::Command;
21 use std::ffi::OsStr;
32 use std::path::Path;
43
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−
154 /// Checks if a file has a given extension
165 // https://stackoverflow.com/questions/72392835/check-if-a-file-is-of-a-given-type
176 pub trait FileExtension {
@@ -30,3 +19,9 @@ impl<P: AsRef<Path>> FileExtension for P {
3019 }
3120 }
3221
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+}