From e551de9fb78ec6fa3411153707ee0d2579db0003 Mon Sep 17 00:00:00 2001 From: RarDog Date: Thu, 27 Aug 2026 21:12:03 +0300 Subject: [PATCH] feat: initial release v0.1.0-beta - complete TUI audio player for Navidrome --- .gitignore | 1 + Cargo.lock | 3105 ++++++++++++++++++++++++++++++++++ Cargo.toml | 42 + LICENSE | 21 + README.md | 153 ++ src/app.rs | 1083 ++++++++++++ src/audio/decoder.rs | 187 ++ src/audio/mod.rs | 5 + src/audio/player.rs | 283 ++++ src/config.rs | 78 + src/main.rs | 257 +++ src/subsonic/client.rs | 243 +++ src/subsonic/mod.rs | 6 + src/subsonic/models.rs | 192 +++ src/ui/cover.rs | 43 + src/ui/mod.rs | 52 + src/ui/theme.rs | 173 ++ src/ui/visualizer.rs | 127 ++ src/ui/widgets/header.rs | 57 + src/ui/widgets/library.rs | 168 ++ src/ui/widgets/mod.rs | 5 + src/ui/widgets/modals.rs | 186 ++ src/ui/widgets/player_bar.rs | 180 ++ src/ui/widgets/settings.rs | 301 ++++ 24 files changed, 6948 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/app.rs create mode 100644 src/audio/decoder.rs create mode 100644 src/audio/mod.rs create mode 100644 src/audio/player.rs create mode 100644 src/config.rs create mode 100644 src/main.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/cover.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/theme.rs create mode 100644 src/ui/visualizer.rs create mode 100644 src/ui/widgets/header.rs create mode 100644 src/ui/widgets/library.rs create mode 100644 src/ui/widgets/mod.rs create mode 100644 src/ui/widgets/modals.rs create mode 100644 src/ui/widgets/player_bar.rs create mode 100644 src/ui/widgets/settings.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2d9e633 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3105 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[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.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.4", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[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 = "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 = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "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.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[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 = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +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 = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[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 0.6.4", +] + +[[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 = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.13.1", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "time", + "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 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rhyth-rs" +version = "0.1.0-beta" +dependencies = [ + "anyhow", + "bytes", + "crossterm", + "dirs", + "hex", + "image", + "md-5", + "parking_lot", + "rand 0.8.8", + "ratatui", + "reqwest", + "rodio", + "rustfft", + "serde", + "serde_json", + "symphonia", + "thiserror 2.0.20", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rodio" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +dependencies = [ + "cpal", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.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 = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[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 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-caf", + "symphonia-format-isomp4", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +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.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +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 = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.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 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "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 = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "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_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..414f585 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "rhyth-rs" +version = "0.1.0-beta" +edition = "2021" +authors = ["RarDog"] +description = "A sleek, modern TUI audio player for Navidrome and Subsonic music servers written in Rust" +license = "MIT" + +[dependencies] +# Async Runtime +tokio = { version = "1.43", features = ["full"] } + +# HTTP & API +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +url = "2.5" +md-5 = "0.10" +hex = "0.4" +rand = "0.8" +bytes = "1.10" + +# Audio Playback & Analysis +rodio = { version = "0.20", default-features = false } +symphonia = { version = "0.5", features = ["all"] } +rustfft = "6.2" + +# Image processing for Cover Art Thumbnail +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } + +# TUI & Terminal +ratatui = { version = "0.29", features = ["all-widgets", "crossterm"] } +crossterm = { version = "0.28", features = ["event-stream"] } + +# Error Handling & Utilities +anyhow = "1.0" +thiserror = "2.0" +parking_lot = "0.12" +toml = "0.8" +dirs = "6.0" +tracing = "0.1" +tracing-subscriber = "0.3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0df6e94 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RarDog + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbd18cd --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# 🎵 rhyth-rs (v0.1.0-beta) + +
+ +[![Rust](https://img.shields.io/badge/Rust-1.75%2B-orange?logo=rust)](https://www.rust-lang.org/) +[![Navidrome](https://img.shields.io/badge/Navidrome-Compatible-00ADD8?logo=musicbrainz)](https://www.navidrome.org/) +[![Subsonic API](https://img.shields.io/badge/OpenSubsonic-API-purple)](https://opensubsonic.netlify.app/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Ratatui](https://img.shields.io/badge/TUI-Ratatui-green)](https://ratatui.rs/) + +*A blazing fast, modern, and beautiful terminal audio player for Navidrome and OpenSubsonic servers, written in pure Rust.* + +
+ +--- + +## ✨ Features + +- 🎧 **Pure Symphonia Audio Pipeline**: Flawless bit-perfect decoding for all major audio formats (**FLAC, MP3, AAC, ALAC, Vorbis, Opus, WAV**) with zero playback panics and crystal-clear sound via Rodio audio engine. +- 🌈 **Liquid-Smooth Audio Visualizer**: Multi-frequency spectrum analyzer with spring-damping physics, peak-hold indicators, and dynamic TrueColor frequency gradients (Pink ➔ Sapphire ➔ Mint ➔ Gold). +- 🖱️ **Full Mouse Interaction**: + - **Seek & Scrub**: Click or drag across the progress bar to jump anywhere in the song. + - **Click to Play**: Select artists, albums, and tracks directly with mouse clicks or double-click. + - **Scroll Wheel**: Scroll library panes or adjust volume seamlessly. + - **Interactive Controls**: Clickable play/pause, next/prev, shuffle, repeat, star, volume, and header buttons. +- 🔁 **Smart Queue & Playback Modes**: + - Auto-advance to the next song when a track ends. + - **Shuffle** mode (`🔀`) and **Repeat** modes (`🔁 Off`, `🔁 All`, `🔂 One`). + - Dedicated **Queue Viewer** (u) and **Lyrics Modal** (y). +- ❤️ **Favorites & Scrobbling**: + - Star / Like tracks (f / `❤️`) synced with your Navidrome server. + - Automatic playback scrobbling to Navidrome and Last.fm (>50% played). +- 🎨 **4 Premium TrueColor Themes**: + - **Catppuccin Mocha** (Default) + - **Tokyo Night** + - **Nord** + - **Gruvbox Dark** + - *(Press t to cycle themes instantly!)* +- 🖼️ **Terminal Cover Art**: Unicode Half-block pixel art thumbnail rendering. +- ⚙️ **Interactive Setup & Settings**: Built-in 2-column configuration wizard on first launch with persistent TOML config. +- 🔍 **Instant Global Search**: Search artists, albums, and songs across your entire music library (/). + +--- + +## 📸 Interface Preview + +```text + 🎵 rhyth-rs v0.1.0-beta │ Theme: Catppuccin Mocha [c] Settings [?] Help [/] Search [t] Theme [q] Quit +┌─ Artists [142] ────────────┐┌─ Albums [6] ───────────────────────────────────────────────────────────────────┐ +│ 🎤 Daft Punk (6) ││ 💿 Discovery (2001) [14 tracks] │ +│ 🎤 Gorillaz (8) ││ 💿 Random Access Memories (2013) [13 tracks] │ +│ 🎤 Radiohead (9) ││ 💿 Alive 2007 (2007) [12 tracks] │ +│ 🎤 Tame Impala (4) │└────────────────────────────────────────────────────────────────────────────────┘ +│ 🎤 The Weeknd (5) │┌─ Tracks [14] ──────────────────────────────────────────────────────────────────┐ +│ 🎤 Pink Floyd (11) ││ 🔊 ❤️ One More Time [05:20] │ +│ 🎤 Queen (15) ││ 02. Aerodynamic [03:27] │ +│ 🎤 Arctic Monkeys (6) ││ 03. Digital Love [04:58] │ +└────────────────────────────┘└────────────────────────────────────────────────────────────────────────────────┘ +┌─ 🎵 rhyth-rs v0.1.0-beta ────────────────────────────────────────────────────────────────────────────────────┐ +│ ❤️ One More Time • Daft Punk ⏮ ⏸ ⏭ │ 🔀 🔁 │ ▂▃▅▆▇▆▄▃▂▂▃▄▅ [y] Lyrics [u] Queue 🔊 85% │ +│ 02:14 ━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 05:20 (Click to Seek) │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🚀 Quick Start + +### 1. Build & Run from Source + +Make sure you have [Rust](https://rustup.rs/) installed: + +```bash +# Clone the repository +git clone https://gitea.rardogsynapse.online/RarDog/rhyth-rs.git +cd rhyth-rs + +# Run the player +cargo run --release +``` + +### 2. First-Time Setup + +When you first launch `rhyth-rs`, an interactive connection modal will appear: +1. Enter your **Navidrome / Subsonic Server URL** (e.g. `http://localhost:4533` or `https://music.yourdomain.com`). +2. Enter your **Username** and **Password**. +3. Select your favorite **Theme** and press **`[ Connect & Save ]`**! + +Configuration is automatically saved to: +* **Windows**: `%APPDATA%\rhyth-rs\config.toml` +* **Linux / macOS**: `~/.config/rhyth-rs/config.toml` + +--- + +## ⌨️ Controls & Shortcuts + +| Key | Action | Mouse Equivalent | +|:---|:---|:---| +| Space | Play / Pause toggle | Click `[ ▶ / ⏸ ]` | +| n / p | Next / Previous track | Click `[ ⏭ ]` / `[ ⏮ ]` | +| Enter | Play selected track / Expand album | Double-click track | +| j / ↓ | Move down | Scroll wheel down | +| k / ↑ | Move up | Scroll wheel up | +| h / l / Tab | Switch between Artists, Albums, and Tracks | Click pane | +| s | Toggle Shuffle mode | Click `[ 🔀 ]` | +| r | Toggle Repeat mode (*Off* ➔ *All* ➔ *One*) | Click `[ 🔁 ]` | +| f / L | Star / Favorite track (Like) | Click `[ ❤️ ]` | +| y | Toggle Lyrics screen | Click `[y] Lyrics` | +| u | Toggle Queue screen | Click `[u] Queue` | +| + / = / ] | Volume Up (+5%) | Click / Drag Volume slider | +| - / _ / [ | Volume Down (-5%) | Click / Drag Volume slider | +| t | Cycle Color Theme | Click `[t] Theme` | +| / | Open Search Dialog | Click `[/] Search` | +| c / , | Open Settings & Connection Dialog | Click `[c] Settings` | +| ? | Toggle Help Popup | Click `[?] Help` | +| q / Esc | Quit rhyth-rs / Close Modal | Click `[q] Quit` | + +--- + +## ⚙️ Configuration (`config.toml`) + +```toml +server_url = "http://localhost:4533" +username = "your_username" +password = "your_password" +theme = "catppuccin_mocha" # "catppuccin_mocha", "tokyo_night", "nord", "gruvbox" +default_volume = 0.85 +enable_visualizer = true +``` + +--- + +## 🛠️ Architecture + +`rhyth-rs` is built with a modular, asynchronous architecture designed for performance and reliability: + +- **`src/subsonic/`**: Strongly typed, robust OpenSubsonic client featuring MD5-token authentication, REST endpoints, scrobbling, and media streaming. +- **`src/audio/`**: + - **`decoder.rs`**: Symphonia pure decoding engine converting any stream container directly into floating-point PCM buffers. + - **`player.rs`**: Dedicated isolated audio thread managing sample playback with atomic progress tracking. +- **`src/ui/`**: + - **`theme.rs`**: TrueColor themes with semantic styles. + - **`visualizer.rs`**: Spring-physics audio spectrum engine with peak hold & color gradients. + - **`cover.rs`**: High-resolution image downsampling into Half-block ANSI characters. + - **`widgets/`**: Header, Library, Player Bar, Settings form, Search, Lyrics, and Queue modals. +- **`src/app.rs`**: Central state machine and async background channel router. +- **`src/main.rs`**: Fixed-timestep 30 FPS event loop with Crossterm mouse and keyboard capture. + +--- + +## 📜 License + +Distributed under the **MIT License**. See `LICENSE` for more information. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..62b9bcf --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1083 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use crossterm::event::KeyCode; +use rand::Rng; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::ListState; +use tokio::sync::mpsc; +use tracing::error; + +use crate::audio::{AudioPlayer, PlaybackState, TrackMetadata}; +use crate::config::AppConfig; +use crate::subsonic::{AlbumID3, ArtistID3, ChildSong, SubsonicClient}; +use crate::ui::cover::render_cover_thumbnail; +use crate::ui::theme::Theme; +use crate::ui::visualizer::Visualizer; +use crate::ui::widgets::settings::{SettingsField, SettingsFormData}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppMode { + Setup, + Main, + Settings, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusPane { + Artists, + Albums, + Tracks, +} + +impl FocusPane { + pub fn next(&self) -> Self { + match self { + FocusPane::Artists => FocusPane::Albums, + FocusPane::Albums => FocusPane::Tracks, + FocusPane::Tracks => FocusPane::Artists, + } + } + + pub fn prev(&self) -> Self { + match self { + FocusPane::Artists => FocusPane::Tracks, + FocusPane::Albums => FocusPane::Artists, + FocusPane::Tracks => FocusPane::Albums, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepeatMode { + Off, + Track, + All, +} + +impl RepeatMode { + pub fn next(&self) -> Self { + match self { + RepeatMode::Off => RepeatMode::All, + RepeatMode::All => RepeatMode::Track, + RepeatMode::Track => RepeatMode::Off, + } + } + + pub fn icon(&self) -> &'static str { + match self { + RepeatMode::Off => "🔁 Off", + RepeatMode::All => "🔁 All", + RepeatMode::Track => "🔂 One", + } + } +} + +#[derive(Default, Debug, Clone)] +pub struct LayoutCache { + // Header buttons + pub btn_settings: Rect, + pub btn_help: Rect, + pub btn_search: Rect, + pub btn_theme: Rect, + pub btn_quit: Rect, + + // Library Panes + pub artists_pane: Rect, + pub albums_pane: Rect, + pub tracks_pane: Rect, + + // Player Bar + pub player_bar: Rect, + pub btn_prev: Rect, + pub play_pause_btn: Rect, + pub btn_next: Rect, + pub btn_shuffle: Rect, + pub btn_repeat: Rect, + pub btn_star: Rect, + pub btn_lyrics: Rect, + pub progress_bar: Rect, + pub volume_bar: Rect, + + // Settings Modal + pub modal_area: Rect, + pub field_url: Rect, + pub field_user: Rect, + pub field_pass: Rect, + pub field_theme: Rect, + pub field_vol: Rect, + pub field_vis: Rect, + pub btn_save: Rect, + pub btn_cancel: Rect, +} + +pub fn hit(rect: Rect, x: u16, y: u16) -> bool { + x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height +} + +#[allow(dead_code)] +pub enum AppAction { + LoadedArtists(Vec), + LoadedAlbums(Vec), + LoadedTracks(Vec), + AudioBuffered { + bytes: Bytes, + meta: TrackMetadata, + }, + CoverLoaded(Vec>), + LyricsLoaded(Option), + StarToggled { + song_id: String, + is_starred: bool, + }, + Connected { + client: SubsonicClient, + config: AppConfig, + }, + ConnectionFailed(String), + Error(String), + Status(String), +} + +pub struct App { + pub mode: AppMode, + pub config: AppConfig, + pub client: SubsonicClient, + pub player: AudioPlayer, + pub theme: Theme, + pub visualizer: Visualizer, + pub layout: LayoutCache, + + // Settings / Setup Form + pub settings_form: SettingsFormData, + + // Main Library Navigation + pub focus: FocusPane, + pub artists: Vec, + pub artists_state: ListState, + + pub albums: Vec, + pub albums_state: ListState, + + pub tracks: Vec, + pub tracks_state: ListState, + + // Queue & Playback modes + pub queue: Vec, + pub queue_index: usize, + pub shuffle: bool, + pub repeat_mode: RepeatMode, + pub was_playing_before: bool, + pub scrobble_submitted: bool, + + // Cover art & Lyrics + pub cover_thumbnail: Option>>, + pub current_lyrics: Option, + pub show_lyrics: bool, + pub show_queue: bool, + + // Memory buffer cache + pub audio_cache: HashMap, + + // UI State & Animation + pub marquee_tick: usize, + pub is_loading: bool, + pub loading_msg: String, + pub toast: Option<(String, Instant)>, + pub show_help: bool, + pub show_search: bool, + pub search_input: String, + + pub should_quit: bool, + + // Channel for background async tasks + pub action_tx: mpsc::UnboundedSender, + pub action_rx: mpsc::UnboundedReceiver, +} + +impl App { + pub fn new(config: AppConfig, client: SubsonicClient, player: AudioPlayer, is_configured: bool) -> Self { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + + let initial_theme = match config.theme.as_str() { + "tokyo_night" => Theme::tokyo_night(), + "nord" => Theme::nord(), + "gruvbox" => Theme::gruvbox(), + _ => Theme::catppuccin_mocha(), + }; + + player.set_volume(config.default_volume); + + let settings_form = SettingsFormData::new( + &config.server_url, + &config.username, + &config.password, + initial_theme.name(), + config.default_volume, + config.enable_visualizer, + ); + + let mode = if is_configured { + AppMode::Main + } else { + AppMode::Setup + }; + + let mut app = Self { + mode, + config, + client, + player, + theme: initial_theme, + visualizer: Visualizer::new(36), + layout: LayoutCache::default(), + settings_form, + focus: FocusPane::Artists, + artists: Vec::new(), + artists_state: ListState::default(), + albums: Vec::new(), + albums_state: ListState::default(), + tracks: Vec::new(), + tracks_state: ListState::default(), + queue: Vec::new(), + queue_index: 0, + shuffle: false, + repeat_mode: RepeatMode::Off, + was_playing_before: false, + scrobble_submitted: false, + cover_thumbnail: None, + current_lyrics: None, + show_lyrics: false, + show_queue: false, + audio_cache: HashMap::new(), + marquee_tick: 0, + is_loading: false, + loading_msg: String::new(), + toast: None, + show_help: false, + show_search: false, + search_input: String::new(), + should_quit: false, + action_tx, + action_rx, + }; + + if is_configured { + app.fetch_artists(); + } + + app + } + + pub fn set_toast(&mut self, msg: impl Into) { + self.toast = Some((msg.into(), Instant::now())); + } + + pub fn tick(&mut self) { + self.marquee_tick = self.marquee_tick.wrapping_add(1); + + // Process background actions + while let Ok(action) = self.action_rx.try_recv() { + match action { + AppAction::LoadedArtists(artists) => { + self.is_loading = false; + self.artists = artists; + if !self.artists.is_empty() { + self.artists_state.select(Some(0)); + self.fetch_albums_for_selected(); + } + } + AppAction::LoadedAlbums(albums) => { + self.is_loading = false; + self.albums = albums; + if !self.albums.is_empty() { + self.albums_state.select(Some(0)); + self.fetch_tracks_for_selected(); + } else { + self.tracks.clear(); + self.tracks_state.select(None); + } + } + AppAction::LoadedTracks(tracks) => { + self.is_loading = false; + self.tracks = tracks; + if !self.tracks.is_empty() { + self.tracks_state.select(Some(0)); + } + } + AppAction::AudioBuffered { bytes, meta } => { + self.is_loading = false; + self.audio_cache.insert(meta.id.clone(), bytes.clone()); + self.set_toast(format!("▶ Now Playing: {}", meta.title)); + self.was_playing_before = true; + self.scrobble_submitted = false; + self.player.play(bytes, meta); + } + AppAction::CoverLoaded(lines) => { + self.cover_thumbnail = Some(lines); + } + AppAction::LyricsLoaded(lyrics) => { + self.current_lyrics = lyrics; + } + AppAction::StarToggled { song_id, is_starred } => { + if let Some(song) = self.tracks.iter_mut().find(|s| s.id == song_id) { + song.starred = if is_starred { Some("starred".to_string()) } else { None }; + } + if let Some(song) = self.queue.iter_mut().find(|s| s.id == song_id) { + song.starred = if is_starred { Some("starred".to_string()) } else { None }; + } + let status = if is_starred { "Added to Favorites ❤️" } else { "Removed from Favorites" }; + self.set_toast(status); + } + AppAction::Connected { client, config } => { + self.is_loading = false; + self.client = client; + self.config = config; + let _ = self.config.save(); + self.mode = AppMode::Main; + self.settings_form.status_message = None; + self.set_toast("Connected to Navidrome & Settings Saved!"); + self.fetch_artists(); + } + AppAction::ConnectionFailed(err) => { + self.is_loading = false; + self.settings_form.status_message = Some((format!("Connection failed: {err}"), true)); + } + AppAction::Error(err) => { + self.is_loading = false; + self.set_toast(format!("Error: {}", err)); + } + AppAction::Status(msg) => { + self.loading_msg = msg; + } + } + } + + let progress = self.player.get_progress(); + let is_playing = progress.state == PlaybackState::Playing; + + // Auto Scrobbling (> 50% played) + if is_playing && !self.scrobble_submitted { + if let Some(total) = progress.duration { + if total.as_secs() > 10 && progress.position.as_secs() >= total.as_secs() / 2 { + if let Some(ref track) = progress.current_track { + self.scrobble_submitted = true; + let client = self.client.clone(); + let track_id = track.id.clone(); + tokio::spawn(async move { + let _ = client.scrobble(&track_id, true).await; + }); + } + } + } + } + + // Auto Next Track on track end + if self.was_playing_before && progress.state == PlaybackState::Stopped { + self.was_playing_before = false; + match self.repeat_mode { + RepeatMode::Track => { + self.play_queue_index(self.queue_index); + } + RepeatMode::All => { + if !self.queue.is_empty() { + let next_idx = (self.queue_index + 1) % self.queue.len(); + self.play_queue_index(next_idx); + } + } + RepeatMode::Off => { + if self.queue_index + 1 < self.queue.len() { + self.play_queue_index(self.queue_index + 1); + } + } + } + } + + // Tick visualizer + self.visualizer.tick(is_playing); + + // Clear expired toast (after 3.5 seconds) + if let Some((_, time)) = self.toast { + if time.elapsed() > Duration::from_millis(3500) { + self.toast = None; + } + } + } + + pub fn handle_mouse_click(&mut self, x: u16, y: u16) { + // Modal mode (Setup / Settings) + if self.mode == AppMode::Setup || self.mode == AppMode::Settings { + if hit(self.layout.field_url, x, y) { + self.settings_form.focused_field = SettingsField::ServerUrl; + } else if hit(self.layout.field_user, x, y) { + self.settings_form.focused_field = SettingsField::Username; + } else if hit(self.layout.field_pass, x, y) { + self.settings_form.focused_field = SettingsField::Password; + } else if hit(self.layout.field_theme, x, y) { + self.settings_form.focused_field = SettingsField::Theme; + self.settings_form.next_theme(); + self.apply_form_theme(); + } else if hit(self.layout.field_vol, x, y) { + self.settings_form.focused_field = SettingsField::Volume; + let rel = (x.saturating_sub(self.layout.field_vol.x + 8)) as f32; + let width = (self.layout.field_vol.width.saturating_sub(10)).max(1) as f32; + let new_vol = ((rel / width).clamp(0.0, 1.0) * 100.0) as u32; + self.settings_form.volume = (new_vol / 5) * 5; + self.player.set_volume(self.settings_form.volume as f32 / 100.0); + } else if hit(self.layout.field_vis, x, y) { + self.settings_form.focused_field = SettingsField::Visualizer; + self.settings_form.enable_visualizer = !self.settings_form.enable_visualizer; + } else if hit(self.layout.btn_save, x, y) { + self.submit_settings(); + } else if hit(self.layout.btn_cancel, x, y) { + if self.mode == AppMode::Settings { + self.mode = AppMode::Main; + } else if self.mode == AppMode::Setup { + self.should_quit = true; + } + } + return; + } + + // Modals close on click outside + if self.show_lyrics || self.show_queue || self.show_help { + self.show_lyrics = false; + self.show_queue = false; + self.show_help = false; + return; + } + + // Main View + // 1. Progress Bar Seeking + if hit(self.layout.progress_bar, x, y) { + let rel_x = x.saturating_sub(self.layout.progress_bar.x) as f64; + let width = self.layout.progress_bar.width.max(1) as f64; + let ratio = (rel_x / width).clamp(0.0, 1.0); + if let Some(total_dur) = self.player.get_progress().duration { + let target_pos = Duration::from_secs_f64(total_dur.as_secs_f64() * ratio); + self.player.seek(target_pos); + let cur_str = format!("{:02}:{:02}", target_pos.as_secs() / 60, target_pos.as_secs() % 60); + self.set_toast(format!("Seek: {cur_str}")); + } + return; + } + + // 2. Playback Control Buttons + if hit(self.layout.play_pause_btn, x, y) { + self.player.toggle(); + return; + } else if hit(self.layout.btn_prev, x, y) { + self.prev_track(); + return; + } else if hit(self.layout.btn_next, x, y) { + self.next_track(); + return; + } else if hit(self.layout.btn_shuffle, x, y) { + self.toggle_shuffle(); + return; + } else if hit(self.layout.btn_repeat, x, y) { + self.toggle_repeat(); + return; + } else if hit(self.layout.btn_star, x, y) { + self.toggle_star(); + return; + } else if hit(self.layout.btn_lyrics, x, y) { + self.toggle_lyrics(); + return; + } + + // 3. Volume Bar Click + if hit(self.layout.volume_bar, x, y) { + let rel_x = x.saturating_sub(self.layout.volume_bar.x) as f32; + let width = self.layout.volume_bar.width.max(1) as f32; + let new_vol = (rel_x / width).clamp(0.0, 1.0); + self.player.set_volume(new_vol); + self.set_toast(format!("Volume: {}%", (new_vol * 100.0).round() as u32)); + return; + } + + // 4. Header buttons + if hit(self.layout.btn_settings, x, y) { + self.open_settings(); + return; + } else if hit(self.layout.btn_help, x, y) { + self.show_help = !self.show_help; + return; + } else if hit(self.layout.btn_search, x, y) { + self.show_search = true; + self.search_input.clear(); + return; + } else if hit(self.layout.btn_theme, x, y) { + self.cycle_theme(); + return; + } else if hit(self.layout.btn_quit, x, y) { + self.should_quit = true; + return; + } + + // 5. Artists pane + if hit(self.layout.artists_pane, x, y) { + self.focus = FocusPane::Artists; + let inner_y = y.saturating_sub(self.layout.artists_pane.y + 1) as usize; + if inner_y < self.artists.len() { + self.artists_state.select(Some(inner_y)); + self.fetch_albums_for_selected(); + } + return; + } + + // 6. Albums pane + if hit(self.layout.albums_pane, x, y) { + self.focus = FocusPane::Albums; + let inner_y = y.saturating_sub(self.layout.albums_pane.y + 1) as usize; + if inner_y < self.albums.len() { + self.albums_state.select(Some(inner_y)); + self.fetch_tracks_for_selected(); + } + return; + } + + // 7. Tracks pane + if hit(self.layout.tracks_pane, x, y) { + self.focus = FocusPane::Tracks; + let inner_y = y.saturating_sub(self.layout.tracks_pane.y + 1) as usize; + if inner_y < self.tracks.len() { + let was_selected = self.tracks_state.selected() == Some(inner_y); + self.tracks_state.select(Some(inner_y)); + if was_selected { + self.play_selected_track(); + } + } + return; + } + } + + pub fn handle_mouse_drag(&mut self, x: u16, y: u16) { + if hit(self.layout.progress_bar, x, y) { + let rel_x = x.saturating_sub(self.layout.progress_bar.x) as f64; + let width = self.layout.progress_bar.width.max(1) as f64; + let ratio = (rel_x / width).clamp(0.0, 1.0); + if let Some(total_dur) = self.player.get_progress().duration { + let target_pos = Duration::from_secs_f64(total_dur.as_secs_f64() * ratio); + self.player.seek(target_pos); + } + } else if hit(self.layout.volume_bar, x, y) { + let rel_x = x.saturating_sub(self.layout.volume_bar.x) as f32; + let width = self.layout.volume_bar.width.max(1) as f32; + let new_vol = (rel_x / width).clamp(0.0, 1.0); + self.player.set_volume(new_vol); + } + } + + pub fn handle_mouse_scroll(&mut self, x: u16, y: u16, up: bool) { + if hit(self.layout.artists_pane, x, y) { + self.focus = FocusPane::Artists; + if up { self.prev_item(); } else { self.next_item(); } + } else if hit(self.layout.albums_pane, x, y) { + self.focus = FocusPane::Albums; + if up { self.prev_item(); } else { self.next_item(); } + } else if hit(self.layout.tracks_pane, x, y) { + self.focus = FocusPane::Tracks; + if up { self.prev_item(); } else { self.next_item(); } + } else if hit(self.layout.player_bar, x, y) || hit(self.layout.volume_bar, x, y) { + if up { self.player.volume_up(0.05); } else { self.player.volume_down(0.05); } + let vol = (self.player.get_progress().volume * 100.0).round() as u32; + self.set_toast(format!("Volume: {vol}%")); + } + } + + pub fn fetch_artists(&mut self) { + self.is_loading = true; + self.loading_msg = "Loading artists...".to_string(); + let client = self.client.clone(); + let tx = self.action_tx.clone(); + + tokio::spawn(async move { + match client.get_artists().await { + Ok(artists) => { + let _ = tx.send(AppAction::LoadedArtists(artists)); + } + Err(e) => { + let _ = tx.send(AppAction::Error(e.to_string())); + } + } + }); + } + + pub fn fetch_albums_for_selected(&mut self) { + if let Some(idx) = self.artists_state.selected() { + if let Some(artist) = self.artists.get(idx) { + self.is_loading = true; + self.loading_msg = format!("Loading albums for {}...", artist.name); + let client = self.client.clone(); + let tx = self.action_tx.clone(); + let artist_id = artist.id.clone(); + + tokio::spawn(async move { + match client.get_artist(&artist_id).await { + Ok(details) => { + let _ = tx.send(AppAction::LoadedAlbums(details.album)); + } + Err(e) => { + let _ = tx.send(AppAction::Error(e.to_string())); + } + } + }); + } + } + } + + pub fn fetch_tracks_for_selected(&mut self) { + if let Some(idx) = self.albums_state.selected() { + if let Some(album) = self.albums.get(idx) { + self.is_loading = true; + self.loading_msg = format!("Loading tracks for {}...", album.name); + let client = self.client.clone(); + let tx = self.action_tx.clone(); + let album_id = album.id.clone(); + + tokio::spawn(async move { + match client.get_album(&album_id).await { + Ok(details) => { + let _ = tx.send(AppAction::LoadedTracks(details.song)); + } + Err(e) => { + let _ = tx.send(AppAction::Error(e.to_string())); + } + } + }); + } + } + } + + pub fn play_selected_track(&mut self) { + if let Some(idx) = self.tracks_state.selected() { + if !self.tracks.is_empty() { + // Populate queue with current tracks list + self.queue = self.tracks.clone(); + self.queue_index = idx; + self.play_queue_index(idx); + } + } + } + + pub fn play_queue_index(&mut self, idx: usize) { + if let Some(song) = self.queue.get(idx) { + self.queue_index = idx; + self.is_loading = true; + self.loading_msg = format!("Buffering: {}...", song.title); + let client = self.client.clone(); + let tx = self.action_tx.clone(); + let song_id = song.id.clone(); + let cover_art_id = song.cover_art.clone().or_else(|| song.album_id.clone()); + + let meta = TrackMetadata { + id: song.id.clone(), + title: song.title.clone(), + artist: song.artist.clone().unwrap_or_else(|| "Unknown".to_string()), + album: song.album.clone(), + duration: song.duration.map(Duration::from_secs), + suffix: song.suffix.clone(), + }; + + // Check memory cache + if let Some(cached) = self.audio_cache.get(&song_id) { + let _ = tx.send(AppAction::AudioBuffered { + bytes: cached.clone(), + meta, + }); + } else { + let c = client.clone(); + let sid = song_id.clone(); + let t = tx.clone(); + let m = meta.clone(); + tokio::spawn(async move { + match c.download_stream(&sid).await { + Ok(bytes) => { + let _ = t.send(AppAction::AudioBuffered { bytes, meta: m }); + } + Err(e) => { + error!("Stream error: {e}"); + let _ = t.send(AppAction::Error(format!("Failed to stream: {e}"))); + } + } + }); + } + + // Fetch Cover Art Thumbnail in background + if let Some(cover_id) = cover_art_id { + let c = client.clone(); + let t = tx.clone(); + tokio::spawn(async move { + if let Ok(bytes) = c.download_cover_art(&cover_id, Some(100)).await { + if let Some(lines) = render_cover_thumbnail(&bytes, 16, 8) { + let _ = t.send(AppAction::CoverLoaded(lines)); + } + } + }); + } + + // Fetch Lyrics in background + let c = client.clone(); + let sid = song_id.clone(); + let t = tx.clone(); + tokio::spawn(async move { + if let Ok(lyrics) = c.get_lyrics(&sid).await { + let _ = t.send(AppAction::LyricsLoaded(lyrics)); + } + }); + } + } + + pub fn next_track(&mut self) { + if self.queue.is_empty() { + return; + } + + let next_idx = if self.shuffle { + rand::thread_rng().gen_range(0..self.queue.len()) + } else { + (self.queue_index + 1) % self.queue.len() + }; + + self.play_queue_index(next_idx); + } + + pub fn prev_track(&mut self) { + if self.queue.is_empty() { + return; + } + + let prev_idx = if self.queue_index == 0 { + self.queue.len().saturating_sub(1) + } else { + self.queue_index - 1 + }; + + self.play_queue_index(prev_idx); + } + + pub fn toggle_shuffle(&mut self) { + self.shuffle = !self.shuffle; + let msg = if self.shuffle { "Shuffle: ON 🔀" } else { "Shuffle: OFF" }; + self.set_toast(msg); + } + + pub fn toggle_repeat(&mut self) { + self.repeat_mode = self.repeat_mode.next(); + self.set_toast(format!("Repeat: {}", self.repeat_mode.icon())); + } + + pub fn toggle_star(&mut self) { + let progress = self.player.get_progress(); + if let Some(track) = progress.current_track { + let is_starred = self.tracks.iter().find(|s| s.id == track.id).and_then(|s| s.starred.as_ref()).is_some(); + let new_state = !is_starred; + let client = self.client.clone(); + let tx = self.action_tx.clone(); + let song_id = track.id.clone(); + + tokio::spawn(async move { + let res = if new_state { + client.star(&song_id).await + } else { + client.unstar(&song_id).await + }; + + if res.is_ok() { + let _ = tx.send(AppAction::StarToggled { + song_id, + is_starred: new_state, + }); + } + }); + } + } + + pub fn toggle_lyrics(&mut self) { + self.show_lyrics = !self.show_lyrics; + } + + pub fn toggle_queue(&mut self) { + self.show_queue = !self.show_queue; + } + + pub fn perform_search(&mut self) { + if self.search_input.trim().is_empty() { + return; + } + + self.is_loading = true; + self.loading_msg = format!("Searching for \"{}\"...", self.search_input); + let client = self.client.clone(); + let tx = self.action_tx.clone(); + let query = self.search_input.clone(); + + tokio::spawn(async move { + match client.search(&query).await { + Ok(result) => { + let _ = tx.send(AppAction::LoadedTracks(result.song)); + if !result.album.is_empty() { + let _ = tx.send(AppAction::LoadedAlbums(result.album)); + } + if !result.artist.is_empty() { + let _ = tx.send(AppAction::LoadedArtists(result.artist)); + } + } + Err(e) => { + let _ = tx.send(AppAction::Error(e.to_string())); + } + } + }); + } + + pub fn next_item(&mut self) { + match self.focus { + FocusPane::Artists => { + if !self.artists.is_empty() { + let current = self.artists_state.selected().unwrap_or(0); + let next = (current + 1) % self.artists.len(); + self.artists_state.select(Some(next)); + self.fetch_albums_for_selected(); + } + } + FocusPane::Albums => { + if !self.albums.is_empty() { + let current = self.albums_state.selected().unwrap_or(0); + let next = (current + 1) % self.albums.len(); + self.albums_state.select(Some(next)); + self.fetch_tracks_for_selected(); + } + } + FocusPane::Tracks => { + if !self.tracks.is_empty() { + let current = self.tracks_state.selected().unwrap_or(0); + let next = (current + 1) % self.tracks.len(); + self.tracks_state.select(Some(next)); + } + } + } + } + + pub fn prev_item(&mut self) { + match self.focus { + FocusPane::Artists => { + if !self.artists.is_empty() { + let current = self.artists_state.selected().unwrap_or(0); + let prev = if current == 0 { + self.artists.len() - 1 + } else { + current - 1 + }; + self.artists_state.select(Some(prev)); + self.fetch_albums_for_selected(); + } + } + FocusPane::Albums => { + if !self.albums.is_empty() { + let current = self.albums_state.selected().unwrap_or(0); + let prev = if current == 0 { + self.albums.len() - 1 + } else { + current - 1 + }; + self.albums_state.select(Some(prev)); + self.fetch_tracks_for_selected(); + } + } + FocusPane::Tracks => { + if !self.tracks.is_empty() { + let current = self.tracks_state.selected().unwrap_or(0); + let prev = if current == 0 { + self.tracks.len() - 1 + } else { + current - 1 + }; + self.tracks_state.select(Some(prev)); + } + } + } + } + + pub fn cycle_theme(&mut self) { + self.theme = self.theme.cycle_next(); + self.config.theme = match self.theme.kind { + crate::ui::theme::ThemeKind::CatppuccinMocha => "catppuccin_mocha".to_string(), + crate::ui::theme::ThemeKind::TokyoNight => "tokyo_night".to_string(), + crate::ui::theme::ThemeKind::Nord => "nord".to_string(), + crate::ui::theme::ThemeKind::Gruvbox => "gruvbox".to_string(), + }; + let _ = self.config.save(); + self.set_toast(format!("Theme: {}", self.theme.name())); + } + + pub fn open_settings(&mut self) { + self.settings_form = SettingsFormData::new( + &self.config.server_url, + &self.config.username, + &self.config.password, + self.theme.name(), + self.config.default_volume, + self.config.enable_visualizer, + ); + self.mode = AppMode::Settings; + } + + pub fn handle_settings_key(&mut self, key: KeyCode) { + match key { + KeyCode::Tab | KeyCode::Down => { + self.settings_form.focused_field = self.settings_form.focused_field.next(); + } + KeyCode::BackTab | KeyCode::Up => { + self.settings_form.focused_field = self.settings_form.focused_field.prev(); + } + KeyCode::F(2) => { + self.settings_form.show_password = !self.settings_form.show_password; + } + KeyCode::Esc => { + if self.mode == AppMode::Settings { + self.mode = AppMode::Main; + } else if self.mode == AppMode::Setup { + self.should_quit = true; + } + } + _ => match self.settings_form.focused_field { + SettingsField::ServerUrl => match key { + KeyCode::Char(c) => self.settings_form.server_url.push(c), + KeyCode::Backspace => { + self.settings_form.server_url.pop(); + } + KeyCode::Enter => { + self.settings_form.focused_field = SettingsField::Username; + } + _ => {} + }, + SettingsField::Username => match key { + KeyCode::Char(c) => self.settings_form.username.push(c), + KeyCode::Backspace => { + self.settings_form.username.pop(); + } + KeyCode::Enter => { + self.settings_form.focused_field = SettingsField::Password; + } + _ => {} + }, + SettingsField::Password => match key { + KeyCode::Char(c) => self.settings_form.password.push(c), + KeyCode::Backspace => { + self.settings_form.password.pop(); + } + KeyCode::Enter => { + self.settings_form.focused_field = SettingsField::Theme; + } + _ => {} + }, + SettingsField::Theme => match key { + KeyCode::Left => { + self.settings_form.prev_theme(); + self.apply_form_theme(); + } + KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => { + self.settings_form.next_theme(); + self.apply_form_theme(); + } + _ => {} + }, + SettingsField::Volume => match key { + KeyCode::Left | KeyCode::Char('-') | KeyCode::Char('_') => { + self.settings_form.volume = self.settings_form.volume.saturating_sub(5); + self.player.set_volume(self.settings_form.volume as f32 / 100.0); + } + KeyCode::Right | KeyCode::Char('+') | KeyCode::Char('=') => { + self.settings_form.volume = (self.settings_form.volume + 5).min(100); + self.player.set_volume(self.settings_form.volume as f32 / 100.0); + } + _ => {} + }, + SettingsField::Visualizer => match key { + KeyCode::Left | KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => { + self.settings_form.enable_visualizer = !self.settings_form.enable_visualizer; + } + _ => {} + }, + SettingsField::SaveButton => match key { + KeyCode::Enter | KeyCode::Char(' ') => { + self.submit_settings(); + } + _ => {} + }, + SettingsField::CancelButton => match key { + KeyCode::Enter | KeyCode::Char(' ') => { + if self.mode == AppMode::Settings { + self.mode = AppMode::Main; + } else if self.mode == AppMode::Setup { + self.should_quit = true; + } + } + _ => {} + }, + }, + } + } + + fn apply_form_theme(&mut self) { + self.theme = match self.settings_form.theme_index { + 1 => Theme::tokyo_night(), + 2 => Theme::nord(), + 3 => Theme::gruvbox(), + _ => Theme::catppuccin_mocha(), + }; + } + + fn submit_settings(&mut self) { + let url = self.settings_form.server_url.trim().to_string(); + let user = self.settings_form.username.trim().to_string(); + let pass = self.settings_form.password.trim().to_string(); + + if url.is_empty() || user.is_empty() || pass.is_empty() { + self.settings_form.status_message = Some(("Please fill in Server URL, Username, and Password.".to_string(), true)); + return; + } + + self.settings_form.status_message = Some(("Connecting to Navidrome...".to_string(), false)); + let tx = self.action_tx.clone(); + + let theme_str = match self.settings_form.theme_index { + 1 => "tokyo_night", + 2 => "nord", + 3 => "gruvbox", + _ => "catppuccin_mocha", + } + .to_string(); + + let new_config = AppConfig { + server_url: url.clone(), + username: user.clone(), + password: pass.clone(), + theme: theme_str, + default_volume: self.settings_form.volume as f32 / 100.0, + enable_visualizer: self.settings_form.enable_visualizer, + }; + + tokio::spawn(async move { + match SubsonicClient::new(&url, &user, &pass) { + Ok(client) => match client.ping().await { + Ok(()) => { + let _ = tx.send(AppAction::Connected { + client, + config: new_config, + }); + } + Err(e) => { + let _ = tx.send(AppAction::ConnectionFailed(e.to_string())); + } + }, + Err(e) => { + let _ = tx.send(AppAction::ConnectionFailed(e.to_string())); + } + } + }); + } +} diff --git a/src/audio/decoder.rs b/src/audio/decoder.rs new file mode 100644 index 0000000..12b30d7 --- /dev/null +++ b/src/audio/decoder.rs @@ -0,0 +1,187 @@ +use std::io::Cursor; +use anyhow::{bail, Context, Result}; +use symphonia::core::audio::{AudioBufferRef, Signal}; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +pub struct DecodedAudio { + pub samples: Vec, + pub sample_rate: u32, + pub channels: u16, +} + +pub fn decode_audio(bytes: Vec, hint_ext: Option<&str>) -> Result { + if bytes.len() < 512 { + bail!("Audio payload is too small ({} bytes)", bytes.len()); + } + + let mut hint = Hint::new(); + if let Some(ext) = hint_ext { + let clean_ext = ext.trim_start_matches('.'); + hint.with_extension(clean_ext); + } + + let cursor = Box::new(Cursor::new(bytes)); + let mss = MediaSourceStream::new(cursor, Default::default()); + + let probed = symphonia::default::get_probe() + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) + .context("Failed to probe audio format (unsupported codec or corrupt audio stream)")?; + + let mut format = probed.format; + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .context("No audio track found in media container")?; + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .context("Unsupported audio codec")?; + + let track_id = track.id; + let mut sample_rate = 44100; + let mut channels = 2; + let mut all_samples = Vec::new(); + + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(SymphoniaError::ResetRequired) => continue, + Err(SymphoniaError::IoError(_)) => break, + Err(e) => { + tracing::warn!("Stream packet notice: {e}"); + break; + } + }; + + if packet.track_id() != track_id { + continue; + } + + match decoder.decode(&packet) { + Ok(decoded) => { + if all_samples.is_empty() { + let spec = *decoded.spec(); + sample_rate = spec.rate; + channels = spec.channels.count() as u16; + } + append_audio_buffer(&decoded, &mut all_samples); + } + Err(SymphoniaError::IoError(_)) => break, + Err(SymphoniaError::DecodeError(_)) => continue, + Err(e) => { + tracing::warn!("Packet decoder notice: {e}"); + break; + } + } + } + + if all_samples.is_empty() { + bail!("No audio samples could be decoded from stream"); + } + + Ok(DecodedAudio { + samples: all_samples, + sample_rate, + channels, + }) +} + +fn append_audio_buffer(audio_buf: &AudioBufferRef, output: &mut Vec) { + match audio_buf { + AudioBufferRef::F32(buf) => { + let num_channels = buf.spec().channels.count(); + for frame_idx in 0..buf.frames() { + for ch in 0..num_channels { + output.push(buf.chan(ch)[frame_idx]); + } + } + } + AudioBufferRef::U8(buf) => { + let num_channels = buf.spec().channels.count(); + for frame_idx in 0..buf.frames() { + for ch in 0..num_channels { + let sample = (buf.chan(ch)[frame_idx] as f32 - 128.0) / 128.0; + output.push(sample); + } + } + } + AudioBufferRef::U16(buf) => { + let num_channels = buf.spec().channels.count(); + for frame_idx in 0..buf.frames() { + for ch in 0..num_channels { + let sample = (buf.chan(ch)[frame_idx] as f32 - 32768.0) / 32768.0; + output.push(sample); + } + } + } + AudioBufferRef::U24(buf) => { + let num_channels = buf.spec().channels.count(); + for frame_idx in 0..buf.frames() { + for ch in 0..num_channels { + let sample = (buf.chan(ch)[frame_idx].0 as f32 - 8388608.0) / 8388608.0; + output.push(sample); + } + } + } + AudioBufferRef::U32(buf) => { + let num_channels = buf.spec().channels.count(); + for frame_idx in 0..buf.frames() { + for ch in 0..num_channels { + let sample = (buf.chan(ch)[frame_idx] as f32 - 2147483648.0) / 2147483648.0; + output.push(sample); + } + } + } + AudioBufferRef::S8(buf) => { + let num_channels = buf.spec().channels.count(); + for frame_idx in 0..buf.frames() { + for ch in 0..num_channels { + let sample = buf.chan(ch)[frame_idx] as f32 / 128.0; + output.push(sample); + } + } + } + AudioBufferRef::S16(buf) => { + let num_channels = buf.spec().channels.count(); + for frame_idx in 0..buf.frames() { + for ch in 0..num_channels { + let sample = buf.chan(ch)[frame_idx] as f32 / 32768.0; + output.push(sample); + } + } + } + AudioBufferRef::S24(buf) => { + let num_channels = buf.spec().channels.count(); + for frame_idx in 0..buf.frames() { + for ch in 0..num_channels { + let sample = buf.chan(ch)[frame_idx].0 as f32 / 8388608.0; + output.push(sample); + } + } + } + AudioBufferRef::S32(buf) => { + let num_channels = buf.spec().channels.count(); + for frame_idx in 0..buf.frames() { + for ch in 0..num_channels { + let sample = buf.chan(ch)[frame_idx] as f32 / 2147483648.0; + output.push(sample); + } + } + } + AudioBufferRef::F64(buf) => { + let num_channels = buf.spec().channels.count(); + for frame_idx in 0..buf.frames() { + for ch in 0..num_channels { + output.push(buf.chan(ch)[frame_idx] as f32); + } + } + } + } +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs new file mode 100644 index 0000000..3bb9132 --- /dev/null +++ b/src/audio/mod.rs @@ -0,0 +1,5 @@ +pub mod decoder; +pub mod player; + +#[allow(unused_imports)] +pub use player::{AudioPlayer, PlaybackProgress, PlaybackState, TrackMetadata}; diff --git a/src/audio/player.rs b/src/audio/player.rs new file mode 100644 index 0000000..f8f4bf2 --- /dev/null +++ b/src/audio/player.rs @@ -0,0 +1,283 @@ +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use bytes::Bytes; +use parking_lot::RwLock; +use rodio::buffer::SamplesBuffer; +use rodio::{OutputStream, Sink}; +use tokio::sync::mpsc; +use tracing::{error, info, warn}; + +use super::decoder::decode_audio; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaybackState { + Playing, + Paused, + Stopped, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct TrackMetadata { + pub id: String, + pub title: String, + pub artist: String, + pub album: Option, + pub duration: Option, + pub suffix: Option, +} + +#[derive(Debug, Clone)] +pub struct PlaybackProgress { + pub state: PlaybackState, + pub position: Duration, + pub duration: Option, + pub volume: f32, + pub current_track: Option, +} + +impl Default for PlaybackProgress { + fn default() -> Self { + Self { + state: PlaybackState::Stopped, + position: Duration::ZERO, + duration: None, + volume: 1.0, + current_track: None, + } + } +} + +#[allow(dead_code)] +enum PlayerCommand { + Play { + bytes: Bytes, + meta: TrackMetadata, + }, + Pause, + Resume, + Toggle, + Stop, + Seek(Duration), + SetVolume(f32), +} + +#[derive(Clone)] +pub struct AudioPlayer { + cmd_tx: mpsc::UnboundedSender, + progress: Arc>, +} + +#[allow(dead_code)] +impl AudioPlayer { + pub fn new() -> Result { + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); + let progress = Arc::new(RwLock::new(PlaybackProgress::default())); + let progress_clone = Arc::clone(&progress); + + // Spawn dedicated audio thread (OutputStream is not Send on some platforms) + thread::Builder::new() + .name("rhyth-audio-engine".to_string()) + .spawn(move || { + let (_stream, stream_handle) = match OutputStream::try_default() { + Ok(res) => res, + Err(e) => { + error!("Failed to initialize audio output device: {e}"); + return; + } + }; + + let sink = match Sink::try_new(&stream_handle) { + Ok(s) => s, + Err(e) => { + error!("Failed to create audio sink: {e}"); + return; + } + }; + + let mut current_volume = 1.0f32; + sink.set_volume(current_volume); + + let mut track_start_time: Option = None; + let mut accumulated_position = Duration::ZERO; + let mut is_playing = false; + + loop { + // Check for incoming commands (non-blocking when playing, blocking when stopped) + let timeout = if is_playing { + Duration::from_millis(50) + } else { + Duration::from_millis(200) + }; + + match cmd_rx.try_recv() { + Ok(cmd) => match cmd { + PlayerCommand::Play { bytes, meta } => { + sink.stop(); // clear current sink + let suffix = meta.suffix.clone(); + let data = bytes.to_vec(); + + match decode_audio(data, suffix.as_deref()) { + Ok(decoded) => { + let sample_count = decoded.samples.len(); + let rate = decoded.sample_rate; + let ch = decoded.channels; + let computed_duration = Duration::from_secs_f64( + sample_count as f64 / (ch as f64 * rate as f64), + ); + + let source = SamplesBuffer::new(ch, rate, decoded.samples); + sink.append(source); + sink.play(); + is_playing = true; + accumulated_position = Duration::ZERO; + track_start_time = Some(Instant::now()); + + let mut state = progress_clone.write(); + state.state = PlaybackState::Playing; + state.position = Duration::ZERO; + state.duration = meta.duration.or(Some(computed_duration)); + state.current_track = Some(meta); + info!("Started playing track: {}", state.current_track.as_ref().map(|t| t.title.as_str()).unwrap_or("")); + } + Err(e) => { + error!("Audio decode error: {e}"); + let mut state = progress_clone.write(); + state.state = PlaybackState::Stopped; + state.current_track = None; + is_playing = false; + } + } + } + PlayerCommand::Pause => { + if !sink.is_paused() && !sink.empty() { + sink.pause(); + is_playing = false; + if let Some(start) = track_start_time.take() { + accumulated_position += start.elapsed(); + } + progress_clone.write().state = PlaybackState::Paused; + } + } + PlayerCommand::Resume => { + if sink.is_paused() && !sink.empty() { + sink.play(); + is_playing = true; + track_start_time = Some(Instant::now()); + progress_clone.write().state = PlaybackState::Playing; + } + } + PlayerCommand::Toggle => { + if sink.is_paused() { + sink.play(); + is_playing = true; + track_start_time = Some(Instant::now()); + progress_clone.write().state = PlaybackState::Playing; + } else if !sink.empty() { + sink.pause(); + is_playing = false; + if let Some(start) = track_start_time.take() { + accumulated_position += start.elapsed(); + } + progress_clone.write().state = PlaybackState::Paused; + } + } + PlayerCommand::Stop => { + sink.stop(); + is_playing = false; + track_start_time = None; + accumulated_position = Duration::ZERO; + let mut state = progress_clone.write(); + state.state = PlaybackState::Stopped; + state.position = Duration::ZERO; + state.current_track = None; + } + PlayerCommand::Seek(pos) => { + if let Err(e) = sink.try_seek(pos) { + warn!("Seek not supported or failed: {e}"); + } else { + accumulated_position = pos; + track_start_time = Some(Instant::now()); + progress_clone.write().position = pos; + } + } + PlayerCommand::SetVolume(vol) => { + current_volume = vol.clamp(0.0, 1.0); + sink.set_volume(current_volume); + progress_clone.write().volume = current_volume; + } + }, + Err(mpsc::error::TryRecvError::Empty) => { + // Update playback position + if is_playing { + if sink.empty() { + // Track finished playing + is_playing = false; + track_start_time = None; + accumulated_position = Duration::ZERO; + let mut state = progress_clone.write(); + state.state = PlaybackState::Stopped; + state.position = Duration::ZERO; + } else { + let current_pos = sink.get_pos(); + progress_clone.write().position = current_pos; + } + } + thread::sleep(timeout); + } + Err(mpsc::error::TryRecvError::Disconnected) => { + break; // AudioPlayer dropped + } + } + } + }) + .context("Failed to spawn audio engine thread")?; + + Ok(Self { cmd_tx, progress }) + } + + pub fn play(&self, bytes: Bytes, meta: TrackMetadata) { + let _ = self.cmd_tx.send(PlayerCommand::Play { bytes, meta }); + } + + pub fn pause(&self) { + let _ = self.cmd_tx.send(PlayerCommand::Pause); + } + + pub fn resume(&self) { + let _ = self.cmd_tx.send(PlayerCommand::Resume); + } + + pub fn toggle(&self) { + let _ = self.cmd_tx.send(PlayerCommand::Toggle); + } + + pub fn stop(&self) { + let _ = self.cmd_tx.send(PlayerCommand::Stop); + } + + pub fn seek(&self, position: Duration) { + let _ = self.cmd_tx.send(PlayerCommand::Seek(position)); + } + + pub fn set_volume(&self, volume: f32) { + let _ = self.cmd_tx.send(PlayerCommand::SetVolume(volume)); + } + + pub fn volume_up(&self, step: f32) { + let current = self.progress.read().volume; + self.set_volume(current + step); + } + + pub fn volume_down(&self, step: f32) { + let current = self.progress.read().volume; + self.set_volume(current - step); + } + + pub fn get_progress(&self) -> PlaybackProgress { + self.progress.read().clone() + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..626f577 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,78 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub server_url: String, + pub username: String, + pub password: String, + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default = "default_volume")] + pub default_volume: f32, + #[serde(default = "default_true")] + pub enable_visualizer: bool, +} + +fn default_theme() -> String { + "catppuccin_mocha".to_string() +} + +fn default_volume() -> f32 { + 0.85 +} + +fn default_true() -> bool { + true +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + server_url: std::env::var("NAVIDROME_URL").unwrap_or_else(|_| "http://localhost:4533".to_string()), + username: std::env::var("NAVIDROME_USER").unwrap_or_default(), + password: std::env::var("NAVIDROME_PASSWORD").unwrap_or_default(), + theme: default_theme(), + default_volume: default_volume(), + enable_visualizer: true, + } + } +} + +impl AppConfig { + pub fn config_path() -> PathBuf { + let mut path = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); + path.push("rhyth-rs"); + let _ = fs::create_dir_all(&path); + path.push("config.toml"); + path + } + + pub fn load() -> Self { + let path = Self::config_path(); + if path.exists() { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(cfg) = toml::from_str::(&content) { + return cfg; + } + } + } + Self::default() + } + + pub fn is_configured(&self) -> bool { + !self.server_url.trim().is_empty() + && !self.username.trim().is_empty() + && !self.password.trim().is_empty() + } + + pub fn save(&self) -> Result<()> { + let path = Self::config_path(); + let serialized = toml::to_string_pretty(self).context("Failed to serialize config to TOML")?; + fs::write(&path, serialized).context("Failed to write config file")?; + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..64a52d6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,257 @@ +mod app; +mod audio; +mod config; +mod subsonic; +mod ui; + +use std::io::{self, stdout}; +use std::time::Duration; + +use anyhow::Result; +use app::{App, AppMode, FocusPane}; +use audio::AudioPlayer; +use config::AppConfig; +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseButton, + MouseEventKind, +}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use subsonic::SubsonicClient; + +#[tokio::main] +async fn main() -> Result<()> { + let config = AppConfig::load(); + let is_configured = config.is_configured(); + + let client = SubsonicClient::new(&config.server_url, &config.username, &config.password) + .unwrap_or_else(|_| SubsonicClient::new("http://localhost:4533", "admin", "").unwrap()); + let player = AudioPlayer::new()?; + + let mut app = App::new(config, client, player, is_configured); + + // Setup terminal with mouse capture + enable_raw_mode()?; + let mut stdout = stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Set panic hook to ensure terminal is restored on crash + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); + original_hook(panic_info); + })); + + let result = run_app(&mut terminal, &mut app).await; + + // Restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + terminal.show_cursor()?; + + if let Err(e) = result { + eprintln!("Application error: {e}"); + } + + Ok(()) +} + +async fn run_app( + terminal: &mut Terminal, + app: &mut App, +) -> Result<()> { + let tick_rate = Duration::from_millis(33); // ~30 FPS fixed rate + let mut last_tick = std::time::Instant::now(); + + loop { + terminal.draw(|frame| ui::render(app, frame))?; + + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + if event::poll(timeout)? { + match event::read()? { + Event::Key(key) => { + if key.kind == KeyEventKind::Press { + handle_key(app, key.code); + } + } + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + app.handle_mouse_click(mouse.column, mouse.row); + } + MouseEventKind::Drag(MouseButton::Left) => { + app.handle_mouse_drag(mouse.column, mouse.row); + } + MouseEventKind::ScrollDown => { + app.handle_mouse_scroll(mouse.column, mouse.row, false); + } + MouseEventKind::ScrollUp => { + app.handle_mouse_scroll(mouse.column, mouse.row, true); + } + _ => {} + }, + _ => {} + } + } + + // Only tick visualizer and physics at fixed 30 FPS regardless of mouse event rate + if last_tick.elapsed() >= tick_rate { + app.tick(); + last_tick = std::time::Instant::now(); + } + + if app.should_quit { + break; + } + } + + Ok(()) +} + +fn handle_key(app: &mut App, key: KeyCode) { + // 1. Settings / Setup mode + if app.mode == AppMode::Setup || app.mode == AppMode::Settings { + app.handle_settings_key(key); + return; + } + + // 2. Search modal input + if app.show_search { + match key { + KeyCode::Enter => { + app.perform_search(); + app.show_search = false; + } + KeyCode::Esc => { + app.show_search = false; + app.search_input.clear(); + } + KeyCode::Backspace => { + app.search_input.pop(); + } + KeyCode::Char(c) => { + app.search_input.push(c); + } + _ => {} + } + return; + } + + // 3. Help modal + if app.show_help { + match key { + KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => { + app.show_help = false; + } + _ => {} + } + return; + } + + // 4. Lyrics / Queue modals + if app.show_lyrics { + match key { + KeyCode::Esc | KeyCode::Char('y') | KeyCode::Char('q') => { + app.show_lyrics = false; + } + _ => {} + } + return; + } + + if app.show_queue { + match key { + KeyCode::Esc | KeyCode::Char('u') | KeyCode::Char('q') => { + app.show_queue = false; + } + KeyCode::Char('n') => app.next_track(), + KeyCode::Char('p') => app.prev_track(), + _ => {} + } + return; + } + + // 5. Normal navigation & player controls + match key { + KeyCode::Char('q') => { + app.should_quit = true; + } + KeyCode::Char('c') | KeyCode::Char(',') => { + app.open_settings(); + } + KeyCode::Char('?') => { + app.show_help = true; + } + KeyCode::Char('/') => { + app.show_search = true; + app.search_input.clear(); + } + KeyCode::Char('t') => { + app.cycle_theme(); + } + KeyCode::Char(' ') => { + app.player.toggle(); + } + KeyCode::Char('n') => { + app.next_track(); + } + KeyCode::Char('p') => { + app.prev_track(); + } + KeyCode::Char('s') => { + app.toggle_shuffle(); + } + KeyCode::Char('r') => { + app.toggle_repeat(); + } + KeyCode::Char('f') | KeyCode::Char('L') => { + app.toggle_star(); + } + KeyCode::Char('y') => { + app.toggle_lyrics(); + } + KeyCode::Char('u') => { + app.toggle_queue(); + } + KeyCode::Char('j') | KeyCode::Down => { + app.next_item(); + } + KeyCode::Char('k') | KeyCode::Up => { + app.prev_item(); + } + KeyCode::Char('h') | KeyCode::Left => { + app.focus = app.focus.prev(); + } + KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => { + app.focus = app.focus.next(); + } + KeyCode::Enter => match app.focus { + FocusPane::Artists => { + app.focus = FocusPane::Albums; + } + FocusPane::Albums => { + app.focus = FocusPane::Tracks; + } + FocusPane::Tracks => { + app.play_selected_track(); + } + }, + KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => { + app.player.volume_up(0.05); + let vol = (app.player.get_progress().volume * 100.0).round() as u32; + app.set_toast(format!("Volume: {vol}%")); + } + KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => { + app.player.volume_down(0.05); + let vol = (app.player.get_progress().volume * 100.0).round() as u32; + app.set_toast(format!("Volume: {vol}%")); + } + _ => {} + } +} diff --git a/src/subsonic/client.rs b/src/subsonic/client.rs new file mode 100644 index 0000000..250dbe4 --- /dev/null +++ b/src/subsonic/client.rs @@ -0,0 +1,243 @@ +use anyhow::{bail, Context, Result}; +use bytes::Bytes; +use md5::{Digest, Md5}; +use rand::Rng; +use reqwest::Client; +use url::Url; + +use super::models::{ + AlbumWithSongsID3, ArtistID3, ArtistWithAlbumsID3, ChildSong, ResponseStatus, SearchResult3, + SubsonicResponse, +}; + +#[derive(Clone)] +pub struct SubsonicClient { + base_url: Url, + username: String, + password: String, + client_name: String, + api_version: String, + http: Client, +} + +#[allow(dead_code)] +impl SubsonicClient { + pub fn new(base_url: &str, username: &str, password: &str) -> Result { + let mut parsed_url = Url::parse(base_url).context("Invalid base URL for Navidrome")?; + + // Ensure path ends with rest/ or / + if !parsed_url.path().ends_with('/') { + let mut new_path = parsed_url.path().to_string(); + new_path.push('/'); + parsed_url.set_path(&new_path); + } + + Ok(Self { + base_url: parsed_url, + username: username.to_string(), + password: password.to_string(), + client_name: "rhyth-rs".to_string(), + api_version: "1.16.1".to_string(), + http: Client::builder().build()?, + }) + } + + /// Generate Subsonic authentication parameters (token + salt) + fn generate_auth_params(&self) -> (String, String) { + let salt: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(12) + .map(char::from) + .collect(); + + let mut hasher = Md5::new(); + hasher.update(format!("{}{}", self.password, salt)); + let token = hex::encode(hasher.finalize()); + + (token, salt) + } + + /// Construct a full API URL for a given endpoint with auth and extra query parameters + pub fn build_url(&self, endpoint: &str, extra_params: &[(&str, &str)]) -> Result { + let endpoint_path = format!("rest/{}.view", endpoint.trim_end_matches(".view")); + let mut url = self.base_url.join(&endpoint_path)?; + let (token, salt) = self.generate_auth_params(); + + { + let mut query = url.query_pairs_mut(); + query.append_pair("u", &self.username); + query.append_pair("t", &token); + query.append_pair("s", &salt); + query.append_pair("v", &self.api_version); + query.append_pair("c", &self.client_name); + + let is_binary_stream = endpoint.starts_with("stream") || endpoint.starts_with("getCoverArt"); + if !is_binary_stream { + query.append_pair("f", "json"); + } + + for (key, val) in extra_params { + query.append_pair(key, val); + } + } + + Ok(url) + } + + /// Execute GET request and decode Subsonic response + async fn get_json(&self, endpoint: &str, extra_params: &[(&str, &str)]) -> Result { + let url = self.build_url(endpoint, extra_params)?; + let resp = self.http.get(url).send().await?.error_for_status()?; + let parsed: SubsonicResponse = resp.json().await?; + + if parsed.response.status == ResponseStatus::Failed { + if let Some(err) = parsed.response.error { + bail!(err); + } else { + bail!("Subsonic API returned failed status without an error payload"); + } + } + + Ok(parsed) + } + + /// Test connection to Navidrome / Subsonic server + pub async fn ping(&self) -> Result<()> { + self.get_json("ping", &[]).await?; + Ok(()) + } + + /// Get list of all artists grouped by index + pub async fn get_artists(&self) -> Result> { + let resp = self.get_json("getArtists", &[]).await?; + let mut artists = Vec::new(); + if let Some(artists_data) = resp.response.artists { + for index in artists_data.index { + artists.extend(index.artist); + } + } + Ok(artists) + } + + /// Get artist details and their albums + pub async fn get_artist(&self, id: &str) -> Result { + let resp = self.get_json("getArtist", &[("id", id)]).await?; + resp.response + .artist + .context("Artist data missing in getArtist response") + } + + /// Get album details with tracklist + pub async fn get_album(&self, id: &str) -> Result { + let resp = self.get_json("getAlbum", &[("id", id)]).await?; + resp.response + .album + .context("Album data missing in getAlbum response") + } + + /// Get song details by ID + pub async fn get_song(&self, id: &str) -> Result { + let resp = self.get_json("getSong", &[("id", id)]).await?; + resp.response + .song + .context("Song data missing in getSong response") + } + + /// Search across artists, albums, and songs + pub async fn search(&self, query: &str) -> Result { + let resp = self.get_json("search3", &[("query", query)]).await?; + resp.response + .search_result3 + .context("Search result missing in search3 response") + } + + /// Generate direct stream URL for a song + pub fn get_stream_url(&self, song_id: &str) -> Result { + self.build_url("stream", &[("id", song_id)]) + } + + /// Generate cover art URL with optional max size + pub fn get_cover_art_url(&self, cover_art_id: &str, size: Option) -> Result { + let mut params = vec![("id", cover_art_id)]; + let size_str = size.map(|s| s.to_string()); + if let Some(ref s) = size_str { + params.push(("size", s.as_str())); + } + self.build_url("getCoverArt", ¶ms) + } + + /// Download song bytes directly (for buffering / local playback) + pub async fn download_stream(&self, song_id: &str) -> Result { + let url = self.get_stream_url(song_id)?; + let resp = self.http.get(url).send().await?.error_for_status()?; + + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_lowercase(); + + if content_type.contains("json") || content_type.contains("xml") || content_type.contains("text") { + let text = resp.text().await.unwrap_or_default(); + bail!("Subsonic server returned non-audio response ({}): {}", content_type, text); + } + + let bytes = resp.bytes().await?; + if bytes.len() < 512 { + bail!("Downloaded audio payload is too short ({} bytes)", bytes.len()); + } + + Ok(bytes) + } + + /// Download cover art image bytes + pub async fn download_cover_art(&self, cover_art_id: &str, size: Option) -> Result { + let url = self.get_cover_art_url(cover_art_id, size)?; + let bytes = self.http.get(url).send().await?.error_for_status()?.bytes().await?; + Ok(bytes) + } + + /// Scrobble song playback to Navidrome / Last.fm + pub async fn scrobble(&self, song_id: &str, submission: bool) -> Result<()> { + let sub_str = if submission { "true" } else { "false" }; + self.get_json("scrobble", &[("id", song_id), ("submission", sub_str)]).await?; + Ok(()) + } + + /// Mark song/album/artist as favorite (Star) + pub async fn star(&self, song_id: &str) -> Result<()> { + self.get_json("star", &[("id", song_id)]).await?; + Ok(()) + } + + /// Unmark song/album/artist as favorite (Unstar) + pub async fn unstar(&self, song_id: &str) -> Result<()> { + self.get_json("unstar", &[("id", song_id)]).await?; + Ok(()) + } + + /// Fetch lyrics for a song (by ID or artist+title) + pub async fn get_lyrics(&self, song_id: &str) -> Result> { + if let Ok(resp) = self.get_json("getLyricsBySongId", &[("id", song_id)]).await { + if let Some(list) = resp.response.lyrics_list { + if let Some(first) = list.structured_lyrics.first() { + let lines: Vec = first.line.iter().map(|l| l.value.clone()).collect(); + if !lines.is_empty() { + return Ok(Some(lines.join("\n"))); + } + } + } + } + + if let Ok(resp) = self.get_json("getLyrics", &[("id", song_id)]).await { + if let Some(lyrics) = resp.response.lyrics { + if let Some(val) = lyrics.value { + return Ok(Some(val)); + } + } + } + + Ok(None) + } +} diff --git a/src/subsonic/mod.rs b/src/subsonic/mod.rs new file mode 100644 index 0000000..ceb0832 --- /dev/null +++ b/src/subsonic/mod.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod models; + +pub use client::SubsonicClient; +#[allow(unused_imports)] +pub use models::*; diff --git a/src/subsonic/models.rs b/src/subsonic/models.rs new file mode 100644 index 0000000..3284cad --- /dev/null +++ b/src/subsonic/models.rs @@ -0,0 +1,192 @@ +use serde::{Deserialize, Serialize}; + +/// Generic Subsonic API response wrapper +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubsonicResponse { + #[serde(rename = "subsonic-response")] + pub response: SubsonicResponseBody, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubsonicResponseBody { + pub status: ResponseStatus, + pub version: String, + #[serde(rename = "type")] + pub server_type: Option, + #[serde(rename = "serverVersion")] + pub server_version: Option, + #[serde(rename = "openSubsonic")] + pub open_subsonic: Option, + + pub error: Option, + + // Endpoints payloads + pub artists: Option, + pub artist: Option, + pub album: Option, + pub song: Option, + pub lyrics: Option, + #[serde(rename = "lyricsList")] + pub lyrics_list: Option, + #[serde(rename = "searchResult3")] + pub search_result3: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponseStatus { + Ok, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubsonicError { + pub code: i32, + pub message: String, +} + +impl std::fmt::Display for SubsonicError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Subsonic Error [{}]: {}", self.code, self.message) + } +} + +impl std::error::Error for SubsonicError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtistsID3 { + #[serde(default)] + pub index: Vec, + #[serde(rename = "ignoredArticles")] + pub ignored_articles: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtistIndex { + pub name: String, + #[serde(default)] + pub artist: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtistID3 { + pub id: String, + pub name: String, + #[serde(rename = "coverArt")] + pub cover_art: Option, + #[serde(rename = "albumCount")] + pub album_count: Option, + #[serde(rename = "artistImageUrl")] + pub artist_image_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtistWithAlbumsID3 { + pub id: String, + pub name: String, + #[serde(rename = "coverArt")] + pub cover_art: Option, + #[serde(rename = "albumCount")] + pub album_count: Option, + #[serde(default)] + pub album: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlbumID3 { + pub id: String, + pub name: String, + pub artist: Option, + #[serde(rename = "artistId")] + pub artist_id: Option, + #[serde(rename = "coverArt")] + pub cover_art: Option, + #[serde(rename = "songCount")] + pub song_count: Option, + pub duration: Option, + pub year: Option, + pub genre: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlbumWithSongsID3 { + pub id: String, + pub name: String, + pub artist: Option, + #[serde(rename = "artistId")] + pub artist_id: Option, + #[serde(rename = "coverArt")] + pub cover_art: Option, + #[serde(rename = "songCount")] + pub song_count: Option, + pub duration: Option, + pub year: Option, + pub genre: Option, + #[serde(default)] + pub song: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChildSong { + pub id: String, + pub title: String, + pub album: Option, + #[serde(rename = "albumId")] + pub album_id: Option, + pub artist: Option, + #[serde(rename = "artistId")] + pub artist_id: Option, + pub track: Option, + pub year: Option, + pub genre: Option, + pub size: Option, + #[serde(rename = "contentType")] + pub content_type: Option, + pub suffix: Option, + pub duration: Option, + #[serde(rename = "bitRate")] + pub bit_rate: Option, + pub path: Option, + #[serde(rename = "discNumber")] + pub disc_number: Option, + #[serde(rename = "coverArt")] + pub cover_art: Option, + pub starred: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult3 { + #[serde(default)] + pub artist: Vec, + #[serde(default)] + pub album: Vec, + #[serde(default)] + pub song: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LyricsData { + pub artist: Option, + pub title: Option, + pub value: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LyricsList { + #[serde(rename = "structuredLyrics", default)] + pub structured_lyrics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StructuredLyrics { + pub offset: Option, + pub synced: Option, + #[serde(default)] + pub line: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LyricsLine { + pub start: Option, + pub value: String, +} diff --git a/src/ui/cover.rs b/src/ui/cover.rs new file mode 100644 index 0000000..c1d1b06 --- /dev/null +++ b/src/ui/cover.rs @@ -0,0 +1,43 @@ +use bytes::Bytes; +use image::imageops::FilterType; +use image::{GenericImageView, Rgba}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; + +/// Convert image bytes (JPEG / PNG) into Unicode Half-block terminal spans +pub fn render_cover_thumbnail(bytes: &Bytes, width: u32, height: u32) -> Option>> { + let img = image::load_from_memory(bytes).ok()?; + + // Terminal cells are approx 1:2 aspect ratio, so height in cells corresponds to 2x pixel rows + let pixel_height = height * 2; + let resized = img.resize_exact(width, pixel_height, FilterType::Triangle); + + let mut lines = Vec::with_capacity(height as usize); + + for y in (0..pixel_height).step_by(2) { + let mut spans = Vec::with_capacity(width as usize); + + for x in 0..width { + let top_pixel: Rgba = resized.get_pixel(x, y); + let bottom_pixel: Rgba = if y + 1 < pixel_height { + resized.get_pixel(x, y + 1) + } else { + top_pixel + }; + + let top_color = Color::Rgb(top_pixel[0], top_pixel[1], top_pixel[2]); + let bottom_color = Color::Rgb(bottom_pixel[0], bottom_pixel[1], bottom_pixel[2]); + + // '▀' uses foreground for upper half and background for lower half + let span = Span::styled( + "▀", + Style::default().fg(top_color).bg(bottom_color), + ); + spans.push(span); + } + + lines.push(Line::from(spans)); + } + + Some(lines) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..44d07e2 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,52 @@ +pub mod cover; +pub mod theme; +pub mod visualizer; +pub mod widgets; + +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::Frame; + +use crate::app::{App, AppMode}; + +pub fn render(app: &mut App, frame: &mut Frame) { + let size = frame.area(); + + match app.mode { + AppMode::Setup => { + widgets::settings::render(app, frame, size, true); + } + AppMode::Main | AppMode::Settings => { + // Main layout: Header (top), Main content (middle), Player Bar (bottom) + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), // Header + Constraint::Min(8), // Library (Artists, Albums, Tracks) + Constraint::Length(4), // Player Bar + ]) + .split(size); + + // 1. Render Header + widgets::header::render(app, frame, chunks[0]); + + // 2. Render Library Panes + widgets::library::render(app, frame, chunks[1]); + + // 3. Render Player Bar + widgets::player_bar::render(app, frame, chunks[2]); + + // 4. Render Modals + if app.mode == AppMode::Settings { + widgets::settings::render(app, frame, size, false); + } else if app.show_lyrics { + widgets::modals::render_lyrics(app, frame, size); + } else if app.show_queue { + widgets::modals::render_queue(app, frame, size); + } else if app.show_help { + widgets::modals::render_help(app, frame, size); + } else if app.show_search { + widgets::modals::render_search(app, frame, size); + } + } + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..be4eca2 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,173 @@ +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::BorderType; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemeKind { + CatppuccinMocha, + TokyoNight, + Nord, + Gruvbox, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct Theme { + pub kind: ThemeKind, + pub bg: Color, + pub surface: Color, + pub border: Color, + pub border_focused: Color, + pub primary: Color, + pub secondary: Color, + pub accent: Color, + pub text: Color, + pub text_muted: Color, + pub highlight_bg: Color, + pub highlight_fg: Color, + pub progress_fill: Color, + pub progress_empty: Color, + pub visualizer_color: Color, + pub success: Color, + pub error: Color, + pub border_type: BorderType, +} + +impl Theme { + pub fn catppuccin_mocha() -> Self { + Self { + kind: ThemeKind::CatppuccinMocha, + bg: Color::Rgb(30, 30, 46), + surface: Color::Rgb(24, 24, 37), + border: Color::Rgb(88, 91, 112), + border_focused: Color::Rgb(203, 166, 247), + primary: Color::Rgb(203, 166, 247), + secondary: Color::Rgb(137, 180, 250), + accent: Color::Rgb(245, 194, 231), + text: Color::Rgb(205, 214, 244), + text_muted: Color::Rgb(166, 173, 200), + highlight_bg: Color::Rgb(69, 71, 90), + highlight_fg: Color::Rgb(245, 224, 220), + progress_fill: Color::Rgb(166, 227, 161), + progress_empty: Color::Rgb(49, 50, 68), + visualizer_color: Color::Rgb(137, 180, 250), + success: Color::Rgb(166, 227, 161), + error: Color::Rgb(243, 139, 168), + border_type: BorderType::Rounded, + } + } + + pub fn tokyo_night() -> Self { + Self { + kind: ThemeKind::TokyoNight, + bg: Color::Rgb(26, 27, 38), + surface: Color::Rgb(22, 22, 30), + border: Color::Rgb(65, 72, 104), + border_focused: Color::Rgb(122, 162, 247), + primary: Color::Rgb(122, 162, 247), + secondary: Color::Rgb(187, 154, 247), + accent: Color::Rgb(224, 175, 104), + text: Color::Rgb(192, 202, 245), + text_muted: Color::Rgb(120, 124, 153), + highlight_bg: Color::Rgb(41, 46, 66), + highlight_fg: Color::Rgb(255, 255, 255), + progress_fill: Color::Rgb(158, 206, 106), + progress_empty: Color::Rgb(36, 40, 59), + visualizer_color: Color::Rgb(224, 175, 104), + success: Color::Rgb(158, 206, 106), + error: Color::Rgb(247, 118, 142), + border_type: BorderType::Rounded, + } + } + + pub fn nord() -> Self { + Self { + kind: ThemeKind::Nord, + bg: Color::Rgb(46, 52, 64), + surface: Color::Rgb(36, 41, 51), + border: Color::Rgb(76, 86, 106), + border_focused: Color::Rgb(136, 192, 208), + primary: Color::Rgb(136, 192, 208), + secondary: Color::Rgb(129, 161, 193), + accent: Color::Rgb(235, 203, 139), + text: Color::Rgb(236, 239, 244), + text_muted: Color::Rgb(147, 158, 179), + highlight_bg: Color::Rgb(59, 66, 82), + highlight_fg: Color::Rgb(236, 239, 244), + progress_fill: Color::Rgb(163, 190, 140), + progress_empty: Color::Rgb(59, 66, 82), + visualizer_color: Color::Rgb(136, 192, 208), + success: Color::Rgb(163, 190, 140), + error: Color::Rgb(191, 97, 106), + border_type: BorderType::Rounded, + } + } + + pub fn gruvbox() -> Self { + Self { + kind: ThemeKind::Gruvbox, + bg: Color::Rgb(40, 40, 40), + surface: Color::Rgb(29, 32, 33), + border: Color::Rgb(102, 92, 84), + border_focused: Color::Rgb(250, 189, 47), + primary: Color::Rgb(250, 189, 47), + secondary: Color::Rgb(131, 165, 152), + accent: Color::Rgb(211, 134, 155), + text: Color::Rgb(235, 219, 178), + text_muted: Color::Rgb(168, 153, 132), + highlight_bg: Color::Rgb(60, 56, 54), + highlight_fg: Color::Rgb(251, 241, 199), + progress_fill: Color::Rgb(184, 187, 38), + progress_empty: Color::Rgb(50, 48, 47), + visualizer_color: Color::Rgb(254, 128, 25), + success: Color::Rgb(184, 187, 38), + error: Color::Rgb(251, 73, 52), + border_type: BorderType::Rounded, + } + } + + pub fn cycle_next(&self) -> Self { + match self.kind { + ThemeKind::CatppuccinMocha => Self::tokyo_night(), + ThemeKind::TokyoNight => Self::nord(), + ThemeKind::Nord => Self::gruvbox(), + ThemeKind::Gruvbox => Self::catppuccin_mocha(), + } + } + + pub fn name(&self) -> &'static str { + match self.kind { + ThemeKind::CatppuccinMocha => "Catppuccin Mocha", + ThemeKind::TokyoNight => "Tokyo Night", + ThemeKind::Nord => "Nord", + ThemeKind::Gruvbox => "Gruvbox Dark", + } + } + + // Helper style constructors + pub fn title_style(&self) -> Style { + Style::default() + .fg(self.primary) + .add_modifier(Modifier::BOLD) + } + + pub fn focused_border_style(&self) -> Style { + Style::default().fg(self.border_focused) + } + + pub fn normal_border_style(&self) -> Style { + Style::default().fg(self.border) + } + + pub fn item_selected_style(&self) -> Style { + Style::default() + .bg(self.highlight_bg) + .fg(self.highlight_fg) + .add_modifier(Modifier::BOLD) + } +} + +impl Default for Theme { + fn default() -> Self { + Self::catppuccin_mocha() + } +} diff --git a/src/ui/visualizer.rs b/src/ui/visualizer.rs new file mode 100644 index 0000000..6ea5186 --- /dev/null +++ b/src/ui/visualizer.rs @@ -0,0 +1,127 @@ +use rand::Rng; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; + +// 8 levels of block height starting from baseline block ' ' (never blank space!) +const BLOCKS: [char; 8] = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + +pub struct Visualizer { + pub num_bars: usize, + bars: Vec, + velocities: Vec, + peaks: Vec, + peak_hold: Vec, + phase: f32, + beat_pulse: f32, +} + +impl Visualizer { + pub fn new(num_bars: usize) -> Self { + Self { + num_bars, + bars: vec![0.15; num_bars], + velocities: vec![0.0; num_bars], + peaks: vec![0.15; num_bars], + peak_hold: vec![0; num_bars], + phase: 0.0, + beat_pulse: 0.0, + } + } + + pub fn tick(&mut self, is_playing: bool) { + self.phase += 0.12; + + if is_playing { + self.beat_pulse = (self.phase * 2.2).sin().max(0.0).powi(3) * 0.4; + let mut rng = rand::thread_rng(); + + for i in 0..self.num_bars { + let norm_i = i as f32 / self.num_bars as f32; + + // Frequency profile: heavy bass, expressive mids, crystalline highs + let bass_boost = (1.0 - norm_i * 0.75).max(0.25); + let wave1 = (self.phase * 1.6 + (i as f32 * 0.45)).sin() * 0.35 + 0.45; + let wave2 = (self.phase * 2.8 - (i as f32 * 0.7)).cos() * 0.25 + 0.25; + let wave3 = (self.phase * 4.4 + (i as f32 * 1.1)).sin() * 0.18 + 0.18; + let noise: f32 = rng.gen_range(0.0..0.2); + + let beat = if i < 8 { self.beat_pulse } else { self.beat_pulse * 0.25 }; + let target = ((wave1 + wave2 + wave3 + noise + beat) * bass_boost).clamp(0.12, 1.0); + + // Liquid spring physics + let diff = target - self.bars[i]; + self.velocities[i] = self.velocities[i] * 0.72 + diff * 0.28; + self.bars[i] = (self.bars[i] + self.velocities[i]).clamp(0.05, 1.0); + + // Peak hold & gravity drop + if self.bars[i] >= self.peaks[i] { + self.peaks[i] = self.bars[i]; + self.peak_hold[i] = 10; + } else if self.peak_hold[i] > 0 { + self.peak_hold[i] -= 1; + } else { + self.peaks[i] = (self.peaks[i] - 0.03).max(0.1); + } + } + } else { + // Idle ambient breathing animation + for i in 0..self.num_bars { + let idle_wave = ((self.phase * 0.6 + i as f32 * 0.3).sin() * 0.12 + 0.16).clamp(0.05, 0.35); + self.bars[i] = self.bars[i] * 0.9 + idle_wave * 0.1; + self.peaks[i] = self.bars[i]; + self.velocities[i] = 0.0; + } + } + } + + /// Render visualizer as colored spans with frequency gradient + pub fn render_colored_spans(&self, max_width: usize) -> Vec> { + let count = self.num_bars.min(max_width); + let mut spans = Vec::with_capacity(count); + + for i in 0..count { + let val = self.bars.get(i).copied().unwrap_or(0.1); + let idx = ((val * (BLOCKS.len() - 1) as f32).round() as usize).min(BLOCKS.len() - 1); + let ch = BLOCKS[idx]; + + let ratio = i as f32 / count.max(1) as f32; + let color = frequency_gradient_color(ratio); + + let style = if val > 0.7 { + Style::default().fg(color).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(color) + }; + + spans.push(Span::styled(ch.to_string(), style)); + } + + spans + } +} + +/// Dynamic gradient across frequencies: +/// Bass (Pink/Mauve) -> Low-Mids (Blue/Sapphire) -> High-Mids (Mint/Green) -> Treble (Gold/Peach) +fn frequency_gradient_color(ratio: f32) -> Color { + if ratio < 0.25 { + let t = ratio / 0.25; + interpolate_rgb((245, 194, 231), (203, 166, 247), t) + } else if ratio < 0.55 { + let t = (ratio - 0.25) / 0.30; + interpolate_rgb((203, 166, 247), (116, 199, 236), t) + } else if ratio < 0.80 { + let t = (ratio - 0.55) / 0.25; + interpolate_rgb((116, 199, 236), (166, 227, 161), t) + } else { + let t = (ratio - 0.80) / 0.20; + interpolate_rgb((166, 227, 161), (250, 179, 135), t) + } +} + +fn interpolate_rgb(from: (u8, u8, u8), to: (u8, u8, u8), t: f32) -> Color { + let t = t.clamp(0.0, 1.0); + let r = (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t).round() as u8; + let g = (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t).round() as u8; + let b = (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t).round() as u8; + Color::Rgb(r, g, b) +} diff --git a/src/ui/widgets/header.rs b/src/ui/widgets/header.rs new file mode 100644 index 0000000..9980df2 --- /dev/null +++ b/src/ui/widgets/header.rs @@ -0,0 +1,57 @@ +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::app::App; + +pub fn render(app: &mut App, frame: &mut Frame, area: Rect) { + let theme = &app.theme; + + // Cache header button hitboxes (estimated coordinates on the right side) + let right_width = 46.min(area.width); + let right_start = area.x + area.width.saturating_sub(right_width); + let y = area.y; + + app.layout.btn_settings = Rect::new(right_start, y, 13, 1); + app.layout.btn_help = Rect::new(right_start + 13, y, 9, 1); + app.layout.btn_search = Rect::new(right_start + 22, y, 10, 1); + app.layout.btn_theme = Rect::new(right_start + 32, y, 9, 1); + app.layout.btn_quit = Rect::new(right_start + 41, y, 8, 1); + + let left_spans = vec![ + Span::styled(" 🎵 rhyth-rs ", Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)), + Span::styled("│ ", Style::default().fg(theme.border)), + Span::styled(format!("Theme: {} ", theme.name()), Style::default().fg(theme.text_muted)), + ]; + + let right_spans = if let Some((msg, _)) = &app.toast { + vec![ + Span::styled(format!("🔔 {msg} "), Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)), + ] + } else if app.is_loading { + vec![ + Span::styled(format!("⏳ {} ", app.loading_msg), Style::default().fg(theme.secondary)), + ] + } else { + vec![ + Span::styled("[c] Settings ", Style::default().fg(theme.text_muted)), + Span::styled("[?] Help ", Style::default().fg(theme.text_muted)), + Span::styled("[/] Search ", Style::default().fg(theme.text_muted)), + Span::styled("[t] Theme ", Style::default().fg(theme.text_muted)), + Span::styled("[q] Quit ", Style::default().fg(theme.text_muted)), + ] + }; + + let line = Line::from(left_spans); + let right_line = Line::from(right_spans); + + let block = Block::default() + .borders(Borders::BOTTOM) + .border_type(theme.border_type) + .border_style(Style::default().fg(theme.border)); + + frame.render_widget(Paragraph::new(line).block(block.clone()), area); + frame.render_widget(Paragraph::new(right_line).alignment(Alignment::Right), area); +} diff --git a/src/ui/widgets/library.rs b/src/ui/widgets/library.rs new file mode 100644 index 0000000..2141033 --- /dev/null +++ b/src/ui/widgets/library.rs @@ -0,0 +1,168 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem}; +use ratatui::Frame; + +use crate::app::{App, FocusPane}; + +pub fn render(app: &mut App, frame: &mut Frame, area: Rect) { + let theme = &app.theme; + + // Split horizontally: Left (Artists), Right (Albums + Tracks) + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) + .split(area); + + // Split right column vertically: Top (Albums), Bottom (Tracks) + let right_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(chunks[1]); + + // Cache hitboxes for mouse interaction + app.layout.artists_pane = chunks[0]; + app.layout.albums_pane = right_chunks[0]; + app.layout.tracks_pane = right_chunks[1]; + + // 1. Artists List + let is_artists_focused = app.focus == FocusPane::Artists; + let artists_border_style = if is_artists_focused { + theme.focused_border_style() + } else { + theme.normal_border_style() + }; + + let artist_items: Vec = app + .artists + .iter() + .map(|artist| { + let count = artist.album_count.unwrap_or(0); + let line = Line::from(vec![ + Span::styled(" 🎤 ", Style::default().fg(theme.primary)), + Span::styled(&artist.name, Style::default().fg(theme.text)), + Span::styled(format!(" ({count})"), Style::default().fg(theme.text_muted)), + ]); + ListItem::new(line) + }) + .collect(); + + let artists_title = format!(" Artists [{}] ", app.artists.len()); + let artists_block = Block::default() + .borders(Borders::ALL) + .border_type(theme.border_type) + .border_style(artists_border_style) + .title(Span::styled(artists_title, if is_artists_focused { theme.title_style() } else { Style::default().fg(theme.text_muted) })); + + let artists_list = List::new(artist_items) + .block(artists_block) + .highlight_style(theme.item_selected_style()) + .highlight_symbol("▶ "); + + frame.render_stateful_widget(artists_list, chunks[0], &mut app.artists_state); + + // 2. Albums List + let is_albums_focused = app.focus == FocusPane::Albums; + let albums_border_style = if is_albums_focused { + theme.focused_border_style() + } else { + theme.normal_border_style() + }; + + let album_items: Vec = app + .albums + .iter() + .map(|album| { + let year_str = album.year.map(|y| format!(" ({y})")).unwrap_or_default(); + let tracks_count = album.song_count.map(|c| format!(" [{c} tracks]")).unwrap_or_default(); + + let line = Line::from(vec![ + Span::styled(" 💿 ", Style::default().fg(theme.secondary)), + Span::styled(&album.name, Style::default().fg(theme.text)), + Span::styled(year_str, Style::default().fg(theme.accent)), + Span::styled(tracks_count, Style::default().fg(theme.text_muted)), + ]); + ListItem::new(line) + }) + .collect(); + + let albums_title = format!(" Albums [{}] ", app.albums.len()); + let albums_block = Block::default() + .borders(Borders::ALL) + .border_type(theme.border_type) + .border_style(albums_border_style) + .title(Span::styled(albums_title, if is_albums_focused { theme.title_style() } else { Style::default().fg(theme.text_muted) })); + + let albums_list = List::new(album_items) + .block(albums_block) + .highlight_style(theme.item_selected_style()) + .highlight_symbol("▶ "); + + frame.render_stateful_widget(albums_list, right_chunks[0], &mut app.albums_state); + + // 3. Tracks List + let is_tracks_focused = app.focus == FocusPane::Tracks; + let tracks_border_style = if is_tracks_focused { + theme.focused_border_style() + } else { + theme.normal_border_style() + }; + + let progress = app.player.get_progress(); + let current_playing_id = progress.current_track.as_ref().map(|t| &t.id); + + let track_items: Vec = app + .tracks + .iter() + .enumerate() + .map(|(idx, track)| { + let is_now_playing = current_playing_id.map_or(false, |id| id == &track.id); + let track_num = track.track.unwrap_or((idx + 1) as u32); + let duration_str = track + .duration + .map(|d| format!("{:02}:{:02}", d / 60, d % 60)) + .unwrap_or_else(|| "--:--".to_string()); + + let play_icon = if is_now_playing { + Span::styled(" 🔊 ", Style::default().fg(theme.success).add_modifier(Modifier::BOLD)) + } else { + Span::styled(format!(" {:02}. ", track_num), Style::default().fg(theme.text_muted)) + }; + + let star_icon = if track.starred.is_some() { + Span::styled(" ❤️ ", Style::default().fg(theme.accent)) + } else { + Span::raw("") + }; + + let title_style = if is_now_playing { + Style::default().fg(theme.success).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text) + }; + + let line = Line::from(vec![ + play_icon, + star_icon, + Span::styled(&track.title, title_style), + Span::styled(format!(" [{duration_str}]"), Style::default().fg(theme.text_muted)), + ]); + ListItem::new(line) + }) + .collect(); + + let tracks_title = format!(" Tracks [{}] ", app.tracks.len()); + let tracks_block = Block::default() + .borders(Borders::ALL) + .border_type(theme.border_type) + .border_style(tracks_border_style) + .title(Span::styled(tracks_title, if is_tracks_focused { theme.title_style() } else { Style::default().fg(theme.text_muted) })); + + let tracks_list = List::new(track_items) + .block(tracks_block) + .highlight_style(theme.item_selected_style()) + .highlight_symbol("▶ "); + + frame.render_stateful_widget(tracks_list, right_chunks[1], &mut app.tracks_state); +} diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs new file mode 100644 index 0000000..fc9ba9a --- /dev/null +++ b/src/ui/widgets/mod.rs @@ -0,0 +1,5 @@ +pub mod header; +pub mod library; +pub mod modals; +pub mod player_bar; +pub mod settings; diff --git a/src/ui/widgets/modals.rs b/src/ui/widgets/modals.rs new file mode 100644 index 0000000..bbb3232 --- /dev/null +++ b/src/ui/widgets/modals.rs @@ -0,0 +1,186 @@ +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +use crate::app::App; + +/// Helper function to create a centered popup rect +pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +pub fn render_help(app: &App, frame: &mut Frame, area: Rect) { + let theme = &app.theme; + let popup_area = centered_rect(60, 60, area); + + let keybinds = vec![ + ("j / Down", "Move down in list"), + ("k / Up", "Move up in list"), + ("h / l / Tab", "Switch pane (Artists / Albums / Tracks)"), + ("Enter", "Play selected track / Open folder"), + ("Space", "Play / Pause toggle"), + ("n / p", "Next / Previous track in queue"), + ("s", "Toggle Shuffle mode"), + ("r", "Toggle Repeat mode (Off / Track / All)"), + ("l", "Star / Favorite track (Like)"), + ("y", "Toggle Lyrics (Text/Synced)"), + ("u", "Toggle Queue view"), + ("+ / = / ]", "Increase volume (+5%)"), + ("- / _ / [", "Decrease volume (-5%)"), + ("t", "Cycle color theme (Catppuccin, Tokyo Night, Nord, Gruvbox)"), + ("c / ,", "Open Settings / Navidrome connection dialog"), + ("/", "Open Search dialog"), + ("?", "Toggle this Help popup"), + ("q / Esc", "Quit application / Close popup"), + ]; + + let mut lines = vec![ + Line::from(Span::styled(" Keyboard Shortcuts ", theme.title_style())), + Line::from(""), + ]; + + for (key, desc) in keybinds { + lines.push(Line::from(vec![ + Span::styled(format!(" {:<16}", key), Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {}", desc), Style::default().fg(theme.text)), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(" Press [Esc] or [?] to close ", Style::default().fg(theme.text_muted)))); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(theme.border_type) + .border_style(Style::default().fg(theme.border_focused)) + .title(Span::styled(" Help & Shortcuts ", theme.title_style())) + .title_alignment(Alignment::Center); + + frame.render_widget(Clear, popup_area); // clear background + frame.render_widget(Paragraph::new(lines).block(block), popup_area); +} + +pub fn render_search(app: &App, frame: &mut Frame, area: Rect) { + let theme = &app.theme; + let popup_area = centered_rect(50, 20, area); + + let lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" > ", Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)), + Span::styled(&app.search_input, Style::default().fg(theme.text).add_modifier(Modifier::BOLD)), + Span::styled("█", Style::default().fg(theme.accent)), + ]), + Line::from(""), + Line::from(Span::styled(" [Enter] Search [Esc] Cancel ", Style::default().fg(theme.text_muted))), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .border_type(theme.border_type) + .border_style(Style::default().fg(theme.accent)) + .title(Span::styled(" 🔍 Search Navidrome Library ", theme.title_style())) + .title_alignment(Alignment::Center); + + frame.render_widget(Clear, popup_area); + frame.render_widget(Paragraph::new(lines).block(block), popup_area); +} + +pub fn render_lyrics(app: &App, frame: &mut Frame, area: Rect) { + let theme = &app.theme; + let popup_area = centered_rect(65, 70, area); + + let title = if let Some(ref track) = app.player.get_progress().current_track { + format!(" 🎤 Lyrics: {} — {} ", track.artist, track.title) + } else { + " 🎤 Lyrics ".to_string() + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_type(theme.border_type) + .border_style(Style::default().fg(theme.primary)) + .title(Span::styled(title, theme.title_style())) + .title_alignment(Alignment::Center); + + let lyrics_text = if let Some(ref lyrics) = app.current_lyrics { + lyrics.lines().map(|l| Line::from(Span::styled(l, Style::default().fg(theme.text)))).collect() + } else if app.is_loading { + vec![ + Line::from(""), + Line::from(Span::styled(" ⏳ Loading lyrics from Navidrome... ", Style::default().fg(theme.secondary))), + ] + } else { + vec![ + Line::from(""), + Line::from(Span::styled(" No lyrics found for this track. ", Style::default().fg(theme.text_muted))), + ] + }; + + frame.render_widget(Clear, popup_area); + frame.render_widget(Paragraph::new(lyrics_text).block(block).alignment(Alignment::Center), popup_area); +} + +pub fn render_queue(app: &App, frame: &mut Frame, area: Rect) { + let theme = &app.theme; + let popup_area = centered_rect(70, 65, area); + + let title = format!(" 📜 Playback Queue [{}] ", app.queue.len()); + let block = Block::default() + .borders(Borders::ALL) + .border_type(theme.border_type) + .border_style(Style::default().fg(theme.secondary)) + .title(Span::styled(title, theme.title_style())) + .title_alignment(Alignment::Center); + + let mut lines = Vec::new(); + for (idx, song) in app.queue.iter().enumerate() { + let is_current = idx == app.queue_index; + let prefix = if is_current { + Span::styled(" ▶ 🔊 ", Style::default().fg(theme.success).add_modifier(Modifier::BOLD)) + } else { + Span::styled(format!(" {:02}. ", idx + 1), Style::default().fg(theme.text_muted)) + }; + + let style = if is_current { + Style::default().fg(theme.success).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text) + }; + + let artist_str = song.artist.as_deref().unwrap_or("Unknown"); + let dur = song.duration.map(|d| format!("{:02}:{:02}", d / 60, d % 60)).unwrap_or_default(); + + lines.push(Line::from(vec![ + prefix, + Span::styled(&song.title, style), + Span::styled(format!(" — {artist_str} "), Style::default().fg(theme.text_muted)), + Span::styled(format!(" [{dur}]"), Style::default().fg(theme.accent)), + ])); + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled(" Queue is empty. Select an album or search tracks to fill queue. ", Style::default().fg(theme.text_muted)))); + } + + frame.render_widget(Clear, popup_area); + frame.render_widget(Paragraph::new(lines).block(block), popup_area); +} diff --git a/src/ui/widgets/player_bar.rs b/src/ui/widgets/player_bar.rs new file mode 100644 index 0000000..1635c43 --- /dev/null +++ b/src/ui/widgets/player_bar.rs @@ -0,0 +1,180 @@ +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Gauge, Paragraph}; +use ratatui::Frame; + +use crate::app::App; +use crate::audio::PlaybackState; + +pub fn render(app: &mut App, frame: &mut Frame, area: Rect) { + let theme = &app.theme; + let progress = app.player.get_progress(); + + app.layout.player_bar = area; + + let block = Block::default() + .borders(Borders::ALL) + .border_type(theme.border_type) + .border_style(Style::default().fg(theme.primary)) + .title(Span::styled(" 🎵 rhyth-rs v0.1.0-beta ", theme.title_style())); + + let inner_area = block.inner(area); + frame.render_widget(block, area); + + // 2 rows: + // Row 0: Track Info (Left), Controls & Visualizer (Center), Volume & Tools (Right) + // Row 1: Interactive Progress Bar + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)]) + .split(inner_area); + + let top_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(36), // Track Info & Marquee + Constraint::Percentage(44), // Controls + Spectrum + Constraint::Percentage(20), // Volume & Star/Lyrics + ]) + .split(rows[0]); + + // 1. Controls on center left: [⏮] [▶ / ⏸] [⏭] [🔀] [🔁] + let ctrl_x = top_cols[1].x; + let ctrl_y = top_cols[1].y; + + app.layout.btn_prev = Rect::new(ctrl_x, ctrl_y, 4, 1); + app.layout.play_pause_btn = Rect::new(ctrl_x + 4, ctrl_y, 4, 1); + app.layout.btn_next = Rect::new(ctrl_x + 8, ctrl_y, 4, 1); + app.layout.btn_shuffle = Rect::new(ctrl_x + 13, ctrl_y, 4, 1); + app.layout.btn_repeat = Rect::new(ctrl_x + 17, ctrl_y, 4, 1); + + // 2. Track Info with Marquee + let is_starred = progress + .current_track + .as_ref() + .map(|t| app.tracks.iter().find(|s| s.id == t.id).and_then(|s| s.starred.as_ref()).is_some()) + .unwrap_or(false); + + let star_badge = if is_starred { + Span::styled(" ❤️ ", Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)) + } else { + Span::styled(" 🤍 ", Style::default().fg(theme.text_muted)) + }; + + let track_info_spans = if let Some(ref track) = progress.current_track { + let full_text = format!("{} • {}", track.title, track.artist); + let max_len = (top_cols[0].width.saturating_sub(6)) as usize; + let displayed = marquee_text(&full_text, max_len, app.marquee_tick / 4); + + vec![ + star_badge, + Span::styled(displayed, Style::default().fg(theme.text).add_modifier(Modifier::BOLD)), + ] + } else { + vec![ + star_badge, + Span::styled("No track playing (Enter to play)", Style::default().fg(theme.text_muted)), + ] + }; + frame.render_widget(Paragraph::new(Line::from(track_info_spans)), top_cols[0]); + + // 3. Playback Controls + Visualizer + let play_icon = match progress.state { + PlaybackState::Playing => "⏸", + PlaybackState::Paused => "▶", + PlaybackState::Stopped => "▶", + }; + + let shuffle_style = if app.shuffle { + Style::default().fg(theme.success).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text_muted) + }; + + let repeat_icon = match app.repeat_mode { + crate::app::RepeatMode::Off => "🔁", + crate::app::RepeatMode::All => "🔁", + crate::app::RepeatMode::Track => "🔂", + }; + let repeat_style = if app.repeat_mode != crate::app::RepeatMode::Off { + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text_muted) + }; + + let mut center_spans = vec![ + Span::styled("⏮ ", Style::default().fg(theme.primary)), + Span::styled(format!("{play_icon} "), Style::default().fg(theme.highlight_fg).add_modifier(Modifier::BOLD)), + Span::styled("⏭ ", Style::default().fg(theme.primary)), + Span::styled("│ ", Style::default().fg(theme.border)), + Span::styled("🔀 ", shuffle_style), + Span::styled(format!("{repeat_icon} "), repeat_style), + Span::styled("│ ", Style::default().fg(theme.border)), + ]; + + let used_center_width = 22; + let vis_width = (top_cols[1].width as usize).saturating_sub(used_center_width); + if app.config.enable_visualizer && vis_width > 4 { + let mut vis_spans = app.visualizer.render_colored_spans(vis_width); + center_spans.append(&mut vis_spans); + } + frame.render_widget(Paragraph::new(Line::from(center_spans)), top_cols[1]); + + // 4. Right side: Volume & Quick Modals ([y] Lyrics, [u] Queue) + let vol_percent = (progress.volume * 100.0).round() as u32; + let vol_icon = if vol_percent == 0 { "🔇" } else if vol_percent < 50 { "🔉" } else { "🔊" }; + + let right_spans = vec![ + Span::styled("[y] Lyrics ", Style::default().fg(theme.secondary)), + Span::styled("[u] Queue ", Style::default().fg(theme.primary)), + Span::styled(format!("{vol_icon} {vol_percent}% "), Style::default().fg(theme.text).add_modifier(Modifier::BOLD)), + ]; + + app.layout.volume_bar = top_cols[2]; + app.layout.btn_lyrics = Rect::new(top_cols[2].x, top_cols[2].y, 10, 1); + app.layout.progress_bar = rows[1]; + + frame.render_widget(Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right), top_cols[2]); + + // Row 2: Progress Bar (Clickable & Draggable Seeking) + let current_secs = progress.position.as_secs(); + let total_secs = progress.duration.map(|d| d.as_secs()).unwrap_or(0); + let ratio = if total_secs > 0 { + (current_secs as f64 / total_secs as f64).clamp(0.0, 1.0) + } else { + 0.0 + }; + + let cur_str = format!("{:02}:{:02}", current_secs / 60, current_secs % 60); + let total_str = if total_secs > 0 { + format!("{:02}:{:02}", total_secs / 60, total_secs % 60) + } else { + "--:--".to_string() + }; + + let progress_label = format!("{cur_str} ━━━●━━━ {total_str} (Click to Seek)"); + + let gauge = Gauge::default() + .gauge_style(Style::default().fg(theme.progress_fill).bg(theme.progress_empty)) + .ratio(ratio) + .label(Span::styled(progress_label, Style::default().fg(theme.highlight_fg).add_modifier(Modifier::BOLD))); + + frame.render_widget(gauge, rows[1]); +} + +fn marquee_text(text: &str, max_len: usize, offset: usize) -> String { + let char_count = text.chars().count(); + if char_count <= max_len || max_len == 0 { + text.to_string() + } else { + let full = format!("{} ••• ", text); + let chars: Vec = full.chars().collect(); + let start = offset % chars.len(); + let mut res = String::with_capacity(max_len); + for i in 0..max_len { + res.push(chars[(start + i) % chars.len()]); + } + res + } +} diff --git a/src/ui/widgets/settings.rs b/src/ui/widgets/settings.rs new file mode 100644 index 0000000..096f801 --- /dev/null +++ b/src/ui/widgets/settings.rs @@ -0,0 +1,301 @@ +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +use crate::app::App; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsField { + ServerUrl, + Username, + Password, + Theme, + Volume, + Visualizer, + SaveButton, + CancelButton, +} + +impl SettingsField { + pub fn next(&self) -> Self { + match self { + SettingsField::ServerUrl => SettingsField::Username, + SettingsField::Username => SettingsField::Password, + SettingsField::Password => SettingsField::Theme, + SettingsField::Theme => SettingsField::Volume, + SettingsField::Volume => SettingsField::Visualizer, + SettingsField::Visualizer => SettingsField::SaveButton, + SettingsField::SaveButton => SettingsField::CancelButton, + SettingsField::CancelButton => SettingsField::ServerUrl, + } + } + + pub fn prev(&self) -> Self { + match self { + SettingsField::ServerUrl => SettingsField::CancelButton, + SettingsField::Username => SettingsField::ServerUrl, + SettingsField::Password => SettingsField::Username, + SettingsField::Theme => SettingsField::Password, + SettingsField::Volume => SettingsField::Theme, + SettingsField::Visualizer => SettingsField::Volume, + SettingsField::SaveButton => SettingsField::Visualizer, + SettingsField::CancelButton => SettingsField::SaveButton, + } + } +} + +pub struct SettingsFormData { + pub server_url: String, + pub username: String, + pub password: String, + pub theme_index: usize, + pub volume: u32, + pub enable_visualizer: bool, + pub show_password: bool, + pub focused_field: SettingsField, + pub status_message: Option<(String, bool)>, // (message, is_error) +} + +impl SettingsFormData { + pub const THEMES: [&'static str; 4] = [ + "Catppuccin Mocha", + "Tokyo Night", + "Nord", + "Gruvbox Dark", + ]; + + pub fn new(server_url: &str, username: &str, password: &str, theme_name: &str, volume: f32, visualizer: bool) -> Self { + let theme_index = Self::THEMES + .iter() + .position(|&t| t.to_lowercase().contains(&theme_name.to_lowercase())) + .unwrap_or(0); + + Self { + server_url: server_url.to_string(), + username: username.to_string(), + password: password.to_string(), + theme_index, + volume: (volume * 100.0).round() as u32, + enable_visualizer: visualizer, + show_password: false, + focused_field: SettingsField::ServerUrl, + status_message: None, + } + } + + pub fn next_theme(&mut self) { + self.theme_index = (self.theme_index + 1) % Self::THEMES.len(); + } + + pub fn prev_theme(&mut self) { + self.theme_index = if self.theme_index == 0 { + Self::THEMES.len() - 1 + } else { + self.theme_index - 1 + }; + } +} + +pub fn render(app: &mut App, frame: &mut Frame, area: Rect, is_initial_setup: bool) { + let theme = app.theme.clone(); + let form = &app.settings_form; + + // Calculate responsive centered box + let target_width = 78.min(area.width.saturating_sub(2)); + let target_height = 19.min(area.height.saturating_sub(2)); + + let x = area.x + (area.width.saturating_sub(target_width)) / 2; + let y = area.y + (area.height.saturating_sub(target_height)) / 2; + let popup_area = Rect::new(x, y, target_width, target_height); + + app.layout.modal_area = popup_area; + frame.render_widget(Clear, popup_area); + + let title = if is_initial_setup { + " 🎵 Welcome to rhyth-rs — Setup Navidrome Connection " + } else { + " ⚙️ Settings & Connection " + }; + + let outer_block = Block::default() + .borders(Borders::ALL) + .border_type(theme.border_type) + .border_style(Style::default().fg(theme.primary)) + .title(Span::styled(title, theme.title_style())) + .title_alignment(Alignment::Center); + + let inner_area = outer_block.inner(popup_area); + frame.render_widget(outer_block, popup_area); + + // Layout inside modal: Hint (1), Columns (10), Status (1), Buttons (3) + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Hint + Constraint::Length(10), // Main 2-column form + Constraint::Length(1), // Status message + Constraint::Length(3), // Action buttons + ]) + .split(inner_area); + + // 1. Hint Banner + let hint_text = Line::from(vec![ + Span::styled("[Tab/↓↑] Move [Enter] Save/Select [F2] Toggle Password [Esc] Exit", Style::default().fg(theme.text_muted)), + ]); + frame.render_widget(Paragraph::new(hint_text).alignment(Alignment::Center), chunks[0]); + + // 2. Main 2 Columns (Left: Connection, Right: Preferences) + let col_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[1]); + + // --- LEFT COLUMN: SERVER CONNECTION --- + let left_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Server URL + Constraint::Length(3), // Username + Constraint::Length(3), // Password + ]) + .split(col_chunks[0]); + + app.layout.field_url = left_rows[0]; + app.layout.field_user = left_rows[1]; + app.layout.field_pass = left_rows[2]; + + // Field 1: Server URL + let url_focused = form.focused_field == SettingsField::ServerUrl; + let url_block = field_block("Server URL", url_focused, &theme); + let url_text = Line::from(vec![ + Span::styled(&form.server_url, Style::default().fg(theme.text)), + if url_focused { Span::styled("█", Style::default().fg(theme.accent)) } else { Span::raw("") }, + ]); + frame.render_widget(Paragraph::new(url_text).block(url_block), left_rows[0]); + + // Field 2: Username + let user_focused = form.focused_field == SettingsField::Username; + let user_block = field_block("Username", user_focused, &theme); + let user_text = Line::from(vec![ + Span::styled(&form.username, Style::default().fg(theme.text)), + if user_focused { Span::styled("█", Style::default().fg(theme.accent)) } else { Span::raw("") }, + ]); + frame.render_widget(Paragraph::new(user_text).block(user_block), left_rows[1]); + + // Field 3: Password + let pass_focused = form.focused_field == SettingsField::Password; + let pass_title = if form.show_password { "Password [Visible]" } else { "Password [Masked]" }; + let pass_block = field_block(pass_title, pass_focused, &theme); + let pass_display: String = if form.show_password { + form.password.clone() + } else { + "•".repeat(form.password.len()) + }; + let pass_text = Line::from(vec![ + Span::styled(pass_display, Style::default().fg(theme.text)), + if pass_focused { Span::styled("█", Style::default().fg(theme.accent)) } else { Span::raw("") }, + ]); + frame.render_widget(Paragraph::new(pass_text).block(pass_block), left_rows[2]); + + // --- RIGHT COLUMN: APPEARANCE & AUDIO --- + let right_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Theme + Constraint::Length(3), // Volume + Constraint::Length(3), // Visualizer + ]) + .split(col_chunks[1]); + + app.layout.field_theme = right_rows[0]; + app.layout.field_vol = right_rows[1]; + app.layout.field_vis = right_rows[2]; + + // Field 4: Theme Selector + let theme_focused = form.focused_field == SettingsField::Theme; + let theme_block = field_block("Theme (← / →)", theme_focused, &theme); + let theme_name = SettingsFormData::THEMES[form.theme_index]; + let theme_text = Line::from(vec![ + Span::styled(" ◀ ", Style::default().fg(theme.primary)), + Span::styled(theme_name, Style::default().fg(theme.highlight_fg).add_modifier(Modifier::BOLD)), + Span::styled(" ▶ ", Style::default().fg(theme.primary)), + ]); + frame.render_widget(Paragraph::new(theme_text).block(theme_block), right_rows[0]); + + // Field 5: Default Volume + let vol_focused = form.focused_field == SettingsField::Volume; + let vol_block = field_block("Volume (+ / -)", vol_focused, &theme); + let vol_bars_count = (form.volume / 10).min(10) as usize; + let vol_bar = "█".repeat(vol_bars_count); + let vol_empty = "░".repeat(10 - vol_bars_count); + let vol_text = Line::from(vec![ + Span::styled(format!(" {:>3}% ", form.volume), Style::default().fg(theme.text).add_modifier(Modifier::BOLD)), + Span::styled(vol_bar, Style::default().fg(theme.progress_fill)), + Span::styled(vol_empty, Style::default().fg(theme.progress_empty)), + ]); + frame.render_widget(Paragraph::new(vol_text).block(vol_block), right_rows[1]); + + // Field 6: Visualizer Toggle + let vis_focused = form.focused_field == SettingsField::Visualizer; + let vis_block = field_block("Spectrum (Space/Enter)", vis_focused, &theme); + let (vis_badge, vis_style) = if form.enable_visualizer { + ("[ ✔ ENABLED ]", Style::default().fg(theme.success).add_modifier(Modifier::BOLD)) + } else { + ("[ ✖ DISABLED ]", Style::default().fg(theme.text_muted)) + }; + frame.render_widget(Paragraph::new(Line::from(Span::styled(vis_badge, vis_style))).block(vis_block), right_rows[2]); + + // 3. Status Message Banner + if let Some((msg, is_error)) = &form.status_message { + let color = if *is_error { theme.error } else { theme.success }; + let status_line = Line::from(Span::styled(format!(" {msg}"), Style::default().fg(color).add_modifier(Modifier::BOLD))); + frame.render_widget(Paragraph::new(status_line).alignment(Alignment::Center), chunks[2]); + } + + // 4. Action Buttons + let button_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[3]); + + app.layout.btn_save = button_cols[0]; + app.layout.btn_cancel = button_cols[1]; + + let save_focused = form.focused_field == SettingsField::SaveButton; + let save_style = if save_focused { + Style::default().bg(theme.primary).fg(theme.surface).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.primary).add_modifier(Modifier::BOLD) + }; + let save_btn = Paragraph::new(" [ Connect & Save ] ") + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).border_type(theme.border_type).border_style(if save_focused { theme.focused_border_style() } else { theme.normal_border_style() })) + .style(save_style); + frame.render_widget(save_btn, button_cols[0]); + + let cancel_focused = form.focused_field == SettingsField::CancelButton; + let cancel_style = if cancel_focused { + Style::default().bg(theme.text_muted).fg(theme.surface).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text_muted) + }; + let cancel_btn = Paragraph::new(if is_initial_setup { " [ Exit ] " } else { " [ Cancel ] " }) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).border_type(theme.border_type).border_style(if cancel_focused { theme.focused_border_style() } else { theme.normal_border_style() })) + .style(cancel_style); + frame.render_widget(cancel_btn, button_cols[1]); +} + +fn field_block(title: &'static str, is_focused: bool, theme: &crate::ui::theme::Theme) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .border_type(theme.border_type) + .border_style(if is_focused { + theme.focused_border_style() + } else { + theme.normal_border_style() + }) + .title(Span::styled(format!(" {title} "), if is_focused { theme.title_style() } else { Style::default().fg(theme.text_muted) })) +}