From 12cc70e6ec5382dd8bd0e2259e18f868038b2a3f Mon Sep 17 00:00:00 2001 From: Jamie Hewitt Date: Tue, 27 Jan 2026 21:43:26 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20ferrosonic=20ter?= =?UTF-8?q?minal=20Subsonic=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal-based Subsonic music client in Rust featuring bit-perfect audio playback via PipeWire sample rate switching, gapless playback, MPRIS2 desktop integration, cava audio visualizer with theme-matched gradients, 13 built-in color themes with custom TOML theme support, mouse controls, artist/album browser, playlist support, and play queue management. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 23 + Cargo.lock | 3270 ++++++++++++++++++++++++++++++++ Cargo.toml | 62 + README.md | 322 ++++ src/app/actions.rs | 111 ++ src/app/mod.rs | 2526 ++++++++++++++++++++++++ src/app/state.rs | 399 ++++ src/audio/mod.rs | 7 + src/audio/mpv.rs | 434 +++++ src/audio/pipewire.rs | 202 ++ src/audio/queue.rs | 321 ++++ src/config/mod.rs | 165 ++ src/config/paths.rs | 46 + src/error.rs | 115 ++ src/main.rs | 131 ++ src/mpris/mod.rs | 5 + src/mpris/server.rs | 372 ++++ src/subsonic/auth.rs | 61 + src/subsonic/client.rs | 384 ++++ src/subsonic/mod.rs | 9 + src/subsonic/models.rs | 235 +++ src/ui/footer.rs | 144 ++ src/ui/header.rs | 168 ++ src/ui/layout.rs | 112 ++ src/ui/mod.rs | 10 + src/ui/pages/artists.rs | 272 +++ src/ui/pages/mod.rs | 7 + src/ui/pages/playlists.rs | 197 ++ src/ui/pages/queue.rs | 118 ++ src/ui/pages/server.rs | 179 ++ src/ui/pages/settings.rs | 123 ++ src/ui/theme.rs | 553 ++++++ src/ui/widgets/cava.rs | 61 + src/ui/widgets/mod.rs | 8 + src/ui/widgets/now_playing.rs | 283 +++ src/ui/widgets/progress_bar.rs | 165 ++ 36 files changed, 11600 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/app/actions.rs create mode 100644 src/app/mod.rs create mode 100644 src/app/state.rs create mode 100644 src/audio/mod.rs create mode 100644 src/audio/mpv.rs create mode 100644 src/audio/pipewire.rs create mode 100644 src/audio/queue.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/paths.rs create mode 100644 src/error.rs create mode 100644 src/main.rs create mode 100644 src/mpris/mod.rs create mode 100644 src/mpris/server.rs create mode 100644 src/subsonic/auth.rs create mode 100644 src/subsonic/client.rs create mode 100644 src/subsonic/mod.rs create mode 100644 src/subsonic/models.rs create mode 100644 src/ui/footer.rs create mode 100644 src/ui/header.rs create mode 100644 src/ui/layout.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/pages/artists.rs create mode 100644 src/ui/pages/mod.rs create mode 100644 src/ui/pages/playlists.rs create mode 100644 src/ui/pages/queue.rs create mode 100644 src/ui/pages/server.rs create mode 100644 src/ui/pages/settings.rs create mode 100644 src/ui/theme.rs create mode 100644 src/ui/widgets/cava.rs create mode 100644 src/ui/widgets/mod.rs create mode 100644 src/ui/widgets/now_playing.rs create mode 100644 src/ui/widgets/progress_bar.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c40523e --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Build artifacts +/target/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Claude Code +CLAUDE.md +CLAUDE.md2 + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Config (may contain credentials) +config.toml diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2c9fa4d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3270 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.3", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.3", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ferrosonic" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "crossterm", + "dirs", + "futures", + "libc", + "md5", + "mpris-server", + "rand", + "ratatui 0.29.0", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "toml", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tui-tree-widget", + "unicode-width 0.2.0", + "url", + "urlencoding", + "vt100", + "zbus 5.13.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mpris-server" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "058bc2227727af394f34aa51da3e36aeecf2c808f39315d35f754872660750ae" +dependencies = [ + "async-channel", + "futures-channel", + "serde", + "trait-variant", + "zbus 4.4.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.1.14", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tui-tree-widget" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0b54061d997162f225bed5d2147574af0648480214759a000e33f6cea0017a" +dependencies = [ + "ratatui 0.28.1", + "unicode-width 0.1.14", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.3", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.13.2", + "zbus_names 4.3.1", + "zvariant 5.9.2", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names 4.3.1", + "zvariant 5.9.2", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow", + "zvariant 5.9.2", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive 5.9.2", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f7b2fc3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "ferrosonic" +version = "0.1.0" +edition = "2021" +description = "A terminal-based Subsonic music client with bit-perfect audio playback" +license = "MIT" +authors = ["ferrosonic contributors"] + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full", "sync", "process", "signal"] } + +# Terminal UI +ratatui = "0.29" +crossterm = "0.28" +tui-tree-widget = "0.22" + +# HTTP client for Subsonic API +reqwest = { version = "0.12", features = ["json"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +# Error handling +thiserror = "2" +anyhow = "1" + +# D-Bus / MPRIS +zbus = "5" +mpris-server = "0.8" + +# Unix utilities +libc = "0.2" + +# Utilities +dirs = "6" +url = "2" +urlencoding = "2" +md5 = "0.7" +rand = "0.8" +unicode-width = "0.2" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" + +# CLI +clap = { version = "4", features = ["derive"] } + +# Async channels +futures = "0.3" + +# Terminal emulator for cava integration +vt100 = "0.15" + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6b95de --- /dev/null +++ b/README.md @@ -0,0 +1,322 @@ +# Ferrosonic + +A terminal-based Subsonic music client written in Rust, featuring bit-perfect audio playback, gapless transitions, and full desktop integration. + +Ferrosonic is inspired by [Termsonic](https://git.sixfoisneuf.fr/termsonic/about/), the original terminal Subsonic client written in Go by [SixFoisNeuf](https://www.sixfoisneuf.fr/posts/termsonic-a-terminal-client-for-subsonic/). Ferrosonic is a ground-up rewrite in Rust with additional features including PipeWire sample rate switching for bit-perfect audio, MPRIS2 media controls, multiple color themes, and mouse support. + +## Features + +- **Bit-perfect audio** - Automatic PipeWire sample rate switching to match the source material (44.1kHz, 48kHz, 96kHz, 192kHz, etc.) +- **Gapless playback** - Seamless transitions between tracks with pre-buffered next track +- **MPRIS2 integration** - Full desktop media control support (play, pause, stop, next, previous, seek) +- **Artist/album browser** - Tree-based navigation with expandable artists and album listings +- **Playlist support** - Browse and play server playlists with shuffle capability +- **Play queue management** - Add, remove, reorder, shuffle, and clear queue history +- **Audio quality display** - Real-time display of sample rate, bit depth, codec format, and channel layout +- **Audio visualizer** - Integrated cava audio visualizer with theme-matched gradient colors +- **13 built-in themes** - Default, Monokai, Dracula, Nord, Gruvbox, Catppuccin, Solarized, Tokyo Night, Rosé Pine, Everforest, Kanagawa, One Dark, and Ayu Dark +- **Custom themes** - Create your own themes as TOML files in `~/.config/ferrosonic/themes/` +- **Mouse support** - Clickable buttons, tabs, lists, and progress bar seeking +- **Artist filtering** - Real-time search/filter on the artist list +- **Multi-disc album support** - Proper disc and track number display +- **Keyboard-driven** - Vim-style navigation (j/k) alongside arrow keys + +## Screenshots + + + +## Installation + +### Dependencies + +Ferrosonic requires the following at runtime: + +| Dependency | Purpose | Required | +|---|---|---| +| **mpv** | Audio playback engine (via JSON IPC) | Yes | +| **PipeWire** | Automatic sample rate switching for bit-perfect audio | Recommended | +| **WirePlumber** | PipeWire session manager | Recommended | +| **D-Bus** | MPRIS2 desktop media controls | Recommended | +| **cava** | Audio visualizer | Optional | + +Build dependencies (needed to compile from source): + +| Dependency | Package (Arch) | Package (Fedora) | Package (Debian/Ubuntu) | +|---|---|---|---| +| **Rust toolchain** | `rustup` | via rustup.rs | via rustup.rs | +| **pkg-config** | `pkgconf` | `pkgconf-pkg-config` | `pkg-config` | +| **D-Bus dev headers** | `dbus` | `dbus-devel` | `libdbus-1-dev` | + +### Arch Linux + +```bash +# Install runtime dependencies +sudo pacman -S mpv pipewire wireplumber + +# Install build dependencies +sudo pacman -S base-devel pkgconf dbus + +# Build from source +git clone https://github.com/jaidaken/ferrosonic.git +cd ferrosonic +cargo build --release + +# Binary is at target/release/ferrosonic +sudo cp target/release/ferrosonic /usr/local/bin/ +``` + +### Fedora + +```bash +# Install runtime dependencies +sudo dnf install mpv pipewire wireplumber + +# Install build dependencies +sudo dnf install gcc pkgconf-pkg-config dbus-devel + +# Install Rust toolchain if not already installed +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Build from source +git clone https://github.com/jaidaken/ferrosonic.git +cd ferrosonic +cargo build --release +sudo cp target/release/ferrosonic /usr/local/bin/ +``` + +### Ubuntu / Debian + +```bash +# Install runtime dependencies +sudo apt install mpv pipewire wireplumber + +# Install build dependencies +sudo apt install build-essential pkg-config libdbus-1-dev + +# Install Rust toolchain if not already installed +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Build from source +git clone https://github.com/jaidaken/ferrosonic.git +cd ferrosonic +cargo build --release +sudo cp target/release/ferrosonic /usr/local/bin/ +``` + +### Generic (any Linux distribution) + +Ensure `mpv` is installed and available in your `PATH`. PipeWire is optional but required for automatic sample rate switching. + +```bash +# Install Rust toolchain +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Build +git clone https://github.com/jaidaken/ferrosonic.git +cd ferrosonic +cargo build --release + +# Install +sudo cp target/release/ferrosonic /usr/local/bin/ +``` + +## Usage + +```bash +# Run with default config (~/.config/ferrosonic/config.toml) +ferrosonic + +# Run with a custom config file +ferrosonic -c /path/to/config.toml + +# Enable verbose/debug logging +ferrosonic -v +``` + +## Configuration + +Configuration is stored at `~/.config/ferrosonic/config.toml`. You can edit it manually or configure the server connection through the application's Server page (F4). + +```toml +BaseURL = "https://your-subsonic-server.com" +Username = "your-username" +Password = "your-password" +Theme = "Default" +``` + +| Field | Description | +|---|---| +| `BaseURL` | URL of your Subsonic-compatible server (Navidrome, Airsonic, Gonic, etc.) | +| `Username` | Your server username | +| `Password` | Your server password | +| `Theme` | Color theme name (e.g. `Default`, `Catppuccin`, `Tokyo Night`) | + +Logs are written to `~/.config/ferrosonic/ferrosonic.log`. + +## Keyboard Shortcuts + +### Global + +| Key | Action | +|---|---| +| `q` | Quit | +| `p` / `Space` | Toggle play/pause | +| `l` | Next track | +| `h` | Previous track | +| `Ctrl+R` | Refresh data from server | +| `t` | Cycle to next theme | +| `F1` | Artists page | +| `F2` | Queue page | +| `F3` | Playlists page | +| `F4` | Server configuration page | +| `F5` | Settings page | + +### Artists Page (F1) + +| Key | Action | +|---|---| +| `/` | Filter artists by name | +| `Esc` | Clear filter | +| `Up` / `k` | Move selection up | +| `Down` / `j` | Move selection down | +| `Left` / `Right` | Switch focus between tree and song list | +| `Enter` | Expand/collapse artist, or play album/song | +| `Backspace` | Return to tree from song list | +| `e` | Add selected item to end of queue | +| `n` | Add selected item as next in queue | + +### Queue Page (F2) + +| Key | Action | +|---|---| +| `Up` / `k` | Move selection up | +| `Down` / `j` | Move selection down | +| `Enter` | Play selected song | +| `d` | Remove selected song from queue | +| `J` (Shift+J) | Move selected song down | +| `K` (Shift+K) | Move selected song up | +| `r` | Shuffle queue (current song stays in place) | +| `c` | Clear played history (remove songs before current) | + +### Playlists Page (F3) + +| Key | Action | +|---|---| +| `Tab` / `Left` / `Right` | Switch focus between playlists and songs | +| `Up` / `k` | Move selection up | +| `Down` / `j` | Move selection down | +| `Enter` | Load playlist songs or play selected song | +| `e` | Add selected item to end of queue | +| `n` | Add selected song as next in queue | +| `r` | Shuffle play all songs in selected playlist | + +### Server Page (F4) + +| Key | Action | +|---|---| +| `Tab` | Move between fields | +| `Enter` | Test connection or Save configuration | +| `Backspace` | Delete character in text field | + +### Settings Page (F5) + +| Key | Action | +|---|---| +| `Up` / `Down` | Move between settings | +| `Left` | Previous option | +| `Right` / `Enter` | Next option | + +Settings include theme selection and cava visualizer toggle. Changes are saved automatically. + +## Mouse Support + +- Click page tabs in the header to switch pages +- Click playback control buttons (Previous, Play, Pause, Stop, Next) in the header +- Click items in lists to select them +- Click the progress bar in the Now Playing widget to seek + +## Audio Features + +### Bit-Perfect Playback + +Ferrosonic uses PipeWire's `pw-metadata` to automatically switch the system sample rate to match the source material. When a track at 96kHz starts playing, PipeWire is instructed to output at 96kHz, avoiding unnecessary resampling. The original sample rate is restored when the application exits. + +### Gapless Playback + +The next track in the queue is pre-loaded into MPV's internal playlist before the current track finishes, allowing seamless transitions with no gap or click between songs. + +### Now Playing Display + +The Now Playing widget shows: +- Artist, album, and track title +- Audio quality: format/codec, bit depth, sample rate, and channel layout +- Visual progress bar with elapsed/total time + +## Themes + +Ferrosonic ships with 13 themes. On first run, the built-in themes are written as TOML files to `~/.config/ferrosonic/themes/`. + +| Theme | Description | +|---|---| +| **Default** | Cyan/yellow on dark background (hardcoded) | +| **Monokai** | Classic Monokai syntax highlighting palette | +| **Dracula** | Purple/pink Dracula color scheme | +| **Nord** | Arctic blue Nord palette | +| **Gruvbox** | Warm retro Gruvbox colors | +| **Catppuccin** | Soothing pastel Catppuccin Mocha palette | +| **Solarized** | Ethan Schoonover's Solarized Dark | +| **Tokyo Night** | Dark Tokyo Night color scheme | +| **Rosé Pine** | Soho vibes Rosé Pine palette | +| **Everforest** | Comfortable green Everforest Dark | +| **Kanagawa** | Dark Kanagawa wave palette | +| **One Dark** | Atom One Dark color scheme | +| **Ayu Dark** | Ayu Dark color scheme | + +Change themes with `t` from any page, from the Settings page (F5), or by editing the `Theme` field in `config.toml`. + +### Custom Themes + +Create a `.toml` file in `~/.config/ferrosonic/themes/` and it will appear in the theme list. The filename becomes the display name (e.g. `my-theme.toml` becomes "My Theme"). + +```toml +[colors] +primary = "#89b4fa" +secondary = "#585b70" +accent = "#f9e2af" +artist = "#a6e3a1" +album = "#f5c2e7" +song = "#94e2d5" +muted = "#6c7086" +highlight_bg = "#45475a" +highlight_fg = "#cdd6f4" +success = "#a6e3a1" +error = "#f38ba8" +playing = "#f9e2af" +played = "#6c7086" +border_focused = "#89b4fa" +border_unfocused = "#45475a" + +[cava] +gradient = ["#a6e3a1", "#94e2d5", "#89dceb", "#74c7ec", "#cba6f7", "#f5c2e7", "#f38ba8", "#f38ba8"] +horizontal_gradient = ["#f38ba8", "#eba0ac", "#fab387", "#f9e2af", "#a6e3a1", "#94e2d5", "#89b4fa", "#cba6f7"] +``` + +You can also edit the built-in theme files to customize them. They will not be overwritten unless deleted. + +## Compatible Servers + +Ferrosonic works with any server implementing the Subsonic API, including: + +- [Navidrome](https://www.navidrome.org/) +- [Airsonic](https://airsonic.github.io/) +- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced) +- [Gonic](https://github.com/sentriz/gonic) +- [Supysonic](https://github.com/spl0k/supysonic) + +## Acknowledgements + +Ferrosonic is inspired by [Termsonic](https://git.sixfoisneuf.fr/termsonic/about/) by SixFoisNeuf, a terminal Subsonic client written in Go. Ferrosonic builds on that concept with a Rust implementation, bit-perfect audio via PipeWire, and additional features. + +## License + +MIT diff --git a/src/app/actions.rs b/src/app/actions.rs new file mode 100644 index 0000000..b743fd9 --- /dev/null +++ b/src/app/actions.rs @@ -0,0 +1,111 @@ +//! Application actions and message passing + +use crate::subsonic::models::{Album, Artist, Child, Playlist}; + +/// Actions that can be sent to the audio backend +#[derive(Debug, Clone)] +pub enum AudioAction { + /// Play a specific song by URL + Play { url: String, song: Child }, + /// Pause playback + Pause, + /// Resume playback + Resume, + /// Toggle pause state + TogglePause, + /// Stop playback + Stop, + /// Seek to position (seconds) + Seek(f64), + /// Seek relative to current position + SeekRelative(f64), + /// Skip to next track + Next, + /// Skip to previous track + Previous, + /// Set volume (0-100) + SetVolume(i32), +} + +/// Actions that can be sent to update the UI +#[derive(Debug, Clone)] +pub enum UiAction { + /// Update playback position + UpdatePosition { position: f64, duration: f64 }, + /// Update playback state + UpdatePlaybackState(PlaybackStateUpdate), + /// Update audio properties + UpdateAudioProperties { + sample_rate: Option, + bit_depth: Option, + format: Option, + }, + /// Track ended (EOF from MPV) + TrackEnded, + /// Show notification + Notify { message: String, is_error: bool }, + /// Artists loaded from server + ArtistsLoaded(Vec), + /// Albums loaded for an artist + AlbumsLoaded { + artist_id: String, + albums: Vec, + }, + /// Songs loaded for an album + SongsLoaded { album_id: String, songs: Vec }, + /// Playlists loaded from server + PlaylistsLoaded(Vec), + /// Playlist songs loaded + PlaylistSongsLoaded { + playlist_id: String, + songs: Vec, + }, + /// Server connection test result + ConnectionTestResult { success: bool, message: String }, + /// Force redraw + Redraw, +} + +/// Playback state update +#[derive(Debug, Clone, Copy)] +pub enum PlaybackStateUpdate { + Playing, + Paused, + Stopped, +} + +/// Actions for the Subsonic client +#[derive(Debug, Clone)] +pub enum SubsonicAction { + /// Fetch all artists + FetchArtists, + /// Fetch albums for an artist + FetchAlbums { artist_id: String }, + /// Fetch songs for an album + FetchAlbum { album_id: String }, + /// Fetch all playlists + FetchPlaylists, + /// Fetch songs in a playlist + FetchPlaylist { playlist_id: String }, + /// Test server connection + TestConnection, +} + +/// Queue manipulation actions +#[derive(Debug, Clone)] +pub enum QueueAction { + /// Append songs to queue + Append(Vec), + /// Insert songs after current position + InsertNext(Vec), + /// Clear the queue + Clear, + /// Remove song at index + Remove(usize), + /// Move song from one index to another + Move { from: usize, to: usize }, + /// Shuffle the queue (keeping current song in place) + Shuffle, + /// Play song at queue index + PlayIndex(usize), +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..22f0d56 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,2526 @@ +//! Main application module + +#![allow(dead_code)] + +pub mod actions; +pub mod state; + +use std::io; +use std::io::Read as _; +use std::os::unix::io::FromRawFd; +use std::time::Duration; + +use crossterm::{ + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseButton, + MouseEventKind, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +use crate::audio::mpv::MpvController; +use crate::audio::pipewire::PipeWireController; +use crate::config::Config; +use crate::error::{Error, UiError}; +use crate::subsonic::SubsonicClient; +use crate::ui; + +pub use actions::*; +pub use state::*; + +/// Channel buffer size +const CHANNEL_SIZE: usize = 256; + +/// Main application +pub struct App { + /// Shared application state + state: SharedState, + /// Subsonic client + subsonic: Option, + /// MPV audio controller + mpv: MpvController, + /// PipeWire sample rate controller + pipewire: PipeWireController, + /// Channel to send UI updates + ui_tx: mpsc::Sender, + /// Channel to receive UI updates + ui_rx: mpsc::Receiver, + /// Channel to send audio actions + audio_tx: mpsc::Sender, + /// Channel to send subsonic actions + subsonic_tx: mpsc::Sender, + /// Channel to send queue actions + queue_tx: mpsc::Sender, + /// Cava child process + cava_process: Option, + /// Cava pty master fd for reading output + cava_pty_master: Option, + /// Cava terminal parser + cava_parser: Option, + /// Last mouse click position and time (for second-click detection) + last_click: Option<(u16, u16, std::time::Instant)>, +} + +impl App { + /// Create a new application instance + pub fn new(config: Config) -> Self { + let (ui_tx, ui_rx) = mpsc::channel(CHANNEL_SIZE); + let (audio_tx, _audio_rx) = mpsc::channel(CHANNEL_SIZE); + let (subsonic_tx, _subsonic_rx) = mpsc::channel(CHANNEL_SIZE); + let (queue_tx, _queue_rx) = mpsc::channel(CHANNEL_SIZE); + + let state = new_shared_state(config.clone()); + + let subsonic = if config.is_configured() { + match SubsonicClient::new(&config.base_url, &config.username, &config.password) { + Ok(client) => Some(client), + Err(e) => { + warn!("Failed to create Subsonic client: {}", e); + None + } + } + } else { + None + }; + + Self { + state, + subsonic, + mpv: MpvController::new(), + pipewire: PipeWireController::new(), + ui_tx, + ui_rx, + audio_tx, + subsonic_tx, + queue_tx, + cava_process: None, + cava_pty_master: None, + cava_parser: None, + last_click: None, + } + } + + /// Run the application + pub async fn run(&mut self) -> Result<(), Error> { + // Start MPV + if let Err(e) = self.mpv.start() { + warn!("Failed to start MPV: {} - audio playback won't work", e); + let mut state = self.state.write().await; + state.notify_error(format!("Failed to start MPV: {}. Is mpv installed?", e)); + drop(state); + } else { + info!("MPV started successfully, ready for playback"); + } + + // Seed and load themes + { + use crate::ui::theme::{load_themes, seed_default_themes}; + if let Some(themes_dir) = crate::config::paths::themes_dir() { + seed_default_themes(&themes_dir); + } + let themes = load_themes(); + let mut state = self.state.write().await; + let theme_name = state.config.theme.clone(); + state.settings_state.themes = themes; + state.settings_state.set_theme_by_name(&theme_name); + } + + // Check if cava is available + let cava_available = std::process::Command::new("which") + .arg("cava") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + { + let mut state = self.state.write().await; + state.cava_available = cava_available; + if !cava_available { + state.settings_state.cava_enabled = false; + } + } + + // Start cava if enabled and available + { + let state = self.state.read().await; + if state.settings_state.cava_enabled && cava_available { + let td = state.settings_state.current_theme(); + let g = td.cava_gradient.clone(); + let h = td.cava_horizontal_gradient.clone(); + drop(state); + self.start_cava(&g, &h); + } + } + + // Setup terminal + enable_raw_mode().map_err(UiError::TerminalInit)?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture) + .map_err(UiError::TerminalInit)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).map_err(UiError::TerminalInit)?; + + info!("Terminal initialized"); + + // Load initial data if configured + if self.subsonic.is_some() { + self.load_initial_data().await; + } + + // Main event loop + let result = self.event_loop(&mut terminal).await; + + // Cleanup cava + self.stop_cava(); + + // Cleanup MPV + let _ = self.mpv.quit(); + + // Cleanup terminal + disable_raw_mode().map_err(UiError::TerminalInit)?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .map_err(UiError::TerminalInit)?; + terminal.show_cursor().map_err(UiError::Render)?; + + info!("Terminal restored"); + result + } + + /// Load initial data from server + async fn load_initial_data(&mut self) { + if let Some(ref client) = self.subsonic { + // Load artists + match client.get_artists().await { + Ok(artists) => { + let mut state = self.state.write().await; + let count = artists.len(); + state.artists.artists = artists; + // Select first artist by default + if count > 0 { + state.artists.selected_index = Some(0); + } + info!("Loaded {} artists", count); + } + Err(e) => { + error!("Failed to load artists: {}", e); + let mut state = self.state.write().await; + state.notify_error(format!("Failed to load artists: {}", e)); + } + } + + // Load playlists + match client.get_playlists().await { + Ok(playlists) => { + let mut state = self.state.write().await; + let count = playlists.len(); + state.playlists.playlists = playlists; + info!("Loaded {} playlists", count); + } + Err(e) => { + error!("Failed to load playlists: {}", e); + // Don't show error for playlists if artists loaded + } + } + } + } + + /// Main event loop + async fn event_loop( + &mut self, + terminal: &mut Terminal>, + ) -> Result<(), Error> { + let mut last_playback_update = std::time::Instant::now(); + + loop { + // Determine tick rate based on whether cava is active + let cava_active = self.cava_parser.is_some(); + let tick_rate = if cava_active { + Duration::from_millis(16) // ~60fps + } else { + Duration::from_millis(100) + }; + + // Draw UI + { + let mut state = self.state.write().await; + terminal + .draw(|frame| ui::draw(frame, &mut state)) + .map_err(UiError::Render)?; + } + + // Check for quit + { + let state = self.state.read().await; + if state.should_quit { + break; + } + } + + // Handle events with timeout + if event::poll(tick_rate).map_err(UiError::Input)? { + let event = event::read().map_err(UiError::Input)?; + self.handle_event(event).await?; + } + + // Process any pending UI actions + while let Ok(action) = self.ui_rx.try_recv() { + self.handle_ui_action(action).await; + } + + // Read cava output (non-blocking) + self.read_cava_output().await; + + // Update playback position every ~500ms + let now = std::time::Instant::now(); + if now.duration_since(last_playback_update) >= Duration::from_millis(500) { + last_playback_update = now; + self.update_playback_info().await; + } + + // Check for notification auto-clear (after 2 seconds) + { + let mut state = self.state.write().await; + state.check_notification_timeout(); + } + } + + Ok(()) + } + + /// Update playback position and audio info from MPV + async fn update_playback_info(&mut self) { + // Only update if something should be playing + let state = self.state.read().await; + let is_playing = state.now_playing.state == PlaybackState::Playing; + let is_active = is_playing || state.now_playing.state == PlaybackState::Paused; + drop(state); + + if !is_active || !self.mpv.is_running() { + return; + } + + // Check for track advancement + if is_playing { + // Early transition: if near end of track and no preloaded next track, + // advance immediately instead of waiting for idle detection + { + let state = self.state.read().await; + let time_remaining = state.now_playing.duration - state.now_playing.position; + let has_next = state + .queue_position + .map(|p| p + 1 < state.queue.len()) + .unwrap_or(false); + drop(state); + + if has_next && time_remaining > 0.0 && time_remaining < 2.0 { + if let Ok(count) = self.mpv.get_playlist_count() { + if count < 2 { + info!("Near end of track with no preloaded next — advancing early"); + let _ = self.next_track().await; + return; + } + } + } + } + + // Re-preload if the appended track was lost + if let Ok(count) = self.mpv.get_playlist_count() { + if count == 1 { + let state = self.state.read().await; + if let Some(pos) = state.queue_position { + if pos + 1 < state.queue.len() { + drop(state); + debug!("Playlist count is 1, re-preloading next track"); + self.preload_next_track(pos).await; + } + } + } + } + + // Check if MPV advanced to next track in playlist (gapless transition) + if let Ok(Some(mpv_pos)) = self.mpv.get_playlist_pos() { + if mpv_pos == 1 { + // Gapless advance happened - update our state to match + let state = self.state.read().await; + if let Some(current_pos) = state.queue_position { + let next_pos = current_pos + 1; + if next_pos < state.queue.len() { + drop(state); + info!("Gapless advancement to track {}", next_pos); + + // Update state - keep audio properties since they'll be similar + // for gapless transitions (same album, same format) + let mut state = self.state.write().await; + state.queue_position = Some(next_pos); + if let Some(song) = state.queue.get(next_pos).cloned() { + state.now_playing.song = Some(song.clone()); + state.now_playing.position = 0.0; + state.now_playing.duration = song.duration.unwrap_or(0) as f64; + // Don't reset audio properties - let them update naturally + // This avoids triggering PipeWire rate changes unnecessarily + } + drop(state); + + // Remove the finished track (index 0) from MPV's playlist + // This is less disruptive than playlist_clear during playback + let _ = self.mpv.playlist_remove(0); + + // Preload the next track for continued gapless playback + self.preload_next_track(next_pos).await; + return; + } + } + drop(state); + } + } + + // Check if MPV went idle (track ended, no preloaded track) + if let Ok(idle) = self.mpv.is_idle() { + if idle { + info!("Track ended, advancing to next"); + let _ = self.next_track().await; + return; + } + } + } + + // Get position from MPV + if let Ok(position) = self.mpv.get_time_pos() { + let mut state = self.state.write().await; + state.now_playing.position = position; + } + + // Get duration if not set + { + let state = self.state.read().await; + if state.now_playing.duration <= 0.0 { + drop(state); + if let Ok(duration) = self.mpv.get_duration() { + if duration > 0.0 { + let mut state = self.state.write().await; + state.now_playing.duration = duration; + } + } + } + } + + // Get audio properties - keep polling until we get valid values + // MPV may not have them ready immediately when playback starts + { + let state = self.state.read().await; + let need_sample_rate = state.now_playing.sample_rate.is_none(); + drop(state); + + if need_sample_rate { + // Try to get audio properties from MPV + let sample_rate = self.mpv.get_sample_rate().ok().flatten(); + let bit_depth = self.mpv.get_bit_depth().ok().flatten(); + let format = self.mpv.get_audio_format().ok().flatten(); + let channels = self.mpv.get_channels().ok().flatten(); + + // Only update if we got a valid sample rate (indicates audio is ready) + if let Some(rate) = sample_rate { + // Only switch PipeWire sample rate if it's actually different + // This avoids unnecessary rate switches during gapless playback + // of albums with the same sample rate + let current_pw_rate = self.pipewire.get_current_rate(); + if current_pw_rate != Some(rate) { + info!("Sample rate change: {:?} -> {} Hz", current_pw_rate, rate); + if let Err(e) = self.pipewire.set_rate(rate) { + warn!("Failed to set PipeWire sample rate: {}", e); + } + } else { + debug!( + "Sample rate unchanged at {} Hz, skipping PipeWire switch", + rate + ); + } + + let mut state = self.state.write().await; + state.now_playing.sample_rate = Some(rate); + state.now_playing.bit_depth = bit_depth; + state.now_playing.format = format; + state.now_playing.channels = channels; + } + } + } + } + + /// Handle terminal events + async fn handle_event(&mut self, event: Event) -> Result<(), Error> { + match event { + Event::Key(key) => { + // Only handle key press events, ignore release and repeat + if key.kind == event::KeyEventKind::Press { + self.handle_key(key).await + } else { + Ok(()) + } + } + Event::Mouse(mouse) => self.handle_mouse(mouse).await, + Event::Resize(_, _) => { + // Restart cava so it picks up the new terminal dimensions + if self.cava_parser.is_some() { + let state = self.state.read().await; + let td = state.settings_state.current_theme(); + let g = td.cava_gradient.clone(); + let h = td.cava_horizontal_gradient.clone(); + drop(state); + self.start_cava(&g, &h); + let mut state = self.state.write().await; + state.cava_screen.clear(); + } + Ok(()) + } + _ => Ok(()), + } + } + + /// Handle keyboard input + async fn handle_key(&mut self, key: event::KeyEvent) -> Result<(), Error> { + let mut state = self.state.write().await; + + // Clear notification on any keypress + state.clear_notification(); + + // Bypass global keybindings when typing in server text fields or filtering artists + let is_server_text_field = + state.page == Page::Server && state.server_state.selected_field <= 2; + let is_filtering = state.page == Page::Artists && state.artists.filter_active; + + if is_server_text_field || is_filtering { + let page = state.page; + drop(state); + return match page { + Page::Server => self.handle_server_key(key).await, + Page::Artists => self.handle_artists_key(key).await, + _ => Ok(()), + }; + } + + // Global keybindings + match (key.code, key.modifiers) { + // Quit + (KeyCode::Char('q'), KeyModifiers::NONE) => { + state.should_quit = true; + return Ok(()); + } + // Page switching + (KeyCode::F(1), _) => { + state.page = Page::Artists; + return Ok(()); + } + (KeyCode::F(2), _) => { + state.page = Page::Queue; + return Ok(()); + } + (KeyCode::F(3), _) => { + state.page = Page::Playlists; + return Ok(()); + } + (KeyCode::F(4), _) => { + state.page = Page::Server; + return Ok(()); + } + (KeyCode::F(5), _) => { + state.page = Page::Settings; + return Ok(()); + } + // Playback controls (global) + (KeyCode::Char('p'), KeyModifiers::NONE) | (KeyCode::Char(' '), KeyModifiers::NONE) => { + // Toggle pause + drop(state); + return self.toggle_pause().await; + } + (KeyCode::Char('l'), KeyModifiers::NONE) => { + // Next track + drop(state); + return self.next_track().await; + } + (KeyCode::Char('h'), KeyModifiers::NONE) => { + // Previous track + drop(state); + return self.prev_track().await; + } + // Cycle theme (global) + (KeyCode::Char('t'), KeyModifiers::NONE) => { + state.settings_state.next_theme(); + state.config.theme = state.settings_state.theme_name().to_string(); + let label = state.settings_state.theme_name().to_string(); + state.notify(format!("Theme: {}", label)); + let _ = state.config.save_default(); + let cava_enabled = state.settings_state.cava_enabled; + let td = state.settings_state.current_theme(); + let g = td.cava_gradient.clone(); + let h = td.cava_horizontal_gradient.clone(); + drop(state); + if cava_enabled { + self.start_cava(&g, &h); + } + return Ok(()); + } + // Ctrl+R to refresh data from server + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + state.notify("Refreshing..."); + drop(state); + self.load_initial_data().await; + let mut state = self.state.write().await; + state.notify("Data refreshed"); + return Ok(()); + } + _ => {} + } + + // Page-specific keybindings + let page = state.page; + drop(state); + match page { + Page::Artists => self.handle_artists_key(key).await, + Page::Queue => self.handle_queue_key(key).await, + Page::Playlists => self.handle_playlists_key(key).await, + Page::Server => self.handle_server_key(key).await, + Page::Settings => self.handle_settings_key(key).await, + } + } + + /// Handle artists page keys + async fn handle_artists_key(&mut self, key: event::KeyEvent) -> Result<(), Error> { + use crate::ui::pages::artists::{build_tree_items, TreeItem}; + + let mut state = self.state.write().await; + + // Handle filter input mode + if state.artists.filter_active { + match key.code { + KeyCode::Esc => { + state.artists.filter_active = false; + state.artists.filter.clear(); + } + KeyCode::Enter => { + state.artists.filter_active = false; + } + KeyCode::Backspace => { + state.artists.filter.pop(); + } + KeyCode::Char(c) => { + state.artists.filter.push(c); + } + _ => {} + } + return Ok(()); + } + + match key.code { + KeyCode::Char('/') => { + state.artists.filter_active = true; + } + KeyCode::Esc => { + state.artists.filter.clear(); + state.artists.expanded.clear(); + state.artists.selected_index = Some(0); + } + KeyCode::Tab => { + state.artists.focus = (state.artists.focus + 1) % 2; + } + KeyCode::Left => { + state.artists.focus = 0; + } + KeyCode::Right => { + // Move focus to songs (right pane) + if !state.artists.songs.is_empty() { + state.artists.focus = 1; + if state.artists.selected_song.is_none() { + state.artists.selected_song = Some(0); + } + } + } + KeyCode::Up | KeyCode::Char('k') => { + if state.artists.focus == 0 { + // Tree navigation + let tree_items = build_tree_items(&state); + if let Some(sel) = state.artists.selected_index { + if sel > 0 { + state.artists.selected_index = Some(sel - 1); + } + } else if !tree_items.is_empty() { + state.artists.selected_index = Some(0); + } + // Preview album songs in right pane + let album_id = state + .artists + .selected_index + .and_then(|i| tree_items.get(i)) + .and_then(|item| match item { + TreeItem::Album { album } => Some(album.id.clone()), + _ => None, + }); + if let Some(album_id) = album_id { + drop(state); + if let Some(ref client) = self.subsonic { + if let Ok((_album, songs)) = client.get_album(&album_id).await { + let mut state = self.state.write().await; + state.artists.songs = songs; + state.artists.selected_song = Some(0); + } + } + return Ok(()); + } + } else { + // Song list + if let Some(sel) = state.artists.selected_song { + if sel > 0 { + state.artists.selected_song = Some(sel - 1); + } + } else if !state.artists.songs.is_empty() { + state.artists.selected_song = Some(0); + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if state.artists.focus == 0 { + // Tree navigation + let tree_items = build_tree_items(&state); + let max = tree_items.len().saturating_sub(1); + if let Some(sel) = state.artists.selected_index { + if sel < max { + state.artists.selected_index = Some(sel + 1); + } + } else if !tree_items.is_empty() { + state.artists.selected_index = Some(0); + } + // Preview album songs in right pane + let album_id = state + .artists + .selected_index + .and_then(|i| tree_items.get(i)) + .and_then(|item| match item { + TreeItem::Album { album } => Some(album.id.clone()), + _ => None, + }); + if let Some(album_id) = album_id { + drop(state); + if let Some(ref client) = self.subsonic { + if let Ok((_album, songs)) = client.get_album(&album_id).await { + let mut state = self.state.write().await; + state.artists.songs = songs; + state.artists.selected_song = Some(0); + } + } + return Ok(()); + } + } else { + // Song list + let max = state.artists.songs.len().saturating_sub(1); + if let Some(sel) = state.artists.selected_song { + if sel < max { + state.artists.selected_song = Some(sel + 1); + } + } else if !state.artists.songs.is_empty() { + state.artists.selected_song = Some(0); + } + } + } + KeyCode::Enter => { + if state.artists.focus == 0 { + // Get current tree item + let tree_items = build_tree_items(&state); + if let Some(idx) = state.artists.selected_index { + if let Some(item) = tree_items.get(idx) { + match item { + TreeItem::Artist { artist, expanded } => { + let artist_id = artist.id.clone(); + let artist_name = artist.name.clone(); + let was_expanded = *expanded; + + if was_expanded { + state.artists.expanded.remove(&artist_id); + } else { + if !state.artists.albums_cache.contains_key(&artist_id) { + drop(state); + if let Some(ref client) = self.subsonic { + match client.get_artist(&artist_id).await { + Ok((_artist, albums)) => { + let mut state = self.state.write().await; + let count = albums.len(); + state.artists.albums_cache.insert(artist_id.clone(), albums); + state.artists.expanded.insert(artist_id); + info!("Loaded {} albums for {}", count, artist_name); + } + Err(e) => { + let mut state = self.state.write().await; + state.notify_error(format!("Failed to load: {}", e)); + } + } + } + return Ok(()); + } else { + state.artists.expanded.insert(artist_id); + } + } + } + TreeItem::Album { album } => { + let album_id = album.id.clone(); + let album_name = album.name.clone(); + drop(state); + + if let Some(ref client) = self.subsonic { + match client.get_album(&album_id).await { + Ok((_album, songs)) => { + if songs.is_empty() { + let mut state = self.state.write().await; + state.notify_error("Album has no songs"); + return Ok(()); + } + + let first_song = songs[0].clone(); + let stream_url = client.get_stream_url(&first_song.id); + + let mut state = self.state.write().await; + let count = songs.len(); + state.queue.clear(); + state.queue.extend(songs.clone()); + state.queue_position = Some(0); + state.artists.songs = songs; + state.artists.selected_song = Some(0); + state.artists.focus = 1; + state.now_playing.song = Some(first_song.clone()); + state.now_playing.state = PlaybackState::Playing; + state.now_playing.position = 0.0; + state.now_playing.duration = first_song.duration.unwrap_or(0) as f64; + state.now_playing.sample_rate = None; + state.now_playing.bit_depth = None; + state.now_playing.format = None; + state.now_playing.channels = None; + state.notify(format!("Playing album: {} ({} songs)", album_name, count)); + drop(state); + + match stream_url { + Ok(url) => { + if self.mpv.is_paused().unwrap_or(false) { + let _ = self.mpv.resume(); + } + if let Err(e) = self.mpv.loadfile(&url) { + error!("Failed to play: {}", e); + } + } + Err(e) => { + error!("Failed to get stream URL: {}", e); + } + } + } + Err(e) => { + let mut state = self.state.write().await; + state.notify_error(format!("Failed to load album: {}", e)); + } + } + } + return Ok(()); + } + } + } + } + } else { + // Play selected song from current position + if let Some(idx) = state.artists.selected_song { + if idx < state.artists.songs.len() { + let song = state.artists.songs[idx].clone(); + let songs = state.artists.songs.clone(); + state.queue.clear(); + state.queue.extend(songs); + state.queue_position = Some(idx); + state.now_playing.song = Some(song.clone()); + state.now_playing.state = PlaybackState::Playing; + state.now_playing.position = 0.0; + state.now_playing.duration = song.duration.unwrap_or(0) as f64; + state.now_playing.sample_rate = None; + state.now_playing.bit_depth = None; + state.now_playing.format = None; + state.now_playing.channels = None; + state.notify(format!("Playing: {}", song.title)); + drop(state); + + if let Some(ref client) = self.subsonic { + match client.get_stream_url(&song.id) { + Ok(url) => { + if self.mpv.is_paused().unwrap_or(false) { + let _ = self.mpv.resume(); + } + if let Err(e) = self.mpv.loadfile(&url) { + error!("Failed to play: {}", e); + } + } + Err(e) => { + error!("Failed to get stream URL: {}", e); + } + } + } + return Ok(()); + } + } + } + } + KeyCode::Backspace => { + if state.artists.focus == 1 { + state.artists.focus = 0; + } + } + KeyCode::Char('e') => { + if state.artists.focus == 1 { + if let Some(idx) = state.artists.selected_song { + if let Some(song) = state.artists.songs.get(idx).cloned() { + let title = song.title.clone(); + state.queue.push(song); + state.notify(format!("Added to queue: {}", title)); + } + } + } else { + if !state.artists.songs.is_empty() { + let count = state.artists.songs.len(); + let songs = state.artists.songs.clone(); + state.queue.extend(songs); + state.notify(format!("Added {} songs to queue", count)); + } + } + } + KeyCode::Char('n') => { + let insert_pos = state.queue_position.map(|p| p + 1).unwrap_or(0); + if state.artists.focus == 1 { + if let Some(idx) = state.artists.selected_song { + if let Some(song) = state.artists.songs.get(idx).cloned() { + let title = song.title.clone(); + state.queue.insert(insert_pos, song); + state.notify(format!("Playing next: {}", title)); + } + } + } else { + if !state.artists.songs.is_empty() { + let count = state.artists.songs.len(); + let songs: Vec<_> = state.artists.songs.iter().cloned().collect(); + for (i, song) in songs.into_iter().enumerate() { + state.queue.insert(insert_pos + i, song); + } + state.notify(format!("Playing {} songs next", count)); + } + } + } + _ => {} + } + + Ok(()) + } + + /// Handle queue page keys + async fn handle_queue_key(&mut self, key: event::KeyEvent) -> Result<(), Error> { + let mut state = self.state.write().await; + + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if let Some(sel) = state.queue_state.selected { + if sel > 0 { + state.queue_state.selected = Some(sel - 1); + } + } else if !state.queue.is_empty() { + state.queue_state.selected = Some(0); + } + } + KeyCode::Down | KeyCode::Char('j') => { + let max = state.queue.len().saturating_sub(1); + if let Some(sel) = state.queue_state.selected { + if sel < max { + state.queue_state.selected = Some(sel + 1); + } + } else if !state.queue.is_empty() { + state.queue_state.selected = Some(0); + } + } + KeyCode::Enter => { + // Play selected song + if let Some(idx) = state.queue_state.selected { + if idx < state.queue.len() { + drop(state); + return self.play_queue_position(idx).await; + } + } + } + KeyCode::Char('d') => { + // Remove selected song + if let Some(idx) = state.queue_state.selected { + if idx < state.queue.len() { + let song = state.queue.remove(idx); + state.notify(format!("Removed: {}", song.title)); + // Adjust selection + if state.queue.is_empty() { + state.queue_state.selected = None; + } else if idx >= state.queue.len() { + state.queue_state.selected = Some(state.queue.len() - 1); + } + // Adjust queue position + if let Some(pos) = state.queue_position { + if idx < pos { + state.queue_position = Some(pos - 1); + } else if idx == pos { + state.queue_position = None; + } + } + } + } + } + KeyCode::Char('J') => { + // Move down + if let Some(idx) = state.queue_state.selected { + if idx < state.queue.len() - 1 { + state.queue.swap(idx, idx + 1); + state.queue_state.selected = Some(idx + 1); + // Adjust queue position if needed + if let Some(pos) = state.queue_position { + if pos == idx { + state.queue_position = Some(idx + 1); + } else if pos == idx + 1 { + state.queue_position = Some(idx); + } + } + } + } + } + KeyCode::Char('K') => { + // Move up + if let Some(idx) = state.queue_state.selected { + if idx > 0 { + state.queue.swap(idx, idx - 1); + state.queue_state.selected = Some(idx - 1); + // Adjust queue position if needed + if let Some(pos) = state.queue_position { + if pos == idx { + state.queue_position = Some(idx - 1); + } else if pos == idx - 1 { + state.queue_position = Some(idx); + } + } + } + } + } + KeyCode::Char('r') => { + // Shuffle queue + use rand::seq::SliceRandom; + let mut rng = rand::thread_rng(); + + if let Some(pos) = state.queue_position { + // Keep current song in place, shuffle the rest + if pos < state.queue.len() { + let current = state.queue.remove(pos); + state.queue.shuffle(&mut rng); + state.queue.insert(0, current); + state.queue_position = Some(0); + } + } else { + state.queue.shuffle(&mut rng); + } + state.notify("Queue shuffled"); + } + KeyCode::Char('c') => { + // Clear history (remove all songs before current position) + if let Some(pos) = state.queue_position { + if pos > 0 { + let removed = pos; + state.queue.drain(0..pos); + state.queue_position = Some(0); + // Adjust selection + if let Some(sel) = state.queue_state.selected { + if sel < pos { + state.queue_state.selected = Some(0); + } else { + state.queue_state.selected = Some(sel - pos); + } + } + state.notify(format!("Cleared {} played songs", removed)); + } else { + state.notify("No history to clear"); + } + } else { + state.notify("No history to clear"); + } + } + _ => {} + } + + Ok(()) + } + + /// Handle playlists page keys + async fn handle_playlists_key(&mut self, key: event::KeyEvent) -> Result<(), Error> { + let mut state = self.state.write().await; + + match key.code { + KeyCode::Tab => { + state.playlists.focus = (state.playlists.focus + 1) % 2; + } + KeyCode::Left => { + state.playlists.focus = 0; + } + KeyCode::Right => { + if !state.playlists.songs.is_empty() { + state.playlists.focus = 1; + if state.playlists.selected_song.is_none() { + state.playlists.selected_song = Some(0); + } + } + } + KeyCode::Up | KeyCode::Char('k') => { + if state.playlists.focus == 0 { + // Playlist list + if let Some(sel) = state.playlists.selected_playlist { + if sel > 0 { + state.playlists.selected_playlist = Some(sel - 1); + } + } else if !state.playlists.playlists.is_empty() { + state.playlists.selected_playlist = Some(0); + } + } else { + // Song list + if let Some(sel) = state.playlists.selected_song { + if sel > 0 { + state.playlists.selected_song = Some(sel - 1); + } + } else if !state.playlists.songs.is_empty() { + state.playlists.selected_song = Some(0); + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if state.playlists.focus == 0 { + let max = state.playlists.playlists.len().saturating_sub(1); + if let Some(sel) = state.playlists.selected_playlist { + if sel < max { + state.playlists.selected_playlist = Some(sel + 1); + } + } else if !state.playlists.playlists.is_empty() { + state.playlists.selected_playlist = Some(0); + } + } else { + let max = state.playlists.songs.len().saturating_sub(1); + if let Some(sel) = state.playlists.selected_song { + if sel < max { + state.playlists.selected_song = Some(sel + 1); + } + } else if !state.playlists.songs.is_empty() { + state.playlists.selected_song = Some(0); + } + } + } + KeyCode::Enter => { + if state.playlists.focus == 0 { + // Load playlist songs + if let Some(idx) = state.playlists.selected_playlist { + if let Some(playlist) = state.playlists.playlists.get(idx) { + let playlist_id = playlist.id.clone(); + let playlist_name = playlist.name.clone(); + drop(state); + + if let Some(ref client) = self.subsonic { + match client.get_playlist(&playlist_id).await { + Ok((_playlist, songs)) => { + let mut state = self.state.write().await; + let count = songs.len(); + state.playlists.songs = songs; + state.playlists.selected_song = + if count > 0 { Some(0) } else { None }; + state.playlists.focus = 1; + state.notify(format!( + "Loaded playlist: {} ({} songs)", + playlist_name, count + )); + } + Err(e) => { + let mut state = self.state.write().await; + state.notify_error(format!( + "Failed to load playlist: {}", + e + )); + } + } + } + return Ok(()); + } + } + } else { + // Play selected song from playlist + if let Some(idx) = state.playlists.selected_song { + if idx < state.playlists.songs.len() { + let songs = state.playlists.songs.clone(); + state.queue.clear(); + state.queue.extend(songs); + drop(state); + return self.play_queue_position(idx).await; + } + } + } + } + KeyCode::Char('e') => { + // Add to queue + if state.playlists.focus == 1 { + if let Some(idx) = state.playlists.selected_song { + if let Some(song) = state.playlists.songs.get(idx).cloned() { + let title = song.title.clone(); + state.queue.push(song); + state.notify(format!("Added to queue: {}", title)); + } + } + } else { + // Add whole playlist + if !state.playlists.songs.is_empty() { + let count = state.playlists.songs.len(); + let songs = state.playlists.songs.clone(); + state.queue.extend(songs); + state.notify(format!("Added {} songs to queue", count)); + } + } + } + KeyCode::Char('n') => { + // Add next + let insert_pos = state.queue_position.map(|p| p + 1).unwrap_or(0); + if state.playlists.focus == 1 { + if let Some(idx) = state.playlists.selected_song { + if let Some(song) = state.playlists.songs.get(idx).cloned() { + let title = song.title.clone(); + state.queue.insert(insert_pos, song); + state.notify(format!("Playing next: {}", title)); + } + } + } + } + KeyCode::Char('r') => { + // Shuffle play playlist + use rand::seq::SliceRandom; + if !state.playlists.songs.is_empty() { + let mut songs = state.playlists.songs.clone(); + songs.shuffle(&mut rand::thread_rng()); + state.queue.clear(); + state.queue.extend(songs); + drop(state); + return self.play_queue_position(0).await; + } + } + _ => {} + } + + Ok(()) + } + + /// Handle server page keys + async fn handle_server_key(&mut self, key: event::KeyEvent) -> Result<(), Error> { + let mut state = self.state.write().await; + + let field = state.server_state.selected_field; + let is_text_field = field <= 2; + + match key.code { + // Navigation - always works + KeyCode::Up => { + if field > 0 { + state.server_state.selected_field -= 1; + } + } + KeyCode::Down => { + if field < 4 { + state.server_state.selected_field += 1; + } + } + KeyCode::Tab => { + // Tab moves to next field, wrapping around + state.server_state.selected_field = (field + 1) % 5; + } + // Text input for text fields (0=URL, 1=Username, 2=Password) + KeyCode::Char(c) if is_text_field => match field { + 0 => state.server_state.base_url.push(c), + 1 => state.server_state.username.push(c), + 2 => state.server_state.password.push(c), + _ => {} + }, + KeyCode::Backspace if is_text_field => match field { + 0 => { + state.server_state.base_url.pop(); + } + 1 => { + state.server_state.username.pop(); + } + 2 => { + state.server_state.password.pop(); + } + _ => {} + }, + // Enter activates buttons, ignored on text fields + KeyCode::Enter => { + match field { + 3 => { + // Test connection + let url = state.server_state.base_url.clone(); + let user = state.server_state.username.clone(); + let pass = state.server_state.password.clone(); + state.server_state.status = Some("Testing connection...".to_string()); + drop(state); + + match SubsonicClient::new(&url, &user, &pass) { + Ok(client) => match client.ping().await { + Ok(_) => { + let mut state = self.state.write().await; + state.server_state.status = + Some("Connection successful!".to_string()); + } + Err(e) => { + let mut state = self.state.write().await; + state.server_state.status = + Some(format!("Connection failed: {}", e)); + } + }, + Err(e) => { + let mut state = self.state.write().await; + state.server_state.status = Some(format!("Invalid URL: {}", e)); + } + } + return Ok(()); + } + 4 => { + // Save config and reconnect + info!( + "Saving config: url='{}', user='{}'", + state.server_state.base_url, state.server_state.username + ); + state.config.base_url = state.server_state.base_url.clone(); + state.config.username = state.server_state.username.clone(); + state.config.password = state.server_state.password.clone(); + + let url = state.config.base_url.clone(); + let user = state.config.username.clone(); + let pass = state.config.password.clone(); + + match state.config.save_default() { + Ok(_) => { + info!("Config saved successfully"); + state.server_state.status = + Some("Saved! Connecting...".to_string()); + } + Err(e) => { + info!("Config save failed: {}", e); + state.server_state.status = Some(format!("Save failed: {}", e)); + return Ok(()); + } + } + drop(state); + + // Create new client and load data + match SubsonicClient::new(&url, &user, &pass) { + Ok(client) => { + self.subsonic = Some(client); + self.load_initial_data().await; + let mut state = self.state.write().await; + state.server_state.status = + Some("Connected and loaded data!".to_string()); + } + Err(e) => { + let mut state = self.state.write().await; + state.server_state.status = + Some(format!("Saved but connection failed: {}", e)); + } + } + return Ok(()); + } + _ => {} // Ignore Enter on text fields (handles paste with newlines) + } + } + _ => {} + } + + Ok(()) + } + + /// Start cava process in noncurses mode via a pty + fn start_cava(&mut self, cava_gradient: &[String; 8], cava_horizontal_gradient: &[String; 8]) { + self.stop_cava(); + + // Compute pty dimensions to match the cava widget area + let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24)); + let cava_h = (term_h as u32 * 40 / 100).max(4) as u16; + let cava_w = term_w; + + // Open a pty pair + let mut master: libc::c_int = 0; + let mut slave: libc::c_int = 0; + unsafe { + if libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) != 0 + { + error!("openpty failed"); + return; + } + + // Set pty size so cava knows its dimensions + let ws = libc::winsize { + ws_row: cava_h, + ws_col: cava_w, + ws_xpixel: 0, + ws_ypixel: 0, + }; + libc::ioctl(slave, libc::TIOCSWINSZ, &ws); + } + + // Generate themed cava config and write to temp file + // Dup slave fd before converting to File (from_raw_fd takes ownership) + let slave_stdin_fd = unsafe { libc::dup(slave) }; + let slave_stderr_fd = unsafe { libc::dup(slave) }; + let slave_stdout = unsafe { std::fs::File::from_raw_fd(slave) }; + let slave_stdin = unsafe { std::fs::File::from_raw_fd(slave_stdin_fd) }; + let slave_stderr = unsafe { std::fs::File::from_raw_fd(slave_stderr_fd) }; + let config_path = std::env::temp_dir().join("ferrosonic-cava.conf"); + if let Err(e) = std::fs::write(&config_path, generate_cava_config(cava_gradient, cava_horizontal_gradient)) { + error!("Failed to write cava config: {}", e); + return; + } + let mut cmd = std::process::Command::new("cava"); + cmd.arg("-p").arg(&config_path); + cmd.stdout(std::process::Stdio::from(slave_stdout)) + .stderr(std::process::Stdio::from(slave_stderr)) + .stdin(std::process::Stdio::from(slave_stdin)) + .env("TERM", "xterm-256color"); + + match cmd.spawn() { + Ok(child) => { + // Set master to non-blocking + unsafe { + let flags = libc::fcntl(master, libc::F_GETFL); + libc::fcntl(master, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + + let master_file = unsafe { std::fs::File::from_raw_fd(master) }; + let parser = vt100::Parser::new(cava_h, cava_w, 0); + + self.cava_process = Some(child); + self.cava_pty_master = Some(master_file); + self.cava_parser = Some(parser); + info!("Cava started in noncurses mode ({}x{})", cava_w, cava_h); + } + Err(e) => { + error!("Failed to start cava: {}", e); + unsafe { + libc::close(master); + } + } + } + } + + /// Stop cava process and clean up + fn stop_cava(&mut self) { + if let Some(ref mut child) = self.cava_process { + let _ = child.kill(); + let _ = child.wait(); + } + self.cava_process = None; + self.cava_pty_master = None; + self.cava_parser = None; + } + + /// Read cava pty output and snapshot screen to state + async fn read_cava_output(&mut self) { + let (Some(ref mut master), Some(ref mut parser)) = + (&mut self.cava_pty_master, &mut self.cava_parser) + else { + return; + }; + + // Read all available bytes from the pty master + let mut buf = [0u8; 16384]; + let mut got_data = false; + loop { + match master.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + parser.process(&buf[..n]); + got_data = true; + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(_) => return, + } + } + + if !got_data { + return; + } + + // Snapshot the vt100 screen into shared state + let screen = parser.screen(); + let (rows, cols) = screen.size(); + let mut cava_screen = Vec::with_capacity(rows as usize); + + for row in 0..rows { + let mut spans: Vec = Vec::new(); + let mut cur_text = String::new(); + let mut cur_fg = CavaColor::Default; + let mut cur_bg = CavaColor::Default; + + for col in 0..cols { + let cell = screen.cell(row, col).unwrap(); + let fg = vt100_color_to_cava(cell.fgcolor()); + let bg = vt100_color_to_cava(cell.bgcolor()); + + if fg != cur_fg || bg != cur_bg { + if !cur_text.is_empty() { + spans.push(CavaSpan { + text: std::mem::take(&mut cur_text), + fg: cur_fg, + bg: cur_bg, + }); + } + cur_fg = fg; + cur_bg = bg; + } + + let contents = cell.contents(); + if contents.is_empty() { + cur_text.push(' '); + } else { + cur_text.push_str(&contents); + } + } + if !cur_text.is_empty() { + spans.push(CavaSpan { + text: cur_text, + fg: cur_fg, + bg: cur_bg, + }); + } + cava_screen.push(CavaRow { spans }); + } + + let mut state = self.state.write().await; + state.cava_screen = cava_screen; + } + + /// Handle settings page keys + async fn handle_settings_key(&mut self, key: event::KeyEvent) -> Result<(), Error> { + let mut config_changed = false; + + { + let mut state = self.state.write().await; + let field = state.settings_state.selected_field; + + match key.code { + // Navigate between fields + KeyCode::Up | KeyCode::Char('k') => { + if field > 0 { + state.settings_state.selected_field = field - 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if field < 1 { + state.settings_state.selected_field = field + 1; + } + } + // Left + KeyCode::Left | KeyCode::Char('h') => match field { + 0 => { + state.settings_state.prev_theme(); + state.config.theme = state.settings_state.theme_name().to_string(); + let label = state.settings_state.theme_name().to_string(); + state.notify(format!("Theme: {}", label)); + config_changed = true; + } + 1 if state.cava_available => { + state.settings_state.cava_enabled = !state.settings_state.cava_enabled; + state.config.cava = state.settings_state.cava_enabled; + let status = if state.settings_state.cava_enabled { + "On" + } else { + "Off" + }; + state.notify(format!("Cava: {}", status)); + config_changed = true; + } + _ => {} + }, + // Right / Enter / Space + KeyCode::Right | KeyCode::Char('l') | KeyCode::Enter | KeyCode::Char(' ') => { + match field { + 0 => { + state.settings_state.next_theme(); + state.config.theme = state.settings_state.theme_name().to_string(); + let label = state.settings_state.theme_name().to_string(); + state.notify(format!("Theme: {}", label)); + config_changed = true; + } + 1 if state.cava_available => { + state.settings_state.cava_enabled = !state.settings_state.cava_enabled; + state.config.cava = state.settings_state.cava_enabled; + let status = if state.settings_state.cava_enabled { + "On" + } else { + "Off" + }; + state.notify(format!("Cava: {}", status)); + config_changed = true; + } + _ => {} + } + } + _ => {} + } + } + + if config_changed { + // Save config + let state = self.state.read().await; + if let Err(e) = state.config.save_default() { + drop(state); + let mut state = self.state.write().await; + state.notify_error(format!("Failed to save: {}", e)); + } else { + // Start/stop cava based on new setting, or restart on theme change + let cava_enabled = state.settings_state.cava_enabled; + let td = state.settings_state.current_theme(); + let g = td.cava_gradient.clone(); + let h = td.cava_horizontal_gradient.clone(); + let cava_running = self.cava_parser.is_some(); + drop(state); + if cava_enabled { + // (Re)start cava — picks up new theme colors or toggle-on + self.start_cava(&g, &h); + } else if cava_running { + self.stop_cava(); + let mut state = self.state.write().await; + state.cava_screen.clear(); + } + } + } + + Ok(()) + } + + /// Handle mouse input + async fn handle_mouse(&mut self, mouse: event::MouseEvent) -> Result<(), Error> { + let x = mouse.column; + let y = mouse.row; + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + self.handle_mouse_click(x, y).await + } + MouseEventKind::ScrollUp => { + self.handle_mouse_scroll_up().await + } + MouseEventKind::ScrollDown => { + self.handle_mouse_scroll_down().await + } + _ => Ok(()), + } + } + + /// Handle left mouse click + async fn handle_mouse_click(&mut self, x: u16, y: u16) -> Result<(), Error> { + use crate::ui::header::{Header, HeaderRegion}; + + let state = self.state.read().await; + let layout = state.layout.clone(); + let page = state.page; + let duration = state.now_playing.duration; + drop(state); + + // Check header area + if y >= layout.header.y && y < layout.header.y + layout.header.height { + if let Some(region) = Header::region_at(layout.header, x, y) { + match region { + HeaderRegion::Tab(tab_page) => { + let mut state = self.state.write().await; + state.page = tab_page; + } + HeaderRegion::PrevButton => { + return self.prev_track().await; + } + HeaderRegion::PlayButton => { + return self.toggle_pause().await; + } + HeaderRegion::PauseButton => { + return self.toggle_pause().await; + } + HeaderRegion::StopButton => { + return self.stop_playback().await; + } + HeaderRegion::NextButton => { + return self.next_track().await; + } + } + } + return Ok(()); + } + + // Check now playing area (progress bar seeking) + if y >= layout.now_playing.y && y < layout.now_playing.y + layout.now_playing.height { + // The progress bar is on the last content line of the now_playing block. + // The block has a 1-cell border, so inner area starts at y+1. + // Progress bar row depends on layout height, but it's always the last inner row. + let inner_bottom = layout.now_playing.y + layout.now_playing.height - 2; // -1 for border, -1 for 0-index + if y == inner_bottom && duration > 0.0 { + // Calculate seek position from x coordinate within the now_playing area + // The progress bar renders centered: "MM:SS / MM:SS [━━━━━────]" + // We approximate: the bar occupies roughly the right portion of the inner area + let inner_x_start = layout.now_playing.x + 1; // border + let inner_width = layout.now_playing.width.saturating_sub(2); + if inner_width > 15 && x >= inner_x_start { + let rel_x = x - inner_x_start; + // Time text is roughly "MM:SS / MM:SS " = ~15 chars, bar fills the rest + let time_width = 15u16; + let bar_width = inner_width.saturating_sub(time_width + 2); + let bar_start = (inner_width.saturating_sub(time_width + 2 + bar_width)) / 2 + time_width + 2; + if bar_width > 0 && rel_x >= bar_start && rel_x < bar_start + bar_width { + let fraction = (rel_x - bar_start) as f64 / bar_width as f64; + let seek_pos = fraction * duration; + let _ = self.mpv.seek(seek_pos); + let mut state = self.state.write().await; + state.now_playing.position = seek_pos; + } + } + } + return Ok(()); + } + + // Check content area + if y >= layout.content.y && y < layout.content.y + layout.content.height { + return self.handle_content_click(x, y, page, &layout).await; + } + + Ok(()) + } + + /// Handle click within the content area + async fn handle_content_click( + &mut self, + x: u16, + y: u16, + page: Page, + layout: &LayoutAreas, + ) -> Result<(), Error> { + match page { + Page::Artists => self.handle_artists_click(x, y, layout).await, + Page::Queue => self.handle_queue_click(y, layout).await, + Page::Playlists => self.handle_playlists_click(x, y, layout).await, + _ => Ok(()), + } + } + + /// Handle click on artists page + async fn handle_artists_click( + &mut self, + x: u16, + y: u16, + layout: &LayoutAreas, + ) -> Result<(), Error> { + use crate::ui::pages::artists::{build_tree_items, TreeItem}; + + let mut state = self.state.write().await; + let left = layout.content_left.unwrap_or(layout.content); + let right = layout.content_right.unwrap_or(layout.content); + + if x >= left.x && x < left.x + left.width && y >= left.y && y < left.y + left.height { + // Tree pane click — account for border (1 row top) + let row_in_viewport = y.saturating_sub(left.y + 1) as usize; + let item_index = state.artists.tree_scroll_offset + row_in_viewport; + let tree_items = build_tree_items(&state); + + if item_index < tree_items.len() { + let was_selected = state.artists.selected_index == Some(item_index); + state.artists.focus = 0; + state.artists.selected_index = Some(item_index); + + // Second click = activate (same as Enter) + let is_second_click = was_selected + && self.last_click.map_or(false, |(lx, ly, t)| { + lx == x && ly == y && t.elapsed().as_millis() < 500 + }); + + if is_second_click { + // Activate: expand/collapse artist, or play album + match &tree_items[item_index] { + TreeItem::Artist { artist, expanded } => { + let artist_id = artist.id.clone(); + let artist_name = artist.name.clone(); + let was_expanded = *expanded; + + if was_expanded { + state.artists.expanded.remove(&artist_id); + } else { + if !state.artists.albums_cache.contains_key(&artist_id) { + drop(state); + if let Some(ref client) = self.subsonic { + match client.get_artist(&artist_id).await { + Ok((_artist, albums)) => { + let mut state = self.state.write().await; + let count = albums.len(); + state.artists.albums_cache.insert(artist_id.clone(), albums); + state.artists.expanded.insert(artist_id); + info!("Loaded {} albums for {}", count, artist_name); + } + Err(e) => { + let mut state = self.state.write().await; + state.notify_error(format!("Failed to load: {}", e)); + } + } + } + self.last_click = Some((x, y, std::time::Instant::now())); + return Ok(()); + } else { + state.artists.expanded.insert(artist_id); + } + } + } + TreeItem::Album { album } => { + let album_id = album.id.clone(); + let album_name = album.name.clone(); + drop(state); + + if let Some(ref client) = self.subsonic { + match client.get_album(&album_id).await { + Ok((_album, songs)) => { + if songs.is_empty() { + let mut state = self.state.write().await; + state.notify_error("Album has no songs"); + self.last_click = Some((x, y, std::time::Instant::now())); + return Ok(()); + } + + let first_song = songs[0].clone(); + let stream_url = client.get_stream_url(&first_song.id); + + let mut state = self.state.write().await; + let count = songs.len(); + state.queue.clear(); + state.queue.extend(songs.clone()); + state.queue_position = Some(0); + state.artists.songs = songs; + state.artists.selected_song = Some(0); + state.artists.focus = 1; + state.now_playing.song = Some(first_song.clone()); + state.now_playing.state = PlaybackState::Playing; + state.now_playing.position = 0.0; + state.now_playing.duration = first_song.duration.unwrap_or(0) as f64; + state.now_playing.sample_rate = None; + state.now_playing.bit_depth = None; + state.now_playing.format = None; + state.now_playing.channels = None; + state.notify(format!("Playing album: {} ({} songs)", album_name, count)); + drop(state); + + if let Ok(url) = stream_url { + if self.mpv.is_paused().unwrap_or(false) { + let _ = self.mpv.resume(); + } + if let Err(e) = self.mpv.loadfile(&url) { + error!("Failed to play: {}", e); + } + } + } + Err(e) => { + let mut state = self.state.write().await; + state.notify_error(format!("Failed to load album: {}", e)); + } + } + } + self.last_click = Some((x, y, std::time::Instant::now())); + return Ok(()); + } + } + } else { + // First click on album: preview songs in right pane + if let TreeItem::Album { album } = &tree_items[item_index] { + let album_id = album.id.clone(); + drop(state); + if let Some(ref client) = self.subsonic { + if let Ok((_album, songs)) = client.get_album(&album_id).await { + let mut state = self.state.write().await; + state.artists.songs = songs; + state.artists.selected_song = Some(0); + } + } + self.last_click = Some((x, y, std::time::Instant::now())); + return Ok(()); + } + } + } + } else if x >= right.x && x < right.x + right.width && y >= right.y && y < right.y + right.height { + // Songs pane click + let row_in_viewport = y.saturating_sub(right.y + 1) as usize; + let item_index = state.artists.song_scroll_offset + row_in_viewport; + + if item_index < state.artists.songs.len() { + let was_selected = state.artists.selected_song == Some(item_index); + state.artists.focus = 1; + state.artists.selected_song = Some(item_index); + + let is_second_click = was_selected + && self.last_click.map_or(false, |(lx, ly, t)| { + lx == x && ly == y && t.elapsed().as_millis() < 500 + }); + + if is_second_click { + // Play selected song + let song = state.artists.songs[item_index].clone(); + let songs = state.artists.songs.clone(); + state.queue.clear(); + state.queue.extend(songs); + state.queue_position = Some(item_index); + state.now_playing.song = Some(song.clone()); + state.now_playing.state = PlaybackState::Playing; + state.now_playing.position = 0.0; + state.now_playing.duration = song.duration.unwrap_or(0) as f64; + state.now_playing.sample_rate = None; + state.now_playing.bit_depth = None; + state.now_playing.format = None; + state.now_playing.channels = None; + state.notify(format!("Playing: {}", song.title)); + drop(state); + + if let Some(ref client) = self.subsonic { + if let Ok(url) = client.get_stream_url(&song.id) { + if self.mpv.is_paused().unwrap_or(false) { + let _ = self.mpv.resume(); + } + if let Err(e) = self.mpv.loadfile(&url) { + error!("Failed to play: {}", e); + } + } + } + self.last_click = Some((x, y, std::time::Instant::now())); + return Ok(()); + } + } + } + + self.last_click = Some((x, y, std::time::Instant::now())); + Ok(()) + } + + /// Handle click on queue page + async fn handle_queue_click(&mut self, y: u16, layout: &LayoutAreas) -> Result<(), Error> { + let mut state = self.state.write().await; + let content = layout.content; + + // Account for border (1 row top) + let row_in_viewport = y.saturating_sub(content.y + 1) as usize; + let item_index = state.queue_state.scroll_offset + row_in_viewport; + + if item_index < state.queue.len() { + let was_selected = state.queue_state.selected == Some(item_index); + state.queue_state.selected = Some(item_index); + + let is_second_click = was_selected + && self.last_click.map_or(false, |(_, ly, t)| { + ly == y && t.elapsed().as_millis() < 500 + }); + + if is_second_click { + drop(state); + self.last_click = Some((0, y, std::time::Instant::now())); + return self.play_queue_position(item_index).await; + } + } + + self.last_click = Some((0, y, std::time::Instant::now())); + Ok(()) + } + + /// Handle click on playlists page + async fn handle_playlists_click( + &mut self, + x: u16, + y: u16, + layout: &LayoutAreas, + ) -> Result<(), Error> { + let mut state = self.state.write().await; + let left = layout.content_left.unwrap_or(layout.content); + let right = layout.content_right.unwrap_or(layout.content); + + if x >= left.x && x < left.x + left.width && y >= left.y && y < left.y + left.height { + // Playlists pane + let row_in_viewport = y.saturating_sub(left.y + 1) as usize; + let item_index = state.playlists.playlist_scroll_offset + row_in_viewport; + + if item_index < state.playlists.playlists.len() { + let was_selected = state.playlists.selected_playlist == Some(item_index); + state.playlists.focus = 0; + state.playlists.selected_playlist = Some(item_index); + + let is_second_click = was_selected + && self.last_click.map_or(false, |(lx, ly, t)| { + lx == x && ly == y && t.elapsed().as_millis() < 500 + }); + + if is_second_click { + // Load playlist songs (same as Enter) + let playlist = state.playlists.playlists[item_index].clone(); + let playlist_id = playlist.id.clone(); + let playlist_name = playlist.name.clone(); + drop(state); + + if let Some(ref client) = self.subsonic { + match client.get_playlist(&playlist_id).await { + Ok((_playlist, songs)) => { + let mut state = self.state.write().await; + let count = songs.len(); + state.playlists.songs = songs; + state.playlists.selected_song = if count > 0 { Some(0) } else { None }; + state.playlists.focus = 1; + state.notify(format!("Loaded playlist: {} ({} songs)", playlist_name, count)); + } + Err(e) => { + let mut state = self.state.write().await; + state.notify_error(format!("Failed to load playlist: {}", e)); + } + } + } + self.last_click = Some((x, y, std::time::Instant::now())); + return Ok(()); + } + } + } else if x >= right.x && x < right.x + right.width && y >= right.y && y < right.y + right.height { + // Songs pane + let row_in_viewport = y.saturating_sub(right.y + 1) as usize; + let item_index = state.playlists.song_scroll_offset + row_in_viewport; + + if item_index < state.playlists.songs.len() { + let was_selected = state.playlists.selected_song == Some(item_index); + state.playlists.focus = 1; + state.playlists.selected_song = Some(item_index); + + let is_second_click = was_selected + && self.last_click.map_or(false, |(lx, ly, t)| { + lx == x && ly == y && t.elapsed().as_millis() < 500 + }); + + if is_second_click { + // Play selected song from playlist + let songs = state.playlists.songs.clone(); + state.queue.clear(); + state.queue.extend(songs); + drop(state); + self.last_click = Some((x, y, std::time::Instant::now())); + return self.play_queue_position(item_index).await; + } + } + } + + self.last_click = Some((x, y, std::time::Instant::now())); + Ok(()) + } + + /// Handle mouse scroll up (move selection up in current list) + async fn handle_mouse_scroll_up(&mut self) -> Result<(), Error> { + let mut state = self.state.write().await; + match state.page { + Page::Artists => { + if state.artists.focus == 0 { + if let Some(sel) = state.artists.selected_index { + if sel > 0 { + state.artists.selected_index = Some(sel - 1); + } + } + } else { + if let Some(sel) = state.artists.selected_song { + if sel > 0 { + state.artists.selected_song = Some(sel - 1); + } + } + } + } + Page::Queue => { + if let Some(sel) = state.queue_state.selected { + if sel > 0 { + state.queue_state.selected = Some(sel - 1); + } + } else if !state.queue.is_empty() { + state.queue_state.selected = Some(0); + } + } + Page::Playlists => { + if state.playlists.focus == 0 { + if let Some(sel) = state.playlists.selected_playlist { + if sel > 0 { + state.playlists.selected_playlist = Some(sel - 1); + } + } + } else { + if let Some(sel) = state.playlists.selected_song { + if sel > 0 { + state.playlists.selected_song = Some(sel - 1); + } + } + } + } + _ => {} + } + Ok(()) + } + + /// Handle mouse scroll down (move selection down in current list) + async fn handle_mouse_scroll_down(&mut self) -> Result<(), Error> { + let mut state = self.state.write().await; + match state.page { + Page::Artists => { + if state.artists.focus == 0 { + let tree_items = crate::ui::pages::artists::build_tree_items(&state); + let max = tree_items.len().saturating_sub(1); + if let Some(sel) = state.artists.selected_index { + if sel < max { + state.artists.selected_index = Some(sel + 1); + } + } else if !tree_items.is_empty() { + state.artists.selected_index = Some(0); + } + } else { + let max = state.artists.songs.len().saturating_sub(1); + if let Some(sel) = state.artists.selected_song { + if sel < max { + state.artists.selected_song = Some(sel + 1); + } + } else if !state.artists.songs.is_empty() { + state.artists.selected_song = Some(0); + } + } + } + Page::Queue => { + let max = state.queue.len().saturating_sub(1); + if let Some(sel) = state.queue_state.selected { + if sel < max { + state.queue_state.selected = Some(sel + 1); + } + } else if !state.queue.is_empty() { + state.queue_state.selected = Some(0); + } + } + Page::Playlists => { + if state.playlists.focus == 0 { + let max = state.playlists.playlists.len().saturating_sub(1); + if let Some(sel) = state.playlists.selected_playlist { + if sel < max { + state.playlists.selected_playlist = Some(sel + 1); + } + } else if !state.playlists.playlists.is_empty() { + state.playlists.selected_playlist = Some(0); + } + } else { + let max = state.playlists.songs.len().saturating_sub(1); + if let Some(sel) = state.playlists.selected_song { + if sel < max { + state.playlists.selected_song = Some(sel + 1); + } + } else if !state.playlists.songs.is_empty() { + state.playlists.selected_song = Some(0); + } + } + } + _ => {} + } + Ok(()) + } + + /// Handle UI action from async tasks + async fn handle_ui_action(&mut self, action: UiAction) { +match action { + UiAction::UpdatePosition { position, duration } => { + let mut state = self.state.write().await; + state.now_playing.position = position; + state.now_playing.duration = duration; + } + UiAction::UpdatePlaybackState(pbs) => { + let mut state = self.state.write().await; + state.now_playing.state = match pbs { + PlaybackStateUpdate::Playing => PlaybackState::Playing, + PlaybackStateUpdate::Paused => PlaybackState::Paused, + PlaybackStateUpdate::Stopped => PlaybackState::Stopped, + }; + } + UiAction::UpdateAudioProperties { + sample_rate, + bit_depth, + format, + } => { + let mut state = self.state.write().await; + state.now_playing.sample_rate = sample_rate; + state.now_playing.bit_depth = bit_depth; + state.now_playing.format = format; + } + UiAction::TrackEnded => { + // Advance to next track + let mut state = self.state.write().await; + if let Some(pos) = state.queue_position { + if pos + 1 < state.queue.len() { + state.queue_position = Some(pos + 1); + // Would trigger play of next track + } else { + state.queue_position = None; + state.now_playing.state = PlaybackState::Stopped; + } + } + } + UiAction::Notify { message, is_error } => { + let mut state = self.state.write().await; + if is_error { + state.notify_error(message); + } else { + state.notify(message); + } + } + UiAction::ArtistsLoaded(artists) => { + let mut state = self.state.write().await; + let has_artists = !artists.is_empty(); + state.artists.artists = artists; + if has_artists && state.artists.selected_index.is_none() { + state.artists.selected_index = Some(0); + } + } + UiAction::AlbumsLoaded { artist_id, albums } => { + let mut state = self.state.write().await; + state.artists.albums_cache.insert(artist_id.clone(), albums); + state.artists.expanded.insert(artist_id); + } + UiAction::SongsLoaded { songs, .. } => { + let mut state = self.state.write().await; + state.artists.songs = songs; + } + UiAction::PlaylistsLoaded(playlists) => { + let mut state = self.state.write().await; + state.playlists.playlists = playlists; + } + UiAction::PlaylistSongsLoaded { songs, .. } => { + let mut state = self.state.write().await; + state.playlists.songs = songs; + } + UiAction::ConnectionTestResult { + success: _, + message, + } => { + let mut state = self.state.write().await; + state.server_state.status = Some(message); + } + UiAction::Redraw => { + // Will redraw on next iteration + } +} + } + + /// Toggle play/pause + async fn toggle_pause(&mut self) -> Result<(), Error> { +let state = self.state.read().await; +let is_playing = state.now_playing.state == PlaybackState::Playing; +let is_paused = state.now_playing.state == PlaybackState::Paused; +drop(state); + +if !is_playing && !is_paused { + return Ok(()); +} + +match self.mpv.toggle_pause() { + Ok(now_paused) => { + let mut state = self.state.write().await; + if now_paused { + state.now_playing.state = PlaybackState::Paused; + debug!("Paused playback"); + } else { + state.now_playing.state = PlaybackState::Playing; + debug!("Resumed playback"); + } + } + Err(e) => { + error!("Failed to toggle pause: {}", e); + } +} +Ok(()) + } + + /// Pause playback (only if currently playing) + async fn pause_playback(&mut self) -> Result<(), Error> { +let state = self.state.read().await; +if state.now_playing.state != PlaybackState::Playing { + return Ok(()); +} +drop(state); + +match self.mpv.pause() { + Ok(()) => { + let mut state = self.state.write().await; + state.now_playing.state = PlaybackState::Paused; + debug!("Paused playback"); + } + Err(e) => { + error!("Failed to pause: {}", e); + } +} +Ok(()) + } + + /// Resume playback (only if currently paused) + async fn resume_playback(&mut self) -> Result<(), Error> { +let state = self.state.read().await; +if state.now_playing.state != PlaybackState::Paused { + return Ok(()); +} +drop(state); + +match self.mpv.resume() { + Ok(()) => { + let mut state = self.state.write().await; + state.now_playing.state = PlaybackState::Playing; + debug!("Resumed playback"); + } + Err(e) => { + error!("Failed to resume: {}", e); + } +} +Ok(()) + } + + /// Play next track in queue + async fn next_track(&mut self) -> Result<(), Error> { +let state = self.state.read().await; +let queue_len = state.queue.len(); +let current_pos = state.queue_position; +drop(state); + +if queue_len == 0 { + return Ok(()); +} + +let next_pos = match current_pos { + Some(pos) if pos + 1 < queue_len => pos + 1, + _ => { + info!("Reached end of queue"); + let _ = self.mpv.stop(); + let mut state = self.state.write().await; + state.now_playing.state = PlaybackState::Stopped; + state.now_playing.position = 0.0; + return Ok(()); + } +}; + +self.play_queue_position(next_pos).await + } + + /// Play previous track in queue (or restart current if < 3 seconds in) + async fn prev_track(&mut self) -> Result<(), Error> { +let state = self.state.read().await; +let queue_len = state.queue.len(); +let current_pos = state.queue_position; +let position = state.now_playing.position; +drop(state); + +if queue_len == 0 { + return Ok(()); +} + +if position < 3.0 { + if let Some(pos) = current_pos { + if pos > 0 { + return self.play_queue_position(pos - 1).await; + } + } + if let Err(e) = self.mpv.seek(0.0) { + error!("Failed to restart track: {}", e); + } else { + let mut state = self.state.write().await; + state.now_playing.position = 0.0; + } + return Ok(()); +} + +debug!("Restarting current track (position: {:.1}s)", position); +if let Err(e) = self.mpv.seek(0.0) { + error!("Failed to restart track: {}", e); +} else { + let mut state = self.state.write().await; + state.now_playing.position = 0.0; +} +Ok(()) + } + + /// Play a specific position in the queue + async fn play_queue_position(&mut self, pos: usize) -> Result<(), Error> { +let state = self.state.read().await; +let song = match state.queue.get(pos) { + Some(s) => s.clone(), + None => return Ok(()), +}; +drop(state); + +let stream_url = if let Some(ref client) = self.subsonic { + match client.get_stream_url(&song.id) { + Ok(url) => url, + Err(e) => { + error!("Failed to get stream URL: {}", e); + let mut state = self.state.write().await; + state.notify_error(format!("Failed to get stream URL: {}", e)); + return Ok(()); + } + } +} else { + return Ok(()); +}; + +{ + let mut state = self.state.write().await; + state.queue_position = Some(pos); + state.now_playing.song = Some(song.clone()); + state.now_playing.state = PlaybackState::Playing; + state.now_playing.position = 0.0; + state.now_playing.duration = song.duration.unwrap_or(0) as f64; + state.now_playing.sample_rate = None; + state.now_playing.bit_depth = None; + state.now_playing.format = None; + state.now_playing.channels = None; +} + +info!("Playing: {} (queue pos {})", song.title, pos); +if self.mpv.is_paused().unwrap_or(false) { + let _ = self.mpv.resume(); +} +if let Err(e) = self.mpv.loadfile(&stream_url) { + error!("Failed to play: {}", e); + let mut state = self.state.write().await; + state.notify_error(format!("MPV error: {}", e)); + return Ok(()); +} + +self.preload_next_track(pos).await; + +Ok(()) + } + + /// Pre-load the next track into MPV's playlist for gapless playback + async fn preload_next_track(&mut self, current_pos: usize) { +let state = self.state.read().await; +let next_pos = current_pos + 1; + +if next_pos >= state.queue.len() { + return; +} + +let next_song = match state.queue.get(next_pos) { + Some(s) => s.clone(), + None => return, +}; +drop(state); + +if let Some(ref client) = self.subsonic { + if let Ok(url) = client.get_stream_url(&next_song.id) { + debug!("Pre-loading next track for gapless: {}", next_song.title); + if let Err(e) = self.mpv.loadfile_append(&url) { + debug!("Failed to pre-load next track: {}", e); + } else if let Ok(count) = self.mpv.get_playlist_count() { + if count < 2 { + warn!("Preload may have failed: playlist count is {} (expected 2)", count); + } else { + debug!("Preload confirmed: playlist count is {}", count); + } + } + } +} + } + + /// Stop playback and clear the queue + async fn stop_playback(&mut self) -> Result<(), Error> { +if let Err(e) = self.mpv.stop() { + error!("Failed to stop: {}", e); +} + +let mut state = self.state.write().await; +state.now_playing.state = PlaybackState::Stopped; +state.now_playing.song = None; +state.now_playing.position = 0.0; +state.now_playing.duration = 0.0; +state.now_playing.sample_rate = None; +state.now_playing.bit_depth = None; +state.now_playing.format = None; +state.now_playing.channels = None; +state.queue.clear(); +state.queue_position = None; +Ok(()) + } +} + +/// Convert vt100 color to our CavaColor type +fn vt100_color_to_cava(color: vt100::Color) -> CavaColor { + match color { + vt100::Color::Default => CavaColor::Default, + vt100::Color::Idx(i) => CavaColor::Indexed(i), + vt100::Color::Rgb(r, g, b) => CavaColor::Rgb(r, g, b), + } +} + +/// Generate a cava configuration string with theme-appropriate gradient colors +fn generate_cava_config(g: &[String; 8], h: &[String; 8]) -> String { + + format!( + "\ +[general] +framerate = 60 +autosens = 1 +overshoot = 0 +bars = 0 +bar_width = 1 +bar_spacing = 0 +lower_cutoff_freq = 10 +higher_cutoff_freq = 18000 + +[input] +sample_rate = 96000 +sample_bits = 32 +remix = 1 + +[output] +method = noncurses +orientation = horizontal +channels = mono +mono_option = average +synchronized_sync = 1 +disable_blanking = 1 + +[color] +gradient = 1 +gradient_color_1 = '{g0}' +gradient_color_2 = '{g1}' +gradient_color_3 = '{g2}' +gradient_color_4 = '{g3}' +gradient_color_5 = '{g4}' +gradient_color_6 = '{g5}' +gradient_color_7 = '{g6}' +gradient_color_8 = '{g7}' +horizontal_gradient = 1 +horizontal_gradient_color_1 = '{h0}' +horizontal_gradient_color_2 = '{h1}' +horizontal_gradient_color_3 = '{h2}' +horizontal_gradient_color_4 = '{h3}' +horizontal_gradient_color_5 = '{h4}' +horizontal_gradient_color_6 = '{h5}' +horizontal_gradient_color_7 = '{h6}' +horizontal_gradient_color_8 = '{h7}' + +[smoothing] +monstercat = 0 +waves = 0 +noise_reduction = 11 +", + g0 = g[0], g1 = g[1], g2 = g[2], g3 = g[3], + g4 = g[4], g5 = g[5], g6 = g[6], g7 = g[7], + h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], + h4 = h[4], h5 = h[5], h6 = h[6], h7 = h[7], + ) +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 0000000..6f8de23 --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,399 @@ +//! Shared application state + +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::RwLock; + +use ratatui::layout::Rect; + +use crate::config::Config; +use crate::subsonic::models::{Album, Artist, Child, Playlist}; +use crate::ui::theme::{ThemeColors, ThemeData}; + +/// Current page in the application +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Page { + #[default] + Artists, + Queue, + Playlists, + Server, + Settings, +} + +impl Page { + pub fn index(&self) -> usize { + match self { + Page::Artists => 0, + Page::Queue => 1, + Page::Playlists => 2, + Page::Server => 3, + Page::Settings => 4, + } + } + + pub fn from_index(index: usize) -> Self { + match index { + 0 => Page::Artists, + 1 => Page::Queue, + 2 => Page::Playlists, + 3 => Page::Server, + 4 => Page::Settings, + _ => Page::Artists, + } + } + + pub fn label(&self) -> &'static str { + match self { + Page::Artists => "Artists", + Page::Queue => "Queue", + Page::Playlists => "Playlists", + Page::Server => "Server", + Page::Settings => "Settings", + } + } + + pub fn shortcut(&self) -> &'static str { + match self { + Page::Artists => "F1", + Page::Queue => "F2", + Page::Playlists => "F3", + Page::Server => "F4", + Page::Settings => "F5", + } + } +} + +/// Playback state +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PlaybackState { + #[default] + Stopped, + Playing, + Paused, +} + +/// Now playing information +#[derive(Debug, Clone, Default)] +pub struct NowPlaying { + /// Currently playing song + pub song: Option, + /// Playback state + pub state: PlaybackState, + /// Current position in seconds + pub position: f64, + /// Total duration in seconds + pub duration: f64, + /// Audio sample rate (Hz) + pub sample_rate: Option, + /// Audio bit depth + pub bit_depth: Option, + /// Audio format/codec + pub format: Option, + /// Audio channel layout (e.g., "Stereo", "Mono", "5.1ch") + pub channels: Option, +} + +impl NowPlaying { + pub fn progress_percent(&self) -> f64 { + if self.duration > 0.0 { + (self.position / self.duration).clamp(0.0, 1.0) + } else { + 0.0 + } + } + + pub fn format_position(&self) -> String { + format_duration(self.position) + } + + pub fn format_duration(&self) -> String { + format_duration(self.duration) + } +} + +/// Format duration in MM:SS or HH:MM:SS format +pub fn format_duration(seconds: f64) -> String { + let total_secs = seconds as u64; + let hours = total_secs / 3600; + let mins = (total_secs % 3600) / 60; + let secs = total_secs % 60; + + if hours > 0 { + format!("{:02}:{:02}:{:02}", hours, mins, secs) + } else { + format!("{:02}:{:02}", mins, secs) + } +} + +/// Artists page state +#[derive(Debug, Clone, Default)] +pub struct ArtistsState { + /// List of all artists + pub artists: Vec, + /// Currently selected index in the tree (artists + expanded albums) + pub selected_index: Option, + /// Set of expanded artist IDs + pub expanded: std::collections::HashSet, + /// Albums cached per artist ID + pub albums_cache: std::collections::HashMap>, + /// Songs in the selected album (shown in right pane) + pub songs: Vec, + /// Currently selected song index + pub selected_song: Option, + /// Artist filter text + pub filter: String, + /// Whether filter input is active + pub filter_active: bool, + /// Focus: 0 = tree, 1 = songs + pub focus: usize, + /// Scroll offset for the tree list (set after render) + pub tree_scroll_offset: usize, + /// Scroll offset for the songs list (set after render) + pub song_scroll_offset: usize, +} + +/// Queue page state +#[derive(Debug, Clone, Default)] +pub struct QueueState { + /// Currently selected index in the queue + pub selected: Option, + /// Scroll offset for the queue list (set after render) + pub scroll_offset: usize, +} + +/// Playlists page state +#[derive(Debug, Clone, Default)] +pub struct PlaylistsState { + /// List of all playlists + pub playlists: Vec, + /// Currently selected playlist index + pub selected_playlist: Option, + /// Songs in the selected playlist + pub songs: Vec, + /// Currently selected song index + pub selected_song: Option, + /// Focus: 0 = playlists, 1 = songs + pub focus: usize, + /// Scroll offset for the playlists list (set after render) + pub playlist_scroll_offset: usize, + /// Scroll offset for the songs list (set after render) + pub song_scroll_offset: usize, +} + +/// Server page state (connection settings) +#[derive(Debug, Clone, Default)] +pub struct ServerState { + /// Currently focused field (0-4: URL, Username, Password, Test, Save) + pub selected_field: usize, + /// Edit values + pub base_url: String, + pub username: String, + pub password: String, + /// Status message + pub status: Option, +} + +/// Settings page state +#[derive(Debug, Clone)] +pub struct SettingsState { + /// Currently focused field (0=Theme, 1=Cava) + pub selected_field: usize, + /// Available themes (Default + loaded from files) + pub themes: Vec, + /// Index of the currently selected theme in `themes` + pub theme_index: usize, + /// Cava visualizer enabled + pub cava_enabled: bool, +} + +impl Default for SettingsState { + fn default() -> Self { + Self { + selected_field: 0, + themes: vec![ThemeData::default_theme()], + theme_index: 0, + cava_enabled: false, + } + } +} + +impl SettingsState { + /// Current theme name + pub fn theme_name(&self) -> &str { + &self.themes[self.theme_index].name + } + + /// Current theme colors + pub fn theme_colors(&self) -> &ThemeColors { + &self.themes[self.theme_index].colors + } + + /// Current theme data + pub fn current_theme(&self) -> &ThemeData { + &self.themes[self.theme_index] + } + + /// Cycle to next theme + pub fn next_theme(&mut self) { + self.theme_index = (self.theme_index + 1) % self.themes.len(); + } + + /// Cycle to previous theme + pub fn prev_theme(&mut self) { + self.theme_index = (self.theme_index + self.themes.len() - 1) % self.themes.len(); + } + + /// Set theme by name, returning true if found + pub fn set_theme_by_name(&mut self, name: &str) -> bool { + if let Some(idx) = self.themes.iter().position(|t| t.name.eq_ignore_ascii_case(name)) { + self.theme_index = idx; + true + } else { + self.theme_index = 0; // Fall back to Default + false + } + } +} + +/// Notification/alert to display +#[derive(Debug, Clone)] +pub struct Notification { + pub message: String, + pub is_error: bool, + pub created_at: Instant, +} + +/// Cached layout rectangles from the last render, used for mouse hit-testing. +/// Automatically updated every frame, so resize and visualiser toggle are handled. +#[derive(Debug, Clone, Default)] +pub struct LayoutAreas { + pub header: Rect, + pub cava: Option, + pub content: Rect, + pub now_playing: Rect, + pub footer: Rect, + /// Left pane for dual-pane pages (Artists tree, Playlists list) + pub content_left: Option, + /// Right pane for dual-pane pages (Songs list) + pub content_right: Option, +} + +/// Complete application state +#[derive(Debug, Default)] +pub struct AppState { + /// Application configuration + pub config: Config, + /// Current page + pub page: Page, + /// Now playing information + pub now_playing: NowPlaying, + /// Play queue (songs) + pub queue: Vec, + /// Current position in queue + pub queue_position: Option, + /// Artists page state + pub artists: ArtistsState, + /// Queue page state + pub queue_state: QueueState, + /// Playlists page state + pub playlists: PlaylistsState, + /// Server page state (connection settings) + pub server_state: ServerState, + /// Settings page state (app preferences) + pub settings_state: SettingsState, + /// Current notification + pub notification: Option, + /// Whether the app should quit + pub should_quit: bool, + /// Cava visualizer screen content (rows of styled spans) + pub cava_screen: Vec, + /// Whether the cava binary is available on the system + pub cava_available: bool, + /// Cached layout areas from last render (for mouse hit-testing) + pub layout: LayoutAreas, +} + +/// A row of styled segments from cava's terminal output +#[derive(Debug, Clone, Default)] +pub struct CavaRow { + pub spans: Vec, +} + +/// A styled text segment from cava's terminal output +#[derive(Debug, Clone)] +pub struct CavaSpan { + pub text: String, + pub fg: CavaColor, + pub bg: CavaColor, +} + +/// Color from cava's terminal output +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum CavaColor { + #[default] + Default, + Indexed(u8), + Rgb(u8, u8, u8), +} + +impl AppState { + pub fn new(config: Config) -> Self { + let mut state = Self { + config: config.clone(), + ..Default::default() + }; + // Initialize server page with current values + state.server_state.base_url = config.base_url.clone(); + state.server_state.username = config.username.clone(); + state.server_state.password = config.password.clone(); + // Initialize cava from config + state.settings_state.cava_enabled = config.cava; + state + } + + /// Get the currently playing song from the queue + pub fn current_song(&self) -> Option<&Child> { + self.queue_position.and_then(|pos| self.queue.get(pos)) + } + + /// Show a notification + pub fn notify(&mut self, message: impl Into) { + self.notification = Some(Notification { + message: message.into(), + is_error: false, + created_at: Instant::now(), + }); + } + + /// Show an error notification + pub fn notify_error(&mut self, message: impl Into) { + self.notification = Some(Notification { + message: message.into(), + is_error: true, + created_at: Instant::now(), + }); + } + + /// Check if notification should be auto-cleared (after 2 seconds) + pub fn check_notification_timeout(&mut self) { + if let Some(ref notif) = self.notification { + if notif.created_at.elapsed().as_secs() >= 2 { + self.notification = None; + } + } + } + + /// Clear the notification + pub fn clear_notification(&mut self) { + self.notification = None; + } +} + +/// Thread-safe shared state +pub type SharedState = Arc>; + +/// Create new shared state +pub fn new_shared_state(config: Config) -> SharedState { + Arc::new(RwLock::new(AppState::new(config))) +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs new file mode 100644 index 0000000..230a8f0 --- /dev/null +++ b/src/audio/mod.rs @@ -0,0 +1,7 @@ +//! Audio playback module + +#![allow(dead_code)] + +pub mod mpv; +pub mod pipewire; +pub mod queue; diff --git a/src/audio/mpv.rs b/src/audio/mpv.rs new file mode 100644 index 0000000..9bd7311 --- /dev/null +++ b/src/audio/mpv.rs @@ -0,0 +1,434 @@ +//! MPV controller via JSON IPC + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tracing::{debug, info, trace}; + +use crate::config::paths::mpv_socket_path; +use crate::error::AudioError; + +/// MPV IPC command +#[derive(Debug, Serialize)] +struct MpvCommand { + command: Vec, + request_id: u64, +} + +/// MPV IPC response +#[derive(Debug, Deserialize)] +struct MpvResponse { + #[serde(default)] + request_id: Option, + #[serde(default)] + data: Option, + #[serde(default)] + error: String, +} + +/// MPV event +#[derive(Debug, Deserialize)] +struct MpvEvent { + event: String, + #[serde(default)] + name: Option, + #[serde(default)] + data: Option, +} + +/// Events emitted by MPV +#[derive(Debug, Clone)] +pub enum MpvEvent2 { + /// Track reached end of file + EndFile, + /// Playback paused + Pause, + /// Playback resumed + Unpause, + /// Position changed (time in seconds) + TimePos(f64), + /// Audio properties changed + AudioProperties { + sample_rate: Option, + bit_depth: Option, + format: Option, + }, + /// MPV shut down + Shutdown, +} + +/// MPV controller +pub struct MpvController { + /// Path to the IPC socket + socket_path: PathBuf, + /// MPV process handle + process: Option, + /// Request ID counter + request_id: AtomicU64, + /// Socket connection + socket: Option, +} + +impl MpvController { + /// Create a new MPV controller + pub fn new() -> Self { + Self { + socket_path: mpv_socket_path(), + process: None, + request_id: AtomicU64::new(1), + socket: None, + } + } + + /// Start MPV process if not running + pub fn start(&mut self) -> Result<(), AudioError> { + if self.process.is_some() { + return Ok(()); + } + + // Remove existing socket if present + let _ = std::fs::remove_file(&self.socket_path); + + info!("Starting MPV with socket: {}", self.socket_path.display()); + + let child = Command::new("mpv") + .arg("--idle") // Stay running when nothing playing + .arg("--no-video") // Audio only + .arg("--no-terminal") // No MPV UI + .arg("--gapless-audio=yes") // Gapless playback between tracks + .arg("--prefetch-playlist=yes") // Pre-buffer next track + .arg("--cache=yes") // Enable cache for network streams + .arg("--cache-secs=120") // Cache up to 2 minutes ahead + .arg("--demuxer-max-bytes=100MiB") // Allow large demuxer buffer + .arg(format!("--input-ipc-server={}", self.socket_path.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(AudioError::MpvSpawn)?; + + self.process = Some(child); + + // Wait for socket to become available + for _ in 0..50 { + if self.socket_path.exists() { + std::thread::sleep(Duration::from_millis(50)); + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + + if !self.socket_path.exists() { + return Err(AudioError::MpvIpc("Socket not created".to_string())); + } + + self.connect()?; + info!("MPV started successfully"); + Ok(()) + } + + /// Connect to the MPV socket + fn connect(&mut self) -> Result<(), AudioError> { + let stream = UnixStream::connect(&self.socket_path).map_err(AudioError::MpvSocket)?; + + // Set read timeout + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .map_err(AudioError::MpvSocket)?; + + self.socket = Some(stream); + debug!("Connected to MPV socket"); + Ok(()) + } + + /// Check if MPV is running + pub fn is_running(&self) -> bool { + self.socket.is_some() + } + + /// Send a command to MPV + fn send_command(&mut self, args: Vec) -> Result, AudioError> { + let socket = self.socket.as_mut().ok_or(AudioError::MpvNotRunning)?; + + let request_id = self.request_id.fetch_add(1, Ordering::SeqCst); + let cmd = MpvCommand { + command: args, + request_id, + }; + + let json = serde_json::to_string(&cmd)?; + debug!("Sending MPV command: {}", json); + + writeln!(socket, "{}", json).map_err(|e| AudioError::MpvIpc(e.to_string()))?; + socket + .flush() + .map_err(|e| AudioError::MpvIpc(e.to_string()))?; + + // Read response + let mut reader = BufReader::new(socket.try_clone().map_err(AudioError::MpvSocket)?); + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => return Err(AudioError::MpvIpc("Socket closed".to_string())), + Ok(_) => { + if let Ok(resp) = serde_json::from_str::(&line) { + if resp.request_id == Some(request_id) { + if resp.error != "success" { + return Err(AudioError::MpvIpc(resp.error)); + } + return Ok(resp.data); + } + } + // Log discarded events for diagnostics + if let Ok(event) = serde_json::from_str::(&line) { + trace!("MPV event: {:?}", event); + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // Timeout, try again + continue; + } + Err(e) => return Err(AudioError::MpvIpc(e.to_string())), + } + } + } + + /// Load and play a file/URL (replaces current playlist) + pub fn loadfile(&mut self, path: &str) -> Result<(), AudioError> { + info!("Loading: {}", path.split('?').next().unwrap_or(path)); + self.send_command(vec![json!("loadfile"), json!(path), json!("replace")])?; + Ok(()) + } + + /// Append a file/URL to the playlist (for gapless playback) + pub fn loadfile_append(&mut self, path: &str) -> Result<(), AudioError> { + debug!( + "Appending to playlist: {}", + path.split('?').next().unwrap_or(path) + ); + self.send_command(vec![json!("loadfile"), json!(path), json!("append")])?; + Ok(()) + } + + /// Clear the playlist except current track + pub fn playlist_clear(&mut self) -> Result<(), AudioError> { + debug!("Clearing playlist"); + self.send_command(vec![json!("playlist-clear")])?; + Ok(()) + } + + /// Remove a specific entry from the playlist by index + pub fn playlist_remove(&mut self, index: usize) -> Result<(), AudioError> { + debug!("Removing playlist entry {}", index); + self.send_command(vec![json!("playlist-remove"), json!(index)])?; + Ok(()) + } + + /// Get current playlist position (0-indexed) + pub fn get_playlist_pos(&mut self) -> Result, AudioError> { + let data = self.send_command(vec![json!("get_property"), json!("playlist-pos")])?; + Ok(data.and_then(|v| v.as_i64())) + } + + /// Get playlist count + pub fn get_playlist_count(&mut self) -> Result { + let data = self.send_command(vec![json!("get_property"), json!("playlist-count")])?; + Ok(data.and_then(|v| v.as_u64()).unwrap_or(0) as usize) + } + + /// Pause playback + pub fn pause(&mut self) -> Result<(), AudioError> { + debug!("Pausing playback"); + self.send_command(vec![json!("set_property"), json!("pause"), json!(true)])?; + Ok(()) + } + + /// Resume playback + pub fn resume(&mut self) -> Result<(), AudioError> { + debug!("Resuming playback"); + self.send_command(vec![json!("set_property"), json!("pause"), json!(false)])?; + Ok(()) + } + + /// Toggle pause + pub fn toggle_pause(&mut self) -> Result { + let paused = self.is_paused()?; + if paused { + self.resume()?; + } else { + self.pause()?; + } + Ok(!paused) + } + + /// Check if paused + pub fn is_paused(&mut self) -> Result { + let data = self.send_command(vec![json!("get_property"), json!("pause")])?; + Ok(data.and_then(|v| v.as_bool()).unwrap_or(false)) + } + + /// Stop playback + pub fn stop(&mut self) -> Result<(), AudioError> { + debug!("Stopping playback"); + self.send_command(vec![json!("stop")])?; + Ok(()) + } + + /// Seek to position (seconds) + pub fn seek(&mut self, position: f64) -> Result<(), AudioError> { + debug!("Seeking to {:.1}s", position); + self.send_command(vec![json!("seek"), json!(position), json!("absolute")])?; + Ok(()) + } + + /// Seek relative to current position + pub fn seek_relative(&mut self, offset: f64) -> Result<(), AudioError> { + debug!("Seeking {:+.1}s", offset); + self.send_command(vec![json!("seek"), json!(offset), json!("relative")])?; + Ok(()) + } + + /// Get current playback position in seconds + pub fn get_time_pos(&mut self) -> Result { + let data = self.send_command(vec![json!("get_property"), json!("time-pos")])?; + Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0)) + } + + /// Get total duration in seconds + pub fn get_duration(&mut self) -> Result { + let data = self.send_command(vec![json!("get_property"), json!("duration")])?; + Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0)) + } + + /// Get volume (0-100) + pub fn get_volume(&mut self) -> Result { + let data = self.send_command(vec![json!("get_property"), json!("volume")])?; + Ok(data + .and_then(|v| v.as_f64()) + .map(|v| v as i32) + .unwrap_or(100)) + } + + /// Set volume (0-100) + pub fn set_volume(&mut self, volume: i32) -> Result<(), AudioError> { + debug!("Setting volume to {}", volume); + self.send_command(vec![ + json!("set_property"), + json!("volume"), + json!(volume.clamp(0, 100)), + ])?; + Ok(()) + } + + /// Get audio sample rate + pub fn get_sample_rate(&mut self) -> Result, AudioError> { + let data = self.send_command(vec![ + json!("get_property"), + json!("audio-params/samplerate"), + ])?; + Ok(data.and_then(|v| v.as_u64()).map(|v| v as u32)) + } + + /// Get audio bit depth + pub fn get_bit_depth(&mut self) -> Result, AudioError> { + // MPV returns format string like "s16" or "s32" + let data = self.send_command(vec![json!("get_property"), json!("audio-params/format")])?; + let format = data.and_then(|v| v.as_str().map(String::from)); + + Ok(format.and_then(|f| { + if f.contains("32") || f.contains("float") { + Some(32) + } else if f.contains("24") { + Some(24) + } else if f.contains("16") { + Some(16) + } else if f.contains("8") { + Some(8) + } else { + None + } + })) + } + + /// Get audio format string + pub fn get_audio_format(&mut self) -> Result, AudioError> { + let data = self.send_command(vec![json!("get_property"), json!("audio-params/format")])?; + Ok(data.and_then(|v| v.as_str().map(String::from))) + } + + /// Get audio channel layout + pub fn get_channels(&mut self) -> Result, AudioError> { + let data = self.send_command(vec![ + json!("get_property"), + json!("audio-params/channel-count"), + ])?; + let count = data.and_then(|v| v.as_u64()).map(|v| v as u32); + + Ok(count.map(|c| match c { + 1 => "Mono".to_string(), + 2 => "Stereo".to_string(), + n => format!("{}ch", n), + })) + } + + /// Get current filename/URL + pub fn get_path(&mut self) -> Result, AudioError> { + let data = self.send_command(vec![json!("get_property"), json!("path")])?; + Ok(data.and_then(|v| v.as_str().map(String::from))) + } + + /// Check if anything is loaded + pub fn is_idle(&mut self) -> Result { + let data = self.send_command(vec![json!("get_property"), json!("idle-active")])?; + Ok(data.and_then(|v| v.as_bool()).unwrap_or(true)) + } + + /// Check if current file has reached EOF + pub fn is_eof(&mut self) -> Result { + let data = self.send_command(vec![json!("get_property"), json!("eof-reached")])?; + Ok(data.and_then(|v| v.as_bool()).unwrap_or(false)) + } + + /// Quit MPV + pub fn quit(&mut self) -> Result<(), AudioError> { + if self.socket.is_some() { + let _ = self.send_command(vec![json!("quit")]); + } + + if let Some(mut child) = self.process.take() { + let _ = child.kill(); + let _ = child.wait(); + } + + self.socket = None; + let _ = std::fs::remove_file(&self.socket_path); + + info!("MPV shut down"); + Ok(()) + } + + /// Observe a property for changes + pub fn observe_property(&mut self, id: u64, name: &str) -> Result<(), AudioError> { + self.send_command(vec![json!("observe_property"), json!(id), json!(name)])?; + Ok(()) + } +} + +impl Drop for MpvController { + fn drop(&mut self) { + let _ = self.quit(); + } +} + +impl Default for MpvController { + fn default() -> Self { + Self::new() + } +} diff --git a/src/audio/pipewire.rs b/src/audio/pipewire.rs new file mode 100644 index 0000000..ea14273 --- /dev/null +++ b/src/audio/pipewire.rs @@ -0,0 +1,202 @@ +//! PipeWire sample rate control + +use std::process::Command; +use tracing::{debug, error, info}; + +use crate::error::AudioError; + +/// Default audio device ID for PipeWire +const DEFAULT_DEVICE_ID: u32 = 0; + +/// PipeWire sample rate controller +pub struct PipeWireController { + /// Original sample rate before ferrosonic started + original_rate: Option, + /// Current forced sample rate + current_rate: Option, +} + +impl PipeWireController { + /// Create a new PipeWire controller + pub fn new() -> Self { + let original_rate = Self::get_current_rate_internal().ok(); + debug!("Original PipeWire sample rate: {:?}", original_rate); + + Self { + original_rate, + current_rate: None, + } + } + + /// Get current sample rate from PipeWire + fn get_current_rate_internal() -> Result { + let output = Command::new("pw-metadata") + .arg("-n") + .arg("settings") + .arg("0") + .arg("clock.force-rate") + .output() + .map_err(|e| AudioError::PipeWire(format!("Failed to run pw-metadata: {}", e)))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse output like: "update: id:0 key:'clock.force-rate' value:'48000' type:''" + for line in stdout.lines() { + if line.contains("clock.force-rate") && line.contains("value:") { + if let Some(start) = line.find("value:'") { + let rest = &line[start + 7..]; + if let Some(end) = rest.find('\'') { + let rate_str = &rest[..end]; + if let Ok(rate) = rate_str.parse::() { + return Ok(rate); + } + } + } + } + } + + // No forced rate, return default + Ok(0) + } + + /// Get the current forced sample rate + pub fn get_current_rate(&self) -> Option { + self.current_rate + } + + /// Set the sample rate + pub fn set_rate(&mut self, rate: u32) -> Result<(), AudioError> { + if self.current_rate == Some(rate) { + debug!("Sample rate already set to {}", rate); + return Ok(()); + } + + info!("Setting PipeWire sample rate to {} Hz", rate); + + let output = Command::new("pw-metadata") + .arg("-n") + .arg("settings") + .arg("0") + .arg("clock.force-rate") + .arg(rate.to_string()) + .output() + .map_err(|e| AudioError::PipeWire(format!("Failed to run pw-metadata: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AudioError::PipeWire(format!( + "pw-metadata failed: {}", + stderr + ))); + } + + self.current_rate = Some(rate); + Ok(()) + } + + /// Restore original sample rate + pub fn restore_original(&mut self) -> Result<(), AudioError> { + if let Some(rate) = self.original_rate { + if rate > 0 { + info!("Restoring original sample rate: {} Hz", rate); + self.set_rate(rate)?; + } else { + info!("Clearing forced sample rate"); + self.clear_forced_rate()?; + } + } + Ok(()) + } + + /// Clear the forced sample rate (let PipeWire use default) + pub fn clear_forced_rate(&mut self) -> Result<(), AudioError> { + info!("Clearing PipeWire forced sample rate"); + + let output = Command::new("pw-metadata") + .arg("-n") + .arg("settings") + .arg("0") + .arg("clock.force-rate") + .arg("0") + .output() + .map_err(|e| AudioError::PipeWire(format!("Failed to run pw-metadata: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(AudioError::PipeWire(format!( + "pw-metadata failed: {}", + stderr + ))); + } + + self.current_rate = None; + Ok(()) + } + + /// Check if PipeWire is available + pub fn is_available() -> bool { + Command::new("pw-metadata") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + /// Get the effective sample rate (from pw-metadata or system default) + pub fn get_effective_rate() -> Option { + // Try to get from PipeWire + let output = Command::new("pw-metadata") + .arg("-n") + .arg("settings") + .output() + .ok()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Look for clock.rate or clock.force-rate + for line in stdout.lines() { + if (line.contains("clock.rate") || line.contains("clock.force-rate")) + && line.contains("value:") + { + if let Some(start) = line.find("value:'") { + let rest = &line[start + 7..]; + if let Some(end) = rest.find('\'') { + let rate_str = &rest[..end]; + if let Ok(rate) = rate_str.parse::() { + if rate > 0 { + return Some(rate); + } + } + } + } + } + } + + None + } +} + +impl Default for PipeWireController { + fn default() -> Self { + Self::new() + } +} + +impl Drop for PipeWireController { + fn drop(&mut self) { + if let Err(e) = self.restore_original() { + error!("Failed to restore sample rate: {}", e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_available() { + // This test just checks the function doesn't panic + let _ = PipeWireController::is_available(); + } +} diff --git a/src/audio/queue.rs b/src/audio/queue.rs new file mode 100644 index 0000000..37bc48a --- /dev/null +++ b/src/audio/queue.rs @@ -0,0 +1,321 @@ +//! Play queue management + +use rand::seq::SliceRandom; +use tracing::debug; + +use crate::subsonic::models::Child; +use crate::subsonic::SubsonicClient; + +/// Play queue +#[derive(Debug, Clone, Default)] +pub struct PlayQueue { + /// Songs in the queue + songs: Vec, + /// Current position in the queue (None = stopped) + position: Option, +} + +impl PlayQueue { + /// Create a new empty queue + pub fn new() -> Self { + Self::default() + } + + /// Get the songs in the queue + pub fn songs(&self) -> &[Child] { + &self.songs + } + + /// Get the current position + pub fn position(&self) -> Option { + self.position + } + + /// Get the current song + pub fn current(&self) -> Option<&Child> { + self.position.and_then(|pos| self.songs.get(pos)) + } + + /// Get number of songs in queue + pub fn len(&self) -> usize { + self.songs.len() + } + + /// Check if queue is empty + pub fn is_empty(&self) -> bool { + self.songs.is_empty() + } + + /// Add songs to the end of the queue + pub fn append(&mut self, songs: impl IntoIterator) { + self.songs.extend(songs); + debug!("Queue now has {} songs", self.songs.len()); + } + + /// Insert songs after the current position + pub fn insert_next(&mut self, songs: impl IntoIterator) { + let insert_pos = self.position.map(|p| p + 1).unwrap_or(0); + let new_songs: Vec<_> = songs.into_iter().collect(); + let count = new_songs.len(); + + for (i, song) in new_songs.into_iter().enumerate() { + self.songs.insert(insert_pos + i, song); + } + + debug!("Inserted {} songs at position {}", count, insert_pos); + } + + /// Clear the queue + pub fn clear(&mut self) { + self.songs.clear(); + self.position = None; + debug!("Queue cleared"); + } + + /// Remove song at index + pub fn remove(&mut self, index: usize) -> Option { + if index >= self.songs.len() { + return None; + } + + let song = self.songs.remove(index); + + // Adjust position if needed + if let Some(pos) = self.position { + if index < pos { + self.position = Some(pos - 1); + } else if index == pos { + // Removed current song + if self.songs.is_empty() { + self.position = None; + } else if pos >= self.songs.len() { + self.position = Some(self.songs.len() - 1); + } + } + } + + debug!("Removed song at index {}", index); + Some(song) + } + + /// Move song from one position to another + pub fn move_song(&mut self, from: usize, to: usize) { + if from >= self.songs.len() || to >= self.songs.len() { + return; + } + + let song = self.songs.remove(from); + self.songs.insert(to, song); + + // Adjust position if needed + if let Some(pos) = self.position { + if from == pos { + self.position = Some(to); + } else if from < pos && to >= pos { + self.position = Some(pos - 1); + } else if from > pos && to <= pos { + self.position = Some(pos + 1); + } + } + + debug!("Moved song from {} to {}", from, to); + } + + /// Shuffle the queue, keeping current song in place + pub fn shuffle(&mut self) { + if self.songs.len() <= 1 { + return; + } + + let mut rng = rand::thread_rng(); + + if let Some(pos) = self.position { + // Keep current song, shuffle the rest + let current = self.songs.remove(pos); + + // Shuffle remaining songs + self.songs.shuffle(&mut rng); + + // Put current song at the front + self.songs.insert(0, current); + self.position = Some(0); + } else { + // No current song, shuffle everything + self.songs.shuffle(&mut rng); + } + + debug!("Queue shuffled"); + } + + /// Set current position + pub fn set_position(&mut self, position: Option) { + if let Some(pos) = position { + if pos < self.songs.len() { + self.position = Some(pos); + debug!("Position set to {}", pos); + } + } else { + self.position = None; + debug!("Position cleared"); + } + } + + /// Advance to next song + /// Returns true if there was a next song + pub fn next(&mut self) -> bool { + match self.position { + Some(pos) if pos + 1 < self.songs.len() => { + self.position = Some(pos + 1); + debug!("Advanced to position {}", pos + 1); + true + } + _ => { + self.position = None; + debug!("Reached end of queue"); + false + } + } + } + + /// Go to previous song + /// Returns true if there was a previous song + pub fn previous(&mut self) -> bool { + match self.position { + Some(pos) if pos > 0 => { + self.position = Some(pos - 1); + debug!("Went back to position {}", pos - 1); + true + } + _ => { + if !self.songs.is_empty() { + self.position = Some(0); + } + debug!("At start of queue"); + false + } + } + } + + /// Get stream URL for current song + pub fn current_stream_url(&self, client: &SubsonicClient) -> Option { + self.current() + .and_then(|song| client.get_stream_url(&song.id).ok()) + } + + /// Get stream URL for song at index + pub fn stream_url_at(&self, index: usize, client: &SubsonicClient) -> Option { + self.songs + .get(index) + .and_then(|song| client.get_stream_url(&song.id).ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_song(id: &str, title: &str) -> Child { + Child { + id: id.to_string(), + title: title.to_string(), + parent: None, + is_dir: false, + album: None, + artist: None, + track: None, + year: None, + genre: None, + cover_art: None, + size: None, + content_type: None, + suffix: None, + duration: None, + bit_rate: None, + path: None, + disc_number: None, + } + } + + #[test] + fn test_append_and_len() { + let mut queue = PlayQueue::new(); + assert!(queue.is_empty()); + + queue.append(vec![make_song("1", "Song 1"), make_song("2", "Song 2")]); + assert_eq!(queue.len(), 2); + } + + #[test] + fn test_position_and_navigation() { + let mut queue = PlayQueue::new(); + queue.append(vec![ + make_song("1", "Song 1"), + make_song("2", "Song 2"), + make_song("3", "Song 3"), + ]); + + assert!(queue.current().is_none()); + + queue.set_position(Some(0)); + assert_eq!(queue.current().unwrap().id, "1"); + + assert!(queue.next()); + assert_eq!(queue.current().unwrap().id, "2"); + + assert!(queue.next()); + assert_eq!(queue.current().unwrap().id, "3"); + + assert!(!queue.next()); + assert!(queue.current().is_none()); + } + + #[test] + fn test_remove() { + let mut queue = PlayQueue::new(); + queue.append(vec![ + make_song("1", "Song 1"), + make_song("2", "Song 2"), + make_song("3", "Song 3"), + ]); + queue.set_position(Some(1)); + + // Remove song before current + queue.remove(0); + assert_eq!(queue.position(), Some(0)); + assert_eq!(queue.current().unwrap().id, "2"); + + // Remove current song + queue.remove(0); + assert_eq!(queue.current().unwrap().id, "3"); + } + + #[test] + fn test_insert_next() { + let mut queue = PlayQueue::new(); + queue.append(vec![make_song("1", "Song 1"), make_song("3", "Song 3")]); + queue.set_position(Some(0)); + + queue.insert_next(vec![make_song("2", "Song 2")]); + + assert_eq!(queue.songs[0].id, "1"); + assert_eq!(queue.songs[1].id, "2"); + assert_eq!(queue.songs[2].id, "3"); + } + + #[test] + fn test_move_song() { + let mut queue = PlayQueue::new(); + queue.append(vec![ + make_song("1", "Song 1"), + make_song("2", "Song 2"), + make_song("3", "Song 3"), + ]); + queue.set_position(Some(0)); + + queue.move_song(0, 2); + assert_eq!(queue.songs[0].id, "2"); + assert_eq!(queue.songs[1].id, "3"); + assert_eq!(queue.songs[2].id, "1"); + assert_eq!(queue.position(), Some(2)); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..1d7c4f2 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,165 @@ +//! Configuration loading and management + +pub mod paths; + +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tracing::{debug, info, warn}; + +use crate::error::ConfigError; + +/// Main application configuration +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + /// Subsonic server base URL + #[serde(rename = "BaseURL", default)] + pub base_url: String, + + /// Username for authentication + #[serde(rename = "Username", default)] + pub username: String, + + /// Password for authentication + #[serde(rename = "Password", default)] + pub password: String, + + /// UI Theme name + #[serde(rename = "Theme", default)] + pub theme: String, + + /// Enable cava audio visualizer + #[serde(rename = "Cava", default)] + pub cava: bool, +} + +impl Config { + /// Create a new empty config + pub fn new() -> Self { + Self::default() + } + + /// Load config from the default location + pub fn load_default() -> Result { + let path = paths::config_file().ok_or_else(|| ConfigError::NotFound { + path: "default config location".to_string(), + })?; + + if path.exists() { + Self::load_from_file(&path) + } else { + info!("No config file found at {}, using defaults", path.display()); + Ok(Self::new()) + } + } + + /// Load config from a specific file + pub fn load_from_file(path: &Path) -> Result { + debug!("Loading config from {}", path.display()); + + if !path.exists() { + return Err(ConfigError::NotFound { + path: path.display().to_string(), + }); + } + + let contents = std::fs::read_to_string(path)?; + let config: Config = toml::from_str(&contents)?; + + debug!("Config loaded successfully"); + Ok(config) + } + + /// Save config to the default location + pub fn save_default(&self) -> Result<(), ConfigError> { + let path = paths::config_file().ok_or_else(|| ConfigError::NotFound { + path: "default config location".to_string(), + })?; + + self.save_to_file(&path) + } + + /// Save config to a specific file + pub fn save_to_file(&self, path: &Path) -> Result<(), ConfigError> { + debug!("Saving config to {}", path.display()); + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent)?; + } + } + + let contents = toml::to_string_pretty(self)?; + std::fs::write(path, contents)?; + + info!("Config saved to {}", path.display()); + Ok(()) + } + + /// Check if the config has valid server settings + pub fn is_configured(&self) -> bool { + !self.base_url.is_empty() && !self.username.is_empty() && !self.password.is_empty() + } + + /// Validate the config + #[allow(dead_code)] + pub fn validate(&self) -> Result<(), ConfigError> { + if self.base_url.is_empty() { + return Err(ConfigError::MissingField { + field: "BaseURL".to_string(), + }); + } + + // Validate URL format + if url::Url::parse(&self.base_url).is_err() { + return Err(ConfigError::InvalidUrl { + url: self.base_url.clone(), + }); + } + + if self.username.is_empty() { + warn!("Username is empty"); + } + + if self.password.is_empty() { + warn!("Password is empty"); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_config_parse() { + let toml_content = r#" +BaseURL = "https://example.com" +Username = "testuser" +Password = "testpass" +"#; + + let mut file = NamedTempFile::new().unwrap(); + file.write_all(toml_content.as_bytes()).unwrap(); + + let config = Config::load_from_file(file.path()).unwrap(); + assert_eq!(config.base_url, "https://example.com"); + assert_eq!(config.username, "testuser"); + assert_eq!(config.password, "testpass"); + } + + #[test] + fn test_is_configured() { + let mut config = Config::new(); + assert!(!config.is_configured()); + + config.base_url = "https://example.com".to_string(); + config.username = "user".to_string(); + config.password = "pass".to_string(); + assert!(config.is_configured()); + } +} diff --git a/src/config/paths.rs b/src/config/paths.rs new file mode 100644 index 0000000..75da071 --- /dev/null +++ b/src/config/paths.rs @@ -0,0 +1,46 @@ +//! Platform-specific configuration paths + +use std::path::PathBuf; + +/// Get the default config directory for ferrosonic +pub fn config_dir() -> Option { + dirs::config_dir().map(|p| p.join("ferrosonic")) +} + +/// Get the default config file path +pub fn config_file() -> Option { + config_dir().map(|p| p.join("config.toml")) +} + +/// Get the themes directory path +pub fn themes_dir() -> Option { + config_dir().map(|p| p.join("themes")) +} + +/// Get the log file path +#[allow(dead_code)] +pub fn log_file() -> Option { + config_dir().map(|p| p.join("ferrosonic.log")) +} + +/// Get the MPV socket path +pub fn mpv_socket_path() -> PathBuf { + std::env::temp_dir().join("ferrosonic-mpv.sock") +} + +/// Ensure the config directory exists +#[allow(dead_code)] +pub fn ensure_config_dir() -> std::io::Result { + let dir = config_dir().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "Could not determine config directory", + ) + })?; + + if !dir.exists() { + std::fs::create_dir_all(&dir)?; + } + + Ok(dir) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6bf9955 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,115 @@ +//! Error types for ferrosonic + +#![allow(dead_code)] + +use thiserror::Error; + +/// Main error type for ferrosonic +#[derive(Error, Debug)] +pub enum Error { + #[error("Configuration error: {0}")] + Config(#[from] ConfigError), + + #[error("Subsonic API error: {0}")] + Subsonic(#[from] SubsonicError), + + #[error("Audio playback error: {0}")] + Audio(#[from] AudioError), + + #[error("UI error: {0}")] + Ui(#[from] UiError), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Configuration-related errors +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Config file not found at {path}")] + NotFound { path: String }, + + #[error("Failed to parse config: {0}")] + Parse(#[from] toml::de::Error), + + #[error("Failed to serialize config: {0}")] + Serialize(#[from] toml::ser::Error), + + #[error("Missing required field: {field}")] + MissingField { field: String }, + + #[error("Invalid URL: {url}")] + InvalidUrl { url: String }, + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Subsonic API errors +#[derive(Error, Debug)] +pub enum SubsonicError { + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), + + #[error("API error {code}: {message}")] + Api { code: i32, message: String }, + + #[error("Authentication failed")] + AuthFailed, + + #[error("Server not configured")] + NotConfigured, + + #[error("Failed to parse response: {0}")] + Parse(String), + + #[error("URL parse error: {0}")] + UrlParse(#[from] url::ParseError), +} + +/// Audio playback errors +#[derive(Error, Debug)] +pub enum AudioError { + #[error("MPV not running")] + MpvNotRunning, + + #[error("Failed to spawn MPV: {0}")] + MpvSpawn(std::io::Error), + + #[error("MPV IPC error: {0}")] + MpvIpc(String), + + #[error("MPV socket connection failed: {0}")] + MpvSocket(std::io::Error), + + #[error("PipeWire command failed: {0}")] + PipeWire(String), + + #[error("Queue is empty")] + QueueEmpty, + + #[error("Invalid queue index: {index}")] + InvalidIndex { index: usize }, + + #[error("JSON serialization error: {0}")] + Json(#[from] serde_json::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// UI-related errors +#[derive(Error, Debug)] +pub enum UiError { + #[error("Terminal initialization failed: {0}")] + TerminalInit(std::io::Error), + + #[error("Render error: {0}")] + Render(std::io::Error), + + #[error("Input error: {0}")] + Input(std::io::Error), +} + +/// Result type alias using our Error +pub type Result = std::result::Result; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..244b204 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,131 @@ +//! Termsonic - A terminal-based Subsonic music client +//! +//! Features: +//! - Bit-perfect audio playback via MPV and PipeWire sample rate switching +//! - MPRIS2 desktop integration for media controls +//! - Browse artists, albums, and playlists +//! - Play queue with shuffle and reorder support +//! - Server configuration with connection testing + +mod app; +mod audio; +mod config; +mod error; +mod mpris; +mod subsonic; +mod ui; + +use clap::Parser; +use std::fs::{self, File}; +use std::path::PathBuf; +use tracing::info; +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + +use crate::app::App; +use crate::config::paths::config_dir; +use crate::config::Config; + +/// Termsonic - Terminal Subsonic Music Client +#[derive(Parser, Debug)] +#[command(name = "ferrosonic")] +#[command(author, version, about, long_about = None)] +struct Args { + /// Path to config file + #[arg(short, long, value_name = "FILE")] + config: Option, + + /// Enable verbose/debug logging + #[arg(short, long)] + verbose: bool, +} + +/// Initialize file-based logging +fn init_logging(verbose: bool) -> Option { + // Get log directory (same as config dir for consistency with Go version) + let log_dir = config_dir().unwrap_or_else(|| PathBuf::from("/tmp")); + + // Create log directory if needed + if let Err(e) = fs::create_dir_all(&log_dir) { + eprintln!("Warning: Could not create log directory: {}", e); + return None; + } + + let log_file = log_dir.join("ferrosonic.log"); + + // Open log file (truncate on each run) + let file = match File::create(&log_file) { + Ok(f) => f, + Err(e) => { + eprintln!("Warning: Could not create log file: {}", e); + return None; + } + }; + + let (non_blocking, guard) = tracing_appender::non_blocking(file); + + let filter = if verbose { + EnvFilter::new("ferrosonic=debug") + } else { + EnvFilter::new("ferrosonic=info") + }; + + tracing_subscriber::registry() + .with(filter) + .with( + fmt::layer() + .with_writer(non_blocking) + .with_ansi(false) + .with_target(false), + ) + .init(); + + if verbose { + eprintln!("Logging to: {}", log_file.display()); + } + + Some(guard) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + // Initialize file-based logging (keep guard alive for duration of program) + let _log_guard = init_logging(args.verbose); + + info!("Termsonic starting..."); + + // Load configuration + let config = match args.config { + Some(path) => { + info!("Loading config from {}", path.display()); + Config::load_from_file(&path)? + } + None => { + info!("Loading default config"); + Config::load_default().unwrap_or_else(|e| { + info!("No config found ({}), using defaults", e); + Config::new() + }) + } + }; + + info!( + "Server: {}", + if config.base_url.is_empty() { + "(not configured)" + } else { + &config.base_url + } + ); + + // Run the application + let mut app = App::new(config); + if let Err(e) = app.run().await { + tracing::error!("Application error: {}", e); + return Err(e.into()); + } + + info!("Termsonic exiting..."); + Ok(()) +} diff --git a/src/mpris/mod.rs b/src/mpris/mod.rs new file mode 100644 index 0000000..6057400 --- /dev/null +++ b/src/mpris/mod.rs @@ -0,0 +1,5 @@ +//! MPRIS2 D-Bus integration module + +#![allow(dead_code)] + +pub mod server; diff --git a/src/mpris/server.rs b/src/mpris/server.rs new file mode 100644 index 0000000..0df321c --- /dev/null +++ b/src/mpris/server.rs @@ -0,0 +1,372 @@ +//! MPRIS2 D-Bus server implementation + +use mpris_server::{ + zbus::{fdo, Result}, + LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface, + Server, Time, TrackId, Volume, +}; +use tokio::sync::mpsc; +use tracing::info; +use url::Url; + +use crate::app::actions::AudioAction; +use crate::app::state::{NowPlaying, PlaybackState, SharedState}; +use crate::config::Config; +use crate::subsonic::auth::generate_auth_params; +use crate::subsonic::models::Child; + +/// API version for Subsonic +const API_VERSION: &str = "1.16.1"; +/// Client name for Subsonic +const CLIENT_NAME: &str = "ferrosonic"; + +/// Build a cover art URL from config and cover art ID +fn build_cover_art_url(config: &Config, cover_art_id: &str) -> Option { + if config.base_url.is_empty() || cover_art_id.is_empty() { + return None; + } + + let (salt, token) = generate_auth_params(&config.password); + let mut url = Url::parse(&format!("{}/rest/getCoverArt", config.base_url)).ok()?; + + url.query_pairs_mut() + .append_pair("id", cover_art_id) + .append_pair("u", &config.username) + .append_pair("t", &token) + .append_pair("s", &salt) + .append_pair("v", API_VERSION) + .append_pair("c", CLIENT_NAME); + + Some(url.to_string()) +} + +/// MPRIS server instance name +const PLAYER_NAME: &str = "ferrosonic"; + +/// MPRIS2 player implementation +pub struct MprisPlayer { + state: SharedState, + audio_tx: mpsc::Sender, +} + +impl MprisPlayer { + pub fn new(state: SharedState, audio_tx: mpsc::Sender) -> Self { + Self { state, audio_tx } + } + + async fn get_state(&self) -> (NowPlaying, Option, Config) { + let state = self.state.read().await; + let now_playing = state.now_playing.clone(); + let current_song = state.current_song().cloned(); + let config = state.config.clone(); + (now_playing, current_song, config) + } +} + +impl RootInterface for MprisPlayer { + async fn raise(&self) -> fdo::Result<()> { + // We're a terminal app, can't raise + Ok(()) + } + + async fn quit(&self) -> fdo::Result<()> { + let mut state = self.state.write().await; + state.should_quit = true; + Ok(()) + } + + async fn can_quit(&self) -> fdo::Result { + Ok(true) + } + + async fn fullscreen(&self) -> fdo::Result { + Ok(false) + } + + async fn set_fullscreen(&self, _fullscreen: bool) -> Result<()> { + Ok(()) + } + + async fn can_set_fullscreen(&self) -> fdo::Result { + Ok(false) + } + + async fn can_raise(&self) -> fdo::Result { + Ok(false) + } + + async fn has_track_list(&self) -> fdo::Result { + Ok(false) + } + + async fn identity(&self) -> fdo::Result { + Ok("Termsonic".to_string()) + } + + async fn desktop_entry(&self) -> fdo::Result { + Ok("ferrosonic".to_string()) + } + + async fn supported_uri_schemes(&self) -> fdo::Result> { + Ok(vec!["http".to_string(), "https".to_string()]) + } + + async fn supported_mime_types(&self) -> fdo::Result> { + Ok(vec![ + "audio/mpeg".to_string(), + "audio/flac".to_string(), + "audio/ogg".to_string(), + "audio/wav".to_string(), + "audio/x-wav".to_string(), + ]) + } +} + +impl PlayerInterface for MprisPlayer { + async fn next(&self) -> fdo::Result<()> { + let _ = self.audio_tx.send(AudioAction::Next).await; + Ok(()) + } + + async fn previous(&self) -> fdo::Result<()> { + let _ = self.audio_tx.send(AudioAction::Previous).await; + Ok(()) + } + + async fn pause(&self) -> fdo::Result<()> { + let _ = self.audio_tx.send(AudioAction::Pause).await; + Ok(()) + } + + async fn play_pause(&self) -> fdo::Result<()> { + let _ = self.audio_tx.send(AudioAction::TogglePause).await; + Ok(()) + } + + async fn stop(&self) -> fdo::Result<()> { + let _ = self.audio_tx.send(AudioAction::Stop).await; + Ok(()) + } + + async fn play(&self) -> fdo::Result<()> { + let _ = self.audio_tx.send(AudioAction::Resume).await; + Ok(()) + } + + async fn seek(&self, offset: Time) -> fdo::Result<()> { + let offset_secs = offset.as_micros() as f64 / 1_000_000.0; + let _ = self + .audio_tx + .send(AudioAction::SeekRelative(offset_secs)) + .await; + Ok(()) + } + + async fn set_position(&self, _track_id: TrackId, position: Time) -> fdo::Result<()> { + let position_secs = position.as_micros() as f64 / 1_000_000.0; + let _ = self.audio_tx.send(AudioAction::Seek(position_secs)).await; + Ok(()) + } + + async fn open_uri(&self, _uri: String) -> fdo::Result<()> { + // Not supported for now + Ok(()) + } + + async fn playback_status(&self) -> fdo::Result { + let (now_playing, _, _) = self.get_state().await; + Ok(match now_playing.state { + PlaybackState::Playing => PlaybackStatus::Playing, + PlaybackState::Paused => PlaybackStatus::Paused, + PlaybackState::Stopped => PlaybackStatus::Stopped, + }) + } + + async fn loop_status(&self) -> fdo::Result { + Ok(LoopStatus::None) + } + + async fn set_loop_status(&self, _loop_status: LoopStatus) -> Result<()> { + Ok(()) + } + + async fn rate(&self) -> fdo::Result { + Ok(1.0) + } + + async fn set_rate(&self, _rate: PlaybackRate) -> Result<()> { + Ok(()) + } + + async fn shuffle(&self) -> fdo::Result { + Ok(false) + } + + async fn set_shuffle(&self, _shuffle: bool) -> Result<()> { + Ok(()) + } + + async fn metadata(&self) -> fdo::Result { + let (_now_playing, current_song, config) = self.get_state().await; + + let mut metadata = Metadata::new(); + + if let Some(song) = current_song { + metadata.set_trackid( + Some(TrackId::try_from(format!("/org/mpris/MediaPlayer2/Track/{}", song.id)).ok()) + .flatten(), + ); + metadata.set_title(Some(song.title)); + metadata.set_artist(song.artist.map(|a| vec![a])); + metadata.set_album(song.album); + + if let Some(duration) = song.duration { + metadata.set_length(Some(Time::from_micros(duration as i64 * 1_000_000))); + } + + if let Some(track) = song.track { + metadata.set_track_number(Some(track)); + } + + if let Some(disc) = song.disc_number { + metadata.set_disc_number(Some(disc)); + } + + // Add cover art URL + if let Some(ref cover_art_id) = song.cover_art { + if let Some(cover_url) = build_cover_art_url(&config, cover_art_id) { + metadata.set_art_url(Some(cover_url)); + } + } + } + + Ok(metadata) + } + + async fn volume(&self) -> fdo::Result { + Ok(1.0) + } + + async fn set_volume(&self, volume: Volume) -> Result<()> { + let volume_int = (volume * 100.0) as i32; + let _ = self.audio_tx.send(AudioAction::SetVolume(volume_int)).await; + Ok(()) + } + + async fn position(&self) -> fdo::Result