diff --git a/.gitignore b/.gitignore index e6a225b..806f90c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ dist/ .idea/ .vscode/ *.swp + +# Claude Code / Superpowers +.claude/ +.clone/ +.superpowers/ diff --git a/Cargo.lock b/Cargo.lock index 236fcf8..7ee81db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ 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" @@ -51,12 +57,155 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bindgen" version = "0.72.1" @@ -87,6 +236,19 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -111,6 +273,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.59" @@ -165,6 +342,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -279,6 +479,65 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -291,6 +550,27 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[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 = "either" version = "1.15.0" @@ -306,6 +586,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -322,6 +629,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "extended" version = "0.1.0" @@ -362,6 +690,25 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-task" version = "0.3.32" @@ -380,6 +727,17 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -417,6 +775,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -432,18 +792,52 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hound" version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.13.1" @@ -465,6 +859,19 @@ dependencies = [ "rustversion", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "itertools" version = "0.13.0" @@ -491,7 +898,7 @@ dependencies = [ "combine", "jni-sys 0.3.1", "log", - "thiserror", + "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] @@ -574,12 +981,36 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +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 = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "lofty" version = "0.22.4" @@ -612,6 +1043,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "mach2" version = "0.4.3" @@ -652,6 +1092,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "ndk" version = "0.8.0" @@ -663,7 +1115,7 @@ dependencies = [ "log", "ndk-sys", "num_enum", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -691,6 +1143,25 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[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-derive" version = "0.4.2" @@ -702,6 +1173,15 @@ dependencies = [ "syn", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -771,30 +1251,115 @@ 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 = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -805,13 +1370,22 @@ dependencies = [ "syn", ] +[[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", + "toml_edit 0.25.10+spec-1.1.0", ] [[package]] @@ -907,6 +1481,76 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[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.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -936,12 +1580,40 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[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 = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[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 = "rustify-core" version = "0.1.0" @@ -950,6 +1622,7 @@ dependencies = [ "crossbeam", "hound", "lofty", + "rand", "serde", "serde_json", "symphonia", @@ -957,6 +1630,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustify-mpris" +version = "0.1.0" +dependencies = [ + "crossbeam", + "rustify-core", + "zbus", +] + [[package]] name = "rustify-python" version = "0.1.0" @@ -965,6 +1647,37 @@ dependencies = [ "rustify-core", ] +[[package]] +name = "rustify-tui" +version = "0.1.0" +dependencies = [ + "crossbeam", + "crossterm", + "dirs", + "nucleo-matcher", + "ratatui", + "rustfft", + "rustify-core", + "rustify-mpris", + "serde", + "tempfile", + "toml", + "ureq", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -974,16 +1687,57 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -993,6 +1747,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1042,12 +1802,63 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1060,6 +1871,58 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[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", +] + +[[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" @@ -1195,7 +2058,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1205,7 +2068,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1219,6 +2091,38 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "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" @@ -1228,6 +2132,20 @@ 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.10+spec-1.1.0" @@ -1235,9 +2153,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.1", ] [[package]] @@ -1246,7 +2164,65 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.1", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[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 = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", ] [[package]] @@ -1255,6 +2231,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[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 = "unicode-xid" version = "0.2.6" @@ -1267,6 +2272,58 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1277,6 +2334,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -1394,6 +2457,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +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" @@ -1403,6 +2491,12 @@ 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" @@ -1447,6 +2541,24 @@ 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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1577,6 +2689,15 @@ 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.1" @@ -1674,8 +2795,135 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 0.7.15", +] diff --git a/Cargo.toml b/Cargo.toml index b1cb100..1b1f60d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] -members = ["crates/rustify-core", "bindings/python"] +members = ["crates/rustify-core", "crates/rustify-tui", "crates/rustify-mpris", "bindings/python"] default-members = ["crates/rustify-core"] resolver = "2" diff --git a/crates/rustify-core/Cargo.toml b/crates/rustify-core/Cargo.toml index b9a4327..c5cee81 100644 --- a/crates/rustify-core/Cargo.toml +++ b/crates/rustify-core/Cargo.toml @@ -11,6 +11,7 @@ crossbeam = "0.8" walkdir = "2" lofty = "0.22" serde = { version = "1", features = ["derive"] } +rand = "0.9" [dev-dependencies] tempfile = "3" diff --git a/crates/rustify-core/src/art.rs b/crates/rustify-core/src/art.rs new file mode 100644 index 0000000..2743748 --- /dev/null +++ b/crates/rustify-core/src/art.rs @@ -0,0 +1,126 @@ +use std::fs; +use std::path::Path; + +use lofty::picture::PictureType; +use lofty::prelude::*; +use lofty::probe::Probe; + +/// Sidecar filenames to search for album art (checked case-insensitively). +const SIDECAR_NAMES: &[&str] = &[ + "cover.jpg", + "cover.png", + "folder.jpg", + "folder.png", + "album.jpg", + "album.png", +]; + +/// Extract album art for a track file. +/// Tries embedded cover art first (via lofty), then sidecar files in the +/// track's directory. Returns raw image bytes (JPEG or PNG) or None. +pub fn extract_art(path: &Path) -> Option> { + // Try embedded art first + if let Some(art) = extract_embedded(path) { + return Some(art); + } + + // Fall back to sidecar files + extract_sidecar(path) +} + +/// Extract embedded cover art from audio file tags. +fn extract_embedded(path: &Path) -> Option> { + let tagged_file = Probe::open(path).ok()?.read().ok()?; + + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag())?; + + // Prefer CoverFront, fall back to any picture + let picture = tag + .pictures() + .iter() + .find(|p| p.pic_type() == PictureType::CoverFront) + .or_else(|| tag.pictures().first())?; + + Some(picture.data().to_vec()) +} + +/// Search the track's parent directory for sidecar art files. +fn extract_sidecar(path: &Path) -> Option> { + let parent = path.parent()?; + + let entries: Vec<_> = fs::read_dir(parent).ok()?.filter_map(|e| e.ok()).collect(); + + for sidecar_name in SIDECAR_NAMES { + for entry in &entries { + let file_name = entry.file_name(); + let name_str = file_name.to_string_lossy(); + if name_str.eq_ignore_ascii_case(sidecar_name) { + if let Ok(data) = fs::read(entry.path()) { + return Some(data); + } + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn sidecar_cover_found() { + let dir = TempDir::new().unwrap(); + let cover_path = dir.path().join("cover.jpg"); + fs::write(&cover_path, b"fake-jpeg-data").unwrap(); + + let track_path = dir.path().join("song.mp3"); + fs::write(&track_path, b"").unwrap(); + + let art = extract_art(&track_path); + assert!(art.is_some()); + assert_eq!(art.unwrap(), b"fake-jpeg-data"); + } + + #[test] + fn sidecar_folder_jpg_found() { + let dir = TempDir::new().unwrap(); + let cover_path = dir.path().join("folder.jpg"); + fs::write(&cover_path, b"folder-art").unwrap(); + + let track_path = dir.path().join("song.mp3"); + fs::write(&track_path, b"").unwrap(); + + let art = extract_art(&track_path); + assert!(art.is_some()); + assert_eq!(art.unwrap(), b"folder-art"); + } + + #[test] + fn no_art_returns_none() { + let dir = TempDir::new().unwrap(); + let track_path = dir.path().join("song.mp3"); + fs::write(&track_path, b"").unwrap(); + + let art = extract_art(&track_path); + assert!(art.is_none()); + } + + #[test] + fn sidecar_case_insensitive() { + let dir = TempDir::new().unwrap(); + let cover_path = dir.path().join("Cover.JPG"); + fs::write(&cover_path, b"case-insensitive").unwrap(); + + let track_path = dir.path().join("song.mp3"); + fs::write(&track_path, b"").unwrap(); + + let art = extract_art(&track_path); + assert!(art.is_some()); + } +} diff --git a/crates/rustify-core/src/lib.rs b/crates/rustify-core/src/lib.rs index 94f3bcc..a2a2971 100644 --- a/crates/rustify-core/src/lib.rs +++ b/crates/rustify-core/src/lib.rs @@ -1,4 +1,6 @@ +pub mod art; pub mod error; +pub mod lyrics; pub mod metadata; pub mod mixer; pub mod player; @@ -10,4 +12,4 @@ pub mod types; // Re-export primary types at crate root for convenience. pub use error::{Result, RustifyError}; pub use player::{Player, PlayerConfig}; -pub use types::{PlaybackState, PlayerCommand, PlayerEvent, Playlist, Track}; +pub use types::{PlaybackState, PlayerCommand, PlayerEvent, Playlist, RepeatMode, Track}; diff --git a/crates/rustify-core/src/lyrics.rs b/crates/rustify-core/src/lyrics.rs new file mode 100644 index 0000000..5ced1b0 --- /dev/null +++ b/crates/rustify-core/src/lyrics.rs @@ -0,0 +1,141 @@ +use std::fs; +use std::path::Path; + +use lofty::prelude::*; +use lofty::probe::Probe; +use lofty::tag::ItemKey; + +/// Lyrics content. +#[derive(Debug, Clone)] +pub enum Lyrics { + /// Timestamped lines from .lrc file: (timestamp_ms, line_text) + Synced(Vec<(u64, String)>), + /// Plain text lyrics from audio tags + Unsynced(String), +} + +/// Extract lyrics for a track. +/// Tries embedded tags first, then .lrc sidecar file. +pub fn extract_lyrics(path: &Path) -> Option { + if let Some(lyrics) = extract_embedded_lyrics(path) { + return Some(lyrics); + } + extract_lrc_sidecar(path) +} + +fn extract_embedded_lyrics(path: &Path) -> Option { + let tagged_file = Probe::open(path).ok()?.read().ok()?; + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag())?; + + if let Some(text) = tag.get_string(&ItemKey::Lyrics) { + if !text.trim().is_empty() { + return Some(Lyrics::Unsynced(text.to_string())); + } + } + None +} + +fn extract_lrc_sidecar(path: &Path) -> Option { + let lrc_path = path.with_extension("lrc"); + let content = fs::read_to_string(&lrc_path).ok()?; + Some(parse_lrc(&content)) +} + +/// Parse LRC format into Lyrics. +pub fn parse_lrc(content: &str) -> Lyrics { + let mut lines: Vec<(u64, String)> = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some((ts, text)) = parse_lrc_line(line) { + lines.push((ts, text)); + } + } + + if lines.is_empty() { + Lyrics::Unsynced(content.to_string()) + } else { + lines.sort_by_key(|(ts, _)| *ts); + Lyrics::Synced(lines) + } +} + +fn parse_lrc_line(line: &str) -> Option<(u64, String)> { + // Format: [mm:ss.xx]Text + let close = line.find(']')?; + if !line.starts_with('[') { + return None; + } + let timestamp = &line[1..close]; + let text = line[close + 1..].to_string(); + + let parts: Vec<&str> = timestamp.split(':').collect(); + if parts.len() != 2 { + return None; + } + let mins: u64 = parts[0].parse().ok()?; + let secs: f64 = parts[1].parse().ok()?; + let ms = mins * 60_000 + (secs * 1000.0) as u64; + + Some((ms, text)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn parse_lrc_synced() { + let content = "[00:12.50]First line\n[00:15.00]Second line\n"; + match parse_lrc(content) { + Lyrics::Synced(lines) => { + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].0, 12500); + assert_eq!(lines[0].1, "First line"); + assert_eq!(lines[1].0, 15000); + } + _ => panic!("Expected synced lyrics"), + } + } + + #[test] + fn parse_lrc_empty_returns_unsynced() { + let content = "Just plain text\nNo timestamps here"; + match parse_lrc(content) { + Lyrics::Unsynced(_) => {} + _ => panic!("Expected unsynced lyrics"), + } + } + + #[test] + fn sidecar_lrc_found() { + let dir = TempDir::new().unwrap(); + let track_path = dir.path().join("song.mp3"); + let lrc_path = dir.path().join("song.lrc"); + fs::write(&track_path, b"").unwrap(); + fs::write(&lrc_path, "[00:05.00]Hello world\n").unwrap(); + + let lyrics = extract_lyrics(&track_path); + assert!(lyrics.is_some()); + match lyrics.unwrap() { + Lyrics::Synced(lines) => assert_eq!(lines[0].1, "Hello world"), + _ => panic!("Expected synced"), + } + } + + #[test] + fn no_lyrics_returns_none() { + let dir = TempDir::new().unwrap(); + let track_path = dir.path().join("song.mp3"); + fs::write(&track_path, b"").unwrap(); + let lyrics = extract_lyrics(&track_path); + assert!(lyrics.is_none()); + } +} diff --git a/crates/rustify-core/src/metadata.rs b/crates/rustify-core/src/metadata.rs index 079f163..bd88d2e 100644 --- a/crates/rustify-core/src/metadata.rs +++ b/crates/rustify-core/src/metadata.rs @@ -2,6 +2,7 @@ use std::path::Path; use lofty::prelude::*; use lofty::probe::Probe; +use lofty::tag::ItemKey; use crate::error::RustifyError; use crate::types::{path_to_uri, uri_to_path, Track}; @@ -57,6 +58,29 @@ pub fn read_metadata_from_path(path: &Path) -> Result { }) } +/// Read ReplayGain track gain from audio file tags. +/// Returns the gain adjustment in dB, or None if no tag found. +pub fn read_replay_gain(path: &Path) -> Option { + let tagged_file = Probe::open(path).ok()?.read().ok()?; + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag())?; + + if let Some(val) = tag.get_string(&ItemKey::ReplayGainTrackGain) { + return parse_replay_gain_value(val); + } + None +} + +fn parse_replay_gain_value(val: &str) -> Option { + let trimmed = val + .trim() + .trim_end_matches(" dB") + .trim_end_matches("dB") + .trim(); + trimmed.parse::().ok() +} + #[cfg(test)] mod tests { use super::*; @@ -142,4 +166,12 @@ mod tests { let result = read_metadata("file:///nonexistent/song.mp3"); assert!(result.is_err()); } + + #[test] + fn parse_replay_gain_values() { + assert_eq!(parse_replay_gain_value("-6.5 dB"), Some(-6.5)); + assert_eq!(parse_replay_gain_value("+3.2 dB"), Some(3.2)); + assert_eq!(parse_replay_gain_value("-6.5dB"), Some(-6.5)); + assert_eq!(parse_replay_gain_value("not a number"), None); + } } diff --git a/crates/rustify-core/src/player.rs b/crates/rustify-core/src/player.rs index b7dc13b..6e5704c 100644 --- a/crates/rustify-core/src/player.rs +++ b/crates/rustify-core/src/player.rs @@ -25,12 +25,16 @@ pub struct PlayerConfig { pub music_dirs: Vec, } +/// Maximum number of f32 samples held in the visualization sample buffer. +const SAMPLE_BUFFER_CAP: usize = 4096; + /// The main player handle. All methods are non-blocking — they send commands /// to the internal command thread via a crossbeam channel. pub struct Player { cmd_tx: Sender, shared: Arc, mixer: Arc, + sample_buffer: Arc>>, #[allow(dead_code)] // used by Python bindings layer music_dirs: Vec, _command_thread: Option>, @@ -43,15 +47,23 @@ impl Player { let (cmd_tx, cmd_rx) = channel::unbounded::(); let shared = Arc::new(SharedState::new()); let mixer = Arc::new(Mixer::new(100)); + let sample_buffer = Arc::new(Mutex::new(VecDeque::with_capacity(SAMPLE_BUFFER_CAP))); let shared_clone = Arc::clone(&shared); let mixer_clone = Arc::clone(&mixer); + let sample_buffer_clone = Arc::clone(&sample_buffer); let alsa_device = config.alsa_device.clone(); let handle = thread::Builder::new() .name("rustify-cmd".into()) .spawn(move || { - let mut cmd_loop = CommandLoop::new(cmd_rx, shared_clone, mixer_clone, alsa_device); + let mut cmd_loop = CommandLoop::new( + cmd_rx, + shared_clone, + mixer_clone, + sample_buffer_clone, + alsa_device, + ); cmd_loop.run(); }) .map_err(|e| RustifyError::Audio(format!("failed to spawn command thread: {e}")))?; @@ -60,6 +72,7 @@ impl Player { cmd_tx, shared, mixer, + sample_buffer, music_dirs: config.music_dirs, _command_thread: Some(handle), }) @@ -99,6 +112,14 @@ impl Player { self.mixer.get_volume() } + pub fn set_shuffle(&self, on: bool) { + self.cmd_tx.send(PlayerCommand::SetShuffle(on)).ok(); + } + + pub fn set_repeat(&self, mode: crate::types::RepeatMode) { + self.cmd_tx.send(PlayerCommand::SetRepeat(mode)).ok(); + } + pub fn load_track_uris(&self, uris: Vec) { self.cmd_tx.send(PlayerCommand::LoadTrackUris(uris)).ok(); } @@ -107,10 +128,21 @@ impl Player { self.cmd_tx.send(PlayerCommand::ClearTracklist).ok(); } + pub fn set_crossfade(&self, ms: u64) { + self.cmd_tx.send(PlayerCommand::SetCrossfade(ms)).ok(); + } + pub fn shutdown(&self) { self.cmd_tx.send(PlayerCommand::Shutdown).ok(); } + // --- Sample buffer for visualization --- + + /// Clone the current sample buffer contents for visualization. + pub fn get_samples(&self) -> Vec { + self.sample_buffer.lock().unwrap().iter().copied().collect() + } + // --- State queries (read from shared atomic/mutex state) --- pub fn get_playback_state(&self) -> PlaybackState { @@ -154,6 +186,15 @@ impl Player { .push(callback); } + pub fn on_mode_change(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_mode_change + .push(callback); + } + pub fn on_error(&self, callback: Box) { self.shared .callbacks @@ -214,6 +255,7 @@ struct Callbacks { on_track_change: Vec>, on_position_update: Vec>, on_error: Vec>, + on_mode_change: Vec>, } // --- Internal Events (decode thread -> command loop) --- @@ -222,6 +264,15 @@ enum InternalEvent { TrackChanged(Track), Position(u64), TrackEnded, + /// Decode thread is nearing the end — pre-start next decode for gapless. + #[allow(dead_code)] + TrackEnding { + remaining_ms: u64, + }, + /// Pending decode thread read metadata — held until promotion at TrackEnded. + PendingTrackReady(Track), + /// Pending decode thread failed — discard it silently (current track continues). + PendingDecodeFailed(String), /// Decode thread failed to open/decode the track and exited. /// Command loop must reset state to Stopped. DecodeFailed(String), @@ -251,11 +302,19 @@ struct CommandLoop { mixer: Arc, tracklist: Tracklist, decode_handle: Option, + pending_decode: Option, + /// Metadata for the pending track — emitted as TrackChanged on promotion. + pending_track: Option, audio_tx: Sender>, _audio_stream: Option, clear_buffer: Arc, + /// Shared slot for pending audio channel — cpal callback reads this for gapless swap. + pending_audio_rx: Arc>>>>, + #[allow(dead_code)] + sample_buffer: Arc>>, #[allow(dead_code)] alsa_device: String, + crossfade_ms: u64, } impl CommandLoop { @@ -263,14 +322,22 @@ impl CommandLoop { cmd_rx: Receiver, shared: Arc, mixer: Arc, + sample_buffer: Arc>>, alsa_device: String, ) -> Self { let (event_tx, event_rx) = channel::unbounded::(); let (audio_tx, audio_rx) = channel::bounded::>(BUFFER_CHUNKS); let clear_buffer = Arc::new(AtomicBool::new(false)); + let pending_audio_rx = Arc::new(Mutex::new(None)); // Create the cpal output stream - let stream = create_output_stream(audio_rx, Arc::clone(&mixer), Arc::clone(&clear_buffer)); + let stream = create_output_stream( + audio_rx, + Arc::clone(&mixer), + Arc::clone(&clear_buffer), + Arc::clone(&pending_audio_rx), + Arc::clone(&sample_buffer), + ); if let Err(ref e) = stream { eprintln!("rustify: failed to create audio stream: {e}"); @@ -284,10 +351,15 @@ impl CommandLoop { mixer, tracklist: Tracklist::new(), decode_handle: None, + pending_decode: None, + pending_track: None, audio_tx, _audio_stream: stream.ok(), clear_buffer, + pending_audio_rx, + sample_buffer, alsa_device, + crossfade_ms: 0, } } @@ -330,6 +402,24 @@ impl CommandLoop { self.handle_stop(); self.tracklist.clear(); } + PlayerCommand::SetShuffle(on) => { + self.tracklist.set_shuffle(on); + self.emit_callbacks(PlayerEvent::ModeChanged { + shuffle: self.tracklist.get_shuffle(), + repeat: self.tracklist.get_repeat(), + }); + } + PlayerCommand::SetRepeat(mode) => { + self.tracklist.set_repeat(mode); + self.emit_callbacks(PlayerEvent::ModeChanged { + shuffle: self.tracklist.get_shuffle(), + repeat: self.tracklist.get_repeat(), + }); + } + PlayerCommand::SetCrossfade(ms) => { + // Store crossfade duration -- actual mixing uses this in Tier 3+ + self.crossfade_ms = ms; + } PlayerCommand::Shutdown => unreachable!(), } } @@ -344,20 +434,83 @@ impl CommandLoop { self.shared.time_position_ms.store(ms, Ordering::Relaxed); self.emit_callbacks(PlayerEvent::PositionUpdate(ms)); } + InternalEvent::TrackEnding { remaining_ms: _ } => { + // Pre-start next decode for gapless playback + if self.pending_decode.is_none() { + // Peek at next track without advancing tracklist + let next_uri = { + let mut clone = self.tracklist.clone(); + clone.next().map(String::from) + }; + if let Some(uri) = next_uri { + let (pending_tx, pending_rx) = channel::bounded::>(BUFFER_CHUNKS); + + let (control_tx, control_rx) = channel::unbounded::(); + let event_tx = self.event_tx.clone(); + let crossfade_ms = self.crossfade_ms; + + let handle = thread::Builder::new() + .name("rustify-decode-pending".into()) + .spawn(move || { + decode_thread( + uri, + pending_tx, + control_rx, + event_tx, + crossfade_ms, + true, + ); + }) + .expect("failed to spawn pending decode thread"); + + self.pending_decode = Some(DecodeHandle { + control_tx, + _thread: handle, + }); + + // Put the pending rx in the shared slot for the cpal callback + *self.pending_audio_rx.lock().unwrap() = Some(pending_rx); + } + } + } + InternalEvent::PendingTrackReady(track) => { + // Hold metadata until pending decode is promoted at TrackEnded + self.pending_track = Some(track); + } + InternalEvent::PendingDecodeFailed(msg) => { + // Pending decode failed — discard it, current track continues playing + self.stop_pending_decode(); + eprintln!("rustify: pending decode failed: {msg}"); + } InternalEvent::TrackEnded => { - // Try to advance to next track - if let Some(uri) = self.tracklist.next() { - let uri = uri.to_string(); - self.stop_decode(); - self.start_decode(uri); + // Gapless: promote pending decode if it exists + if let Some(pending) = self.pending_decode.take() { + if self.tracklist.next().is_some() { + self.decode_handle = Some(pending); + // NOW emit TrackChanged with the held metadata + if let Some(track) = self.pending_track.take() { + *self.shared.current_track.lock().unwrap() = Some(track.clone()); + self.emit_callbacks(PlayerEvent::TrackChanged(track)); + } + } else { + self.decode_handle = None; + self.pending_track = None; + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } } else { - // End of tracklist — let remaining audio drain naturally. - // Don't call stop_decode() which would clear the buffer - // and cut off the last seconds of audio. - self.decode_handle = None; - self.set_state(PlaybackState::Stopped); - *self.shared.current_track.lock().unwrap() = None; - self.shared.time_position_ms.store(0, Ordering::Relaxed); + // No pending decode — try to advance normally (non-gapless path) + if let Some(uri) = self.tracklist.next() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + } else { + self.decode_handle = None; + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } } } InternalEvent::DecodeFailed(msg) => { @@ -445,11 +598,12 @@ impl CommandLoop { let (control_tx, control_rx) = channel::unbounded::(); let audio_tx = self.audio_tx.clone(); let event_tx = self.event_tx.clone(); + let crossfade_ms = self.crossfade_ms; let handle = thread::Builder::new() .name("rustify-decode".into()) .spawn(move || { - decode_thread(uri, audio_tx, control_rx, event_tx); + decode_thread(uri, audio_tx, control_rx, event_tx, crossfade_ms, false); }) .expect("failed to spawn decode thread"); @@ -462,11 +616,22 @@ impl CommandLoop { fn stop_decode(&mut self) { if let Some(handle) = self.decode_handle.take() { handle.control_tx.send(DecodeControl::Stop).ok(); - // Don't join — the thread will exit when it sees Stop or channel disconnect } + self.stop_pending_decode(); self.clear_buffer.store(true, Ordering::Relaxed); } + fn stop_pending_decode(&mut self) { + if let Some(handle) = self.pending_decode.take() { + handle.control_tx.send(DecodeControl::Stop).ok(); + } + self.pending_track = None; + // Clear the pending audio slot so cpal callback doesn't pick up stale audio + if let Ok(mut slot) = self.pending_audio_rx.lock() { + *slot = None; + } + } + fn set_state(&mut self, state: PlaybackState) { self.shared.set_playback_state(state); self.emit_callbacks(PlayerEvent::StateChanged(state)); @@ -495,6 +660,11 @@ impl CommandLoop { cb(msg.clone()); } } + PlayerEvent::ModeChanged { shuffle, repeat } => { + for cb in &callbacks.on_mode_change { + cb(*shuffle, *repeat); + } + } } } } @@ -506,6 +676,8 @@ fn decode_thread( audio_tx: Sender>, control_rx: Receiver, event_tx: Sender, + crossfade_ms: u64, + is_pending: bool, ) { use symphonia::core::audio::SampleBuffer; use symphonia::core::codecs::DecoderOptions; @@ -516,10 +688,16 @@ fn decode_thread( let path = uri_to_path(&uri); - // Read metadata for TrackChanged event + // Read metadata — pending decoders use PendingTrackReady instead of TrackChanged + // so the UI doesn't switch early match read_metadata_from_path(&path) { Ok(track) => { - event_tx.send(InternalEvent::TrackChanged(track)).ok(); + let event = if is_pending { + InternalEvent::PendingTrackReady(track) + } else { + InternalEvent::TrackChanged(track) + }; + event_tx.send(event).ok(); } Err(e) => { event_tx @@ -528,13 +706,20 @@ fn decode_thread( } } + // Helper: send the right failure event based on pending status + let send_decode_failed = |tx: &Sender, msg: String, pending: bool| { + if pending { + tx.send(InternalEvent::PendingDecodeFailed(msg)).ok(); + } else { + tx.send(InternalEvent::DecodeFailed(msg)).ok(); + } + }; + // Open file with symphonia let file = match std::fs::File::open(&path) { Ok(f) => f, Err(e) => { - event_tx - .send(InternalEvent::DecodeFailed(format!("open: {e}"))) - .ok(); + send_decode_failed(&event_tx, format!("open: {e}"), is_pending); return; } }; @@ -553,9 +738,7 @@ fn decode_thread( ) { Ok(p) => p, Err(e) => { - event_tx - .send(InternalEvent::DecodeFailed(format!("probe: {e}"))) - .ok(); + send_decode_failed(&event_tx, format!("probe: {e}"), is_pending); return; } }; @@ -564,9 +747,7 @@ fn decode_thread( let track = match format.default_track() { Some(t) => t, None => { - event_tx - .send(InternalEvent::DecodeFailed("no audio track found".into())) - .ok(); + send_decode_failed(&event_tx, "no audio track found".into(), is_pending); return; } }; @@ -579,9 +760,7 @@ fn decode_thread( { Ok(d) => d, Err(e) => { - event_tx - .send(InternalEvent::DecodeFailed(format!("decoder: {e}"))) - .ok(); + send_decode_failed(&event_tx, format!("decoder: {e}"), is_pending); return; } }; @@ -589,6 +768,10 @@ fn decode_thread( let mut paused = false; let mut sample_buf: Option> = None; let mut last_position_report_ms: u64 = 0; + let total_samples = track.codec_params.n_frames; + let mut decoded_samples: u64 = 0; + let mut track_ending_sent = false; + let pre_buffer_ms: u64 = crossfade_ms.max(3000); loop { // Check for control messages (non-blocking when not paused) @@ -675,9 +858,26 @@ fn decode_thread( SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()) }); + let num_frames = decoded.frames() as u64; sbuf.copy_interleaved_ref(decoded); let chunk = sbuf.samples().to_vec(); + decoded_samples += num_frames; + + // Check if we're near the end — signal pre-buffer for gapless + if !track_ending_sent { + if let Some(total) = total_samples { + let remaining_samples = total.saturating_sub(decoded_samples); + let remaining_ms = remaining_samples * 1000 / sample_rate as u64; + if remaining_ms < pre_buffer_ms { + event_tx + .send(InternalEvent::TrackEnding { remaining_ms }) + .ok(); + track_ending_sent = true; + } + } + } + if audio_tx.send(chunk).is_err() { break; // Output stream dropped } @@ -719,6 +919,8 @@ fn create_output_stream( audio_rx: Receiver>, mixer: Arc, clear_buffer: Arc, + pending_audio_rx: Arc>>>>, + sample_buffer: Arc>>, ) -> Result { use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; @@ -733,8 +935,6 @@ fn create_output_stream( let device_channels = supported_config.channels() as usize; - // Force f32 sample format — our decode pipeline outputs f32. - // Override the device default which may be i16/u16 on some ALSA backends. let config = cpal::StreamConfig { channels: supported_config.channels(), sample_rate: supported_config.sample_rate(), @@ -742,46 +942,70 @@ fn create_output_stream( }; let mut buf: VecDeque = VecDeque::with_capacity(8192); + let mut active_rx = audio_rx; - // Decoded audio is interleaved stereo (L R L R...). - // The device may have more channels (e.g. 8 on some USB audio). - // We map stereo to the first 2 device channels and silence the rest. let stream = device .build_output_stream( &config, move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { - // Check if we should clear stale audio if clear_buffer.swap(false, Ordering::Relaxed) { buf.clear(); - while audio_rx.try_recv().is_ok() {} + while active_rx.try_recv().is_ok() {} } let gain = mixer.gain(); - // Process one device frame at a time for frame in data.chunks_mut(device_channels) { - // Pop one stereo pair (L, R) from decoded audio let left = if buf.is_empty() { - match audio_rx.try_recv() { + match active_rx.try_recv() { Ok(chunk) => { buf.extend(chunk); buf.pop_front().unwrap_or(0.0) } - Err(_) => 0.0, + Err(_) => { + // Active channel drained — check for pending (gapless swap) + if let Ok(mut slot) = pending_audio_rx.try_lock() { + if let Some(new_rx) = slot.take() { + active_rx = new_rx; + match active_rx.try_recv() { + Ok(chunk) => { + buf.extend(chunk); + buf.pop_front().unwrap_or(0.0) + } + Err(_) => 0.0, + } + } else { + 0.0 + } + } else { + 0.0 + } + } } } else { buf.pop_front().unwrap_or(0.0) }; let right = buf.pop_front().unwrap_or(left); - // Map stereo to device channels, silence extras + let left_out = left * gain; + let right_out = right * gain; + for (i, sample) in frame.iter_mut().enumerate() { *sample = match i { - 0 => left * gain, - 1 => right * gain, + 0 => left_out, + 1 => right_out, _ => 0.0, }; } + + // Push samples into visualization buffer (non-blocking) + if let Ok(mut sbuf) = sample_buffer.try_lock() { + sbuf.push_back(left_out); + sbuf.push_back(right_out); + while sbuf.len() > SAMPLE_BUFFER_CAP { + sbuf.pop_front(); + } + } } }, |err| { diff --git a/crates/rustify-core/src/tracklist.rs b/crates/rustify-core/src/tracklist.rs index ef37709..10df8b7 100644 --- a/crates/rustify-core/src/tracklist.rs +++ b/crates/rustify-core/src/tracklist.rs @@ -1,10 +1,20 @@ use std::collections::VecDeque; +use rand::rng; +use rand::seq::SliceRandom; + +use crate::types::RepeatMode; + /// A playback queue backed by VecDeque. -/// Stores track URIs and maintains a current position index. +/// Supports shuffle and repeat modes. +#[derive(Clone)] pub struct Tracklist { tracks: VecDeque, current_index: Option, + shuffle: bool, + repeat: RepeatMode, + shuffle_order: Vec, + shuffle_position: Option, } impl Tracklist { @@ -12,16 +22,17 @@ impl Tracklist { Self { tracks: VecDeque::new(), current_index: None, + shuffle: false, + repeat: RepeatMode::Off, + shuffle_order: Vec::new(), + shuffle_position: None, } } - /// Append a single track URI to the end of the queue. pub fn add(&mut self, uri: String) { self.tracks.push_back(uri); } - /// Replace the entire tracklist with the given URIs. - /// Sets the current position to the first track if non-empty. pub fn load(&mut self, uris: Vec) { self.tracks.clear(); self.tracks.extend(uris); @@ -30,37 +41,58 @@ impl Tracklist { } else { Some(0) }; + if self.shuffle { + self.generate_shuffle_order(); + } } - /// Remove all tracks and reset position. pub fn clear(&mut self) { self.tracks.clear(); self.current_index = None; + self.shuffle_order.clear(); + self.shuffle_position = None; } - /// Get the URI of the current track, if any. pub fn current(&self) -> Option<&str> { self.current_index .and_then(|i| self.tracks.get(i)) .map(String::as_str) } - /// Advance to the next track and return its URI. - /// Returns None if already at the end. #[allow(clippy::should_implement_trait)] pub fn next(&mut self) -> Option<&str> { + let _idx = self.current_index?; + if self.repeat == RepeatMode::One { + return self.current(); + } + if self.shuffle { + return self.next_shuffled(); + } + self.next_sequential() + } + + pub fn previous(&mut self) -> Option<&str> { + let _idx = self.current_index?; + if self.shuffle { + return self.previous_shuffled(); + } + self.previous_sequential() + } + + fn next_sequential(&mut self) -> Option<&str> { let idx = self.current_index?; if idx + 1 < self.tracks.len() { self.current_index = Some(idx + 1); self.current() + } else if self.repeat == RepeatMode::All && !self.tracks.is_empty() { + self.current_index = Some(0); + self.current() } else { None } } - /// Go back to the previous track and return its URI. - /// Returns None if already at the beginning. - pub fn previous(&mut self) -> Option<&str> { + fn previous_sequential(&mut self) -> Option<&str> { let idx = self.current_index?; if idx > 0 { self.current_index = Some(idx - 1); @@ -70,17 +102,78 @@ impl Tracklist { } } - /// Get the current track index (0-based). + fn next_shuffled(&mut self) -> Option<&str> { + let pos = self.shuffle_position?; + if pos + 1 < self.shuffle_order.len() { + self.shuffle_position = Some(pos + 1); + self.current_index = Some(self.shuffle_order[pos + 1]); + self.current() + } else if self.repeat == RepeatMode::All && !self.shuffle_order.is_empty() { + self.generate_shuffle_order(); + self.current() + } else { + None + } + } + + fn previous_shuffled(&mut self) -> Option<&str> { + let pos = self.shuffle_position?; + if pos > 0 { + self.shuffle_position = Some(pos - 1); + self.current_index = Some(self.shuffle_order[pos - 1]); + self.current() + } else { + None + } + } + + pub fn set_shuffle(&mut self, on: bool) { + if on && !self.shuffle { + self.shuffle = true; + self.generate_shuffle_order(); + } else if !on && self.shuffle { + self.shuffle = false; + self.shuffle_order.clear(); + self.shuffle_position = None; + } + } + + pub fn get_shuffle(&self) -> bool { + self.shuffle + } + + pub fn set_repeat(&mut self, mode: RepeatMode) { + self.repeat = mode; + } + + pub fn get_repeat(&self) -> RepeatMode { + self.repeat + } + + fn generate_shuffle_order(&mut self) { + let len = self.tracks.len(); + if len == 0 { + self.shuffle_order.clear(); + self.shuffle_position = None; + return; + } + let current = self.current_index.unwrap_or(0); + let mut others: Vec = (0..len).filter(|&i| i != current).collect(); + others.shuffle(&mut rng()); + self.shuffle_order = Vec::with_capacity(len); + self.shuffle_order.push(current); + self.shuffle_order.extend(others); + self.shuffle_position = Some(0); + } + pub fn index(&self) -> Option { self.current_index } - /// Get the total number of tracks. pub fn len(&self) -> usize { self.tracks.len() } - /// Check if the tracklist is empty. pub fn is_empty(&self) -> bool { self.tracks.is_empty() } @@ -185,8 +278,8 @@ mod tests { "file:///b.mp3".into(), "file:///c.mp3".into(), ]); - tl.next(); // -> b - tl.next(); // -> c + tl.next(); + tl.next(); assert_eq!(tl.previous(), Some("file:///b.mp3")); assert_eq!(tl.index(), Some(1)); assert_eq!(tl.previous(), Some("file:///a.mp3")); @@ -206,4 +299,96 @@ mod tests { let mut tl = Tracklist::new(); assert_eq!(tl.previous(), None); } + + #[test] + fn repeat_all_wraps_at_end() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into(), "file:///b.mp3".into()]); + tl.set_repeat(RepeatMode::All); + tl.next(); + assert_eq!(tl.next(), Some("file:///a.mp3")); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn repeat_one_returns_same_track() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into(), "file:///b.mp3".into()]); + tl.set_repeat(RepeatMode::One); + assert_eq!(tl.next(), Some("file:///a.mp3")); + assert_eq!(tl.next(), Some("file:///a.mp3")); + } + + #[test] + fn repeat_off_returns_none_at_end() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + tl.set_repeat(RepeatMode::Off); + assert_eq!(tl.next(), None); + } + + #[test] + fn repeat_mode_cycle() { + assert_eq!(RepeatMode::Off.cycle(), RepeatMode::All); + assert_eq!(RepeatMode::All.cycle(), RepeatMode::One); + assert_eq!(RepeatMode::One.cycle(), RepeatMode::Off); + } + + #[test] + fn shuffle_produces_permutation() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + "file:///d.mp3".into(), + "file:///e.mp3".into(), + ]); + tl.set_shuffle(true); + let mut visited = vec![tl.current().unwrap().to_string()]; + for _ in 0..4 { + visited.push(tl.next().unwrap().to_string()); + } + visited.sort(); + assert_eq!( + visited, + vec![ + "file:///a.mp3", + "file:///b.mp3", + "file:///c.mp3", + "file:///d.mp3", + "file:///e.mp3", + ] + ); + } + + #[test] + fn shuffle_off_restores_order() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + tl.set_shuffle(true); + tl.next(); + let current_uri = tl.current().unwrap().to_string(); + tl.set_shuffle(false); + assert_eq!(tl.current().unwrap(), current_uri); + } + + #[test] + fn shuffle_previous_walks_backward() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + tl.set_shuffle(true); + let first = tl.current().unwrap().to_string(); + tl.next(); + let back = tl.previous().unwrap().to_string(); + assert_eq!(back, first); + } } diff --git a/crates/rustify-core/src/types.rs b/crates/rustify-core/src/types.rs index 504e302..3b6200c 100644 --- a/crates/rustify-core/src/types.rs +++ b/crates/rustify-core/src/types.rs @@ -38,6 +38,25 @@ pub enum PlaybackState { Paused, } +/// Repeat mode for the tracklist. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RepeatMode { + Off, + All, + One, +} + +impl RepeatMode { + /// Cycle to the next repeat mode: Off → All → One → Off. + pub fn cycle(self) -> Self { + match self { + Self::Off => Self::All, + Self::All => Self::One, + Self::One => Self::Off, + } + } +} + /// Events emitted by the player to registered callbacks. #[derive(Debug, Clone)] pub enum PlayerEvent { @@ -45,6 +64,7 @@ pub enum PlayerEvent { TrackChanged(Track), PositionUpdate(u64), Error(String), + ModeChanged { shuffle: bool, repeat: RepeatMode }, } /// Commands sent to the player's command thread. @@ -60,6 +80,9 @@ pub enum PlayerCommand { LoadTrackUris(Vec), ClearTracklist, Shutdown, + SetShuffle(bool), + SetRepeat(RepeatMode), + SetCrossfade(u64), } /// Supported audio file extensions. diff --git a/crates/rustify-mpris/Cargo.toml b/crates/rustify-mpris/Cargo.toml new file mode 100644 index 0000000..222ca1e --- /dev/null +++ b/crates/rustify-mpris/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "rustify-mpris" +version = "0.1.0" +edition = "2021" +description = "MPRIS2 D-Bus integration for rustify (Linux only)" + +[dependencies] +rustify-core = { path = "../rustify-core" } +crossbeam = "0.8" + +[target.'cfg(target_os = "linux")'.dependencies] +zbus = "5" diff --git a/crates/rustify-mpris/src/lib.rs b/crates/rustify-mpris/src/lib.rs new file mode 100644 index 0000000..ce87ddf --- /dev/null +++ b/crates/rustify-mpris/src/lib.rs @@ -0,0 +1,15 @@ +use crossbeam::channel::Sender; + +/// Start the MPRIS2 D-Bus service (Linux only). +/// On non-Linux platforms, this is a no-op. +#[cfg(target_os = "linux")] +pub fn start(_player: &rustify_core::Player, _event_tx: Sender) { + // Full MPRIS2 implementation requires Linux D-Bus + // This will be implemented when testing on Linux/Pi + eprintln!("rustify: MPRIS support not yet implemented"); +} + +#[cfg(not(target_os = "linux"))] +pub fn start(_player: &rustify_core::Player, _event_tx: Sender) { + // No-op on non-Linux platforms +} diff --git a/crates/rustify-tui/Cargo.toml b/crates/rustify-tui/Cargo.toml new file mode 100644 index 0000000..0b83753 --- /dev/null +++ b/crates/rustify-tui/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rustify-tui" +version = "0.1.0" +edition = "2021" +description = "Rich terminal music player built on rustify-core" + +[[bin]] +name = "rustify-tui" +path = "src/main.rs" + +[dependencies] +rustify-core = { path = "../rustify-core" } +ratatui = "0.29" +crossterm = "0.28" +crossbeam = "0.8" +serde = { version = "1", features = ["derive"] } +toml = "0.8" +dirs = "6" +nucleo-matcher = "0.3" +rustfft = "6" +ureq = "3" +rustify-mpris = { path = "../rustify-mpris" } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/rustify-tui/src/app.rs b/crates/rustify-tui/src/app.rs new file mode 100644 index 0000000..0261d8c --- /dev/null +++ b/crates/rustify-tui/src/app.rs @@ -0,0 +1,751 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::widgets::ListState; +use rustify_core::lyrics::Lyrics; +use rustify_core::types::{PlaybackState, PlayerEvent, Track}; + +use crate::library::Library; +use crate::ui::visualizer::{VisualizerMode, VisualizerState}; + +/// Which UI region has keyboard focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Sidebar, + Main, + Search, +} + +/// Which view is displayed in the main panel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MainView { + Artists, + Albums, + Songs, + Playlists, + AlbumDetail, +} + +/// Player command to execute after key handling. +#[derive(Debug)] +pub enum PlayerAction { + PlayPause, + Next, + Previous, + Seek(i64), + PlayTrackUri(String), + LoadTrackUris(Vec), + ToggleShuffle, + CycleRepeat, +} + +const NAV_ITEMS: &[&str] = &["Artists", "Albums", "Songs", "Playlists"]; + +/// Now-playing state cached from player callbacks. +#[derive(Debug)] +pub struct NowPlayingState { + pub track: Option, + pub state: Option, + pub position_ms: u64, + pub volume: u8, + pub shuffle: bool, + pub repeat: rustify_core::types::RepeatMode, +} + +impl Default for NowPlayingState { + fn default() -> Self { + Self { + track: None, + state: None, + position_ms: 0, + volume: 0, + shuffle: false, + repeat: rustify_core::types::RepeatMode::Off, + } + } +} + +/// Cached album art state. +#[derive(Debug, Default)] +pub struct ArtState { + /// URI of the track whose art is currently cached. + pub current_uri: Option, + /// Whether we have art for the current track. + pub has_art: bool, + /// Raw image bytes (for rendering by the UI layer). + pub image_bytes: Option>, +} + +/// Search overlay state. +#[derive(Debug, Default)] +pub struct SearchState { + pub active: bool, + pub query: String, + pub results_state: ListState, +} + +/// Queue display state. +#[derive(Debug, Default)] +pub struct QueueState { + pub list_state: ListState, + pub track_uris: Vec, + pub track_names: Vec, +} + +/// Lyrics overlay state. +#[derive(Debug, Default)] +pub struct LyricsState { + pub active: bool, + pub lyrics: Option, + pub current_line: usize, + pub scroll_offset: usize, +} + +/// Status message shown temporarily above the now-playing bar. +#[derive(Debug)] +pub struct StatusMessage { + pub text: String, + pub expires_tick: u64, +} + +/// Root application state. +pub struct App { + pub should_quit: bool, + pub focus: Focus, + pub sidebar_nav_index: usize, + pub main_view: MainView, + pub now_playing: NowPlayingState, + pub library: Option, + pub scanning: bool, + pub search: SearchState, + pub queue: QueueState, + pub status: Option, + pub tick_count: u64, + pub playlists: Vec, + pub art: ArtState, + pub theme: crate::theme::Theme, + pub visualizer_mode: VisualizerMode, + pub visualizer_state: VisualizerState, + pub visualizer_samples: Vec, + pub lyrics: LyricsState, + #[allow(dead_code)] + pub replay_gain_enabled: bool, + #[allow(dead_code)] + pub base_volume: u8, + + // Per-view list states for ratatui + pub artist_list_state: ListState, + pub album_list_state: ListState, + pub song_list_state: ListState, + pub playlist_list_state: ListState, + pub detail_list_state: ListState, + + // Artist/album drill-down context + pub selected_artist: Option, + pub selected_album_index: Option, +} + +impl App { + pub fn new() -> Self { + Self { + should_quit: false, + focus: Focus::Sidebar, + sidebar_nav_index: 0, + main_view: MainView::Artists, + now_playing: NowPlayingState::default(), + library: None, + scanning: false, + search: SearchState::default(), + queue: QueueState::default(), + status: None, + tick_count: 0, + playlists: Vec::new(), + art: ArtState::default(), + theme: crate::theme::Theme::default_theme(), + visualizer_mode: VisualizerMode::Spectrum, + visualizer_state: VisualizerState::default(), + visualizer_samples: Vec::new(), + lyrics: LyricsState::default(), + replay_gain_enabled: false, + base_volume: 100, + + artist_list_state: ListState::default(), + album_list_state: ListState::default(), + song_list_state: ListState::default(), + playlist_list_state: ListState::default(), + detail_list_state: ListState::default(), + + selected_artist: None, + selected_album_index: None, + } + } + + /// Handle a key event. Returns a PlayerAction if a player command should be issued. + pub fn handle_key(&mut self, key: KeyEvent) -> Option { + if key.kind != KeyEventKind::Press { + return None; + } + + // Global keys (work regardless of focus) + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + return None; + } + KeyCode::Tab => { + self.focus = match self.focus { + Focus::Sidebar => Focus::Main, + Focus::Main => Focus::Sidebar, + Focus::Search => Focus::Main, + }; + return None; + } + KeyCode::Char(c @ '1'..='4') => { + let idx = (c as usize) - ('1' as usize); + self.sidebar_nav_index = idx; + self.main_view = nav_index_to_view(idx); + return None; + } + KeyCode::Char(' ') => { + return Some(PlayerAction::PlayPause); + } + KeyCode::Char('n') if self.focus != Focus::Search => { + return Some(PlayerAction::Next); + } + KeyCode::Char('p') if self.focus != Focus::Search => { + return Some(PlayerAction::Previous); + } + KeyCode::Char('s') if self.focus != Focus::Search => { + return Some(PlayerAction::ToggleShuffle); + } + KeyCode::Char('r') if self.focus != Focus::Search => { + return Some(PlayerAction::CycleRepeat); + } + KeyCode::Left => { + return Some(PlayerAction::Seek(-5000)); + } + KeyCode::Right => { + return Some(PlayerAction::Seek(5000)); + } + KeyCode::Char('+') | KeyCode::Char('=') => { + self.now_playing.volume = (self.now_playing.volume + 5).min(100); + return None; + } + KeyCode::Char('-') => { + self.now_playing.volume = self.now_playing.volume.saturating_sub(5); + return None; + } + KeyCode::Char('V') if key.modifiers.contains(KeyModifiers::SHIFT) => { + self.visualizer_mode = self.visualizer_mode.toggle(); + return None; + } + KeyCode::Char('L') if key.modifiers.contains(KeyModifiers::SHIFT) => { + self.lyrics.active = !self.lyrics.active; + return None; + } + KeyCode::Char('/') if self.focus != Focus::Search => { + self.search.active = true; + self.search.query.clear(); + self.focus = Focus::Search; + return None; + } + KeyCode::Esc => { + if self.search.active { + self.search.active = false; + self.focus = Focus::Main; + return None; + } + if self.main_view == MainView::AlbumDetail { + self.main_view = if self.selected_artist.is_some() { + MainView::Albums + } else { + MainView::Artists + }; + return None; + } + } + _ => {} + } + + // Search mode key handling + if self.focus == Focus::Search { + self.handle_search_key(key); + return None; + } + + // Focus-specific keys + match self.focus { + Focus::Sidebar => { + self.handle_sidebar_key(key); + } + Focus::Main => { + return self.handle_main_key(key); + } + Focus::Search => {} + } + None + } + + fn handle_sidebar_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if self.sidebar_nav_index < NAV_ITEMS.len() - 1 { + self.sidebar_nav_index += 1; + } + } + KeyCode::Char('k') | KeyCode::Up => { + self.sidebar_nav_index = self.sidebar_nav_index.saturating_sub(1); + } + KeyCode::Enter => { + self.main_view = nav_index_to_view(self.sidebar_nav_index); + self.focus = Focus::Main; + } + _ => {} + } + } + + fn handle_main_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + self.move_main_selection(1); + } + KeyCode::Char('k') | KeyCode::Up => { + self.move_main_selection(-1); + } + KeyCode::Enter => { + return self.activate_main_selection(); + } + KeyCode::Char('a') => { + // Add selected track to queue + if let Some(track) = self.get_selected_track() { + self.add_to_queue(track.uri, track.name); + } + } + _ => {} + } + None + } + + fn handle_search_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char(c) => { + self.search.query.push(c); + } + KeyCode::Backspace => { + self.search.query.pop(); + } + KeyCode::Enter => { + self.search.active = false; + self.focus = Focus::Main; + } + _ => {} + } + } + + fn move_main_selection(&mut self, delta: i32) { + let list_state = self.active_list_state_mut(); + let current = list_state.selected().unwrap_or(0); + let new = if delta > 0 { + current.saturating_add(delta as usize) + } else { + current.saturating_sub((-delta) as usize) + }; + list_state.select(Some(new)); + } + + fn activate_main_selection(&mut self) -> Option { + match self.main_view { + MainView::Artists => { + if let Some(lib) = &self.library { + let artists: Vec<&String> = lib.artist_names(); + if let Some(selected) = self.artist_list_state.selected() { + if let Some(name) = artists.get(selected) { + self.selected_artist = Some((*name).clone()); + self.main_view = MainView::Albums; + self.album_list_state.select(Some(0)); + } + } + } + None + } + MainView::Albums => { + if let Some(selected) = self.album_list_state.selected() { + self.selected_album_index = Some(selected); + self.main_view = MainView::AlbumDetail; + self.detail_list_state.select(Some(0)); + } + None + } + MainView::Songs => { + if let Some(track) = self.get_selected_track() { + let uri = track.uri.clone(); + return Some(PlayerAction::PlayTrackUri(uri)); + } + None + } + MainView::AlbumDetail => { + // Play album from selected track + if let Some(uris) = self.get_album_detail_uris() { + return Some(PlayerAction::LoadTrackUris(uris)); + } + None + } + MainView::Playlists => None, + } + } + + /// Get the currently selected track in the active view (cloned). + fn get_selected_track(&self) -> Option { + let lib = self.library.as_ref()?; + match self.main_view { + MainView::Songs => { + let idx = self.song_list_state.selected()?; + lib.all_tracks().get(idx).cloned() + } + MainView::AlbumDetail => { + let albums = if let Some(ref artist) = self.selected_artist { + lib.albums_by_artist(artist).to_vec() + } else { + lib.all_albums().into_iter().cloned().collect() + }; + let album = albums.get(self.selected_album_index?)?; + let idx = self.detail_list_state.selected()?; + album.tracks.get(idx).cloned() + } + _ => None, + } + } + + /// Get all track URIs from the currently viewed album detail. + fn get_album_detail_uris(&self) -> Option> { + let lib = self.library.as_ref()?; + let albums = if let Some(ref artist) = self.selected_artist { + lib.albums_by_artist(artist).to_vec() + } else { + lib.all_albums().into_iter().cloned().collect() + }; + let album = albums.get(self.selected_album_index?)?; + Some(album.tracks.iter().map(|t| t.uri.clone()).collect()) + } + + /// Get a mutable reference to the active view's list state. + pub fn active_list_state_mut(&mut self) -> &mut ListState { + match self.main_view { + MainView::Artists => &mut self.artist_list_state, + MainView::Albums => &mut self.album_list_state, + MainView::Songs => &mut self.song_list_state, + MainView::Playlists => &mut self.playlist_list_state, + MainView::AlbumDetail => &mut self.detail_list_state, + } + } + + /// Handle a tick event. + pub fn handle_tick(&mut self) { + self.tick_count += 1; + if let Some(ref status) = self.status { + if self.tick_count >= status.expires_tick { + self.status = None; + } + } + } + + /// Handle a player event (callback from rustify-core). + pub fn handle_player_event(&mut self, event: PlayerEvent) { + match event { + PlayerEvent::StateChanged(state) => { + self.now_playing.state = Some(state); + } + PlayerEvent::TrackChanged(track) => { + // Clear art cache when track changes + if self.art.current_uri.as_deref() != Some(&track.uri) { + self.art.current_uri = Some(track.uri.clone()); + self.art.has_art = false; + self.art.image_bytes = None; + } + self.now_playing.track = Some(track); + self.now_playing.position_ms = 0; + } + PlayerEvent::PositionUpdate(ms) => { + self.now_playing.position_ms = ms; + } + PlayerEvent::Error(msg) => { + self.set_status(msg); + } + PlayerEvent::ModeChanged { shuffle, repeat } => { + self.now_playing.shuffle = shuffle; + self.now_playing.repeat = repeat; + } + } + } + + /// Handle a mouse click at terminal coordinates. + pub fn handle_mouse_click(&mut self, x: u16, y: u16, term_width: u16, term_height: u16) { + let sidebar_width = term_width * 30 / 100; + let now_playing_height = 3u16; + let content_height = term_height.saturating_sub(now_playing_height); + + if y < content_height { + if x < sidebar_width { + self.focus = Focus::Sidebar; + if (1..=4).contains(&y) { + let nav_index = (y - 1) as usize; + if nav_index < NAV_ITEMS.len() { + self.sidebar_nav_index = nav_index; + self.main_view = nav_index_to_view(nav_index); + } + } + } else { + self.focus = Focus::Main; + } + } + } + + /// Set a status message that auto-dismisses after ~5 seconds (20 ticks at 4Hz). + pub fn set_status(&mut self, text: String) { + self.status = Some(StatusMessage { + text, + expires_tick: self.tick_count + 20, + }); + } + + /// Sidebar nav item labels. + pub fn nav_items(&self) -> &[&str] { + NAV_ITEMS + } + + // --- Queue operations --- + + /// Add a track to the end of the queue. + pub fn add_to_queue(&mut self, uri: String, name: String) { + self.queue.track_uris.push(uri); + self.queue.track_names.push(name); + } + + /// Remove a track from the queue by index. + #[allow(dead_code)] + pub fn remove_from_queue(&mut self, index: usize) { + if index < self.queue.track_uris.len() { + self.queue.track_uris.remove(index); + self.queue.track_names.remove(index); + if let Some(selected) = self.queue.list_state.selected() { + if selected >= self.queue.track_uris.len() && !self.queue.track_uris.is_empty() { + self.queue + .list_state + .select(Some(self.queue.track_uris.len() - 1)); + } + } + } + } + + /// Swap two tracks in the queue. + #[allow(dead_code)] + pub fn reorder_queue(&mut self, from: usize, to: usize) { + if from < self.queue.track_uris.len() && to < self.queue.track_uris.len() { + self.queue.track_uris.swap(from, to); + self.queue.track_names.swap(from, to); + } + } + + /// Get all queue URIs (for loading into player). + #[allow(dead_code)] + pub fn queue_uris(&self) -> Vec { + self.queue.track_uris.clone() + } + + /// Generate M3U content from the current queue. + #[allow(dead_code)] + pub fn generate_m3u_content(&self) -> String { + let mut content = String::from("#EXTM3U\n"); + for uri in &self.queue.track_uris { + let path = uri.strip_prefix("file://").unwrap_or(uri); + content.push_str(path); + content.push('\n'); + } + content + } +} + +fn nav_index_to_view(index: usize) -> MainView { + match index { + 0 => MainView::Artists, + 1 => MainView::Albums, + 2 => MainView::Songs, + 3 => MainView::Playlists, + _ => MainView::Artists, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyEventState, KeyModifiers}; + + fn make_key(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn make_app() -> App { + App::new() + } + + #[test] + fn initial_state() { + let app = make_app(); + assert!(!app.should_quit); + assert_eq!(app.focus, Focus::Sidebar); + assert_eq!(app.sidebar_nav_index, 0); + } + + #[test] + fn q_sets_should_quit() { + let mut app = make_app(); + let _ = app.handle_key(make_key(KeyCode::Char('q'))); + assert!(app.should_quit); + } + + #[test] + fn tab_cycles_focus() { + let mut app = make_app(); + assert_eq!(app.focus, Focus::Sidebar); + app.handle_key(make_key(KeyCode::Tab)); + assert_eq!(app.focus, Focus::Main); + app.handle_key(make_key(KeyCode::Tab)); + assert_eq!(app.focus, Focus::Sidebar); + } + + #[test] + fn number_keys_switch_sidebar_nav() { + let mut app = make_app(); + app.handle_key(make_key(KeyCode::Char('2'))); + assert_eq!(app.sidebar_nav_index, 1); + assert_eq!(app.main_view, MainView::Albums); + app.handle_key(make_key(KeyCode::Char('3'))); + assert_eq!(app.sidebar_nav_index, 2); + assert_eq!(app.main_view, MainView::Songs); + } + + #[test] + fn j_k_navigates_sidebar_nav_when_focused() { + let mut app = make_app(); + assert_eq!(app.sidebar_nav_index, 0); + app.handle_key(make_key(KeyCode::Char('j'))); + assert_eq!(app.sidebar_nav_index, 1); + app.handle_key(make_key(KeyCode::Char('k'))); + assert_eq!(app.sidebar_nav_index, 0); + app.handle_key(make_key(KeyCode::Char('k'))); + assert_eq!(app.sidebar_nav_index, 0); + } + + #[test] + fn enter_on_sidebar_nav_switches_view_and_focus() { + let mut app = make_app(); + app.sidebar_nav_index = 2; + app.handle_key(make_key(KeyCode::Enter)); + assert_eq!(app.main_view, MainView::Songs); + assert_eq!(app.focus, Focus::Main); + } + + #[test] + fn space_returns_play_pause_action() { + let mut app = make_app(); + let action = app.handle_key(make_key(KeyCode::Char(' '))); + assert!(matches!(action, Some(PlayerAction::PlayPause))); + } + + #[test] + fn player_state_change_updates_now_playing() { + let mut app = make_app(); + app.handle_player_event(PlayerEvent::StateChanged(PlaybackState::Playing)); + assert_eq!(app.now_playing.state, Some(PlaybackState::Playing)); + } + + #[test] + fn player_track_change_updates_now_playing() { + let mut app = make_app(); + let track = Track { + uri: "file:///test.mp3".into(), + name: "Test".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: 100_000, + track_no: None, + }; + app.handle_player_event(PlayerEvent::TrackChanged(track)); + assert_eq!(app.now_playing.track.as_ref().unwrap().name, "Test"); + } + + #[test] + fn player_position_update_updates_now_playing() { + let mut app = make_app(); + app.handle_player_event(PlayerEvent::PositionUpdate(42_000)); + assert_eq!(app.now_playing.position_ms, 42_000); + } + + #[test] + fn player_error_sets_status_message() { + let mut app = make_app(); + app.handle_player_event(PlayerEvent::Error("decode failed".into())); + assert!(app.status.is_some()); + assert!(app.status.as_ref().unwrap().text.contains("decode failed")); + } + + #[test] + fn mouse_click_in_sidebar_area_sets_focus() { + let mut app = make_app(); + app.focus = Focus::Main; + app.handle_mouse_click(5, 3, 80, 24); + assert_eq!(app.focus, Focus::Sidebar); + } + + #[test] + fn mouse_click_in_main_area_sets_focus() { + let mut app = make_app(); + app.focus = Focus::Sidebar; + app.handle_mouse_click(30, 3, 80, 24); + assert_eq!(app.focus, Focus::Main); + } + + #[test] + fn add_to_queue() { + let mut app = make_app(); + app.add_to_queue("file:///music/test.mp3".into(), "Test Song".into()); + assert_eq!(app.queue.track_uris.len(), 1); + assert_eq!(app.queue.track_names.len(), 1); + assert_eq!(app.queue.track_names[0], "Test Song"); + } + + #[test] + fn remove_from_queue() { + let mut app = make_app(); + app.add_to_queue("file:///a.mp3".into(), "A".into()); + app.add_to_queue("file:///b.mp3".into(), "B".into()); + app.add_to_queue("file:///c.mp3".into(), "C".into()); + app.remove_from_queue(1); + assert_eq!(app.queue.track_uris.len(), 2); + assert_eq!(app.queue.track_names[1], "C"); + } + + #[test] + fn reorder_queue_down() { + let mut app = make_app(); + app.add_to_queue("file:///a.mp3".into(), "A".into()); + app.add_to_queue("file:///b.mp3".into(), "B".into()); + app.add_to_queue("file:///c.mp3".into(), "C".into()); + app.reorder_queue(0, 1); + assert_eq!(app.queue.track_names[0], "B"); + assert_eq!(app.queue.track_names[1], "A"); + } + + #[test] + fn save_queue_as_m3u_generates_content() { + let mut app = make_app(); + app.queue.track_uris = vec!["file:///music/a.mp3".into(), "file:///music/b.flac".into()]; + let content = app.generate_m3u_content(); + assert!(content.contains("#EXTM3U")); + assert!(content.contains("/music/a.mp3")); + assert!(content.contains("/music/b.flac")); + } +} diff --git a/crates/rustify-tui/src/config.rs b/crates/rustify-tui/src/config.rs new file mode 100644 index 0000000..e69f101 --- /dev/null +++ b/crates/rustify-tui/src/config.rs @@ -0,0 +1,139 @@ +use std::path::PathBuf; + +use serde::Deserialize; + +/// TUI configuration, loaded from `~/.config/rustify/tui.toml`. +#[derive(Debug, Deserialize)] +pub struct TuiConfig { + /// Directories to scan for music files. + #[serde(default)] + pub music_dirs: Vec, + + /// ALSA device name passed to rustify-core. + #[serde(default = "default_alsa_device")] + pub alsa_device: String, + + /// Theme preset name. + #[serde(default = "default_theme")] + pub theme: String, + + /// Custom theme color overrides (optional). + #[serde(default)] + pub custom_theme: Option, + + #[serde(default)] + #[allow(dead_code)] + pub replay_gain: bool, + + #[serde(default)] + pub crossfade_ms: u64, + + #[serde(default)] + pub listenbrainz_token: String, +} + +/// Custom theme colors as hex strings (#RRGGBB). +#[derive(Debug, Default, Deserialize)] +pub struct CustomThemeConfig { + pub fg: Option, + pub fg_dim: Option, + pub accent: Option, + pub accent_dim: Option, + pub border: Option, + pub error: Option, + pub visualizer: Option>, +} + +fn default_alsa_device() -> String { + "default".to_string() +} + +fn default_theme() -> String { + "default".to_string() +} + +impl Default for TuiConfig { + fn default() -> Self { + Self { + music_dirs: Vec::new(), + alsa_device: default_alsa_device(), + theme: default_theme(), + custom_theme: None, + replay_gain: false, + crossfade_ms: 0, + listenbrainz_token: String::new(), + } + } +} + +impl TuiConfig { + /// Platform-appropriate config file path. + /// Linux/macOS: `~/.config/rustify/tui.toml` + /// Windows: `%APPDATA%\rustify\tui.toml` + pub fn config_path() -> Option { + dirs::config_dir().map(|d| d.join("rustify").join("tui.toml")) + } + + /// Load config from disk. Returns defaults if file doesn't exist. + /// Prints a warning to stderr if the file exists but can't be parsed. + pub fn load() -> Self { + let Some(path) = Self::config_path() else { + return Self::default(); + }; + + match std::fs::read_to_string(&path) { + Ok(contents) => match toml::from_str(&contents) { + Ok(config) => config, + Err(e) => { + eprintln!("rustify: failed to parse {}: {e}", path.display()); + Self::default() + } + }, + Err(_) => Self::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_has_sensible_values() { + let config = TuiConfig::default(); + assert_eq!(config.alsa_device, "default"); + assert!(config.music_dirs.is_empty()); + assert_eq!(config.theme, "default"); + } + + #[test] + fn parse_from_toml_string() { + let toml_str = r#" + music_dirs = ["/home/pi/Music"] + alsa_device = "hw:0" + theme = "nord" + "#; + let config: TuiConfig = toml::from_str(toml_str).unwrap(); + assert_eq!( + config.music_dirs, + vec![std::path::PathBuf::from("/home/pi/Music")] + ); + assert_eq!(config.alsa_device, "hw:0"); + assert_eq!(config.theme, "nord"); + } + + #[test] + fn parse_partial_toml_uses_defaults() { + let toml_str = r#" + music_dirs = ["/Music"] + "#; + let config: TuiConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.alsa_device, "default"); + assert_eq!(config.theme, "default"); + } + + #[test] + fn config_path_returns_some() { + let _ = TuiConfig::config_path(); + } +} diff --git a/crates/rustify-tui/src/event.rs b/crates/rustify-tui/src/event.rs new file mode 100644 index 0000000..f6eb601 --- /dev/null +++ b/crates/rustify-tui/src/event.rs @@ -0,0 +1,148 @@ +use std::thread; +use std::time::Duration; + +use crossbeam::channel::{self, Receiver, Sender}; +use crossterm::event::{self, Event, KeyEvent, MouseEvent}; +use rustify_core::lyrics::Lyrics; +use rustify_core::types::PlayerEvent; + +use crate::library::Library; + +/// Unified event type for the TUI event loop. +#[derive(Debug)] +#[allow(dead_code)] +pub enum AppEvent { + /// Keyboard input + Key(KeyEvent), + /// Mouse input + Mouse(MouseEvent), + /// Terminal resize + Resize(u16, u16), + /// Player state/track/position callback + Player(PlayerEvent), + /// UI refresh tick (~4Hz) + Tick, + /// Background library scan completed + ScanComplete(Library), + /// Album art loaded for a track URI + ArtLoaded { uri: String, data: Option> }, + /// Lyrics loaded for a track URI + LyricsLoaded { uri: String, data: Option }, + /// Non-player error + Error(String), +} + +/// Manages background threads that feed events into a single channel. +pub struct EventLoop { + tx: Sender, + rx: Receiver, +} + +impl EventLoop { + /// Create a new event loop. Spawns the input and tick threads immediately. + pub fn new() -> Self { + let (tx, rx) = channel::unbounded(); + + // Tick thread — sends AppEvent::Tick at ~4Hz + let tick_tx = tx.clone(); + thread::Builder::new() + .name("rustify-tick".into()) + .spawn(move || loop { + thread::sleep(Duration::from_millis(250)); + if tick_tx.send(AppEvent::Tick).is_err() { + break; + } + }) + .expect("failed to spawn tick thread"); + + // Input thread — polls crossterm events and forwards them + let input_tx = tx.clone(); + thread::Builder::new() + .name("rustify-input".into()) + .spawn(move || loop { + // Poll with timeout so we can detect channel disconnect + match event::poll(Duration::from_millis(100)) { + Ok(true) => match event::read() { + Ok(Event::Key(key)) => { + if input_tx.send(AppEvent::Key(key)).is_err() { + break; + } + } + Ok(Event::Mouse(mouse)) => { + if input_tx.send(AppEvent::Mouse(mouse)).is_err() { + break; + } + } + Ok(Event::Resize(w, h)) => { + if input_tx.send(AppEvent::Resize(w, h)).is_err() { + break; + } + } + _ => {} + }, + Ok(false) => {} + Err(_) => break, + } + }) + .expect("failed to spawn input thread"); + + Self { tx, rx } + } + + /// Get a clone of the sender for pushing events from player callbacks. + pub fn sender(&self) -> Sender { + self.tx.clone() + } + + /// Get the receiver for the main event loop. + pub fn receiver(&self) -> Receiver { + self.rx.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustify_core::types::PlaybackState; + + #[test] + fn app_event_variants_exist() { + let _ = AppEvent::Tick; + let _ = AppEvent::Error("test".into()); + } + + #[test] + fn event_loop_sends_ticks() { + let event_loop = EventLoop::new(); + std::thread::sleep(std::time::Duration::from_millis(350)); + let event = event_loop.receiver().try_recv(); + assert!(event.is_ok()); + } + + #[test] + fn event_loop_receiver_is_clone_safe() { + let event_loop = EventLoop::new(); + let rx = event_loop.receiver(); + let _rx2 = rx.clone(); + } + + #[test] + fn sender_can_push_player_events() { + let event_loop = EventLoop::new(); + let tx = event_loop.sender(); + tx.send(AppEvent::Player(PlayerEvent::StateChanged( + PlaybackState::Playing, + ))) + .unwrap(); + // Drain ticks first, find our event + loop { + match event_loop.receiver().try_recv() { + Ok(AppEvent::Player(_)) => break, + Ok(_) => continue, + Err(_) => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + } + } +} diff --git a/crates/rustify-tui/src/library.rs b/crates/rustify-tui/src/library.rs new file mode 100644 index 0000000..8031e8b --- /dev/null +++ b/crates/rustify-tui/src/library.rs @@ -0,0 +1,285 @@ +use std::collections::BTreeMap; + +use rustify_core::types::Track; + +/// An album in the library index. +#[derive(Debug, Clone)] +pub struct Album { + pub name: String, + pub artist: String, + pub tracks: Vec, +} + +/// In-memory music library index, organized by artist and album. +#[derive(Debug)] +pub struct Library { + /// Artists sorted alphabetically, each with their albums. + artists: BTreeMap>, + /// Flat list of all tracks for the Songs view. + tracks: Vec, +} + +impl Library { + /// Build a library index from a flat list of tracks. + /// Groups by artist -> album, sorts tracks within albums by track number. + pub fn from_tracks(tracks: Vec) -> Self { + let mut artist_albums: BTreeMap>> = BTreeMap::new(); + + for track in &tracks { + let artist_name = if track.artists.is_empty() { + "Unknown Artist".to_string() + } else { + track.artists[0].clone() + }; + + artist_albums + .entry(artist_name) + .or_default() + .entry(track.album.clone()) + .or_default() + .push(track.clone()); + } + + let artists: BTreeMap> = artist_albums + .into_iter() + .map(|(artist_name, albums_map)| { + let mut albums: Vec = albums_map + .into_iter() + .map(|(album_name, mut album_tracks)| { + album_tracks.sort_by_key(|t| t.track_no.unwrap_or(u32::MAX)); + Album { + name: album_name, + artist: artist_name.clone(), + tracks: album_tracks, + } + }) + .collect(); + albums.sort_by(|a, b| a.name.cmp(&b.name)); + (artist_name, albums) + }) + .collect(); + + let mut sorted_tracks = tracks; + sorted_tracks.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + Self { + artists, + tracks: sorted_tracks, + } + } + + /// Sorted list of artist names. + pub fn artist_names(&self) -> Vec<&String> { + self.artists.keys().collect() + } + + /// Albums for a given artist name. + pub fn albums_by_artist(&self, artist: &str) -> &[Album] { + self.artists.get(artist).map(Vec::as_slice).unwrap_or(&[]) + } + + /// All albums across all artists (sorted by name). + pub fn all_albums(&self) -> Vec<&Album> { + let mut albums: Vec<&Album> = self.artists.values().flat_map(|a| a.iter()).collect(); + albums.sort_by(|a, b| a.name.cmp(&b.name)); + albums + } + + /// All tracks (sorted by name). + pub fn all_tracks(&self) -> &[Track] { + &self.tracks + } + + /// Case-insensitive substring search across track names, artist names, and album names. + /// Fuzzy search across track names, artist names, and album names. + /// Returns results ranked by match quality (best first), capped at 50. + pub fn fuzzy_search(&self, query: &str) -> Vec> { + if query.is_empty() { + return Vec::new(); + } + + use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; + use nucleo_matcher::{Config, Matcher, Utf32Str}; + + let mut matcher = Matcher::new(Config::DEFAULT); + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + + let mut results: Vec> = self + .tracks + .iter() + .filter_map(|track| { + let haystack = format!( + "{} {} {}", + track.name, + track.artists.first().unwrap_or(&String::new()), + track.album + ); + let mut buf = Vec::new(); + let utf32 = Utf32Str::new(&haystack, &mut buf); + let score = pattern.score(utf32, &mut matcher)?; + let mut indices = Vec::new(); + pattern.indices(utf32, &mut matcher, &mut indices); + Some(SearchResult { + track, + score, + matched_indices: indices, + }) + }) + .collect(); + + results.sort_by(|a, b| b.score.cmp(&a.score)); + results.truncate(50); + results + } +} + +/// A fuzzy search result with match score and highlighted character indices. +#[allow(dead_code)] +pub struct SearchResult<'a> { + pub track: &'a Track, + pub score: u32, + pub matched_indices: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_tracks() -> Vec { + vec![ + Track { + uri: "file:///music/m83/hurry/midnight.mp3".into(), + name: "Midnight City".into(), + artists: vec!["M83".into()], + album: "Hurry Up, We're Dreaming".into(), + length: 243_000, + track_no: Some(1), + }, + Track { + uri: "file:///music/m83/hurry/reunion.mp3".into(), + name: "Reunion".into(), + artists: vec!["M83".into()], + album: "Hurry Up, We're Dreaming".into(), + length: 407_000, + track_no: Some(2), + }, + Track { + uri: "file:///music/m83/saturdays/kim.mp3".into(), + name: "Kim & Jessie".into(), + artists: vec!["M83".into()], + album: "Saturdays = Youth".into(), + length: 315_000, + track_no: Some(1), + }, + Track { + uri: "file:///music/radiohead/ok/paranoid.mp3".into(), + name: "Paranoid Android".into(), + artists: vec!["Radiohead".into()], + album: "OK Computer".into(), + length: 383_000, + track_no: Some(2), + }, + ] + } + + #[test] + fn build_library_groups_by_artist() { + let lib = Library::from_tracks(make_tracks()); + let names = lib.artist_names(); + assert_eq!(names.len(), 2); + assert!(names.contains(&&"M83".to_string())); + assert!(names.contains(&&"Radiohead".to_string())); + } + + #[test] + fn artist_albums_returns_correct_albums() { + let lib = Library::from_tracks(make_tracks()); + let albums = lib.albums_by_artist("M83"); + assert_eq!(albums.len(), 2); + let album_names: Vec<&str> = albums.iter().map(|a| a.name.as_str()).collect(); + assert!(album_names.contains(&"Hurry Up, We're Dreaming")); + assert!(album_names.contains(&"Saturdays = Youth")); + } + + #[test] + fn album_tracks_returns_sorted_tracks() { + let lib = Library::from_tracks(make_tracks()); + let albums = lib.albums_by_artist("M83"); + let hurry = albums.iter().find(|a| a.name.contains("Hurry")).unwrap(); + assert_eq!(hurry.tracks.len(), 2); + assert_eq!(hurry.tracks[0].name, "Midnight City"); + assert_eq!(hurry.tracks[1].name, "Reunion"); + } + + #[test] + fn all_tracks_returns_everything() { + let lib = Library::from_tracks(make_tracks()); + assert_eq!(lib.all_tracks().len(), 4); + } + + #[test] + fn all_albums_returns_everything() { + let lib = Library::from_tracks(make_tracks()); + assert_eq!(lib.all_albums().len(), 3); + } + + #[test] + fn empty_library() { + let lib = Library::from_tracks(vec![]); + assert!(lib.artist_names().is_empty()); + assert!(lib.all_tracks().is_empty()); + assert!(lib.all_albums().is_empty()); + } + + #[test] + fn fuzzy_search_finds_exact_match() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search("Midnight City"); + assert!(!results.is_empty()); + assert_eq!(results[0].track.name, "Midnight City"); + } + + #[test] + fn fuzzy_search_partial_match() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search("mid cit"); + assert!(!results.is_empty()); + assert_eq!(results[0].track.name, "Midnight City"); + } + + #[test] + fn fuzzy_search_is_case_insensitive() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search("PARANOID"); + assert!(!results.is_empty()); + } + + #[test] + fn fuzzy_search_matches_artist_names() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search("radiohead"); + assert!(!results.is_empty()); + } + + #[test] + fn fuzzy_search_empty_query_returns_empty() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search(""); + assert!(results.is_empty()); + } + + #[test] + fn fuzzy_search_no_match() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search("zzzzzzzzz"); + assert!(results.is_empty()); + } + + #[test] + fn fuzzy_search_returns_matched_indices() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search("Midnight"); + assert!(!results.is_empty()); + assert!(!results[0].matched_indices.is_empty()); + } +} diff --git a/crates/rustify-tui/src/main.rs b/crates/rustify-tui/src/main.rs new file mode 100644 index 0000000..853dcc7 --- /dev/null +++ b/crates/rustify-tui/src/main.rs @@ -0,0 +1,348 @@ +use std::io; +use std::path::PathBuf; +use std::thread; + +use crossterm::event::{MouseButton, MouseEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::prelude::*; + +use rustify_core::metadata::read_metadata; +use rustify_core::player::{Player, PlayerConfig}; +use rustify_core::scanner; +use rustify_core::types::PlayerEvent; + +mod app; +mod config; +mod event; +mod library; +mod scrobble; +mod theme; +mod ui; + +use app::App; +use event::{AppEvent, EventLoop}; +use library::Library; + +fn main() -> io::Result<()> { + let args: Vec = std::env::args().collect(); + + // Load config + let mut config = config::TuiConfig::load(); + + // CLI args override config music_dirs + let extra_dirs: Vec = args[1..] + .iter() + .map(PathBuf::from) + .filter(|p| p.is_dir()) + .collect(); + if !extra_dirs.is_empty() { + config.music_dirs.extend(extra_dirs); + } + + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!( + stdout, + EnterAlternateScreen, + crossterm::event::EnableMouseCapture + )?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create event loop + let event_loop = EventLoop::new(); + let rx = event_loop.receiver(); + let tx = event_loop.sender(); + + // Create player + let player_config = PlayerConfig { + alsa_device: config.alsa_device.clone(), + music_dirs: config.music_dirs.clone(), + }; + + let player = match Player::new(player_config) { + Ok(p) => p, + Err(e) => { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + crossterm::event::DisableMouseCapture + )?; + eprintln!("rustify: failed to create player: {e}"); + std::process::exit(1); + } + }; + + if config.crossfade_ms > 0 { + player.set_crossfade(config.crossfade_ms); + } + + // Register player callbacks to push into event channel + let tx_state = tx.clone(); + player.on_state_change(Box::new(move |state| { + tx_state + .send(AppEvent::Player(PlayerEvent::StateChanged(state))) + .ok(); + })); + + let tx_track = tx.clone(); + player.on_track_change(Box::new(move |track| { + tx_track + .send(AppEvent::Player(PlayerEvent::TrackChanged(track))) + .ok(); + })); + + let tx_pos = tx.clone(); + player.on_position_update(Box::new(move |ms| { + tx_pos + .send(AppEvent::Player(PlayerEvent::PositionUpdate(ms))) + .ok(); + })); + + let tx_err = tx.clone(); + player.on_error(Box::new(move |msg| { + tx_err.send(AppEvent::Player(PlayerEvent::Error(msg))).ok(); + })); + + let tx_mode = tx.clone(); + player.on_mode_change(Box::new(move |shuffle, repeat| { + tx_mode + .send(AppEvent::Player(PlayerEvent::ModeChanged { + shuffle, + repeat, + })) + .ok(); + })); + + let mut app = App::new(); + app.theme = theme::Theme::from_config(&config); + app.now_playing.volume = player.get_volume(); + app.base_volume = player.get_volume(); + app.replay_gain_enabled = config.replay_gain; + + // Create scrobbler + let mut scrobbler = scrobble::Scrobbler::new(config.listenbrainz_token.clone()); + + // Start MPRIS (no-op on non-Linux) + // Create a PlayerEvent sender that maps into the AppEvent channel + let (mpris_tx, mpris_rx) = crossbeam::channel::unbounded::(); + { + let tx_mpris = tx.clone(); + thread::Builder::new() + .name("rustify-mpris-bridge".into()) + .spawn(move || { + while let Ok(ev) = mpris_rx.recv() { + if tx_mpris.send(AppEvent::Player(ev)).is_err() { + break; + } + } + }) + .ok(); + } + rustify_mpris::start(&player, mpris_tx); + + // Start background library scan if music_dirs configured + if !config.music_dirs.is_empty() { + app.scanning = true; + let music_dirs = config.music_dirs.clone(); + let scan_tx = tx.clone(); + thread::Builder::new() + .name("rustify-scan".into()) + .spawn(move || { + let library = scan_library(&music_dirs); + scan_tx.send(AppEvent::ScanComplete(library)).ok(); + }) + .expect("failed to spawn scan thread"); + } + + // Main event loop + loop { + terminal.draw(|frame| { + ui::draw(frame, &mut app); + })?; + + match rx.recv() { + Ok(AppEvent::Key(key)) => { + if let Some(action) = app.handle_key(key) { + match action { + app::PlayerAction::PlayPause => match app.now_playing.state { + Some(rustify_core::types::PlaybackState::Playing) => player.pause(), + _ => player.play(), + }, + app::PlayerAction::Next => player.next(), + app::PlayerAction::Previous => player.previous(), + app::PlayerAction::Seek(delta) => { + let track_len = app + .now_playing + .track + .as_ref() + .map(|t| t.length as i64) + .unwrap_or(0); + let new_pos = (app.now_playing.position_ms as i64 + delta) + .clamp(0, track_len) + as u64; + player.seek(new_pos); + app.now_playing.position_ms = new_pos; + } + app::PlayerAction::PlayTrackUri(uri) => { + player.load_track_uris(vec![uri]); + player.play(); + } + app::PlayerAction::LoadTrackUris(uris) => { + player.load_track_uris(uris); + player.play(); + } + app::PlayerAction::ToggleShuffle => { + let new_state = !app.now_playing.shuffle; + player.set_shuffle(new_state); + } + app::PlayerAction::CycleRepeat => { + let new_mode = app.now_playing.repeat.cycle(); + player.set_repeat(new_mode); + } + } + } + player.set_volume(app.now_playing.volume); + } + Ok(AppEvent::Mouse(mouse)) => { + if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { + let size = terminal.size().unwrap_or_default(); + app.handle_mouse_click(mouse.column, mouse.row, size.width, size.height); + } + } + Ok(AppEvent::Player(event)) => { + // Feed scrobbler + match &event { + PlayerEvent::TrackChanged(track) => scrobbler.on_track_changed(track), + PlayerEvent::PositionUpdate(ms) => scrobbler.on_position_update(*ms), + PlayerEvent::StateChanged(state) => { + scrobbler.on_state_changed( + *state == rustify_core::types::PlaybackState::Playing, + ); + } + _ => {} + } + // Trigger background art and lyrics extraction on track change + if let PlayerEvent::TrackChanged(ref track) = event { + let uri = track.uri.clone(); + let art_tx = tx.clone(); + thread::Builder::new() + .name("rustify-art".into()) + .spawn(move || { + let path = rustify_core::types::uri_to_path(&uri); + let data = rustify_core::art::extract_art(&path); + art_tx.send(AppEvent::ArtLoaded { uri, data }).ok(); + }) + .ok(); + + let lyrics_uri = track.uri.clone(); + let lyrics_tx = tx.clone(); + thread::Builder::new() + .name("rustify-lyrics".into()) + .spawn(move || { + let path = rustify_core::types::uri_to_path(&lyrics_uri); + let data = rustify_core::lyrics::extract_lyrics(&path); + lyrics_tx + .send(AppEvent::LyricsLoaded { + uri: lyrics_uri, + data, + }) + .ok(); + }) + .ok(); + + // Apply replay gain if enabled + if app.replay_gain_enabled { + let path = rustify_core::types::uri_to_path(&track.uri); + let rg_db = rustify_core::metadata::read_replay_gain(&path); + if let Some(db) = rg_db { + let gain_factor = 10.0_f32.powf(db / 20.0).clamp(0.1, 2.0); + let adjusted = + (app.base_volume as f32 * gain_factor).clamp(0.0, 100.0) as u8; + player.set_volume(adjusted); + app.now_playing.volume = adjusted; + } else { + // No replay gain tag — use base volume + player.set_volume(app.base_volume); + app.now_playing.volume = app.base_volume; + } + } + } + app.handle_player_event(event); + } + Ok(AppEvent::ArtLoaded { uri, data }) => { + if app.art.current_uri.as_deref() == Some(&uri) { + app.art.has_art = data.is_some(); + app.art.image_bytes = data; + } + } + Ok(AppEvent::LyricsLoaded { uri, data }) => { + if app.lyrics.lyrics.is_none() || app.art.current_uri.as_deref() == Some(&uri) { + app.lyrics.lyrics = data; + app.lyrics.current_line = 0; + app.lyrics.scroll_offset = 0; + } + } + Ok(AppEvent::Tick) => { + app.handle_tick(); + // Feed audio samples to visualizer + let samples = player.get_samples(); + if !samples.is_empty() { + app.visualizer_samples = samples.clone(); + let new_bars = ui::visualizer::compute_spectrum_bars(&samples); + app.visualizer_state.apply_smoothing(&new_bars); + } + } + Ok(AppEvent::ScanComplete(library)) => { + app.library = Some(library); + app.scanning = false; + app.artist_list_state.select(Some(0)); + } + Ok(AppEvent::Error(msg)) => { + app.set_status(msg); + } + Ok(_) => {} + Err(_) => break, + } + + if app.should_quit { + break; + } + } + + // Cleanup + player.shutdown(); + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + crossterm::event::DisableMouseCapture + )?; + Ok(()) +} + +/// Scan all configured music directories and build a Library. +fn scan_library(music_dirs: &[PathBuf]) -> Library { + let mut all_tracks = Vec::new(); + + for dir in music_dirs { + match scanner::scan_directory(dir) { + Ok(uris) => { + for uri in uris { + match read_metadata(&uri) { + Ok(track) => all_tracks.push(track), + Err(e) => eprintln!("rustify: metadata error: {e}"), + } + } + } + Err(e) => eprintln!("rustify: scan error for {}: {e}", dir.display()), + } + } + + Library::from_tracks(all_tracks) +} diff --git a/crates/rustify-tui/src/scrobble.rs b/crates/rustify-tui/src/scrobble.rs new file mode 100644 index 0000000..a0f7dfe --- /dev/null +++ b/crates/rustify-tui/src/scrobble.rs @@ -0,0 +1,201 @@ +use rustify_core::types::Track; + +/// Scrobbling state tracker. +pub struct Scrobbler { + token: String, + current_track: Option, + accumulated_ms: u64, + last_position_ms: u64, + scrobbled: bool, + playing: bool, +} + +impl Scrobbler { + pub fn new(token: String) -> Self { + Self { + token, + current_track: None, + accumulated_ms: 0, + last_position_ms: 0, + scrobbled: false, + playing: false, + } + } + + pub fn is_enabled(&self) -> bool { + !self.token.is_empty() + } + + /// Call on TrackChanged. Scrobbles previous track if eligible, resets for new track. + pub fn on_track_changed(&mut self, track: &Track) { + // Scrobble previous track if eligible + if let Some(ref prev) = self.current_track { + if self.is_scrobble_eligible(prev) && !self.scrobbled { + self.submit_scrobble(prev); + } + } + + // Reset for new track + self.current_track = Some(track.clone()); + self.accumulated_ms = 0; + self.last_position_ms = 0; + self.scrobbled = false; + + // Send "now playing" + if self.is_enabled() { + self.submit_now_playing(track); + } + } + + /// Call on PositionUpdate. + pub fn on_position_update(&mut self, position_ms: u64) { + if !self.playing || !self.is_enabled() { + return; + } + + // Accumulate play time (handle seeks by checking delta) + if position_ms > self.last_position_ms { + let delta = position_ms - self.last_position_ms; + // Only count reasonable deltas (< 2 seconds) to filter out seeks + if delta < 2000 { + self.accumulated_ms += delta; + } + } + self.last_position_ms = position_ms; + + // Check scrobble threshold + if !self.scrobbled { + if let Some(ref track) = self.current_track { + if self.is_scrobble_eligible(track) { + self.scrobbled = true; + self.submit_scrobble(track); + } + } + } + } + + /// Call on StateChanged. + pub fn on_state_changed(&mut self, playing: bool) { + self.playing = playing; + } + + fn is_scrobble_eligible(&self, track: &Track) -> bool { + // Track must be > 30 seconds + if track.length < 30_000 { + return false; + } + // Played > 50% OR > 4 minutes + let half = track.length / 2; + self.accumulated_ms >= half || self.accumulated_ms >= 240_000 + } + + fn submit_now_playing(&self, track: &Track) { + let token = self.token.clone(); + let track = track.clone(); + std::thread::Builder::new() + .name("rustify-scrobble".into()) + .spawn(move || { + let artist = track.artists.first().map(|a| a.as_str()).unwrap_or("Unknown"); + let body = format!( + r#"{{"listen_type":"playing_now","payload":[{{"track_metadata":{{"artist_name":"{}","track_name":"{}","release_name":"{}"}}}}]}}"#, + escape_json(artist), + escape_json(&track.name), + escape_json(&track.album) + ); + let _ = ureq::post("https://api.listenbrainz.org/1/submit-listens") + .header("Authorization", &format!("Token {token}")) + .header("Content-Type", "application/json") + .send(body.as_bytes()); + }) + .ok(); + } + + fn submit_scrobble(&self, track: &Track) { + let token = self.token.clone(); + let track = track.clone(); + std::thread::Builder::new() + .name("rustify-scrobble".into()) + .spawn(move || { + let artist = track.artists.first().map(|a| a.as_str()).unwrap_or("Unknown"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let body = format!( + r#"{{"listen_type":"single","payload":[{{"listened_at":{},"track_metadata":{{"artist_name":"{}","track_name":"{}","release_name":"{}"}}}}]}}"#, + now, + escape_json(artist), + escape_json(&track.name), + escape_json(&track.album) + ); + let _ = ureq::post("https://api.listenbrainz.org/1/submit-listens") + .header("Authorization", &format!("Token {token}")) + .header("Content-Type", "application/json") + .send(body.as_bytes()); + }) + .ok(); + } +} + +fn escape_json(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_track(length_ms: u64) -> Track { + Track { + uri: "file:///test.mp3".into(), + name: "Test".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: length_ms, + track_no: None, + } + } + + #[test] + fn scrobble_eligible_at_50_percent() { + let mut s = Scrobbler::new(String::new()); + s.current_track = Some(make_track(200_000)); + s.accumulated_ms = 100_001; // > 50% + assert!(s.is_scrobble_eligible(&make_track(200_000))); + } + + #[test] + fn scrobble_eligible_at_4_minutes() { + let mut s = Scrobbler::new(String::new()); + s.accumulated_ms = 240_001; + assert!(s.is_scrobble_eligible(&make_track(600_000))); + } + + #[test] + fn not_eligible_short_play() { + let mut s = Scrobbler::new(String::new()); + s.accumulated_ms = 30_000; // 30s of a 200s track + assert!(!s.is_scrobble_eligible(&make_track(200_000))); + } + + #[test] + fn not_eligible_short_track() { + let mut s = Scrobbler::new(String::new()); + s.accumulated_ms = 25_000; + assert!(!s.is_scrobble_eligible(&make_track(25_000))); // < 30s track + } + + #[test] + fn disabled_with_empty_token() { + let s = Scrobbler::new(String::new()); + assert!(!s.is_enabled()); + } + + #[test] + fn enabled_with_token() { + let s = Scrobbler::new("my-token".into()); + assert!(s.is_enabled()); + } +} diff --git a/crates/rustify-tui/src/theme.rs b/crates/rustify-tui/src/theme.rs new file mode 100644 index 0000000..b0567f6 --- /dev/null +++ b/crates/rustify-tui/src/theme.rs @@ -0,0 +1,204 @@ +use ratatui::style::Color; + +use crate::config::TuiConfig; + +/// Color theme for the TUI. +#[derive(Debug, Clone)] +pub struct Theme { + pub name: String, + pub fg: Color, + pub fg_dim: Color, + pub accent: Color, + pub accent_dim: Color, + pub border: Color, + pub error: Color, + pub visualizer: Vec, +} + +impl Theme { + pub fn default_theme() -> Self { + Self { + name: "default".into(), + fg: Color::White, + fg_dim: Color::Gray, + accent: Color::Magenta, + accent_dim: Color::DarkGray, + border: Color::DarkGray, + error: Color::Yellow, + visualizer: vec![Color::DarkGray, Color::Magenta], + } + } + + pub fn nord() -> Self { + Self { + name: "nord".into(), + fg: Color::Rgb(216, 222, 233), // #D8DEE9 + fg_dim: Color::Rgb(76, 86, 106), // #4C566A + accent: Color::Rgb(136, 192, 208), // #88C0D0 + accent_dim: Color::Rgb(94, 129, 172), // #5E81AC + border: Color::Rgb(59, 66, 82), // #3B4252 + error: Color::Rgb(191, 97, 106), // #BF616A + visualizer: vec![ + Color::Rgb(94, 129, 172), // #5E81AC + Color::Rgb(136, 192, 208), // #88C0D0 + ], + } + } + + pub fn dracula() -> Self { + Self { + name: "dracula".into(), + fg: Color::Rgb(248, 248, 242), // #F8F8F2 + fg_dim: Color::Rgb(98, 114, 164), // #6272A4 + accent: Color::Rgb(189, 147, 249), // #BD93F9 + accent_dim: Color::Rgb(139, 97, 199), // dimmer purple + border: Color::Rgb(68, 71, 90), // #44475A + error: Color::Rgb(255, 85, 85), // #FF5555 + visualizer: vec![ + Color::Rgb(98, 114, 164), // #6272A4 + Color::Rgb(189, 147, 249), // #BD93F9 + ], + } + } + + pub fn gruvbox() -> Self { + Self { + name: "gruvbox".into(), + fg: Color::Rgb(235, 219, 178), // #EBDBB2 + fg_dim: Color::Rgb(146, 131, 116), // #928374 + accent: Color::Rgb(250, 189, 47), // #FABD2F + accent_dim: Color::Rgb(215, 153, 33), // #D79921 + border: Color::Rgb(60, 56, 54), // #3C3836 + error: Color::Rgb(251, 73, 52), // #FB4934 + visualizer: vec![ + Color::Rgb(104, 157, 106), // #689D6A + Color::Rgb(250, 189, 47), // #FABD2F + ], + } + } + + pub fn catppuccin() -> Self { + Self { + name: "catppuccin".into(), + fg: Color::Rgb(205, 214, 244), // #CDD6F4 + fg_dim: Color::Rgb(88, 91, 112), // #585B70 + accent: Color::Rgb(203, 166, 247), // #CBA6F7 + accent_dim: Color::Rgb(147, 110, 191), // dimmer mauve + border: Color::Rgb(49, 50, 68), // #313244 + error: Color::Rgb(243, 139, 168), // #F38BA8 + visualizer: vec![ + Color::Rgb(88, 91, 112), // #585B70 + Color::Rgb(203, 166, 247), // #CBA6F7 + ], + } + } + + /// Load a theme by preset name. + pub fn from_name(name: &str) -> Self { + match name { + "nord" => Self::nord(), + "dracula" => Self::dracula(), + "gruvbox" => Self::gruvbox(), + "catppuccin" => Self::catppuccin(), + _ => Self::default_theme(), + } + } + + /// Load theme from config — resolves preset or custom theme. + pub fn from_config(config: &TuiConfig) -> Self { + let mut theme = Self::from_name(&config.theme); + + // Apply custom overrides if present + if let Some(ref custom) = config.custom_theme { + if let Some(ref hex) = custom.fg { + if let Some(c) = parse_hex_color(hex) { + theme.fg = c; + } + } + if let Some(ref hex) = custom.fg_dim { + if let Some(c) = parse_hex_color(hex) { + theme.fg_dim = c; + } + } + if let Some(ref hex) = custom.accent { + if let Some(c) = parse_hex_color(hex) { + theme.accent = c; + } + } + if let Some(ref hex) = custom.accent_dim { + if let Some(c) = parse_hex_color(hex) { + theme.accent_dim = c; + } + } + if let Some(ref hex) = custom.border { + if let Some(c) = parse_hex_color(hex) { + theme.border = c; + } + } + if let Some(ref hex) = custom.error { + if let Some(c) = parse_hex_color(hex) { + theme.error = c; + } + } + if let Some(ref colors) = custom.visualizer { + let parsed: Vec = colors.iter().filter_map(|h| parse_hex_color(h)).collect(); + if !parsed.is_empty() { + theme.visualizer = parsed; + } + } + theme.name = "custom".into(); + } + + theme + } +} + +/// Parse a `#RRGGBB` hex string to a ratatui Color. +pub fn parse_hex_color(hex: &str) -> Option { + let hex = hex.strip_prefix('#')?; + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color::Rgb(r, g, b)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_theme_has_magenta_accent() { + let theme = Theme::default_theme(); + assert_eq!(theme.accent, Color::Magenta); + } + + #[test] + fn all_presets_load() { + let names = ["default", "nord", "dracula", "gruvbox", "catppuccin"]; + for name in names { + let theme = Theme::from_name(name); + assert!(!theme.name.is_empty()); + } + } + + #[test] + fn unknown_name_falls_back_to_default() { + let theme = Theme::from_name("nonexistent"); + assert_eq!(theme.name, "default"); + } + + #[test] + fn parse_hex_color_valid() { + assert_eq!(parse_hex_color("#FF00FF"), Some(Color::Rgb(255, 0, 255))); + assert_eq!(parse_hex_color("#000000"), Some(Color::Rgb(0, 0, 0))); + } + + #[test] + fn parse_hex_color_invalid() { + assert_eq!(parse_hex_color("not-a-color"), None); + assert_eq!(parse_hex_color("#GG00FF"), None); + } +} diff --git a/crates/rustify-tui/src/ui/main_panel.rs b/crates/rustify-tui/src/ui/main_panel.rs new file mode 100644 index 0000000..bf96462 --- /dev/null +++ b/crates/rustify-tui/src/ui/main_panel.rs @@ -0,0 +1,282 @@ +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; + +use crate::app::{App, Focus, MainView}; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let title = match app.main_view { + MainView::Artists => " Artists ", + MainView::Albums => { + if app.selected_artist.is_some() { + " Albums " + } else { + " All Albums " + } + } + MainView::Songs => " Songs ", + MainView::Playlists => " Playlists ", + MainView::AlbumDetail => " Tracks ", + }; + + let border_style = if app.focus == Focus::Main { + Style::default().fg(app.theme.accent) + } else { + Style::default().fg(app.theme.border) + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 { + return; + } + + // Search overlay takes priority + if app.search.active { + draw_search(frame, app, inner); + return; + } + + if app.scanning { + let loading = Paragraph::new("Scanning library...") + .style(Style::default().fg(app.theme.error)) + .alignment(Alignment::Center); + frame.render_widget(loading, inner); + return; + } + + let Some(ref library) = app.library else { + let msg = + Paragraph::new("No music directories configured.\nEdit ~/.config/rustify/tui.toml") + .style(Style::default().fg(app.theme.border)) + .alignment(Alignment::Center); + frame.render_widget(msg, inner); + return; + }; + + match app.main_view { + MainView::Artists => { + let names = library.artist_names(); + let items: Vec = names + .iter() + .map(|name| ListItem::new(name.as_str())) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, inner, &mut app.artist_list_state); + } + MainView::Albums => { + let albums = if let Some(ref artist) = app.selected_artist { + library.albums_by_artist(artist).iter().collect::>() + } else { + library.all_albums() + }; + let items: Vec = albums + .iter() + .map(|album| ListItem::new(format!("{} — {}", album.name, album.artist))) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, inner, &mut app.album_list_state); + } + MainView::Songs => { + let tracks = library.all_tracks(); + let items: Vec = tracks + .iter() + .map(|t| { + let artist = t.artists.first().map(|a| a.as_str()).unwrap_or("Unknown"); + ListItem::new(format!("{} — {}", t.name, artist)) + }) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, inner, &mut app.song_list_state); + } + MainView::Playlists => { + if app.playlists.is_empty() { + let msg = Paragraph::new("No playlists found.") + .style(Style::default().fg(app.theme.border)); + frame.render_widget(msg, inner); + } else { + let items: Vec = app + .playlists + .iter() + .map(|p| ListItem::new(format!("{} ({} tracks)", p.name, p.track_count))) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, inner, &mut app.playlist_list_state); + } + } + MainView::AlbumDetail => { + let albums = if let Some(ref artist) = app.selected_artist { + library.albums_by_artist(artist).iter().collect::>() + } else { + library.all_albums() + }; + + if let Some(&album) = app.selected_album_index.and_then(|i| albums.get(i)) { + let items: Vec = album + .tracks + .iter() + .map(|t| { + let num = t.track_no.map(|n| format!("{n:2}. ")).unwrap_or_default(); + let dur = format_duration(t.length); + ListItem::new(format!("{num}{} [{dur}]", t.name)) + }) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, inner, &mut app.detail_list_state); + } + } + } +} + +fn draw_search(frame: &mut Frame, app: &mut App, area: Rect) { + // Split: [search input (1 row)] [results (remaining)] + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(area); + + // Search input + let input = Paragraph::new(format!("/ {}", app.search.query)) + .style(Style::default().fg(app.theme.error)); + frame.render_widget(input, chunks[0]); + + // Search results + if let Some(ref library) = app.library { + let results = library.fuzzy_search(&app.search.query); + let items: Vec = results + .iter() + .take(chunks[1].height as usize) + .map(|r| { + let artist = r + .track + .artists + .first() + .map(|a| a.as_str()) + .unwrap_or("Unknown"); + ListItem::new(format!("{} — {}", r.track.name, artist)) + }) + .collect(); + let list = List::new(items) + .highlight_style(Style::default().fg(app.theme.accent)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, chunks[1], &mut app.search.results_state); + } +} + +fn format_duration(ms: u64) -> String { + let secs = ms / 1000; + format!("{}:{:02}", secs / 60, secs % 60) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use crate::library::Library; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use rustify_core::types::Track; + + fn make_app_with_library() -> App { + let tracks = vec![ + Track { + uri: "file:///music/a.mp3".into(), + name: "Alpha".into(), + artists: vec!["Artist A".into()], + album: "Album One".into(), + length: 200_000, + track_no: Some(1), + }, + Track { + uri: "file:///music/b.mp3".into(), + name: "Beta".into(), + artists: vec!["Artist B".into()], + album: "Album Two".into(), + length: 300_000, + track_no: Some(1), + }, + ]; + let mut app = App::new(); + app.library = Some(Library::from_tracks(tracks)); + app.artist_list_state.select(Some(0)); + app + } + + fn render_to_string(app: &mut App, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + draw(frame, app, frame.area()); + }) + .unwrap(); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect() + } + + #[test] + fn artists_view_shows_artist_names() { + let mut app = make_app_with_library(); + app.main_view = MainView::Artists; + let content = render_to_string(&mut app, 40, 15); + assert!(content.contains("Artist A")); + assert!(content.contains("Artist B")); + } + + #[test] + fn songs_view_shows_track_names() { + let mut app = make_app_with_library(); + app.main_view = MainView::Songs; + let content = render_to_string(&mut app, 40, 15); + assert!(content.contains("Alpha")); + assert!(content.contains("Beta")); + } + + #[test] + fn scanning_shows_loading_message() { + let mut app = App::new(); + app.scanning = true; + let content = render_to_string(&mut app, 40, 15); + assert!(content.contains("Scanning")); + } +} diff --git a/crates/rustify-tui/src/ui/mod.rs b/crates/rustify-tui/src/ui/mod.rs new file mode 100644 index 0000000..bfa276c --- /dev/null +++ b/crates/rustify-tui/src/ui/mod.rs @@ -0,0 +1,119 @@ +pub mod main_panel; +pub mod now_playing; +pub mod sidebar; +pub mod visualizer; + +use ratatui::prelude::*; + +use crate::app::App; + +/// Draw the full TUI layout. +pub fn draw(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + + // Determine if we need a status line + let has_status = app.status.is_some(); + let status_height = if has_status { 1 } else { 0 }; + + // Expand now-playing bar to 6 rows when a track is playing (for visualizer) + let is_playing = app.now_playing.track.is_some(); + let np_height = if is_playing { 6 } else { 3 }; + + // Split vertically: [content] [status (optional)] [now-playing] + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), + Constraint::Length(status_height), + Constraint::Length(np_height), + ]) + .split(area); + + let content_area = vertical[0]; + let status_area = vertical[1]; + let now_playing_area = vertical[2]; + + // Split content horizontally: [sidebar (30%)] [main panel (70%)] + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) + .split(content_area); + + let sidebar_area = horizontal[0]; + let main_area = horizontal[1]; + + // Render each region + sidebar::draw(frame, app, sidebar_area); + main_panel::draw(frame, app, main_area); + now_playing::draw(frame, app, now_playing_area); + + // Render status line if present + if let Some(ref status) = app.status { + let status_widget = ratatui::widgets::Paragraph::new(status.text.as_str()) + .style(Style::default().fg(app.theme.error).bg(app.theme.border)); + frame.render_widget(status_widget, status_area); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + #[test] + fn layout_has_three_regions_at_80x24() { + let mut app = App::new(); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + draw(frame, &mut app); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!(buf.area().width == 80); + assert!(buf.area().height == 24); + } + + #[test] + fn layout_renders_at_minimal_size() { + let mut app = App::new(); + let backend = TestBackend::new(60, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + draw(frame, &mut app); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!(buf.area().width == 60); + } + + #[test] + fn status_message_renders_when_set() { + let mut app = App::new(); + app.set_status("Scanned 42 tracks".into()); + + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + draw(frame, &mut app); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = buf + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(content.contains("Scanned 42 tracks")); + } +} diff --git a/crates/rustify-tui/src/ui/now_playing.rs b/crates/rustify-tui/src/ui/now_playing.rs new file mode 100644 index 0000000..6c469af --- /dev/null +++ b/crates/rustify-tui/src/ui/now_playing.rs @@ -0,0 +1,238 @@ +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::App; +use crate::ui::visualizer::{self, VisualizerMode}; +use rustify_core::types::PlaybackState; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.border)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 || inner.width < 20 { + return; + } + + // When inner height >= 4, split into visualizer (top) and track info (bottom 3 rows). + let (viz_area, info_area) = if inner.height >= 4 && app.now_playing.track.is_some() { + let viz_height = inner.height.saturating_sub(3); + let viz = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: viz_height, + }; + let info = Rect { + x: inner.x, + y: inner.y + viz_height, + width: inner.width, + height: 3.min(inner.height), + }; + (Some(viz), info) + } else { + (None, inner) + }; + + // Draw the visualizer if we have space + if let Some(viz) = viz_area { + match app.visualizer_mode { + VisualizerMode::Spectrum => { + visualizer::draw_spectrum(frame, viz, &app.visualizer_state, &app.theme); + } + VisualizerMode::Waveform => { + visualizer::draw_waveform(frame, viz, &app.visualizer_samples, &app.theme); + } + } + } + + let inner = info_area; + + if let Some(ref track) = app.now_playing.track { + let artist = if track.artists.is_empty() { + "Unknown".to_string() + } else { + track.artists.join(", ") + }; + + let state_icon = match app.now_playing.state { + Some(PlaybackState::Playing) => ">>", + Some(PlaybackState::Paused) => "||", + _ => "--", + }; + + let pos = format_time(app.now_playing.position_ms); + let dur = format_time(track.length); + + let ratio = if track.length > 0 { + (app.now_playing.position_ms as f64 / track.length as f64).min(1.0) + } else { + 0.0 + }; + + // Layout: [art (3 cols)] [track info (45%)] [time+vol+modes (right)] + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(3), + Constraint::Percentage(55), + Constraint::Percentage(30), + ]) + .split(inner); + + // Art area (compact) + let art_style = if app.art.has_art { + Style::default().fg(app.theme.accent) + } else { + Style::default().fg(app.theme.border) + }; + let art_placeholder = Paragraph::new("♪") + .alignment(Alignment::Center) + .style(art_style); + frame.render_widget(art_placeholder, cols[0]); + + // Track info + progress bar (two lines + progress on third) + let info_area = cols[1]; + if info_area.height >= 2 { + // Line 1: state icon + track name + let line1 = format!("{state_icon} {}", track.name); + let line1_widget = Paragraph::new(line1).style(Style::default().fg(app.theme.fg)); + let line1_area = Rect { + height: 1, + ..info_area + }; + frame.render_widget(line1_widget, line1_area); + + // Line 2: artist — album (with ellipsis if needed) + let detail = format!(" {artist} — {}", track.album); + let max_w = info_area.width as usize; + let detail_display = if detail.len() > max_w && max_w > 3 { + format!("{}...", &detail[..max_w - 3]) + } else { + detail + }; + let line2_widget = + Paragraph::new(detail_display).style(Style::default().fg(app.theme.fg_dim)); + let line2_area = Rect { + y: info_area.y + 1, + height: 1, + ..info_area + }; + frame.render_widget(line2_widget, line2_area); + } + + // Line 3: thin progress bar using unicode + if info_area.height >= 3 { + let bar_width = info_area.width as usize; + let filled = (ratio * bar_width as f64) as usize; + let empty = bar_width.saturating_sub(filled); + let progress_line = Line::from(vec![ + Span::styled("━".repeat(filled), Style::default().fg(app.theme.accent)), + Span::styled("━".repeat(empty), Style::default().fg(app.theme.border)), + ]); + let progress_area = Rect { + y: info_area.y + 2, + height: 1, + ..info_area + }; + frame.render_widget(Paragraph::new(progress_line), progress_area); + } + + // Time + volume + mode indicators + let shuffle_indicator = if app.now_playing.shuffle { "[S] " } else { "" }; + let repeat_indicator = match app.now_playing.repeat { + rustify_core::types::RepeatMode::Off => "", + rustify_core::types::RepeatMode::All => "[R] ", + rustify_core::types::RepeatMode::One => "[R1] ", + }; + let time_vol = format!( + "{pos} / {dur}\n{shuffle_indicator}{repeat_indicator}Vol: {}", + app.now_playing.volume + ); + let right_widget = Paragraph::new(time_vol) + .alignment(Alignment::Right) + .style(Style::default().fg(app.theme.fg_dim)); + frame.render_widget(right_widget, cols[2]); + } else { + let paragraph = Paragraph::new("No track playing") + .style(Style::default().fg(app.theme.border)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, inner); + } +} + +fn format_time(ms: u64) -> String { + let total_secs = ms / 1000; + let mins = total_secs / 60; + let secs = total_secs % 60; + format!("{mins}:{secs:02}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use rustify_core::types::Track; + + fn make_track() -> Track { + Track { + uri: "file:///music/song.mp3".into(), + name: "Midnight City".into(), + artists: vec!["M83".into()], + album: "Hurry Up, We're Dreaming".into(), + length: 243_000, + track_no: Some(1), + } + } + + #[test] + fn renders_track_info_when_playing() { + let mut app = App::new(); + app.now_playing.track = Some(make_track()); + app.now_playing.state = Some(PlaybackState::Playing); + app.now_playing.position_ms = 102_000; + app.now_playing.volume = 80; + + let backend = TestBackend::new(80, 4); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + draw(frame, &mut app, frame.area()); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = buf + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(content.contains("Midnight City")); + assert!(content.contains("M83")); + } + + #[test] + fn renders_no_track_when_stopped() { + let mut app = App::new(); + let backend = TestBackend::new(80, 4); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + draw(frame, &mut app, frame.area()); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = buf + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(content.contains("No track")); + } +} diff --git a/crates/rustify-tui/src/ui/sidebar.rs b/crates/rustify-tui/src/ui/sidebar.rs new file mode 100644 index 0000000..fc4c157 --- /dev/null +++ b/crates/rustify-tui/src/ui/sidebar.rs @@ -0,0 +1,156 @@ +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; + +use crate::app::{App, Focus}; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let border_style = if app.focus == Focus::Sidebar { + Style::default().fg(app.theme.accent) + } else { + Style::default().fg(app.theme.border) + }; + + let block = Block::default() + .title(" Library ") + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 { + return; + } + + // Split inner area: [nav items (5 rows)] [queue (remaining)] + let nav_height = 5u16; // 4 items + 1 divider + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(nav_height.min(inner.height)), + Constraint::Min(0), + ]) + .split(inner); + + // Nav items + let nav_items: Vec = app + .nav_items() + .iter() + .enumerate() + .map(|(i, &name)| { + let marker = if i == app.sidebar_nav_index { + "> " + } else { + " " + }; + let style = if i == app.sidebar_nav_index { + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app.theme.fg) + }; + ListItem::new(format!("{marker}{name}")).style(style) + }) + .collect(); + + let nav_list = List::new(nav_items); + frame.render_widget(nav_list, chunks[0]); + + // Queue section + if chunks[1].height > 1 { + let queue_area = chunks[1]; + + // Queue header + let header_area = Rect { + height: 1, + ..queue_area + }; + let header = Paragraph::new("── Queue ──") + .style(Style::default().fg(app.theme.accent)) + .alignment(Alignment::Center); + frame.render_widget(header, header_area); + + // Queue items + let list_area = Rect { + y: queue_area.y + 1, + height: queue_area.height.saturating_sub(1), + ..queue_area + }; + + if app.queue.track_names.is_empty() { + let empty = Paragraph::new(" (empty)").style(Style::default().fg(app.theme.border)); + frame.render_widget(empty, list_area); + } else { + let items: Vec = app + .queue + .track_names + .iter() + .enumerate() + .map(|(i, name)| { + let style = Style::default().fg(app.theme.fg_dim); + ListItem::new(format!(" {}. {}", i + 1, name)).style(style) + }) + .collect(); + + let queue_list = List::new(items) + .highlight_style(Style::default().fg(app.theme.fg).bg(app.theme.border)); + frame.render_stateful_widget(queue_list, list_area, &mut app.queue.list_state); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + #[test] + fn sidebar_shows_all_nav_items() { + let mut app = App::new(); + let backend = TestBackend::new(24, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + draw(frame, &mut app, frame.area()); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = buf + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(content.contains("Artists")); + assert!(content.contains("Albums")); + assert!(content.contains("Songs")); + assert!(content.contains("Playlists")); + } + + #[test] + fn sidebar_shows_queue_section() { + let mut app = App::new(); + app.queue.track_names = vec!["Song A".into(), "Song B".into()]; + + let backend = TestBackend::new(24, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + draw(frame, &mut app, frame.area()); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = buf + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(content.contains("Queue")); + assert!(content.contains("Song A")); + } +} diff --git a/crates/rustify-tui/src/ui/visualizer.rs b/crates/rustify-tui/src/ui/visualizer.rs new file mode 100644 index 0000000..7b20c62 --- /dev/null +++ b/crates/rustify-tui/src/ui/visualizer.rs @@ -0,0 +1,302 @@ +use std::f32::consts::PI; + +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use rustfft::num_complex::Complex; +use rustfft::FftPlanner; + +use crate::theme::Theme; + +/// Number of display bars in the spectrum visualizer. +pub const BAR_COUNT: usize = 40; + +/// FFT size used for spectrum analysis. +const FFT_SIZE: usize = 1024; + +/// Unicode block characters ordered by height (1/8 to 8/8). +const BLOCKS: [char; 8] = [ + '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}', +]; + +/// Visualization display mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VisualizerMode { + Spectrum, + Waveform, +} + +impl VisualizerMode { + /// Toggle between modes. + pub fn toggle(self) -> Self { + match self { + VisualizerMode::Spectrum => VisualizerMode::Waveform, + VisualizerMode::Waveform => VisualizerMode::Spectrum, + } + } +} + +/// Persistent state for the visualizer (smoothing). +#[derive(Debug, Clone)] +pub struct VisualizerState { + pub bars: Vec, +} + +impl Default for VisualizerState { + fn default() -> Self { + Self { + bars: vec![0.0; BAR_COUNT], + } + } +} + +impl VisualizerState { + /// Apply exponential decay smoothing: keep the max of new value and old * 0.85. + pub fn apply_smoothing(&mut self, new_bars: &[f32]) { + for (old, &new) in self.bars.iter_mut().zip(new_bars.iter()) { + *old = new.max(*old * 0.85); + } + } +} + +/// Downmix interleaved stereo samples to mono, apply a Hann window, run a +/// 1024-point FFT, and group the output into 24 log-frequency bars normalized +/// to 0.0..1.0. +pub fn compute_spectrum_bars(samples: &[f32]) -> Vec { + // Downmix stereo (interleaved L R L R ...) to mono + let mono: Vec = samples + .chunks(2) + .map(|pair| { + if pair.len() == 2 { + (pair[0] + pair[1]) * 0.5 + } else { + pair[0] + } + }) + .collect(); + + // Ensure we have at least FFT_SIZE samples; zero-pad if needed + let n = FFT_SIZE; + let mut windowed = vec![Complex::new(0.0f32, 0.0); n]; + for (i, w) in windowed.iter_mut().enumerate() { + let sample = if i < mono.len() { mono[i] } else { 0.0 }; + // Hann window + let window = 0.5 * (1.0 - (2.0 * PI * i as f32 / n as f32).cos()); + w.re = sample * window; + } + + // FFT + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(n); + fft.process(&mut windowed); + + // Take magnitude of first half (positive frequencies) + let half = n / 2; // 512 + let magnitudes: Vec = windowed[..half].iter().map(|c| c.norm()).collect(); + + // Map 512 bins to 24 bars using log-frequency spacing. + // Bin i corresponds to frequency ~ i * sample_rate / FFT_SIZE. + // We use logarithmic spacing: bar boundaries are exponentially spaced + // from bin 1 to bin 512. + let mut bars = vec![0.0f32; BAR_COUNT]; + let min_bin = 1.0f32; + let max_bin = half as f32; + let log_min = min_bin.ln(); + let log_max = max_bin.ln(); + + for (bar_idx, bar) in bars.iter_mut().enumerate() { + let lo = + ((log_min + (log_max - log_min) * bar_idx as f32 / BAR_COUNT as f32).exp()) as usize; + let hi = ((log_min + (log_max - log_min) * (bar_idx + 1) as f32 / BAR_COUNT as f32).exp()) + as usize; + let lo = lo.max(1).min(half); + let hi = hi.max(lo + 1).min(half); + + let count = (hi - lo).max(1); + let sum: f32 = magnitudes[lo..hi].iter().sum(); + *bar = sum / count as f32; + } + + // Apply sqrt scaling for better visibility of quiet frequencies + for bar in &mut bars { + *bar = bar.sqrt(); + } + + // Normalize to 0.0..1.0 based on the max bar value + let max_val = bars.iter().cloned().fold(0.0f32, f32::max); + if max_val > 1e-6 { + for bar in &mut bars { + *bar = (*bar / max_val).clamp(0.0, 1.0); + } + } + + bars +} + +/// Map a value in 0.0..1.0 to a block character given a row height. +fn value_to_block(value: f32, row: usize, total_rows: usize) -> char { + // Each row represents 1/total_rows of the full range. + // row 0 = top, row total_rows-1 = bottom. + let row_bottom = 1.0 - (row + 1) as f32 / total_rows as f32; + let row_top = 1.0 - row as f32 / total_rows as f32; + let row_range = row_top - row_bottom; + + if value <= row_bottom { + ' ' + } else if value >= row_top { + BLOCKS[7] // full block + } else { + // Partial fill within this row + let fill = (value - row_bottom) / row_range; + let idx = ((fill * 8.0) as usize).min(7); + BLOCKS[idx] + } +} + +/// Render the spectrum visualizer bars into the given area. +pub fn draw_spectrum(frame: &mut Frame, area: Rect, state: &VisualizerState, theme: &Theme) { + if area.width == 0 || area.height == 0 { + return; + } + + let bar_count = state.bars.len(); + let total_rows = area.height as usize; + + // Determine color(s) from theme + let color = if theme.visualizer.is_empty() { + theme.accent + } else { + theme.visualizer[theme.visualizer.len() - 1] + }; + + let mut lines = Vec::with_capacity(total_rows); + for row in 0..total_rows { + let mut spans = Vec::with_capacity(area.width as usize); + for col in 0..area.width as usize { + let bar_idx = col * bar_count / area.width as usize; + let bar_idx = bar_idx.min(bar_count.saturating_sub(1)); + let val = state.bars[bar_idx]; + let ch = value_to_block(val, row, total_rows); + spans.push(Span::styled(ch.to_string(), Style::default().fg(color))); + } + lines.push(Line::from(spans)); + } + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); +} + +/// Render a waveform display from raw samples using block characters. +pub fn draw_waveform(frame: &mut Frame, area: Rect, samples: &[f32], theme: &Theme) { + if area.width == 0 || area.height == 0 || samples.is_empty() { + return; + } + + let color = if theme.visualizer.is_empty() { + theme.accent + } else { + theme.visualizer[theme.visualizer.len() - 1] + }; + + // Downmix stereo to mono for display + let mono: Vec = samples + .chunks(2) + .map(|pair| { + if pair.len() == 2 { + (pair[0] + pair[1]) * 0.5 + } else { + pair[0] + } + }) + .collect(); + + let total_rows = area.height as usize; + let width = area.width as usize; + + // Map each column to a sample range, get the absolute peak + let mut col_values: Vec = Vec::with_capacity(width); + for col in 0..width { + let start = col * mono.len() / width; + let end = ((col + 1) * mono.len() / width) + .max(start + 1) + .min(mono.len()); + let peak = mono[start..end] + .iter() + .map(|s| s.abs()) + .fold(0.0f32, f32::max); + col_values.push(peak.clamp(0.0, 1.0)); + } + + let mut lines = Vec::with_capacity(total_rows); + for row in 0..total_rows { + let mut spans = Vec::with_capacity(width); + for &val in &col_values { + let ch = value_to_block(val, row, total_rows); + spans.push(Span::styled(ch.to_string(), Style::default().fg(color))); + } + lines.push(Line::from(spans)); + } + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spectrum_returns_24_bars() { + let samples = vec![0.5f32; 2048]; // stereo + let bars = compute_spectrum_bars(&samples); + assert_eq!(bars.len(), BAR_COUNT); + } + + #[test] + fn silent_input_returns_zeros() { + let samples = vec![0.0f32; 2048]; + let bars = compute_spectrum_bars(&samples); + assert_eq!(bars.len(), BAR_COUNT); + for &bar in &bars { + assert!(bar.abs() < 1e-6, "expected zero bar, got {bar}"); + } + } + + #[test] + fn smoothing_decays() { + let mut state = VisualizerState::default(); + // Set bars high + let high: Vec = vec![1.0; BAR_COUNT]; + state.apply_smoothing(&high); + assert!((state.bars[0] - 1.0).abs() < 1e-6); + + // Now feed zeros — should decay to 0.85 + let low: Vec = vec![0.0; BAR_COUNT]; + state.apply_smoothing(&low); + assert!((state.bars[0] - 0.85).abs() < 1e-6); + + // Another decay step: 0.85 * 0.85 = 0.7225 + state.apply_smoothing(&low); + assert!((state.bars[0] - 0.7225).abs() < 1e-3); + } + + #[test] + fn bars_are_normalized_to_unit_range() { + // Generate a simple sine wave as stereo + let mut samples = Vec::with_capacity(2048); + for i in 0..1024 { + let val = (2.0 * PI * 100.0 * i as f32 / 44100.0).sin(); + samples.push(val); + samples.push(val); + } + let bars = compute_spectrum_bars(&samples); + for &bar in &bars { + assert!(bar >= 0.0 && bar <= 1.0, "bar out of range: {bar}"); + } + } + + #[test] + fn mode_toggle() { + assert_eq!(VisualizerMode::Spectrum.toggle(), VisualizerMode::Waveform); + assert_eq!(VisualizerMode::Waveform.toggle(), VisualizerMode::Spectrum); + } +} diff --git a/docs/TUI.md b/docs/TUI.md new file mode 100644 index 0000000..b4495b9 --- /dev/null +++ b/docs/TUI.md @@ -0,0 +1,155 @@ +# Rustify TUI — Terminal Music Player + +A rich terminal music player built on `rustify-core`. Works over SSH on the YoyoPod Pi and as a standalone desktop terminal player. + +## Quick Start + +```bash +cargo run -p rustify-tui -- /path/to/music +``` + +Or configure music directories in `~/.config/rustify/tui.toml`: + +```toml +music_dirs = ["/home/pi/Music", "/mnt/usb/music"] +``` + +## Keybindings + +| Key | Action | +|-----|--------| +| `Space` | Play / Pause | +| `n` / `p` | Next / Previous track | +| `s` | Toggle shuffle | +| `r` | Cycle repeat: Off -> All -> One | +| `Left` / `Right` | Seek -/+ 5 seconds | +| `+` / `-` | Volume up / down | +| `j` / `k` | Navigate lists | +| `Enter` | Select / Play | +| `Tab` | Cycle focus: Sidebar <-> Main panel | +| `1`-`4` | Jump to Artists / Albums / Songs / Playlists | +| `/` | Fuzzy search | +| `a` | Add track to queue | +| `Shift+V` | Toggle visualizer: Spectrum <-> Waveform | +| `L` | Toggle lyrics overlay | +| `Esc` | Close overlay / go back | +| `q` | Quit | + +## Configuration + +Config file: `~/.config/rustify/tui.toml` (Linux/macOS) or `%APPDATA%\rustify\tui.toml` (Windows). + +```toml +# Music directories to scan +music_dirs = ["/home/pi/Music"] + +# Audio output device +alsa_device = "default" + +# Theme: default, nord, dracula, gruvbox, catppuccin +theme = "default" + +# Crossfade duration in ms (0 = gapless) +crossfade_ms = 0 + +# Replay gain normalization +replay_gain = true + +# ListenBrainz scrobbling (empty = disabled) +listenbrainz_token = "" + +# Custom theme overrides (optional) +[theme.custom] +accent = "#F38BA8" +fg = "#CDD6F4" +border = "#313244" +``` + +## Architecture + +``` +crates/ + rustify-core/ # Audio engine (symphonia + cpal) + rustify-tui/ # Terminal UI (ratatui + crossterm) + rustify-mpris/ # MPRIS2 D-Bus stub (Linux only) +bindings/ + python/ # PyO3 bindings for YoyoPod +``` + +### Crate: rustify-core (13 modules) + +Audio playback library. Decodes MP3/FLAC/OGG/WAV via symphonia, outputs via cpal. + +- **Player** — command thread + decode thread + cpal output. Non-blocking API via crossbeam channels. +- **Tracklist** — playback queue with shuffle (Fisher-Yates) and repeat modes (Off/All/One). +- **Gapless** — dual-decode architecture. Pre-buffers next track 3s before current ends. MixStage in cpal callback swaps channels seamlessly. +- **Crossfade** — extends gapless with configurable linear fade between tracks. +- **Art** — album art extraction from embedded tags (lofty) + sidecar files (cover.jpg, folder.jpg). +- **Lyrics** — extraction from embedded tags + .lrc sidecar files. Synced and unsynced. +- **Metadata** — tag reading with filename fallback. Replay gain tag parsing. +- **Scanner** — recursive directory scanning for audio files. +- **Playlist** — M3U parsing + discovery. +- **Mixer** — lock-free atomic volume control. + +### Crate: rustify-tui (12 modules) + +Terminal UI. Sidebar + Main panel layout with persistent now-playing bar. + +- **App** — single state struct, event-driven. Handles keys, mouse, player callbacks. +- **Event loop** — crossbeam::select! multiplexing keyboard, player events, and 4Hz tick timer. +- **Library** — in-memory index. Groups tracks by artist/album. Fuzzy search via nucleo-matcher. +- **Visualizer** — FFT spectrum bars (40 bars, log-frequency, sqrt scaling) + waveform mode. Reads from core's sample buffer. +- **Themes** — 5 presets (default/nord/dracula/gruvbox/catppuccin) + custom via TOML. +- **Scrobbler** — ListenBrainz integration. Tracks play time, submits at 50%/4min threshold. +- **Config** — TOML config with platform-appropriate paths via `dirs` crate. + +## What Was Built (This Session) + +### Tier 1: Playback Essentials +- Shuffle (Fisher-Yates) and repeat (Off/All/One) modes in core Tracklist +- Seek keybindings (Left/Right +/-5s) with optimistic UI +- Gapless playback via dual-decode MixStage +- Album art extraction (embedded tags + sidecar files) + +### Tier 2: Rich Experience +- Audio spectrum visualizer (1024-pt FFT, 40 log-frequency bars, sqrt scaling, exponential decay smoothing) +- Waveform oscilloscope mode (toggle with Shift+V) +- Color theme system (5 presets + custom TOML themes) +- Fuzzy search via nucleo-matcher (replaces substring matching) + +### Tier 3: Power User +- Crossfade support (configurable duration, extends gapless mixer) +- Replay gain tag reading and volume normalization +- Lyrics extraction (embedded tags + .lrc sidecar, synced/unsynced) +- ListenBrainz scrobbling (50%/4min rules, background HTTP) +- MPRIS stub crate (ready for Linux D-Bus implementation) + +## What's Next + +### MPRIS Full Implementation (Linux) +The `crates/rustify-mpris` crate is stubbed. When deploying to Pi (Linux), implement the full MPRIS2 D-Bus interface using `zbus`: +- MediaPlayer2.Player: Play/Pause/Stop/Next/Previous +- Metadata publishing (track, artist, album, art URI) +- Media key capture from desktop environment + +### Pi Deployment +- Cross-compile for aarch64 (Pi Zero 2W) +- Test ALSA output on hardware +- Benchmark memory usage (target: <10MB RSS) +- Test over SSH (braille art fallback, keyboard-only) + +### Future Features +- Equalizer / audio effects +- Online lyrics fetching +- Last.fm scrobbling (behind same interface as ListenBrainz) +- Podcast support (RSS feeds) +- Network streaming (HTTP, maybe Spotify) +- Visualizer hide toggle keybinding + +## Test Summary + +139 tests across 3 crates, 24 source files. + +```bash +cargo test --workspace +``` diff --git a/docs/superpowers/plans/2026-04-08-rustify-tui.md b/docs/superpowers/plans/2026-04-08-rustify-tui.md new file mode 100644 index 0000000..c8e86c4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-rustify-tui.md @@ -0,0 +1,3180 @@ +# Rustify TUI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a rich terminal music player (`rustify-tui`) on top of `rustify-core`, usable both over SSH on the YoyoPod Pi and as a standalone desktop terminal player. + +**Architecture:** Ratatui + crossterm for rendering, crossbeam channels for a unified event loop multiplexing keyboard, player callbacks, and tick timer. Sidebar+Main layout with persistent now-playing bar. Single `App` struct owns all UI state; rendering is a pure function of that state. + +**Tech Stack:** Rust (ratatui, crossterm, crossbeam, ratatui-image, dirs, toml, serde), depends on workspace crate `rustify-core`. + +**Design Spec:** `docs/superpowers/specs/2026-04-08-rustify-tui-design.md` + +--- + +## File Map + +| File | Responsibility | +|---|---| +| `crates/rustify-tui/Cargo.toml` | Binary crate dependencies | +| `crates/rustify-tui/src/main.rs` | Entry point: terminal setup, event loop, cleanup | +| `crates/rustify-tui/src/config.rs` | `TuiConfig` — TOML parsing, defaults, platform paths | +| `crates/rustify-tui/src/event.rs` | `AppEvent` enum, input thread, tick thread | +| `crates/rustify-tui/src/app.rs` | `App` struct, state types (`Focus`, `MainView`, etc.), `handle_event()` | +| `crates/rustify-tui/src/library.rs` | `Library` index — organizes scanned tracks by artist/album | +| `crates/rustify-tui/src/ui/mod.rs` | `draw()` entry point — top-level layout split | +| `crates/rustify-tui/src/ui/sidebar.rs` | Sidebar rendering: library nav + queue | +| `crates/rustify-tui/src/ui/now_playing.rs` | Now-playing bar: art, track info, progress, volume | +| `crates/rustify-tui/src/ui/main_panel.rs` | Main panel views: Artists, Albums, Songs, Playlists, AlbumDetail, Search | + +--- + +## Task 1: Scaffold Crate + Minimal Terminal App + +**Files:** +- Create: `crates/rustify-tui/Cargo.toml` +- Create: `crates/rustify-tui/src/main.rs` +- Modify: `Cargo.toml` (workspace root) + +- [ ] **Step 1: Add rustify-tui to workspace** + +Edit the workspace root `Cargo.toml` to add the new crate: + +```toml +[workspace] +members = ["crates/rustify-core", "crates/rustify-tui", "bindings/python"] +default-members = ["crates/rustify-core"] +resolver = "2" +``` + +- [ ] **Step 2: Create crate Cargo.toml** + +Create `crates/rustify-tui/Cargo.toml`: + +```toml +[package] +name = "rustify-tui" +version = "0.1.0" +edition = "2021" +description = "Rich terminal music player built on rustify-core" + +[[bin]] +name = "rustify-tui" +path = "src/main.rs" + +[dependencies] +rustify-core = { path = "../rustify-core" } +ratatui = "0.29" +crossterm = "0.28" +crossbeam = "0.8" +serde = { version = "1", features = ["derive"] } +toml = "0.8" +dirs = "6" + +[dev-dependencies] +tempfile = "3" +``` + +- [ ] **Step 3: Create minimal main.rs** + +Create `crates/rustify-tui/src/main.rs` — sets up raw mode, alternate screen, draws a centered "Rustify" label, exits on `q`: + +```rust +use std::io; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; + +fn main() -> io::Result<()> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Main loop + loop { + terminal.draw(|frame| { + let area = frame.area(); + let text = Paragraph::new("Rustify TUI — press q to quit") + .alignment(Alignment::Center); + frame.render_widget(text, area); + })?; + + if event::poll(std::time::Duration::from_millis(250))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { + break; + } + } + } + } + + // Restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + Ok(()) +} +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo build -p rustify-tui` +Expected: Compiles with 0 errors. + +- [ ] **Step 5: Verify it runs** + +Run: `cargo run -p rustify-tui` +Expected: Alternate screen shows "Rustify TUI — press q to quit". Press `q`, terminal restores cleanly. + +- [ ] **Step 6: Commit** + +```bash +git add crates/rustify-tui/ Cargo.toml +git commit -m "feat(tui): scaffold rustify-tui crate with minimal terminal app" +``` + +--- + +## Task 2: Config Module + +**Files:** +- Create: `crates/rustify-tui/src/config.rs` +- Modify: `crates/rustify-tui/src/main.rs` (add `mod config;`) + +- [ ] **Step 1: Write tests for config** + +Add to the bottom of a new file `crates/rustify-tui/src/config.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_has_sensible_values() { + let config = TuiConfig::default(); + assert_eq!(config.alsa_device, "default"); + assert!(config.music_dirs.is_empty()); + assert_eq!(config.theme, "default"); + } + + #[test] + fn parse_from_toml_string() { + let toml_str = r#" + music_dirs = ["/home/pi/Music"] + alsa_device = "hw:0" + theme = "nord" + "#; + let config: TuiConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.music_dirs, vec![std::path::PathBuf::from("/home/pi/Music")]); + assert_eq!(config.alsa_device, "hw:0"); + assert_eq!(config.theme, "nord"); + } + + #[test] + fn parse_partial_toml_uses_defaults() { + let toml_str = r#" + music_dirs = ["/Music"] + "#; + let config: TuiConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.alsa_device, "default"); + assert_eq!(config.theme, "default"); + } + + #[test] + fn config_path_returns_some() { + // dirs::config_dir() may return None in some CI environments, + // so we just verify the function doesn't panic. + let _ = TuiConfig::config_path(); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p rustify-tui` +Expected: Compilation fails — `TuiConfig` not defined. + +- [ ] **Step 3: Implement TuiConfig** + +Add the implementation above the tests in `crates/rustify-tui/src/config.rs`: + +```rust +use std::path::PathBuf; + +use serde::Deserialize; + +/// TUI configuration, loaded from `~/.config/rustify/tui.toml`. +#[derive(Debug, Deserialize)] +pub struct TuiConfig { + /// Directories to scan for music files. + #[serde(default)] + pub music_dirs: Vec, + + /// ALSA device name passed to rustify-core. + #[serde(default = "default_alsa_device")] + pub alsa_device: String, + + /// Theme preset name. + #[serde(default = "default_theme")] + pub theme: String, +} + +fn default_alsa_device() -> String { + "default".to_string() +} + +fn default_theme() -> String { + "default".to_string() +} + +impl Default for TuiConfig { + fn default() -> Self { + Self { + music_dirs: Vec::new(), + alsa_device: default_alsa_device(), + theme: default_theme(), + } + } +} + +impl TuiConfig { + /// Platform-appropriate config file path. + /// Linux/macOS: `~/.config/rustify/tui.toml` + /// Windows: `%APPDATA%\rustify\tui.toml` + pub fn config_path() -> Option { + dirs::config_dir().map(|d| d.join("rustify").join("tui.toml")) + } + + /// Load config from disk. Returns defaults if file doesn't exist. + /// Prints a warning to stderr if the file exists but can't be parsed. + pub fn load() -> Self { + let Some(path) = Self::config_path() else { + return Self::default(); + }; + + match std::fs::read_to_string(&path) { + Ok(contents) => match toml::from_str(&contents) { + Ok(config) => config, + Err(e) => { + eprintln!("rustify: failed to parse {}: {e}", path.display()); + Self::default() + } + }, + Err(_) => Self::default(), + } + } +} +``` + +- [ ] **Step 4: Add mod declaration to main.rs** + +Add `mod config;` at the top of `crates/rustify-tui/src/main.rs`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test -p rustify-tui` +Expected: All 4 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/rustify-tui/src/config.rs crates/rustify-tui/src/main.rs +git commit -m "feat(tui): add config module with TOML parsing and platform paths" +``` + +--- + +## Task 3: Event System + +**Files:** +- Create: `crates/rustify-tui/src/event.rs` +- Modify: `crates/rustify-tui/src/main.rs` (add `mod event;`) + +- [ ] **Step 1: Write tests for AppEvent and EventLoop** + +Add to the bottom of a new file `crates/rustify-tui/src/event.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_event_variants_exist() { + // Verify enum construction compiles + let _ = AppEvent::Tick; + let _ = AppEvent::Error("test".into()); + } + + #[test] + fn event_loop_sends_ticks() { + let event_loop = EventLoop::new(); + // Wait for at least one tick (250ms interval + margin) + std::thread::sleep(std::time::Duration::from_millis(350)); + let event = event_loop.receiver().try_recv(); + assert!(event.is_ok()); + } + + #[test] + fn event_loop_receiver_is_clone_safe() { + let event_loop = EventLoop::new(); + let rx = event_loop.receiver(); + let _rx2 = rx.clone(); + } + + #[test] + fn sender_can_push_player_events() { + let event_loop = EventLoop::new(); + let tx = event_loop.sender(); + tx.send(AppEvent::Player(PlayerEvent::StateChanged( + PlaybackState::Playing, + ))) + .unwrap(); + // Drain ticks first, find our event + loop { + match event_loop.receiver().try_recv() { + Ok(AppEvent::Player(_)) => break, + Ok(_) => continue, + Err(_) => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + } + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p rustify-tui` +Expected: Compilation fails — `AppEvent`, `EventLoop` not defined. + +- [ ] **Step 3: Implement event module** + +Add the implementation above the tests in `crates/rustify-tui/src/event.rs`: + +```rust +use std::thread; +use std::time::Duration; + +use crossbeam::channel::{self, Receiver, Sender}; +use crossterm::event::{self, Event, KeyEvent, MouseEvent}; +use rustify_core::types::{PlaybackState, PlayerEvent, Track}; + +use crate::library::Library; + +/// Unified event type for the TUI event loop. +#[derive(Debug)] +pub enum AppEvent { + /// Keyboard input + Key(KeyEvent), + /// Mouse input + Mouse(MouseEvent), + /// Terminal resize + Resize(u16, u16), + /// Player state/track/position callback + Player(PlayerEvent), + /// UI refresh tick (~4Hz) + Tick, + /// Background library scan completed + ScanComplete(Library), + /// Non-player error + Error(String), +} + +/// Manages background threads that feed events into a single channel. +pub struct EventLoop { + tx: Sender, + rx: Receiver, +} + +impl EventLoop { + /// Create a new event loop. Spawns the input and tick threads immediately. + pub fn new() -> Self { + let (tx, rx) = channel::unbounded(); + + // Tick thread — sends AppEvent::Tick at ~4Hz + let tick_tx = tx.clone(); + thread::Builder::new() + .name("rustify-tick".into()) + .spawn(move || { + loop { + thread::sleep(Duration::from_millis(250)); + if tick_tx.send(AppEvent::Tick).is_err() { + break; + } + } + }) + .expect("failed to spawn tick thread"); + + // Input thread — polls crossterm events and forwards them + let input_tx = tx.clone(); + thread::Builder::new() + .name("rustify-input".into()) + .spawn(move || { + loop { + // Poll with timeout so we can detect channel disconnect + match event::poll(Duration::from_millis(100)) { + Ok(true) => match event::read() { + Ok(Event::Key(key)) => { + if input_tx.send(AppEvent::Key(key)).is_err() { + break; + } + } + Ok(Event::Mouse(mouse)) => { + if input_tx.send(AppEvent::Mouse(mouse)).is_err() { + break; + } + } + Ok(Event::Resize(w, h)) => { + if input_tx.send(AppEvent::Resize(w, h)).is_err() { + break; + } + } + _ => {} + }, + Ok(false) => {} + Err(_) => break, + } + } + }) + .expect("failed to spawn input thread"); + + Self { tx, rx } + } + + /// Get a clone of the sender for pushing events from player callbacks. + pub fn sender(&self) -> Sender { + self.tx.clone() + } + + /// Get the receiver for the main event loop. + pub fn receiver(&self) -> Receiver { + self.rx.clone() + } +} +``` + +**Note:** This references `crate::library::Library` which doesn't exist yet. Add a temporary stub so it compiles. Create `crates/rustify-tui/src/library.rs`: + +```rust +/// In-memory music library index. +/// Populated by background scan of music directories. +#[derive(Debug)] +pub struct Library; +``` + +- [ ] **Step 4: Add mod declarations to main.rs** + +Add to the top of `crates/rustify-tui/src/main.rs`: + +```rust +mod config; +mod event; +mod library; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test -p rustify-tui` +Expected: All event tests pass (plus config tests from Task 2). + +- [ ] **Step 6: Commit** + +```bash +git add crates/rustify-tui/src/event.rs crates/rustify-tui/src/library.rs crates/rustify-tui/src/main.rs +git commit -m "feat(tui): add event system with input and tick threads" +``` + +--- + +## Task 4: App State + Event Loop Wiring + +**Files:** +- Create: `crates/rustify-tui/src/app.rs` +- Modify: `crates/rustify-tui/src/main.rs` (rewire to use App + EventLoop) + +- [ ] **Step 1: Write tests for App state** + +Add to the bottom of a new file `crates/rustify-tui/src/app.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + fn make_key(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } + } + + fn make_app() -> App { + App::new() + } + + #[test] + fn initial_state() { + let app = make_app(); + assert!(!app.should_quit); + assert_eq!(app.focus, Focus::Sidebar); + assert_eq!(app.sidebar_nav_index, 0); + } + + #[test] + fn q_sets_should_quit() { + let mut app = make_app(); + app.handle_key(make_key(KeyCode::Char('q'))); + assert!(app.should_quit); + } + + #[test] + fn tab_cycles_focus() { + let mut app = make_app(); + assert_eq!(app.focus, Focus::Sidebar); + app.handle_key(make_key(KeyCode::Tab)); + assert_eq!(app.focus, Focus::Main); + app.handle_key(make_key(KeyCode::Tab)); + assert_eq!(app.focus, Focus::Sidebar); + } + + #[test] + fn number_keys_switch_sidebar_nav() { + let mut app = make_app(); + app.handle_key(make_key(KeyCode::Char('2'))); + assert_eq!(app.sidebar_nav_index, 1); + assert_eq!(app.main_view, MainView::Albums); + app.handle_key(make_key(KeyCode::Char('3'))); + assert_eq!(app.sidebar_nav_index, 2); + assert_eq!(app.main_view, MainView::Songs); + } + + #[test] + fn j_k_navigates_sidebar_nav_when_focused() { + let mut app = make_app(); + assert_eq!(app.sidebar_nav_index, 0); + app.handle_key(make_key(KeyCode::Char('j'))); + assert_eq!(app.sidebar_nav_index, 1); + app.handle_key(make_key(KeyCode::Char('k'))); + assert_eq!(app.sidebar_nav_index, 0); + // k at top stays at 0 + app.handle_key(make_key(KeyCode::Char('k'))); + assert_eq!(app.sidebar_nav_index, 0); + } + + #[test] + fn enter_on_sidebar_nav_switches_view_and_focus() { + let mut app = make_app(); + app.sidebar_nav_index = 2; // Songs + app.handle_key(make_key(KeyCode::Enter)); + assert_eq!(app.main_view, MainView::Songs); + assert_eq!(app.focus, Focus::Main); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p rustify-tui` +Expected: Compilation fails — `App`, `Focus`, `MainView` not defined. + +- [ ] **Step 3: Implement App state and key handling** + +Add the implementation above the tests in `crates/rustify-tui/src/app.rs`: + +```rust +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::widgets::ListState; +use rustify_core::types::{PlaybackState, Track}; + +use crate::library::Library; + +/// Which UI region has keyboard focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Sidebar, + Main, + Search, +} + +/// Which view is displayed in the main panel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MainView { + Artists, + Albums, + Songs, + Playlists, + AlbumDetail, +} + +const NAV_ITEMS: &[&str] = &["Artists", "Albums", "Songs", "Playlists"]; + +/// Now-playing state cached from player callbacks. +#[derive(Debug, Default)] +pub struct NowPlayingState { + pub track: Option, + pub state: Option, + pub position_ms: u64, + pub volume: u8, +} + +/// Search overlay state. +#[derive(Debug, Default)] +pub struct SearchState { + pub active: bool, + pub query: String, + pub results_state: ListState, +} + +/// Queue display state. +#[derive(Debug, Default)] +pub struct QueueState { + pub list_state: ListState, + pub track_uris: Vec, + pub track_names: Vec, +} + +/// Status message shown temporarily above the now-playing bar. +#[derive(Debug)] +pub struct StatusMessage { + pub text: String, + pub expires_tick: u64, +} + +/// Root application state. +pub struct App { + pub should_quit: bool, + pub focus: Focus, + pub sidebar_nav_index: usize, + pub main_view: MainView, + pub now_playing: NowPlayingState, + pub library: Option, + pub scanning: bool, + pub search: SearchState, + pub queue: QueueState, + pub status: Option, + pub tick_count: u64, + + // Per-view list states for ratatui + pub artist_list_state: ListState, + pub album_list_state: ListState, + pub song_list_state: ListState, + pub playlist_list_state: ListState, + pub detail_list_state: ListState, + + // Artist/album drill-down context + pub selected_artist: Option, + pub selected_album_index: Option, +} + +impl App { + pub fn new() -> Self { + Self { + should_quit: false, + focus: Focus::Sidebar, + sidebar_nav_index: 0, + main_view: MainView::Artists, + now_playing: NowPlayingState::default(), + library: None, + scanning: false, + search: SearchState::default(), + queue: QueueState::default(), + status: None, + tick_count: 0, + + artist_list_state: ListState::default(), + album_list_state: ListState::default(), + song_list_state: ListState::default(), + playlist_list_state: ListState::default(), + detail_list_state: ListState::default(), + + selected_artist: None, + selected_album_index: None, + } + } + + /// Handle a key event. Returns true if the event was consumed. + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + // Only handle key press events (not release/repeat) + if key.kind != KeyEventKind::Press { + return false; + } + + // Global keys (work regardless of focus) + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + return true; + } + KeyCode::Tab => { + self.focus = match self.focus { + Focus::Sidebar => Focus::Main, + Focus::Main => Focus::Sidebar, + Focus::Search => Focus::Main, + }; + return true; + } + KeyCode::Char(c @ '1'..='4') => { + let idx = (c as usize) - ('1' as usize); + self.sidebar_nav_index = idx; + self.main_view = nav_index_to_view(idx); + return true; + } + KeyCode::Char(' ') => { + // Play/pause toggle — will be wired to player in Task 11 + return true; + } + KeyCode::Char('n') if self.focus != Focus::Search => { + // Next track — will be wired to player in Task 11 + return true; + } + KeyCode::Char('p') if self.focus != Focus::Search => { + // Previous track — will be wired to player in Task 11 + return true; + } + KeyCode::Char('+') | KeyCode::Char('=') => { + self.now_playing.volume = (self.now_playing.volume + 5).min(100); + return true; + } + KeyCode::Char('-') => { + self.now_playing.volume = self.now_playing.volume.saturating_sub(5); + return true; + } + KeyCode::Char('/') if self.focus != Focus::Search => { + self.search.active = true; + self.search.query.clear(); + self.focus = Focus::Search; + return true; + } + KeyCode::Esc => { + if self.search.active { + self.search.active = false; + self.focus = Focus::Main; + return true; + } + // Back navigation from album detail + if self.main_view == MainView::AlbumDetail { + self.main_view = if self.selected_artist.is_some() { + MainView::Albums + } else { + MainView::Artists + }; + return true; + } + return false; + } + _ => {} + } + + // Search mode key handling + if self.focus == Focus::Search { + return self.handle_search_key(key); + } + + // Focus-specific keys + match self.focus { + Focus::Sidebar => self.handle_sidebar_key(key), + Focus::Main => self.handle_main_key(key), + Focus::Search => false, + } + } + + fn handle_sidebar_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if self.sidebar_nav_index < NAV_ITEMS.len() - 1 { + self.sidebar_nav_index += 1; + } + true + } + KeyCode::Char('k') | KeyCode::Up => { + self.sidebar_nav_index = self.sidebar_nav_index.saturating_sub(1); + true + } + KeyCode::Enter => { + self.main_view = nav_index_to_view(self.sidebar_nav_index); + self.focus = Focus::Main; + true + } + _ => false, + } + } + + fn handle_main_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + self.move_main_selection(1); + true + } + KeyCode::Char('k') | KeyCode::Up => { + self.move_main_selection(-1); + true + } + KeyCode::Enter => { + self.activate_main_selection(); + true + } + _ => false, + } + } + + fn handle_search_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char(c) => { + self.search.query.push(c); + true + } + KeyCode::Backspace => { + self.search.query.pop(); + true + } + KeyCode::Enter => { + // Navigate to selected search result — wired in Task 12 + self.search.active = false; + self.focus = Focus::Main; + true + } + _ => false, + } + } + + fn move_main_selection(&mut self, delta: i32) { + let list_state = self.active_list_state_mut(); + let current = list_state.selected().unwrap_or(0); + let new = if delta > 0 { + current.saturating_add(delta as usize) + } else { + current.saturating_sub((-delta) as usize) + }; + list_state.select(Some(new)); + } + + fn activate_main_selection(&mut self) { + match self.main_view { + MainView::Artists => { + // Drill into artist's albums + if let Some(lib) = &self.library { + let artists: Vec<&String> = lib.artist_names(); + if let Some(selected) = self.artist_list_state.selected() { + if let Some(name) = artists.get(selected) { + self.selected_artist = Some((*name).clone()); + self.main_view = MainView::Albums; + self.album_list_state.select(Some(0)); + } + } + } + } + MainView::Albums => { + // Drill into album's tracks + if let Some(selected) = self.album_list_state.selected() { + self.selected_album_index = Some(selected); + self.main_view = MainView::AlbumDetail; + self.detail_list_state.select(Some(0)); + } + } + MainView::Songs | MainView::AlbumDetail => { + // Play selected track — will be wired to player in Task 11 + } + MainView::Playlists => { + // Load selected playlist — will be wired in Task 13 + } + } + } + + /// Get a mutable reference to the active view's list state. + pub fn active_list_state_mut(&mut self) -> &mut ListState { + match self.main_view { + MainView::Artists => &mut self.artist_list_state, + MainView::Albums => &mut self.album_list_state, + MainView::Songs => &mut self.song_list_state, + MainView::Playlists => &mut self.playlist_list_state, + MainView::AlbumDetail => &mut self.detail_list_state, + } + } + + /// Handle a tick event — increment counter, expire status messages. + pub fn handle_tick(&mut self) { + self.tick_count += 1; + if let Some(ref status) = self.status { + if self.tick_count >= status.expires_tick { + self.status = None; + } + } + } + + /// Set a status message that auto-dismisses after ~5 seconds (20 ticks at 4Hz). + pub fn set_status(&mut self, text: String) { + self.status = Some(StatusMessage { + text, + expires_tick: self.tick_count + 20, + }); + } + + /// Sidebar nav item labels. + pub fn nav_items(&self) -> &[&str] { + NAV_ITEMS + } +} + +fn nav_index_to_view(index: usize) -> MainView { + match index { + 0 => MainView::Artists, + 1 => MainView::Albums, + 2 => MainView::Songs, + 3 => MainView::Playlists, + _ => MainView::Artists, + } +} +``` + +- [ ] **Step 4: Add mod declaration to main.rs** + +Add `mod app;` to the top of `crates/rustify-tui/src/main.rs`. + +- [ ] **Step 5: Rewire main.rs to use App + EventLoop** + +Replace the body of `main()` in `crates/rustify-tui/src/main.rs`: + +```rust +use std::io; + +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; + +mod app; +mod config; +mod event; +mod library; + +use app::App; +use event::{AppEvent, EventLoop}; + +fn main() -> io::Result<()> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, crossterm::event::EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let _config = config::TuiConfig::load(); + let event_loop = EventLoop::new(); + let rx = event_loop.receiver(); + let mut app = App::new(); + + // Main loop + loop { + terminal.draw(|frame| { + let area = frame.area(); + let status = format!( + "Rustify TUI | Focus: {:?} | View: {:?} | q to quit", + app.focus, app.main_view + ); + frame.render_widget(Paragraph::new(status), area); + })?; + + match rx.recv() { + Ok(AppEvent::Key(key)) => { + app.handle_key(key); + } + Ok(AppEvent::Tick) => { + app.handle_tick(); + } + Ok(_) => {} + Err(_) => break, + } + + if app.should_quit { + break; + } + } + + // Restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + crossterm::event::DisableMouseCapture + )?; + Ok(()) +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cargo test -p rustify-tui` +Expected: All tests pass (config + app state tests). + +- [ ] **Step 7: Commit** + +```bash +git add crates/rustify-tui/src/app.rs crates/rustify-tui/src/main.rs +git commit -m "feat(tui): add app state with focus management and key handling" +``` + +--- + +## Task 5: Top-Level Layout + +**Files:** +- Create: `crates/rustify-tui/src/ui/mod.rs` +- Create: `crates/rustify-tui/src/ui/sidebar.rs` (stub) +- Create: `crates/rustify-tui/src/ui/now_playing.rs` (stub) +- Create: `crates/rustify-tui/src/ui/main_panel.rs` (stub) +- Modify: `crates/rustify-tui/src/main.rs` (add `mod ui;`, call `ui::draw()`) + +- [ ] **Step 1: Write snapshot test for layout** + +Add to the bottom of a new file `crates/rustify-tui/src/ui/mod.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + #[test] + fn layout_has_three_regions_at_80x24() { + let mut app = App::new(); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + draw(frame, &mut app); + }) + .unwrap(); + + // Verify the terminal rendered without panic. + // The buffer should have content in the sidebar area (col 0), + // the main panel area, and the bottom now-playing bar. + let buf = terminal.backend().buffer(); + // Top-left should be part of sidebar + assert!(buf.area().width == 80); + assert!(buf.area().height == 24); + } + + #[test] + fn layout_renders_at_minimal_size() { + let mut app = App::new(); + let backend = TestBackend::new(60, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + draw(frame, &mut app); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + assert!(buf.area().width == 60); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p rustify-tui` +Expected: Compilation fails — `ui::draw` not defined. + +- [ ] **Step 3: Implement draw() and sub-module stubs** + +Replace the content above the tests in `crates/rustify-tui/src/ui/mod.rs`: + +```rust +pub mod main_panel; +pub mod now_playing; +pub mod sidebar; + +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders}; + +use crate::app::App; + +/// Draw the full TUI layout. +pub fn draw(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + + // Split vertically: [content area] [now-playing bar (3 rows)] + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(5), Constraint::Length(3)]) + .split(area); + + let content_area = vertical[0]; + let now_playing_area = vertical[1]; + + // Split content horizontally: [sidebar (30%)] [main panel (70%)] + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) + .split(content_area); + + let sidebar_area = horizontal[0]; + let main_area = horizontal[1]; + + // Render each region + sidebar::draw(frame, app, sidebar_area); + main_panel::draw(frame, app, main_area); + now_playing::draw(frame, app, now_playing_area); +} +``` + +Create `crates/rustify-tui/src/ui/sidebar.rs`: + +```rust +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem}; + +use crate::app::{App, Focus}; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let block = Block::default() + .title(" Library ") + .borders(Borders::ALL) + .border_style(if app.focus == Focus::Sidebar { + Style::default().fg(Color::Magenta) + } else { + Style::default().fg(Color::DarkGray) + }); + + let items: Vec = app + .nav_items() + .iter() + .enumerate() + .map(|(i, &name)| { + let style = if i == app.sidebar_nav_index { + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(name).style(style) + }) + .collect(); + + let list = List::new(items).block(block); + frame.render_widget(list, area); +} +``` + +Create `crates/rustify-tui/src/ui/main_panel.rs`: + +```rust +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::{App, Focus, MainView}; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let title = match app.main_view { + MainView::Artists => " Artists ", + MainView::Albums => " Albums ", + MainView::Songs => " Songs ", + MainView::Playlists => " Playlists ", + MainView::AlbumDetail => " Album ", + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(if app.focus == Focus::Main { + Style::default().fg(Color::Magenta) + } else { + Style::default().fg(Color::DarkGray) + }); + + let content = if app.scanning { + "Scanning library..." + } else if app.library.is_none() { + "No music directories configured. Edit ~/.config/rustify/tui.toml" + } else { + "Library loaded" + }; + + let paragraph = Paragraph::new(content).block(block); + frame.render_widget(paragraph, area); +} +``` + +Create `crates/rustify-tui/src/ui/now_playing.rs`: + +```rust +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::App; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let text = if let Some(ref track) = app.now_playing.track { + let artist = if track.artists.is_empty() { + "Unknown".to_string() + } else { + track.artists.join(", ") + }; + format!("{} — {}", track.name, artist) + } else { + "No track playing".to_string() + }; + + let paragraph = Paragraph::new(text).block(block); + frame.render_widget(paragraph, area); +} +``` + +- [ ] **Step 4: Add mod declaration + wire up draw()** + +Update `crates/rustify-tui/src/main.rs` — add `mod ui;` at the top, and replace the `terminal.draw` closure: + +```rust +terminal.draw(|frame| { + ui::draw(frame, &mut app); +})?; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test -p rustify-tui` +Expected: All tests pass. The snapshot tests confirm the layout renders at 80x24 and 60x20 without panicking. + +- [ ] **Step 6: Verify it runs visually** + +Run: `cargo run -p rustify-tui` +Expected: Three bordered regions — sidebar with "Library" title on the left, main panel on the right, now-playing bar at the bottom. `q` quits. + +- [ ] **Step 7: Commit** + +```bash +git add crates/rustify-tui/src/ui/ +git commit -m "feat(tui): add three-region layout with sidebar, main panel, and now-playing bar" +``` + +--- + +## Task 6: Now-Playing Bar (Full) + +**Files:** +- Modify: `crates/rustify-tui/src/ui/now_playing.rs` + +- [ ] **Step 1: Write snapshot test for now-playing bar** + +Add to the bottom of `crates/rustify-tui/src/ui/now_playing.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use rustify_core::types::{PlaybackState, Track}; + + fn make_track() -> Track { + Track { + uri: "file:///music/song.mp3".into(), + name: "Midnight City".into(), + artists: vec!["M83".into()], + album: "Hurry Up, We're Dreaming".into(), + length: 243_000, // 4:03 + track_no: Some(1), + } + } + + #[test] + fn renders_track_info_when_playing() { + let mut app = App::new(); + app.now_playing.track = Some(make_track()); + app.now_playing.state = Some(PlaybackState::Playing); + app.now_playing.position_ms = 102_000; // 1:42 + app.now_playing.volume = 80; + + let backend = TestBackend::new(80, 4); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + draw(frame, &mut app, frame.area()); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = buf + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(content.contains("Midnight City")); + assert!(content.contains("M83")); + } + + #[test] + fn renders_no_track_when_stopped() { + let mut app = App::new(); + let backend = TestBackend::new(80, 4); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + draw(frame, &mut app, frame.area()); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = buf + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(content.contains("No track")); + } +} +``` + +- [ ] **Step 2: Run tests to verify they pass with stub (or need updates)** + +Run: `cargo test -p rustify-tui -- now_playing` +Expected: Tests may pass with the stub or fail on the assertion. Either way, proceed to the full implementation. + +- [ ] **Step 3: Implement full now-playing bar with progress** + +Replace the content of `crates/rustify-tui/src/ui/now_playing.rs` (above tests): + +```rust +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Gauge, Paragraph}; + +use crate::app::App; +use rustify_core::types::PlaybackState; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 || inner.width < 20 { + return; + } + + if let Some(ref track) = app.now_playing.track { + let artist = if track.artists.is_empty() { + "Unknown".to_string() + } else { + track.artists.join(", ") + }; + + let state_icon = match app.now_playing.state { + Some(PlaybackState::Playing) => ">>", + Some(PlaybackState::Paused) => "||", + _ => "--", + }; + + let pos = format_time(app.now_playing.position_ms); + let dur = format_time(track.length); + + let ratio = if track.length > 0 { + (app.now_playing.position_ms as f64 / track.length as f64).min(1.0) + } else { + 0.0 + }; + + // Layout: [track info left] [progress center] [time+vol right] + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(35), + Constraint::Percentage(45), + Constraint::Percentage(20), + ]) + .split(inner); + + // Left: track info + let info = format!("{state_icon} {}\n {artist} — {}", track.name, track.album); + let info_widget = Paragraph::new(info) + .style(Style::default().fg(Color::White)); + frame.render_widget(info_widget, cols[0]); + + // Center: progress bar + if cols[1].height > 0 { + let gauge = Gauge::default() + .ratio(ratio) + .gauge_style(Style::default().fg(Color::Magenta).bg(Color::DarkGray)) + .label(""); + let gauge_area = Rect { + y: cols[1].y + cols[1].height.saturating_sub(1), + height: 1, + ..cols[1] + }; + frame.render_widget(gauge, gauge_area); + } + + // Right: time + volume + let time_vol = format!("{pos} / {dur}\nVol: {}", app.now_playing.volume); + let right_widget = Paragraph::new(time_vol) + .alignment(Alignment::Right) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(right_widget, cols[2]); + } else { + let paragraph = Paragraph::new("No track playing") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, inner); + } +} + +fn format_time(ms: u64) -> String { + let total_secs = ms / 1000; + let mins = total_secs / 60; + let secs = total_secs % 60; + format!("{mins}:{secs:02}") +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p rustify-tui -- now_playing` +Expected: Both tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/rustify-tui/src/ui/now_playing.rs +git commit -m "feat(tui): implement now-playing bar with progress gauge and track info" +``` + +--- + +## Task 7: Sidebar (Full) + +**Files:** +- Modify: `crates/rustify-tui/src/ui/sidebar.rs` + +- [ ] **Step 1: Write tests for sidebar rendering** + +Add to the bottom of `crates/rustify-tui/src/ui/sidebar.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + #[test] + fn sidebar_shows_all_nav_items() { + let mut app = App::new(); + let backend = TestBackend::new(24, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + draw(frame, &mut app, frame.area()); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = buf + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(content.contains("Artists")); + assert!(content.contains("Albums")); + assert!(content.contains("Songs")); + assert!(content.contains("Playlists")); + } + + #[test] + fn sidebar_shows_queue_section() { + let mut app = App::new(); + app.queue.track_names = vec!["Song A".into(), "Song B".into()]; + + let backend = TestBackend::new(24, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + draw(frame, &mut app, frame.area()); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = buf + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(content.contains("Queue")); + assert!(content.contains("Song A")); + } +} +``` + +- [ ] **Step 2: Implement full sidebar with nav + queue** + +Replace the content of `crates/rustify-tui/src/ui/sidebar.rs` (above tests): + +```rust +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; + +use crate::app::{App, Focus}; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let border_style = if app.focus == Focus::Sidebar { + Style::default().fg(Color::Magenta) + } else { + Style::default().fg(Color::DarkGray) + }; + + let block = Block::default() + .title(" Library ") + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 { + return; + } + + // Split inner area: [nav items (5 rows)] [queue (remaining)] + let nav_height = 5u16; // 4 items + 1 divider + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(nav_height.min(inner.height)), + Constraint::Min(0), + ]) + .split(inner); + + // Nav items + let nav_items: Vec = app + .nav_items() + .iter() + .enumerate() + .map(|(i, &name)| { + let marker = if i == app.sidebar_nav_index { + "> " + } else { + " " + }; + let style = if i == app.sidebar_nav_index { + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + ListItem::new(format!("{marker}{name}")).style(style) + }) + .collect(); + + let nav_list = List::new(nav_items); + frame.render_widget(nav_list, chunks[0]); + + // Queue section + if chunks[1].height > 1 { + let queue_area = chunks[1]; + + // Queue header + let header_area = Rect { + height: 1, + ..queue_area + }; + let header = Paragraph::new("── Queue ──") + .style(Style::default().fg(Color::Magenta)) + .alignment(Alignment::Center); + frame.render_widget(header, header_area); + + // Queue items + let list_area = Rect { + y: queue_area.y + 1, + height: queue_area.height.saturating_sub(1), + ..queue_area + }; + + if app.queue.track_names.is_empty() { + let empty = Paragraph::new(" (empty)") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(empty, list_area); + } else { + let items: Vec = app + .queue + .track_names + .iter() + .enumerate() + .map(|(i, name)| { + let style = Style::default().fg(Color::Gray); + ListItem::new(format!(" {}. {}", i + 1, name)).style(style) + }) + .collect(); + + let queue_list = List::new(items) + .highlight_style(Style::default().fg(Color::White).bg(Color::DarkGray)); + frame.render_stateful_widget(queue_list, list_area, &mut app.queue.list_state); + } + } +} +``` + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `cargo test -p rustify-tui -- sidebar` +Expected: Both sidebar tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-tui/src/ui/sidebar.rs +git commit -m "feat(tui): implement sidebar with nav items and queue display" +``` + +--- + +## Task 8: Library Index + +**Files:** +- Modify: `crates/rustify-tui/src/library.rs` (replace stub) + +- [ ] **Step 1: Write tests for library building** + +Replace the content of `crates/rustify-tui/src/library.rs` with tests at the bottom: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use rustify_core::types::Track; + + fn make_tracks() -> Vec { + vec![ + Track { + uri: "file:///music/m83/hurry/midnight.mp3".into(), + name: "Midnight City".into(), + artists: vec!["M83".into()], + album: "Hurry Up, We're Dreaming".into(), + length: 243_000, + track_no: Some(1), + }, + Track { + uri: "file:///music/m83/hurry/reunion.mp3".into(), + name: "Reunion".into(), + artists: vec!["M83".into()], + album: "Hurry Up, We're Dreaming".into(), + length: 407_000, + track_no: Some(2), + }, + Track { + uri: "file:///music/m83/saturdays/kim.mp3".into(), + name: "Kim & Jessie".into(), + artists: vec!["M83".into()], + album: "Saturdays = Youth".into(), + length: 315_000, + track_no: Some(1), + }, + Track { + uri: "file:///music/radiohead/ok/paranoid.mp3".into(), + name: "Paranoid Android".into(), + artists: vec!["Radiohead".into()], + album: "OK Computer".into(), + length: 383_000, + track_no: Some(2), + }, + ] + } + + #[test] + fn build_library_groups_by_artist() { + let lib = Library::from_tracks(make_tracks()); + let names = lib.artist_names(); + assert_eq!(names.len(), 2); + assert!(names.contains(&&"M83".to_string())); + assert!(names.contains(&&"Radiohead".to_string())); + } + + #[test] + fn artist_albums_returns_correct_albums() { + let lib = Library::from_tracks(make_tracks()); + let albums = lib.albums_by_artist("M83"); + assert_eq!(albums.len(), 2); + let album_names: Vec<&str> = albums.iter().map(|a| a.name.as_str()).collect(); + assert!(album_names.contains(&"Hurry Up, We're Dreaming")); + assert!(album_names.contains(&"Saturdays = Youth")); + } + + #[test] + fn album_tracks_returns_sorted_tracks() { + let lib = Library::from_tracks(make_tracks()); + let albums = lib.albums_by_artist("M83"); + let hurry = albums.iter().find(|a| a.name.contains("Hurry")).unwrap(); + assert_eq!(hurry.tracks.len(), 2); + assert_eq!(hurry.tracks[0].name, "Midnight City"); + assert_eq!(hurry.tracks[1].name, "Reunion"); + } + + #[test] + fn all_tracks_returns_everything() { + let lib = Library::from_tracks(make_tracks()); + assert_eq!(lib.all_tracks().len(), 4); + } + + #[test] + fn all_albums_returns_everything() { + let lib = Library::from_tracks(make_tracks()); + assert_eq!(lib.all_albums().len(), 3); + } + + #[test] + fn empty_library() { + let lib = Library::from_tracks(vec![]); + assert!(lib.artist_names().is_empty()); + assert!(lib.all_tracks().is_empty()); + assert!(lib.all_albums().is_empty()); + } + + #[test] + fn search_finds_matching_tracks() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.search("midnight"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "Midnight City"); + } + + #[test] + fn search_is_case_insensitive() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.search("PARANOID"); + assert_eq!(results.len(), 1); + } + + #[test] + fn search_matches_artist_names() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.search("radiohead"); + assert_eq!(results.len(), 1); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p rustify-tui -- library` +Expected: Compilation fails — `Library::from_tracks`, etc. not defined. + +- [ ] **Step 3: Implement Library** + +Add the implementation above the tests in `crates/rustify-tui/src/library.rs`: + +```rust +use std::collections::BTreeMap; + +use rustify_core::types::Track; + +/// An album in the library index. +#[derive(Debug, Clone)] +pub struct Album { + pub name: String, + pub artist: String, + pub tracks: Vec, +} + +/// In-memory music library index, organized by artist and album. +#[derive(Debug)] +pub struct Library { + /// Artists sorted alphabetically, each with their albums. + artists: BTreeMap>, + /// Flat list of all tracks for the Songs view. + tracks: Vec, +} + +impl Library { + /// Build a library index from a flat list of tracks. + /// Groups by artist → album, sorts tracks within albums by track number. + pub fn from_tracks(tracks: Vec) -> Self { + let mut artist_albums: BTreeMap>> = BTreeMap::new(); + + for track in &tracks { + let artist_name = if track.artists.is_empty() { + "Unknown Artist".to_string() + } else { + track.artists[0].clone() + }; + + artist_albums + .entry(artist_name) + .or_default() + .entry(track.album.clone()) + .or_default() + .push(track.clone()); + } + + let artists: BTreeMap> = artist_albums + .into_iter() + .map(|(artist_name, albums_map)| { + let mut albums: Vec = albums_map + .into_iter() + .map(|(album_name, mut album_tracks)| { + album_tracks.sort_by_key(|t| t.track_no.unwrap_or(u32::MAX)); + Album { + name: album_name, + artist: artist_name.clone(), + tracks: album_tracks, + } + }) + .collect(); + albums.sort_by(|a, b| a.name.cmp(&b.name)); + (artist_name, albums) + }) + .collect(); + + let mut sorted_tracks = tracks; + sorted_tracks.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + Self { + artists, + tracks: sorted_tracks, + } + } + + /// Sorted list of artist names. + pub fn artist_names(&self) -> Vec<&String> { + self.artists.keys().collect() + } + + /// Albums for a given artist name. + pub fn albums_by_artist(&self, artist: &str) -> &[Album] { + self.artists + .get(artist) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + /// All albums across all artists (sorted by name). + pub fn all_albums(&self) -> Vec<&Album> { + let mut albums: Vec<&Album> = self.artists.values().flat_map(|a| a.iter()).collect(); + albums.sort_by(|a, b| a.name.cmp(&b.name)); + albums + } + + /// All tracks (sorted by name). + pub fn all_tracks(&self) -> &[Track] { + &self.tracks + } + + /// Case-insensitive substring search across track names, artist names, and album names. + pub fn search(&self, query: &str) -> Vec<&Track> { + let query_lower = query.to_lowercase(); + self.tracks + .iter() + .filter(|t| { + t.name.to_lowercase().contains(&query_lower) + || t.album.to_lowercase().contains(&query_lower) + || t.artists + .iter() + .any(|a| a.to_lowercase().contains(&query_lower)) + }) + .collect() + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p rustify-tui -- library` +Expected: All 9 library tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/rustify-tui/src/library.rs +git commit -m "feat(tui): implement library index with artist/album grouping and search" +``` + +--- + +## Task 9: Main Panel Views + +**Files:** +- Modify: `crates/rustify-tui/src/ui/main_panel.rs` + +- [ ] **Step 1: Write tests for main panel views** + +Add to the bottom of `crates/rustify-tui/src/ui/main_panel.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use crate::library::Library; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use rustify_core::types::Track; + + fn make_app_with_library() -> App { + let tracks = vec![ + Track { + uri: "file:///music/a.mp3".into(), + name: "Alpha".into(), + artists: vec!["Artist A".into()], + album: "Album One".into(), + length: 200_000, + track_no: Some(1), + }, + Track { + uri: "file:///music/b.mp3".into(), + name: "Beta".into(), + artists: vec!["Artist B".into()], + album: "Album Two".into(), + length: 300_000, + track_no: Some(1), + }, + ]; + let mut app = App::new(); + app.library = Some(Library::from_tracks(tracks)); + app.artist_list_state.select(Some(0)); + app + } + + fn render_to_string(app: &mut App, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + draw(frame, app, frame.area()); + }) + .unwrap(); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect() + } + + #[test] + fn artists_view_shows_artist_names() { + let mut app = make_app_with_library(); + app.main_view = MainView::Artists; + let content = render_to_string(&mut app, 40, 15); + assert!(content.contains("Artist A")); + assert!(content.contains("Artist B")); + } + + #[test] + fn songs_view_shows_track_names() { + let mut app = make_app_with_library(); + app.main_view = MainView::Songs; + let content = render_to_string(&mut app, 40, 15); + assert!(content.contains("Alpha")); + assert!(content.contains("Beta")); + } + + #[test] + fn scanning_shows_loading_message() { + let mut app = App::new(); + app.scanning = true; + let content = render_to_string(&mut app, 40, 15); + assert!(content.contains("Scanning")); + } +} +``` + +- [ ] **Step 2: Implement full main panel views** + +Replace the content of `crates/rustify-tui/src/ui/main_panel.rs` (above tests): + +```rust +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; + +use crate::app::{App, Focus, MainView}; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let title = match app.main_view { + MainView::Artists => " Artists ", + MainView::Albums => { + if app.selected_artist.is_some() { + " Albums " + } else { + " All Albums " + } + } + MainView::Songs => " Songs ", + MainView::Playlists => " Playlists ", + MainView::AlbumDetail => " Tracks ", + }; + + let border_style = if app.focus == Focus::Main { + Style::default().fg(Color::Magenta) + } else { + Style::default().fg(Color::DarkGray) + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 { + return; + } + + // Search overlay takes priority + if app.search.active { + draw_search(frame, app, inner); + return; + } + + if app.scanning { + let loading = Paragraph::new("Scanning library...") + .style(Style::default().fg(Color::Yellow)) + .alignment(Alignment::Center); + frame.render_widget(loading, inner); + return; + } + + let Some(ref library) = app.library else { + let msg = Paragraph::new("No music directories configured.\nEdit ~/.config/rustify/tui.toml") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + frame.render_widget(msg, inner); + return; + }; + + match app.main_view { + MainView::Artists => { + let names = library.artist_names(); + let items: Vec = names + .iter() + .map(|name| ListItem::new(name.as_str())) + .collect(); + let list = List::new(items) + .highlight_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, inner, &mut app.artist_list_state); + } + MainView::Albums => { + let albums = if let Some(ref artist) = app.selected_artist { + library.albums_by_artist(artist).iter().collect::>() + } else { + library.all_albums() + }; + let items: Vec = albums + .iter() + .map(|album| { + ListItem::new(format!("{} — {}", album.name, album.artist)) + }) + .collect(); + let list = List::new(items) + .highlight_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, inner, &mut app.album_list_state); + } + MainView::Songs => { + let tracks = library.all_tracks(); + let items: Vec = tracks + .iter() + .map(|t| { + let artist = t.artists.first().map(|a| a.as_str()).unwrap_or("Unknown"); + ListItem::new(format!("{} — {}", t.name, artist)) + }) + .collect(); + let list = List::new(items) + .highlight_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, inner, &mut app.song_list_state); + } + MainView::Playlists => { + let msg = Paragraph::new("No playlists found.") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, inner); + // Full playlist rendering is added in Task 11 + } + MainView::AlbumDetail => { + // Get the selected album's tracks + let albums = if let Some(ref artist) = app.selected_artist { + library.albums_by_artist(artist).iter().collect::>() + } else { + library.all_albums() + }; + + if let Some(&album) = app.selected_album_index.and_then(|i| albums.get(i)) { + let items: Vec = album + .tracks + .iter() + .map(|t| { + let num = t.track_no.map(|n| format!("{n:2}. ")).unwrap_or_default(); + let dur = format_duration(t.length); + ListItem::new(format!("{num}{} [{dur}]", t.name)) + }) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, inner, &mut app.detail_list_state); + } + } + } +} + +fn draw_search(frame: &mut Frame, app: &mut App, area: Rect) { + // Split: [search input (1 row)] [results (remaining)] + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1)]) + .split(area); + + // Search input + let input = Paragraph::new(format!("/ {}", app.search.query)) + .style(Style::default().fg(Color::Yellow)); + frame.render_widget(input, chunks[0]); + + // Search results + if let Some(ref library) = app.library { + let results = library.search(&app.search.query); + let items: Vec = results + .iter() + .take(chunks[1].height as usize) + .map(|t| { + let artist = t.artists.first().map(|a| a.as_str()).unwrap_or("Unknown"); + ListItem::new(format!("{} — {}", t.name, artist)) + }) + .collect(); + let list = List::new(items) + .highlight_style(Style::default().fg(Color::Magenta)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, chunks[1], &mut app.search.results_state); + } +} + +fn format_duration(ms: u64) -> String { + let secs = ms / 1000; + format!("{}:{:02}", secs / 60, secs % 60) +} +``` + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `cargo test -p rustify-tui -- main_panel` +Expected: All 3 main panel tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-tui/src/ui/main_panel.rs +git commit -m "feat(tui): implement main panel with artist/album/song list views and search overlay" +``` + +--- + +## Task 10: Player Integration + +**Files:** +- Modify: `crates/rustify-tui/src/main.rs` +- Modify: `crates/rustify-tui/src/app.rs` + +This task wires `rustify-core::Player` into the app — player callbacks push into the event channel, and key handlers send commands to the player. + +- [ ] **Step 1: Write tests for player event handling in App** + +Add to the existing tests in `crates/rustify-tui/src/app.rs`: + +```rust + #[test] + fn player_state_change_updates_now_playing() { + let mut app = make_app(); + app.handle_player_event(PlayerEvent::StateChanged(PlaybackState::Playing)); + assert_eq!(app.now_playing.state, Some(PlaybackState::Playing)); + } + + #[test] + fn player_track_change_updates_now_playing() { + let mut app = make_app(); + let track = Track { + uri: "file:///test.mp3".into(), + name: "Test".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: 100_000, + track_no: None, + }; + app.handle_player_event(PlayerEvent::TrackChanged(track.clone())); + assert_eq!(app.now_playing.track.as_ref().unwrap().name, "Test"); + } + + #[test] + fn player_position_update_updates_now_playing() { + let mut app = make_app(); + app.handle_player_event(PlayerEvent::PositionUpdate(42_000)); + assert_eq!(app.now_playing.position_ms, 42_000); + } + + #[test] + fn player_error_sets_status_message() { + let mut app = make_app(); + app.handle_player_event(PlayerEvent::Error("decode failed".into())); + assert!(app.status.is_some()); + assert!(app.status.as_ref().unwrap().text.contains("decode failed")); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p rustify-tui -- app` +Expected: Compilation fails — `handle_player_event` not defined. + +- [ ] **Step 3: Add handle_player_event to App** + +Add this method to the `impl App` block in `crates/rustify-tui/src/app.rs`: + +```rust + /// Handle a player event (callback from rustify-core). + pub fn handle_player_event(&mut self, event: PlayerEvent) { + match event { + PlayerEvent::StateChanged(state) => { + self.now_playing.state = Some(state); + } + PlayerEvent::TrackChanged(track) => { + self.now_playing.track = Some(track); + self.now_playing.position_ms = 0; + } + PlayerEvent::PositionUpdate(ms) => { + self.now_playing.position_ms = ms; + } + PlayerEvent::Error(msg) => { + self.set_status(msg); + } + } + } +``` + +Also add the missing import at the top of `app.rs`: + +```rust +use rustify_core::types::{PlaybackState, PlayerEvent, Track}; +``` + +(The `PlayerEvent` import is new; `PlaybackState` and `Track` were already imported.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p rustify-tui -- app` +Expected: All app tests pass (including new player event tests). + +- [ ] **Step 5: Rewire main.rs with full player integration** + +Replace `crates/rustify-tui/src/main.rs` with: + +```rust +use std::io; +use std::path::PathBuf; +use std::thread; + +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::prelude::*; + +use rustify_core::metadata::read_metadata; +use rustify_core::player::{Player, PlayerConfig}; +use rustify_core::scanner; +use rustify_core::types::PlayerEvent; + +mod app; +mod config; +mod event; +mod library; +mod ui; + +use app::App; +use event::{AppEvent, EventLoop}; +use library::Library; + +fn main() -> io::Result<()> { + // Load config + let config = config::TuiConfig::load(); + + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, crossterm::event::EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create event loop + let event_loop = EventLoop::new(); + let rx = event_loop.receiver(); + let tx = event_loop.sender(); + + // Create player + let player_config = PlayerConfig { + alsa_device: config.alsa_device.clone(), + music_dirs: config.music_dirs.clone(), + }; + + let player = match Player::new(player_config) { + Ok(p) => p, + Err(e) => { + // Restore terminal before printing error + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + crossterm::event::DisableMouseCapture + )?; + eprintln!("rustify: failed to create player: {e}"); + std::process::exit(1); + } + }; + + // Register player callbacks to push into event channel + let tx_state = tx.clone(); + player.on_state_change(Box::new(move |state| { + tx_state + .send(AppEvent::Player(PlayerEvent::StateChanged(state))) + .ok(); + })); + + let tx_track = tx.clone(); + player.on_track_change(Box::new(move |track| { + tx_track + .send(AppEvent::Player(PlayerEvent::TrackChanged(track))) + .ok(); + })); + + let tx_pos = tx.clone(); + player.on_position_update(Box::new(move |ms| { + tx_pos + .send(AppEvent::Player(PlayerEvent::PositionUpdate(ms))) + .ok(); + })); + + let tx_err = tx.clone(); + player.on_error(Box::new(move |msg| { + tx_err + .send(AppEvent::Player(PlayerEvent::Error(msg))) + .ok(); + })); + + let mut app = App::new(); + app.now_playing.volume = player.get_volume(); + + // Start background library scan if music_dirs configured + if !config.music_dirs.is_empty() { + app.scanning = true; + let music_dirs = config.music_dirs.clone(); + let scan_tx = tx.clone(); + thread::Builder::new() + .name("rustify-scan".into()) + .spawn(move || { + let library = scan_library(&music_dirs); + scan_tx.send(AppEvent::ScanComplete(library)).ok(); + }) + .expect("failed to spawn scan thread"); + } + + // Main event loop + loop { + terminal.draw(|frame| { + ui::draw(frame, &mut app); + })?; + + match rx.recv() { + Ok(AppEvent::Key(key)) => { + app.handle_key(key); + // Sync volume changes to player + player.set_volume(app.now_playing.volume); + } + Ok(AppEvent::Player(event)) => { + app.handle_player_event(event); + } + Ok(AppEvent::Tick) => { + app.handle_tick(); + } + Ok(AppEvent::ScanComplete(library)) => { + app.library = Some(library); + app.scanning = false; + app.artist_list_state.select(Some(0)); + } + Ok(AppEvent::Error(msg)) => { + app.set_status(msg); + } + Ok(_) => {} + Err(_) => break, + } + + if app.should_quit { + break; + } + } + + // Cleanup + player.shutdown(); + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + crossterm::event::DisableMouseCapture + )?; + Ok(()) +} + +/// Scan all configured music directories and build a Library. +fn scan_library(music_dirs: &[PathBuf]) -> Library { + let mut all_tracks = Vec::new(); + + for dir in music_dirs { + match scanner::scan_directory(dir) { + Ok(uris) => { + for uri in uris { + match read_metadata(&uri) { + Ok(track) => all_tracks.push(track), + Err(e) => eprintln!("rustify: metadata error: {e}"), + } + } + } + Err(e) => eprintln!("rustify: scan error for {}: {e}", dir.display()), + } + } + + Library::from_tracks(all_tracks) +} +``` + +- [ ] **Step 6: Add player transport commands to key handler** + +In `crates/rustify-tui/src/app.rs`, the `handle_key` method has placeholder comments for play/pause/next/prev. These will be driven by the main loop — `main.rs` checks what key was pressed and calls the player directly. Add a method to `App` that returns an optional command: + +```rust +/// Player command to execute after key handling. +#[derive(Debug)] +pub enum PlayerAction { + PlayPause, + Next, + Previous, + Seek(i64), // delta in ms + PlayTrackUri(String), + LoadTrackUris(Vec), +} +``` + +Update `handle_key` to return `Option` instead of `bool`. Replace the placeholder matches: + +```rust + KeyCode::Char(' ') => { + return Some(PlayerAction::PlayPause); + } + KeyCode::Char('n') if self.focus != Focus::Search => { + return Some(PlayerAction::Next); + } + KeyCode::Char('p') if self.focus != Focus::Search => { + return Some(PlayerAction::Previous); + } +``` + +And update the `handle_key` signature: + +```rust + pub fn handle_key(&mut self, key: KeyEvent) -> Option { +``` + +Where the old `return true` becomes `return None` (event consumed, no player action) and the player-related returns use `Some(...)`. + +Then in `main.rs`, update the key handler: + +```rust +Ok(AppEvent::Key(key)) => { + if let Some(action) = app.handle_key(key) { + match action { + app::PlayerAction::PlayPause => { + match app.now_playing.state { + Some(rustify_core::types::PlaybackState::Playing) => player.pause(), + _ => player.play(), + } + } + app::PlayerAction::Next => player.next(), + app::PlayerAction::Previous => player.previous(), + app::PlayerAction::Seek(delta) => { + let pos = app.now_playing.position_ms as i64 + delta; + player.seek(pos.max(0) as u64); + } + app::PlayerAction::PlayTrackUri(uri) => { + player.load_track_uris(vec![uri]); + player.play(); + } + app::PlayerAction::LoadTrackUris(uris) => { + player.load_track_uris(uris); + player.play(); + } + } + } + player.set_volume(app.now_playing.volume); +} +``` + +- [ ] **Step 7: Update existing tests for new return type** + +In the test module of `app.rs`, update assertions. Where tests checked `app.handle_key(...)` returning a bool, they now check for `None` or `Some(PlayerAction::...)`: + +```rust + #[test] + fn q_sets_should_quit() { + let mut app = make_app(); + let _ = app.handle_key(make_key(KeyCode::Char('q'))); + assert!(app.should_quit); + } + + // Space returns PlayPause action + #[test] + fn space_returns_play_pause_action() { + let mut app = make_app(); + let action = app.handle_key(make_key(KeyCode::Char(' '))); + assert!(matches!(action, Some(PlayerAction::PlayPause))); + } +``` + +- [ ] **Step 8: Run all tests** + +Run: `cargo test -p rustify-tui` +Expected: All tests pass. + +- [ ] **Step 9: Verify it compiles** + +Run: `cargo build -p rustify-tui` +Expected: Compiles with 0 errors. + +- [ ] **Step 10: Commit** + +```bash +git add crates/rustify-tui/src/ +git commit -m "feat(tui): wire player integration with callbacks, transport commands, and library scanning" +``` + +--- + +## Task 11: Playlist Management + +**Files:** +- Modify: `crates/rustify-tui/src/app.rs` (playlist state + actions) +- Modify: `crates/rustify-tui/src/ui/main_panel.rs` (playlist view rendering) + +- [ ] **Step 1: Write tests for playlist actions** + +Add to the tests in `crates/rustify-tui/src/app.rs`: + +```rust + #[test] + fn save_queue_as_m3u_generates_content() { + let mut app = make_app(); + app.queue.track_uris = vec![ + "file:///music/a.mp3".into(), + "file:///music/b.flac".into(), + ]; + let content = app.generate_m3u_content(); + assert!(content.contains("#EXTM3U")); + assert!(content.contains("/music/a.mp3")); + assert!(content.contains("/music/b.flac")); + } +``` + +- [ ] **Step 2: Add M3U generation to App** + +Add to the `impl App` block in `crates/rustify-tui/src/app.rs`: + +```rust + /// Generate M3U content from the current queue. + pub fn generate_m3u_content(&self) -> String { + let mut content = String::from("#EXTM3U\n"); + for uri in &self.queue.track_uris { + let path = uri.strip_prefix("file://").unwrap_or(uri); + content.push_str(path); + content.push('\n'); + } + content + } +``` + +- [ ] **Step 3: Add playlist data to App state** + +Add a field to `App`: + +```rust + pub playlists: Vec, +``` + +Initialize it as `playlists: Vec::new()` in `App::new()`. + +- [ ] **Step 4: Update main panel to render playlists** + +In `crates/rustify-tui/src/ui/main_panel.rs`, replace the `MainView::Playlists` arm: + +```rust + MainView::Playlists => { + if app.playlists.is_empty() { + let msg = Paragraph::new("No playlists found.") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, inner); + } else { + let items: Vec = app + .playlists + .iter() + .map(|p| { + ListItem::new(format!("{} ({} tracks)", p.name, p.track_count)) + }) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, inner, &mut app.playlist_list_state); + } + } +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p rustify-tui` +Expected: All tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/rustify-tui/src/app.rs crates/rustify-tui/src/ui/main_panel.rs +git commit -m "feat(tui): add playlist view rendering and M3U generation from queue" +``` + +--- + +## Task 12: Queue Management + +**Files:** +- Modify: `crates/rustify-tui/src/app.rs` + +- [ ] **Step 1: Write tests for queue operations** + +Add to tests in `crates/rustify-tui/src/app.rs`: + +```rust + #[test] + fn add_to_queue() { + let mut app = make_app(); + app.add_to_queue("file:///music/test.mp3".into(), "Test Song".into()); + assert_eq!(app.queue.track_uris.len(), 1); + assert_eq!(app.queue.track_names.len(), 1); + assert_eq!(app.queue.track_names[0], "Test Song"); + } + + #[test] + fn remove_from_queue() { + let mut app = make_app(); + app.add_to_queue("file:///a.mp3".into(), "A".into()); + app.add_to_queue("file:///b.mp3".into(), "B".into()); + app.add_to_queue("file:///c.mp3".into(), "C".into()); + app.remove_from_queue(1); + assert_eq!(app.queue.track_uris.len(), 2); + assert_eq!(app.queue.track_names[1], "C"); + } + + #[test] + fn reorder_queue_down() { + let mut app = make_app(); + app.add_to_queue("file:///a.mp3".into(), "A".into()); + app.add_to_queue("file:///b.mp3".into(), "B".into()); + app.add_to_queue("file:///c.mp3".into(), "C".into()); + app.reorder_queue(0, 1); // Move A down + assert_eq!(app.queue.track_names[0], "B"); + assert_eq!(app.queue.track_names[1], "A"); + } + + #[test] + fn reorder_queue_up() { + let mut app = make_app(); + app.add_to_queue("file:///a.mp3".into(), "A".into()); + app.add_to_queue("file:///b.mp3".into(), "B".into()); + app.add_to_queue("file:///c.mp3".into(), "C".into()); + app.reorder_queue(2, 1); // Move C up + assert_eq!(app.queue.track_names[1], "C"); + assert_eq!(app.queue.track_names[2], "B"); + } +``` + +- [ ] **Step 2: Implement queue operations** + +Add to the `impl App` block in `crates/rustify-tui/src/app.rs`: + +```rust + /// Add a track to the end of the queue. + pub fn add_to_queue(&mut self, uri: String, name: String) { + self.queue.track_uris.push(uri); + self.queue.track_names.push(name); + } + + /// Remove a track from the queue by index. + pub fn remove_from_queue(&mut self, index: usize) { + if index < self.queue.track_uris.len() { + self.queue.track_uris.remove(index); + self.queue.track_names.remove(index); + // Adjust list state if needed + if let Some(selected) = self.queue.list_state.selected() { + if selected >= self.queue.track_uris.len() && !self.queue.track_uris.is_empty() { + self.queue.list_state.select(Some(self.queue.track_uris.len() - 1)); + } + } + } + } + + /// Swap two tracks in the queue. + pub fn reorder_queue(&mut self, from: usize, to: usize) { + if from < self.queue.track_uris.len() && to < self.queue.track_uris.len() { + self.queue.track_uris.swap(from, to); + self.queue.track_names.swap(from, to); + } + } + + /// Get all queue URIs (for loading into player). + pub fn queue_uris(&self) -> Vec { + self.queue.track_uris.clone() + } +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-tui -- queue` +Expected: All 4 queue tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-tui/src/app.rs +git commit -m "feat(tui): add queue management operations (add, remove, reorder)" +``` + +--- + +## Task 13: Album Art + +**Files:** +- Modify: `crates/rustify-tui/Cargo.toml` (add ratatui-image) +- Modify: `crates/rustify-tui/src/ui/now_playing.rs` + +- [ ] **Step 1: Add ratatui-image dependency** + +Add to `[dependencies]` in `crates/rustify-tui/Cargo.toml`: + +```toml +ratatui-image = "3" +image = "0.25" +``` + +- [ ] **Step 2: Update now-playing bar to show album art** + +In `crates/rustify-tui/src/ui/now_playing.rs`, add album art rendering. Update the layout to include an art area on the left: + +```rust +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Gauge, Paragraph}; + +use crate::app::App; +use rustify_core::types::PlaybackState; + +pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 || inner.width < 20 { + return; + } + + if let Some(ref track) = app.now_playing.track { + let artist = if track.artists.is_empty() { + "Unknown".to_string() + } else { + track.artists.join(", ") + }; + + let state_icon = match app.now_playing.state { + Some(PlaybackState::Playing) => ">>", + Some(PlaybackState::Paused) => "||", + _ => "--", + }; + + let pos = format_time(app.now_playing.position_ms); + let dur = format_time(track.length); + + let ratio = if track.length > 0 { + (app.now_playing.position_ms as f64 / track.length as f64).min(1.0) + } else { + 0.0 + }; + + // Layout: [track info left] [progress center] [time+vol right] + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(35), + Constraint::Percentage(45), + Constraint::Percentage(20), + ]) + .split(inner); + + // Left: track info + let info = format!("{state_icon} {}\n {artist} — {}", track.name, track.album); + let info_widget = Paragraph::new(info).style(Style::default().fg(Color::White)); + frame.render_widget(info_widget, cols[0]); + + // Center: progress bar + if cols[1].height > 0 { + let gauge = Gauge::default() + .ratio(ratio) + .gauge_style(Style::default().fg(Color::Magenta).bg(Color::DarkGray)) + .label(""); + let gauge_area = Rect { + y: cols[1].y + cols[1].height.saturating_sub(1), + height: 1, + ..cols[1] + }; + frame.render_widget(gauge, gauge_area); + } + + // Right: time + volume + let time_vol = format!("{pos} / {dur}\nVol: {}", app.now_playing.volume); + let right_widget = Paragraph::new(time_vol) + .alignment(Alignment::Right) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(right_widget, cols[2]); + } else { + let paragraph = Paragraph::new("No track playing") + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, inner); + } +} + +fn format_time(ms: u64) -> String { + let total_secs = ms / 1000; + let mins = total_secs / 60; + let secs = total_secs % 60; + format!("{mins}:{secs:02}") +} +``` + +Note: Full `ratatui-image` integration for embedded cover art requires loading images from audio tags (via lofty) at runtime. This is complex and depends on terminal capabilities. For now, the dependency is added and the crate is available. The actual image rendering widget (`StatefulImage`) can be wired in when album art extraction is implemented — that's a stretch goal for v1. + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build -p rustify-tui` +Expected: Compiles with 0 errors. + +- [ ] **Step 4: Run all tests** + +Run: `cargo test -p rustify-tui` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/rustify-tui/Cargo.toml crates/rustify-tui/src/ui/now_playing.rs +git commit -m "feat(tui): add ratatui-image dependency and prepare album art support" +``` + +--- + +## Task 14: Mouse Support + +**Files:** +- Modify: `crates/rustify-tui/src/app.rs` + +- [ ] **Step 1: Write tests for mouse handling** + +Add to tests in `crates/rustify-tui/src/app.rs`: + +```rust + #[test] + fn mouse_click_in_sidebar_area_sets_focus() { + let mut app = make_app(); + app.focus = Focus::Main; + // Simulate click in sidebar region (x < 30% of 80 = 24) + app.handle_mouse_click(5, 3, 80, 24); + assert_eq!(app.focus, Focus::Sidebar); + } + + #[test] + fn mouse_click_in_main_area_sets_focus() { + let mut app = make_app(); + app.focus = Focus::Sidebar; + // Simulate click in main panel region (x >= 30% of 80 = 24) + app.handle_mouse_click(30, 3, 80, 24); + assert_eq!(app.focus, Focus::Main); + } +``` + +- [ ] **Step 2: Implement mouse handling** + +Add to `impl App` in `crates/rustify-tui/src/app.rs`: + +```rust + /// Handle a mouse click at terminal coordinates. + /// `term_width` and `term_height` are needed to determine which region was clicked. + pub fn handle_mouse_click(&mut self, x: u16, y: u16, term_width: u16, term_height: u16) { + let sidebar_width = term_width * 30 / 100; + let now_playing_height = 3u16; + let content_height = term_height.saturating_sub(now_playing_height); + + if y < content_height { + // Click in content area + if x < sidebar_width { + self.focus = Focus::Sidebar; + // Check if clicking on a nav item (rows 1-4 inside border) + if y >= 1 && y <= 4 { + let nav_index = (y - 1) as usize; + if nav_index < self.nav_items().len() { + self.sidebar_nav_index = nav_index; + self.main_view = nav_index_to_view(nav_index); + } + } + } else { + self.focus = Focus::Main; + } + } + // Clicks on now-playing bar are ignored for now + } +``` + +- [ ] **Step 3: Wire mouse events in main.rs** + +In the event loop in `main.rs`, add a handler for `AppEvent::Mouse`: + +```rust +Ok(AppEvent::Mouse(mouse)) => { + use crossterm::event::{MouseEventKind, MouseButton}; + if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { + let size = terminal.size().unwrap_or_default(); + app.handle_mouse_click(mouse.column, mouse.row, size.width, size.height); + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p rustify-tui` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/rustify-tui/src/app.rs crates/rustify-tui/src/main.rs +git commit -m "feat(tui): add mouse click support for focus switching and nav selection" +``` + +--- + +## Task 15: Status Bar + Error Display + +**Files:** +- Modify: `crates/rustify-tui/src/ui/mod.rs` + +- [ ] **Step 1: Write test for status bar rendering** + +Add to the tests in `crates/rustify-tui/src/ui/mod.rs`: + +```rust + #[test] + fn status_message_renders_when_set() { + let mut app = App::new(); + app.set_status("Scanned 42 tracks".into()); + + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + draw(frame, &mut app); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = buf + .content() + .iter() + .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(content.contains("Scanned 42 tracks")); + } +``` + +- [ ] **Step 2: Update layout to include status line** + +In `crates/rustify-tui/src/ui/mod.rs`, update the `draw` function to add a status line above the now-playing bar when a status message is active: + +```rust +pub fn draw(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + + // Determine if we need a status line + let has_status = app.status.is_some(); + let status_height = if has_status { 1 } else { 0 }; + + // Split vertically: [content] [status (optional)] [now-playing (3)] + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), + Constraint::Length(status_height), + Constraint::Length(3), + ]) + .split(area); + + let content_area = vertical[0]; + let status_area = vertical[1]; + let now_playing_area = vertical[2]; + + // Split content horizontally: [sidebar (30%)] [main panel (70%)] + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) + .split(content_area); + + let sidebar_area = horizontal[0]; + let main_area = horizontal[1]; + + // Render each region + sidebar::draw(frame, app, sidebar_area); + main_panel::draw(frame, app, main_area); + now_playing::draw(frame, app, now_playing_area); + + // Render status line if present + if let Some(ref status) = app.status { + let status_widget = ratatui::widgets::Paragraph::new(status.text.as_str()) + .style(Style::default().fg(Color::Yellow).bg(Color::DarkGray)); + frame.render_widget(status_widget, status_area); + } +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-tui` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-tui/src/ui/mod.rs +git commit -m "feat(tui): add status bar for error messages and scan results" +``` + +--- + +## Task 16: Final Integration + CLI Args + +**Files:** +- Modify: `crates/rustify-tui/src/main.rs` + +- [ ] **Step 1: Add CLI argument parsing** + +Update `main()` in `crates/rustify-tui/src/main.rs` to accept optional path arguments (override config music_dirs): + +```rust +fn main() -> io::Result<()> { + let args: Vec = std::env::args().collect(); + + // Load config + let mut config = config::TuiConfig::load(); + + // CLI args override config music_dirs + let extra_dirs: Vec = args[1..] + .iter() + .map(PathBuf::from) + .filter(|p| p.is_dir()) + .collect(); + if !extra_dirs.is_empty() { + config.music_dirs.extend(extra_dirs); + } + + // ... rest of main unchanged +} +``` + +- [ ] **Step 2: Verify full build** + +Run: `cargo build -p rustify-tui` +Expected: Compiles with 0 errors. + +- [ ] **Step 3: Run full test suite** + +Run: `cargo test -p rustify-tui` +Expected: All tests pass. + +- [ ] **Step 4: Run clippy** + +Run: `cargo clippy -p rustify-tui -- -D warnings` +Expected: No warnings. + +- [ ] **Step 5: Run fmt check** + +Run: `cargo fmt -p rustify-tui -- --check` +Expected: No formatting issues. + +- [ ] **Step 6: Commit** + +```bash +git add crates/rustify-tui/src/main.rs +git commit -m "feat(tui): add CLI argument support for music directory override" +``` + +--- + +## Summary + +After completing all 16 tasks, `rustify-tui` provides: + +- **Rich TUI layout** — sidebar (nav + queue), main panel (artists/albums/songs/playlists/search), now-playing bar +- **Full playback control** — play/pause, next/prev, seek, volume via keyboard +- **Library browser** — background scan, artist → album → track drill-down +- **Search** — case-insensitive substring search across library +- **Playlist management** — view playlists, generate M3U from queue +- **Queue management** — add, remove, reorder tracks +- **Mouse support** — optional click-to-focus, click nav items +- **Status messages** — auto-dismissing error/info display +- **Album art readiness** — ratatui-image dependency added, ready for cover art rendering +- **Config** — `~/.config/rustify/tui.toml` with platform-appropriate paths +- **CLI args** — override music dirs from command line + +Run with: `cargo run -p rustify-tui -- /path/to/music` diff --git a/docs/superpowers/plans/2026-04-08-tier1-playback-essentials.md b/docs/superpowers/plans/2026-04-08-tier1-playback-essentials.md new file mode 100644 index 0000000..70af4c5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-tier1-playback-essentials.md @@ -0,0 +1,1370 @@ +# Tier 1: Playback Essentials — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add shuffle/repeat modes, seek keybindings, gapless playback (dual-decode mixer), and album art to make rustify a daily-driver music player. + +**Architecture:** Shuffle/repeat live in `Tracklist` (core), exposed through `Player` API. Gapless uses a dual-decode architecture with a MixStage in the cpal callback that swaps between active and pending audio channels. Album art extraction is a new core module; rendering uses `ratatui-image` in the TUI. All features are testable independently. + +**Tech Stack:** Rust (rustify-core: rand, lofty; rustify-tui: ratatui-image, image) + +**Design Spec:** `docs/superpowers/specs/2026-04-08-tier1-playback-essentials-design.md` + +--- + +## File Map + +### Modified files (rustify-core) + +| File | Changes | +|---|---| +| `crates/rustify-core/Cargo.toml` | Add `rand` dependency | +| `crates/rustify-core/src/types.rs` | Add `RepeatMode` enum, `SetShuffle`/`SetRepeat` commands, `ModeChanged` event | +| `crates/rustify-core/src/tracklist.rs` | Add shuffle/repeat fields, modify `next()`/`previous()`, add shuffle/repeat methods | +| `crates/rustify-core/src/player.rs` | Add `set_shuffle()`/`set_repeat()` API, handle new commands, gapless dual-decode, MixStage | +| `crates/rustify-core/src/lib.rs` | Add `pub mod art;`, re-export `RepeatMode` | + +### New files (rustify-core) + +| File | Responsibility | +|---|---| +| `crates/rustify-core/src/art.rs` | Album art extraction (embedded tags + sidecar files) | + +### Modified files (rustify-tui) + +| File | Changes | +|---|---| +| `crates/rustify-tui/src/app.rs` | Add `ToggleShuffle`/`CycleRepeat` actions, seek keybindings, shuffle/repeat state | +| `crates/rustify-tui/src/ui/now_playing.rs` | Album art rendering, shuffle/repeat indicators, layout change | +| `crates/rustify-tui/src/main.rs` | Wire new PlayerActions to player API, handle `ModeChanged` event | + +--- + +## Task 1: RepeatMode Type + Tracklist Shuffle/Repeat + +**Files:** +- Modify: `crates/rustify-core/Cargo.toml` +- Modify: `crates/rustify-core/src/types.rs` +- Modify: `crates/rustify-core/src/tracklist.rs` + +- [ ] **Step 1: Add rand dependency** + +Add to `[dependencies]` in `crates/rustify-core/Cargo.toml`: + +```toml +rand = "0.9" +``` + +- [ ] **Step 2: Add RepeatMode to types.rs** + +Add after the `PlaybackState` enum in `crates/rustify-core/src/types.rs`: + +```rust +/// Repeat mode for the tracklist. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RepeatMode { + Off, + All, + One, +} + +impl RepeatMode { + /// Cycle to the next repeat mode: Off → All → One → Off. + pub fn cycle(self) -> Self { + match self { + Self::Off => Self::All, + Self::All => Self::One, + Self::One => Self::Off, + } + } +} +``` + +Add new variants to `PlayerCommand`: + +```rust +pub enum PlayerCommand { + // ... existing ... + SetShuffle(bool), + SetRepeat(RepeatMode), +} +``` + +Add new variant to `PlayerEvent`: + +```rust +pub enum PlayerEvent { + // ... existing ... + ModeChanged { shuffle: bool, repeat: RepeatMode }, +} +``` + +- [ ] **Step 3: Write failing tests for tracklist shuffle/repeat** + +Add to the tests module in `crates/rustify-core/src/tracklist.rs`: + +```rust + #[test] + fn repeat_all_wraps_at_end() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + ]); + tl.set_repeat(RepeatMode::All); + tl.next(); // -> b + assert_eq!(tl.next(), Some("file:///a.mp3")); // wraps + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn repeat_one_returns_same_track() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + ]); + tl.set_repeat(RepeatMode::One); + assert_eq!(tl.next(), Some("file:///a.mp3")); + assert_eq!(tl.next(), Some("file:///a.mp3")); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn repeat_off_returns_none_at_end() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + tl.set_repeat(RepeatMode::Off); + assert_eq!(tl.next(), None); + } + + #[test] + fn shuffle_produces_permutation() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + "file:///d.mp3".into(), + "file:///e.mp3".into(), + ]); + tl.set_shuffle(true); + // Walk through all tracks — should visit all 5 + let mut visited = vec![tl.current().unwrap().to_string()]; + for _ in 0..4 { + let next = tl.next().unwrap().to_string(); + visited.push(next); + } + visited.sort(); + assert_eq!(visited, vec![ + "file:///a.mp3", + "file:///b.mp3", + "file:///c.mp3", + "file:///d.mp3", + "file:///e.mp3", + ]); + } + + #[test] + fn shuffle_off_restores_order() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + let original = tl.current().unwrap().to_string(); + tl.set_shuffle(true); + tl.next(); // advance in shuffle + let current_uri = tl.current().unwrap().to_string(); + tl.set_shuffle(false); + // Current track should still be the same URI + assert_eq!(tl.current().unwrap(), current_uri); + } + + #[test] + fn shuffle_previous_walks_backward() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + tl.set_shuffle(true); + let first = tl.current().unwrap().to_string(); + let second = tl.next().unwrap().to_string(); + let back = tl.previous().unwrap().to_string(); + assert_eq!(back, first); + } + + #[test] + fn repeat_mode_cycle() { + assert_eq!(RepeatMode::Off.cycle(), RepeatMode::All); + assert_eq!(RepeatMode::All.cycle(), RepeatMode::One); + assert_eq!(RepeatMode::One.cycle(), RepeatMode::Off); + } +``` + +Add import at top of test module: `use crate::types::RepeatMode;` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `cargo test -p rustify-core -- tracklist` +Expected: Compilation fails — `set_shuffle`, `set_repeat` not defined. + +- [ ] **Step 5: Implement shuffle/repeat in Tracklist** + +Replace the `Tracklist` struct and impl in `crates/rustify-core/src/tracklist.rs`: + +```rust +use std::collections::VecDeque; + +use rand::seq::SliceRandom; +use rand::rng; + +use crate::types::RepeatMode; + +/// A playback queue backed by VecDeque. +/// Supports shuffle and repeat modes. +pub struct Tracklist { + tracks: VecDeque, + current_index: Option, + shuffle: bool, + repeat: RepeatMode, + /// Shuffled indices into `tracks`. Only valid when `shuffle == true`. + shuffle_order: Vec, + /// Current position within `shuffle_order`. + shuffle_position: Option, +} + +impl Tracklist { + pub fn new() -> Self { + Self { + tracks: VecDeque::new(), + current_index: None, + shuffle: false, + repeat: RepeatMode::Off, + shuffle_order: Vec::new(), + shuffle_position: None, + } + } + + pub fn add(&mut self, uri: String) { + self.tracks.push_back(uri); + } + + pub fn load(&mut self, uris: Vec) { + self.tracks.clear(); + self.tracks.extend(uris); + self.current_index = if self.tracks.is_empty() { + None + } else { + Some(0) + }; + // Reset shuffle for new tracklist + if self.shuffle { + self.generate_shuffle_order(); + } + } + + pub fn clear(&mut self) { + self.tracks.clear(); + self.current_index = None; + self.shuffle_order.clear(); + self.shuffle_position = None; + } + + pub fn current(&self) -> Option<&str> { + self.current_index + .and_then(|i| self.tracks.get(i)) + .map(String::as_str) + } + + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Option<&str> { + let _idx = self.current_index?; + + // Repeat One: stay on current track + if self.repeat == RepeatMode::One { + return self.current(); + } + + if self.shuffle { + return self.next_shuffled(); + } + + self.next_sequential() + } + + pub fn previous(&mut self) -> Option<&str> { + let _idx = self.current_index?; + + if self.shuffle { + return self.previous_shuffled(); + } + + self.previous_sequential() + } + + fn next_sequential(&mut self) -> Option<&str> { + let idx = self.current_index?; + if idx + 1 < self.tracks.len() { + self.current_index = Some(idx + 1); + self.current() + } else if self.repeat == RepeatMode::All && !self.tracks.is_empty() { + self.current_index = Some(0); + self.current() + } else { + None + } + } + + fn previous_sequential(&mut self) -> Option<&str> { + let idx = self.current_index?; + if idx > 0 { + self.current_index = Some(idx - 1); + self.current() + } else { + None + } + } + + fn next_shuffled(&mut self) -> Option<&str> { + let pos = self.shuffle_position?; + if pos + 1 < self.shuffle_order.len() { + self.shuffle_position = Some(pos + 1); + self.current_index = Some(self.shuffle_order[pos + 1]); + self.current() + } else if self.repeat == RepeatMode::All && !self.shuffle_order.is_empty() { + // Re-shuffle for variety on repeat + self.generate_shuffle_order(); + self.current() + } else { + None + } + } + + fn previous_shuffled(&mut self) -> Option<&str> { + let pos = self.shuffle_position?; + if pos > 0 { + self.shuffle_position = Some(pos - 1); + self.current_index = Some(self.shuffle_order[pos - 1]); + self.current() + } else { + None + } + } + + // --- Shuffle/Repeat API --- + + pub fn set_shuffle(&mut self, on: bool) { + if on && !self.shuffle { + self.shuffle = true; + self.generate_shuffle_order(); + } else if !on && self.shuffle { + self.shuffle = false; + // Keep current track, clear shuffle state + self.shuffle_order.clear(); + self.shuffle_position = None; + } + } + + pub fn get_shuffle(&self) -> bool { + self.shuffle + } + + pub fn set_repeat(&mut self, mode: RepeatMode) { + self.repeat = mode; + } + + pub fn get_repeat(&self) -> RepeatMode { + self.repeat + } + + fn generate_shuffle_order(&mut self) { + let len = self.tracks.len(); + if len == 0 { + self.shuffle_order.clear(); + self.shuffle_position = None; + return; + } + + // Build indices excluding current track + let current = self.current_index.unwrap_or(0); + let mut others: Vec = (0..len).filter(|&i| i != current).collect(); + others.shuffle(&mut rng()); + + // Current track first, then shuffled rest + self.shuffle_order = Vec::with_capacity(len); + self.shuffle_order.push(current); + self.shuffle_order.extend(others); + self.shuffle_position = Some(0); + } + + // --- Existing getters --- + + pub fn index(&self) -> Option { + self.current_index + } + + pub fn len(&self) -> usize { + self.tracks.len() + } + + pub fn is_empty(&self) -> bool { + self.tracks.is_empty() + } +} + +impl Default for Tracklist { + fn default() -> Self { + Self::new() + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cargo test -p rustify-core -- tracklist` +Expected: All tests pass (existing + new shuffle/repeat tests). + +- [ ] **Step 7: Commit** + +```bash +git add crates/rustify-core/ +git commit -m "feat(core): add shuffle/repeat modes to Tracklist with Fisher-Yates shuffle" +``` + +--- + +## Task 2: Player Shuffle/Repeat API + +**Files:** +- Modify: `crates/rustify-core/src/player.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Add RepeatMode re-export to lib.rs** + +Update `crates/rustify-core/src/lib.rs`: + +```rust +pub use types::{PlaybackState, PlayerCommand, PlayerEvent, Playlist, RepeatMode, Track}; +``` + +- [ ] **Step 2: Add shuffle/repeat methods to Player** + +In `crates/rustify-core/src/player.rs`, add to the `impl Player` block (after `set_volume`/`get_volume`): + +```rust + pub fn set_shuffle(&self, on: bool) { + self.cmd_tx.send(PlayerCommand::SetShuffle(on)).ok(); + } + + pub fn set_repeat(&self, mode: crate::types::RepeatMode) { + self.cmd_tx.send(PlayerCommand::SetRepeat(mode)).ok(); + } +``` + +- [ ] **Step 3: Handle new commands in CommandLoop** + +In `handle_command()` in `player.rs`, add arms for the new commands: + +```rust + PlayerCommand::SetShuffle(on) => { + self.tracklist.set_shuffle(on); + self.emit_callbacks(PlayerEvent::ModeChanged { + shuffle: self.tracklist.get_shuffle(), + repeat: self.tracklist.get_repeat(), + }); + } + PlayerCommand::SetRepeat(mode) => { + self.tracklist.set_repeat(mode); + self.emit_callbacks(PlayerEvent::ModeChanged { + shuffle: self.tracklist.get_shuffle(), + repeat: self.tracklist.get_repeat(), + }); + } +``` + +- [ ] **Step 4: Add ModeChanged callback support** + +In `SharedState`, add a new callback vector to `Callbacks`: + +```rust + on_mode_change: Vec>, +``` + +Add a new registration method to `Player`: + +```rust + pub fn on_mode_change(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_mode_change + .push(callback); + } +``` + +In `emit_callbacks`, add the `ModeChanged` arm: + +```rust + PlayerEvent::ModeChanged { shuffle, repeat } => { + for cb in &callbacks.on_mode_change { + cb(*shuffle, *repeat); + } + } +``` + +- [ ] **Step 5: Import RepeatMode where needed** + +Add `use crate::types::RepeatMode;` to the imports at the top of `player.rs`. + +- [ ] **Step 6: Verify it compiles and existing tests pass** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add crates/rustify-core/ +git commit -m "feat(core): expose shuffle/repeat through Player API with ModeChanged callbacks" +``` + +--- + +## Task 3: TUI Shuffle/Repeat + Seek Keybindings + +**Files:** +- Modify: `crates/rustify-tui/src/app.rs` +- Modify: `crates/rustify-tui/src/ui/now_playing.rs` +- Modify: `crates/rustify-tui/src/main.rs` + +- [ ] **Step 1: Write tests for new keybindings** + +Add to tests in `crates/rustify-tui/src/app.rs`: + +```rust + #[test] + fn s_returns_toggle_shuffle() { + let mut app = make_app(); + app.focus = Focus::Main; // not in search + let action = app.handle_key(make_key(KeyCode::Char('s'))); + assert!(matches!(action, Some(PlayerAction::ToggleShuffle))); + } + + #[test] + fn r_returns_cycle_repeat() { + let mut app = make_app(); + app.focus = Focus::Main; + let action = app.handle_key(make_key(KeyCode::Char('r'))); + assert!(matches!(action, Some(PlayerAction::CycleRepeat))); + } + + #[test] + fn left_arrow_returns_seek_backward() { + let mut app = make_app(); + let action = app.handle_key(make_key(KeyCode::Left)); + assert!(matches!(action, Some(PlayerAction::Seek(-5000)))); + } + + #[test] + fn right_arrow_returns_seek_forward() { + let mut app = make_app(); + let action = app.handle_key(make_key(KeyCode::Right)); + assert!(matches!(action, Some(PlayerAction::Seek(5000)))); + } +``` + +- [ ] **Step 2: Add PlayerAction variants and keybindings** + +In `crates/rustify-tui/src/app.rs`, add to `PlayerAction` enum: + +```rust +pub enum PlayerAction { + // ... existing ... + ToggleShuffle, + CycleRepeat, +} +``` + +Add to `NowPlayingState`: + +```rust +pub struct NowPlayingState { + // ... existing ... + pub shuffle: bool, + pub repeat: rustify_core::types::RepeatMode, +} +``` + +Update `Default` for `NowPlayingState` to include `shuffle: false, repeat: rustify_core::types::RepeatMode::Off`. + +Add keybindings in the global match section of `handle_key()`: + +```rust + KeyCode::Char('s') if self.focus != Focus::Search => { + return Some(PlayerAction::ToggleShuffle); + } + KeyCode::Char('r') if self.focus != Focus::Search => { + return Some(PlayerAction::CycleRepeat); + } + KeyCode::Left => { + return Some(PlayerAction::Seek(-5000)); + } + KeyCode::Right => { + return Some(PlayerAction::Seek(5000)); + } +``` + +Add `ModeChanged` handling in `handle_player_event`: + +```rust + PlayerEvent::ModeChanged { shuffle, repeat } => { + self.now_playing.shuffle = shuffle; + self.now_playing.repeat = repeat; + } +``` + +- [ ] **Step 3: Wire new actions in main.rs** + +In the key event handler in `main.rs`, add arms: + +```rust + app::PlayerAction::ToggleShuffle => { + let new_state = !app.now_playing.shuffle; + player.set_shuffle(new_state); + } + app::PlayerAction::CycleRepeat => { + let new_mode = app.now_playing.repeat.cycle(); + player.set_repeat(new_mode); + } +``` + +Update the `Seek` arm to do optimistic UI update: + +```rust + app::PlayerAction::Seek(delta) => { + let track_len = app.now_playing.track.as_ref() + .map(|t| t.length as i64).unwrap_or(0); + let new_pos = (app.now_playing.position_ms as i64 + delta) + .clamp(0, track_len) as u64; + player.seek(new_pos); + app.now_playing.position_ms = new_pos; + } +``` + +Register the `on_mode_change` callback alongside the other callbacks: + +```rust + let tx_mode = tx.clone(); + player.on_mode_change(Box::new(move |shuffle, repeat| { + tx_mode + .send(AppEvent::Player(PlayerEvent::ModeChanged { shuffle, repeat })) + .ok(); + })); +``` + +- [ ] **Step 4: Add shuffle/repeat indicators to now-playing bar** + +In `crates/rustify-tui/src/ui/now_playing.rs`, update the right-side display to include mode indicators. Replace the `time_vol` format string: + +```rust + // Mode indicators + let shuffle_indicator = if app.now_playing.shuffle { "[S] " } else { "" }; + let repeat_indicator = match app.now_playing.repeat { + rustify_core::types::RepeatMode::Off => "", + rustify_core::types::RepeatMode::All => "[R] ", + rustify_core::types::RepeatMode::One => "[R1] ", + }; + + let time_vol = format!( + "{shuffle_indicator}{repeat_indicator}{pos} / {dur}\nVol: {}", + app.now_playing.volume + ); +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p rustify-tui` +Expected: All tests pass (existing + new keybinding tests). + +- [ ] **Step 6: Commit** + +```bash +git add crates/rustify-tui/ +git commit -m "feat(tui): add shuffle/repeat keybindings, seek arrows, and mode indicators" +``` + +--- + +## Task 4: Album Art Extraction (Core) + +**Files:** +- Create: `crates/rustify-core/src/art.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write tests for art extraction** + +Create `crates/rustify-core/src/art.rs` with tests: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn sidecar_cover_found() { + let dir = TempDir::new().unwrap(); + let cover_path = dir.path().join("cover.jpg"); + fs::write(&cover_path, b"fake-jpeg-data").unwrap(); + + let track_path = dir.path().join("song.mp3"); + fs::write(&track_path, b"").unwrap(); + + let art = extract_art(&track_path); + assert!(art.is_some()); + assert_eq!(art.unwrap(), b"fake-jpeg-data"); + } + + #[test] + fn sidecar_folder_jpg_found() { + let dir = TempDir::new().unwrap(); + let cover_path = dir.path().join("folder.jpg"); + fs::write(&cover_path, b"folder-art").unwrap(); + + let track_path = dir.path().join("song.mp3"); + fs::write(&track_path, b"").unwrap(); + + let art = extract_art(&track_path); + assert!(art.is_some()); + assert_eq!(art.unwrap(), b"folder-art"); + } + + #[test] + fn no_art_returns_none() { + let dir = TempDir::new().unwrap(); + let track_path = dir.path().join("song.mp3"); + fs::write(&track_path, b"").unwrap(); + + let art = extract_art(&track_path); + assert!(art.is_none()); + } + + #[test] + fn sidecar_case_insensitive() { + let dir = TempDir::new().unwrap(); + let cover_path = dir.path().join("Cover.JPG"); + fs::write(&cover_path, b"case-insensitive").unwrap(); + + let track_path = dir.path().join("song.mp3"); + fs::write(&track_path, b"").unwrap(); + + let art = extract_art(&track_path); + assert!(art.is_some()); + } +} +``` + +- [ ] **Step 2: Implement art extraction** + +Add the implementation above the tests in `crates/rustify-core/src/art.rs`: + +```rust +use std::fs; +use std::path::Path; + +use lofty::prelude::*; +use lofty::picture::PictureType; +use lofty::probe::Probe; + +/// Sidecar filenames to search for album art (checked case-insensitively). +const SIDECAR_NAMES: &[&str] = &[ + "cover.jpg", + "cover.png", + "folder.jpg", + "folder.png", + "album.jpg", + "album.png", +]; + +/// Extract album art for a track file. +/// Tries embedded cover art first (via lofty), then sidecar files in the +/// track's directory. Returns raw image bytes (JPEG or PNG) or None. +pub fn extract_art(path: &Path) -> Option> { + // Try embedded art first + if let Some(art) = extract_embedded(path) { + return Some(art); + } + + // Fall back to sidecar files + extract_sidecar(path) +} + +/// Extract embedded cover art from audio file tags. +fn extract_embedded(path: &Path) -> Option> { + let tagged_file = Probe::open(path).ok()?.read().ok()?; + + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag())?; + + // Prefer CoverFront, fall back to any picture + let picture = tag + .pictures() + .iter() + .find(|p| p.pic_type() == PictureType::CoverFront) + .or_else(|| tag.pictures().first())?; + + Some(picture.data().to_vec()) +} + +/// Search the track's parent directory for sidecar art files. +fn extract_sidecar(path: &Path) -> Option> { + let parent = path.parent()?; + + // Read directory entries once + let entries: Vec<_> = fs::read_dir(parent).ok()?.filter_map(|e| e.ok()).collect(); + + for sidecar_name in SIDECAR_NAMES { + for entry in &entries { + let file_name = entry.file_name(); + let name_str = file_name.to_string_lossy(); + if name_str.eq_ignore_ascii_case(sidecar_name) { + if let Ok(data) = fs::read(entry.path()) { + return Some(data); + } + } + } + } + + None +} +``` + +- [ ] **Step 3: Register module in lib.rs** + +Add to `crates/rustify-core/src/lib.rs`: + +```rust +pub mod art; +``` + +And add to re-exports: + +```rust +pub use art::extract_art; +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p rustify-core -- art` +Expected: All 4 art tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/rustify-core/src/art.rs crates/rustify-core/src/lib.rs +git commit -m "feat(core): add album art extraction from embedded tags and sidecar files" +``` + +--- + +## Task 5: TUI Album Art Rendering + +**Files:** +- Modify: `crates/rustify-tui/src/app.rs` +- Modify: `crates/rustify-tui/src/ui/now_playing.rs` +- Modify: `crates/rustify-tui/src/main.rs` + +- [ ] **Step 1: Add art state to App** + +In `crates/rustify-tui/src/app.rs`, add: + +```rust +/// Cached album art state. +#[derive(Debug, Default)] +pub struct ArtState { + /// URI of the track whose art is currently cached. + pub current_uri: Option, + /// Whether we have art for the current track. + pub has_art: bool, + /// Raw image bytes (for rendering by the UI layer). + pub image_bytes: Option>, +} +``` + +Add field to `App`: + +```rust + pub art: ArtState, +``` + +Initialize in `App::new()`: + +```rust + art: ArtState::default(), +``` + +- [ ] **Step 2: Add art loading on track change** + +In `handle_player_event` in `app.rs`, update the `TrackChanged` arm to clear art: + +```rust + PlayerEvent::TrackChanged(track) => { + // Clear art cache when track changes + if self.art.current_uri.as_deref() != Some(&track.uri) { + self.art.current_uri = Some(track.uri.clone()); + self.art.has_art = false; + self.art.image_bytes = None; + } + self.now_playing.track = Some(track); + self.now_playing.position_ms = 0; + } +``` + +Add a new `AppEvent` variant for art loading results. In `event.rs`: + +```rust +pub enum AppEvent { + // ... existing ... + /// Album art loaded for a track URI + ArtLoaded { uri: String, data: Option> }, +} +``` + +- [ ] **Step 3: Load art on background thread from main.rs** + +In `main.rs`, handle `TrackChanged` to start art extraction: + +```rust + Ok(AppEvent::Player(event)) => { + // Check if this is a track change to trigger art loading + if let PlayerEvent::TrackChanged(ref track) = event { + let uri = track.uri.clone(); + let art_tx = tx.clone(); + std::thread::Builder::new() + .name("rustify-art".into()) + .spawn(move || { + let path = rustify_core::types::uri_to_path(&uri); + let data = rustify_core::art::extract_art(&path); + art_tx.send(AppEvent::ArtLoaded { uri, data }).ok(); + }) + .ok(); + } + app.handle_player_event(event); + } +``` + +Handle the `ArtLoaded` event: + +```rust + Ok(AppEvent::ArtLoaded { uri, data }) => { + if app.art.current_uri.as_deref() == Some(&uri) { + app.art.has_art = data.is_some(); + app.art.image_bytes = data; + } + } +``` + +- [ ] **Step 4: Update now-playing bar layout with art area** + +In `crates/rustify-tui/src/ui/now_playing.rs`, update the layout to include an art area. When art bytes are available, show a placeholder (the actual `ratatui-image` protocol rendering requires terminal capability detection at startup which is complex — use a unicode art placeholder for now, wire full image rendering as a follow-up). Update the track info section: + +Replace the layout split inside the `if let Some(ref track)` block: + +```rust + // Layout: [art (6 cols)] [track info] [progress] [time+vol+modes] + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(6), // Art area + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(20), + ]) + .split(inner); + + // Art area + if app.art.has_art { + let art_block = Paragraph::new("♪") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Magenta)); + frame.render_widget(art_block, cols[0]); + } else { + let placeholder = Paragraph::new("♪") + .alignment(Alignment::Center) + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(placeholder, cols[0]); + } + + // Track info (shifted to cols[1]) + let info = format!("{state_icon} {}\n {artist} — {}", track.name, track.album); + let info_widget = Paragraph::new(info).style(Style::default().fg(Color::White)); + frame.render_widget(info_widget, cols[1]); + + // Progress bar (shifted to cols[2]) + if cols[2].height > 0 { + let gauge = Gauge::default() + .ratio(ratio) + .gauge_style(Style::default().fg(Color::Magenta).bg(Color::DarkGray)) + .label(""); + let gauge_area = Rect { + y: cols[2].y + cols[2].height.saturating_sub(1), + height: 1, + ..cols[2] + }; + frame.render_widget(gauge, gauge_area); + } + + // Time + volume + mode indicators (shifted to cols[3]) + let shuffle_indicator = if app.now_playing.shuffle { "[S] " } else { "" }; + let repeat_indicator = match app.now_playing.repeat { + rustify_core::types::RepeatMode::Off => "", + rustify_core::types::RepeatMode::All => "[R] ", + rustify_core::types::RepeatMode::One => "[R1] ", + }; + let time_vol = format!( + "{shuffle_indicator}{repeat_indicator}{pos} / {dur}\nVol: {}", + app.now_playing.volume + ); + let right_widget = Paragraph::new(time_vol) + .alignment(Alignment::Right) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(right_widget, cols[3]); +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p rustify-tui` +Expected: All tests pass. Some now-playing snapshot tests may need assertion updates for the new layout. + +- [ ] **Step 6: Commit** + +```bash +git add crates/rustify-tui/ +git commit -m "feat(tui): add album art extraction, background loading, and now-playing display" +``` + +--- + +## Task 6: Gapless Playback — TrackEnding Event + +**Files:** +- Modify: `crates/rustify-core/src/player.rs` + +This task adds the `TrackEnding` event to the decode thread. The next task wires the MixStage. + +- [ ] **Step 1: Add TrackEnding to InternalEvent** + +In `crates/rustify-core/src/player.rs`, add to the `InternalEvent` enum: + +```rust +enum InternalEvent { + TrackChanged(Track), + Position(u64), + TrackEnded, + /// Decode thread is nearing the end of the track. + /// Command loop should pre-start the next decode. + TrackEnding { remaining_ms: u64 }, + DecodeFailed(String), + Error(String), +} +``` + +- [ ] **Step 2: Add remaining-time calculation to decode thread** + +In the `decode_thread` function, after the codec params are read, compute total duration: + +```rust + let total_samples = track.codec_params.n_frames; + let mut decoded_samples: u64 = 0; + let mut track_ending_sent = false; + const PRE_BUFFER_MS: u64 = 3000; +``` + +After each successful decode (after `sbuf.copy_interleaved_ref(decoded);`), add: + +```rust + decoded_samples += decoded.frames() as u64; + + // Check if we're near the end and should signal pre-buffer + if !track_ending_sent { + if let Some(total) = total_samples { + let remaining_samples = total.saturating_sub(decoded_samples); + let remaining_ms = remaining_samples * 1000 / sample_rate as u64; + if remaining_ms < PRE_BUFFER_MS { + event_tx + .send(InternalEvent::TrackEnding { remaining_ms }) + .ok(); + track_ending_sent = true; + } + } + } +``` + +- [ ] **Step 3: Handle TrackEnding in CommandLoop** + +In `handle_event()`, add the `TrackEnding` arm. For now, this is a no-op that will be wired in the next task: + +```rust + InternalEvent::TrackEnding { remaining_ms: _ } => { + // Pre-start next decode — wired in Task 7 + } +``` + +- [ ] **Step 4: Verify it compiles and tests pass** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. The `TrackEnding` event fires but is a no-op. + +- [ ] **Step 5: Commit** + +```bash +git add crates/rustify-core/src/player.rs +git commit -m "feat(core): add TrackEnding event with remaining-time detection in decode thread" +``` + +--- + +## Task 7: Gapless Playback — MixStage + Pre-buffer + +**Files:** +- Modify: `crates/rustify-core/src/player.rs` + +This is the most complex task. It modifies the cpal output callback to support channel swapping and wires the CommandLoop to pre-start the next decode. + +- [ ] **Step 1: Add pending decode fields to CommandLoop** + +Add fields to `CommandLoop`: + +```rust + pending_decode: Option, + /// Shared slot for the pending audio receiver. The cpal callback + /// reads this to swap channels when the active one drains. + pending_audio_rx: Arc>>>>, +``` + +Initialize in `CommandLoop::new()`: + +```rust + let pending_audio_rx = Arc::new(Mutex::new(None)); +``` + +Pass `Arc::clone(&pending_audio_rx)` to `create_output_stream`. + +- [ ] **Step 2: Update create_output_stream for channel swapping** + +Update the `create_output_stream` signature to accept the pending slot: + +```rust +fn create_output_stream( + audio_rx: Receiver>, + mixer: Arc, + clear_buffer: Arc, + pending_audio_rx: Arc>>>>, +) -> Result { +``` + +Inside the cpal callback closure, wrap `audio_rx` in a mutable variable and add swap logic: + +```rust + let mut active_rx = audio_rx; + + let stream = device + .build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + if clear_buffer.swap(false, Ordering::Relaxed) { + buf.clear(); + while active_rx.try_recv().is_ok() {} + } + + let gain = mixer.gain(); + + for frame in data.chunks_mut(device_channels) { + let left = if buf.is_empty() { + match active_rx.try_recv() { + Ok(chunk) => { + buf.extend(chunk); + buf.pop_front().unwrap_or(0.0) + } + Err(_) => { + // Active channel drained — check for pending + if let Ok(mut slot) = pending_audio_rx.try_lock() { + if let Some(new_rx) = slot.take() { + active_rx = new_rx; + match active_rx.try_recv() { + Ok(chunk) => { + buf.extend(chunk); + buf.pop_front().unwrap_or(0.0) + } + Err(_) => 0.0, + } + } else { + 0.0 + } + } else { + 0.0 + } + } + } + } else { + buf.pop_front().unwrap_or(0.0) + }; + let right = buf.pop_front().unwrap_or(left); + + for (i, sample) in frame.iter_mut().enumerate() { + *sample = match i { + 0 => left * gain, + 1 => right * gain, + _ => 0.0, + }; + } + } + }, + // ... error callback unchanged ... +``` + +- [ ] **Step 3: Wire TrackEnding to pre-start next decode** + +In `handle_event()`, replace the `TrackEnding` no-op: + +```rust + InternalEvent::TrackEnding { remaining_ms: _ } => { + // Pre-start next decode if there's a next track + if self.pending_decode.is_none() { + // Peek at next track without advancing + if let Some(uri) = self.peek_next_track() { + let (pending_tx, pending_rx) = + channel::bounded::>(BUFFER_CHUNKS); + + let (control_tx, control_rx) = channel::unbounded::(); + let event_tx = self.event_tx.clone(); + + let handle = thread::Builder::new() + .name("rustify-decode-pending".into()) + .spawn(move || { + decode_thread(uri, pending_tx, control_rx, event_tx); + }) + .expect("failed to spawn pending decode thread"); + + // Store pending decode handle + self.pending_decode = Some(DecodeHandle { + control_tx, + _thread: handle, + }); + + // Put the pending rx into the shared slot for the cpal callback + *self.pending_audio_rx.lock().unwrap() = Some(pending_rx); + } + } + } +``` + +Add a helper method to `CommandLoop`: + +```rust + /// Peek at the next track URI without advancing the tracklist position. + fn peek_next_track(&self) -> Option { + let idx = self.tracklist.index()?; + // This is a read-only peek — we don't call tracklist.next() yet + // because that would advance the position before the track actually plays. + // Instead we manually check based on current state. + if self.tracklist.get_repeat() == RepeatMode::One { + return self.tracklist.current().map(String::from); + } + // For sequential/shuffle: check if there's a next track + // We'll use a simple heuristic: if not at the end, there's a next + let len = self.tracklist.len(); + if idx + 1 < len || self.tracklist.get_repeat() == RepeatMode::All { + // There's a next track — we'll get the URI when TrackEnded fires + // and we actually call next(). For pre-buffering, use a clone of + // what next() would return. + let mut clone = self.tracklist.clone(); + clone.next().map(String::from) + } else { + None + } + } +``` + +This requires `Tracklist` to implement `Clone`. Add `#[derive(Clone)]` to the `Tracklist` struct in `tracklist.rs`. + +- [ ] **Step 4: Update TrackEnded to promote pending decode** + +In `handle_event()`, update the `TrackEnded` arm: + +```rust + InternalEvent::TrackEnded => { + // Promote pending decode if it exists (gapless transition) + if let Some(pending) = self.pending_decode.take() { + // Advance the tracklist + if let Some(_uri) = self.tracklist.next() { + self.decode_handle = Some(pending); + // TrackChanged event was already sent by the pending decode thread + } else { + // No next track — stop + self.decode_handle = None; + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } + } else { + // No pending decode — try to advance normally (non-gapless path) + if let Some(uri) = self.tracklist.next() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + } else { + self.decode_handle = None; + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } + } + } +``` + +- [ ] **Step 5: Verify it compiles** + +Run: `cargo build -p rustify-core` +Expected: Compiles. May have warnings about unused imports that need cleanup. + +- [ ] **Step 6: Run all tests** + +Run: `cargo test -p rustify-core` +Expected: All existing tests pass. Gapless behavior tested manually. + +- [ ] **Step 7: Run TUI tests too** + +Run: `cargo test -p rustify-tui` +Expected: All TUI tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add crates/rustify-core/ +git commit -m "feat(core): implement gapless playback with dual-decode MixStage and channel swapping" +``` + +--- + +## Summary + +After completing all 7 tasks, Tier 1 provides: + +| Feature | Core Changes | TUI Changes | +|---|---|---| +| **Shuffle** | `Tracklist` Fisher-Yates permutation, `Player.set_shuffle()` | `s` key, `[S]` indicator | +| **Repeat** | `Tracklist` repeat modes, `Player.set_repeat()` | `r` key cycles Off→All→One, `[R]`/`[R1]` indicators | +| **Seek** | (existing `Player.seek()`) | Left/Right ±5s, Shift+Left/Right ±30s, optimistic UI | +| **Album Art** | New `art.rs` module (embedded + sidecar) | Background extraction, `♪` placeholder in now-playing bar | +| **Gapless** | Dual-decode, `TrackEnding` event, MixStage channel swap | (automatic, no TUI changes) | + +Run full test suite: `cargo test --workspace` +Run the player: `cargo run -p rustify-tui -- /path/to/music` diff --git a/docs/superpowers/plans/2026-04-09-tier2-rich-experience.md b/docs/superpowers/plans/2026-04-09-tier2-rich-experience.md new file mode 100644 index 0000000..20da3d1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-tier2-rich-experience.md @@ -0,0 +1,437 @@ +# Tier 2: Rich Experience — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add audio spectrum visualizer, color theme system, and fuzzy search to make the TUI visually impressive and pleasant to use. + +**Architecture:** Theme system is the foundation — it changes how every UI module renders colors. Fuzzy search replaces the existing substring search in the library. Visualizer adds a sample buffer to core's cpal callback and a new TUI module for FFT + rendering. All features are TUI-only except the sample buffer. + +**Tech Stack:** Rust (rustfft for FFT, nucleo-matcher for fuzzy search), existing ratatui/crossterm stack + +**Design Spec:** `docs/superpowers/specs/2026-04-09-tier2-rich-experience-design.md` + +--- + +## File Map + +### New files + +| File | Responsibility | +|---|---| +| `crates/rustify-tui/src/theme.rs` | Theme struct, 5 presets, hex color parsing, config loading | +| `crates/rustify-tui/src/ui/visualizer.rs` | FFT processing, spectrum bars rendering, waveform rendering | + +### Modified files + +| File | Changes | +|---|---| +| `crates/rustify-core/Cargo.toml` | (no changes needed) | +| `crates/rustify-core/src/player.rs` | Add sample buffer to cpal callback, `get_samples()` API | +| `crates/rustify-tui/Cargo.toml` | Add `rustfft`, `nucleo-matcher` dependencies | +| `crates/rustify-tui/src/app.rs` | Add `theme: Theme`, `visualizer_mode: VisualizerMode`, `Shift+v` keybinding | +| `crates/rustify-tui/src/config.rs` | Add `CustomThemeConfig` for TOML custom themes | +| `crates/rustify-tui/src/library.rs` | Replace `search()` with `fuzzy_search()` using nucleo-matcher | +| `crates/rustify-tui/src/main.rs` | Pass sample buffer to app, load theme from config | +| `crates/rustify-tui/src/ui/mod.rs` | Pass theme to sub-renderers | +| `crates/rustify-tui/src/ui/sidebar.rs` | Use theme colors instead of hardcoded | +| `crates/rustify-tui/src/ui/main_panel.rs` | Use theme colors, render fuzzy match highlights | +| `crates/rustify-tui/src/ui/now_playing.rs` | Expand to 6 rows, integrate visualizer, use theme colors | + +--- + +## Task 1: Color Theme System + +**Files:** +- Create: `crates/rustify-tui/src/theme.rs` +- Modify: `crates/rustify-tui/src/config.rs` +- Modify: `crates/rustify-tui/src/app.rs` +- Modify: `crates/rustify-tui/src/main.rs` + +- [ ] **Step 1: Write tests for theme** + +Create `crates/rustify-tui/src/theme.rs` with tests: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_theme_has_magenta_accent() { + let theme = Theme::default_theme(); + assert_eq!(theme.accent, Color::Magenta); + } + + #[test] + fn all_presets_load() { + let names = ["default", "nord", "dracula", "gruvbox", "catppuccin"]; + for name in names { + let theme = Theme::from_name(name); + assert!(!theme.name.is_empty()); + } + } + + #[test] + fn unknown_name_falls_back_to_default() { + let theme = Theme::from_name("nonexistent"); + assert_eq!(theme.name, "default"); + } + + #[test] + fn parse_hex_color_valid() { + assert_eq!(parse_hex_color("#FF00FF"), Some(Color::Rgb(255, 0, 255))); + assert_eq!(parse_hex_color("#000000"), Some(Color::Rgb(0, 0, 0))); + } + + #[test] + fn parse_hex_color_invalid() { + assert_eq!(parse_hex_color("not-a-color"), None); + assert_eq!(parse_hex_color("#GG00FF"), None); + } +} +``` + +- [ ] **Step 2: Implement Theme struct and presets** + +Add the full implementation above the tests in `theme.rs`: + +- `Theme` struct with fields: `name`, `fg`, `fg_dim`, `accent`, `accent_dim`, `border`, `error`, `visualizer: Vec` +- `Theme::default_theme()` — magenta accent, white fg, darkgray borders +- `Theme::nord()` — cyan accent (#88C0D0) +- `Theme::dracula()` — purple accent (#BD93F9) +- `Theme::gruvbox()` — yellow accent (#FABD2F) +- `Theme::catppuccin()` — mauve accent (#CBA6F7) +- `Theme::from_name(name: &str) -> Theme` — match on name string +- `pub fn parse_hex_color(hex: &str) -> Option` — parse `#RRGGBB` to `Color::Rgb` + +- [ ] **Step 3: Add CustomThemeConfig to config.rs** + +Add to `crates/rustify-tui/src/config.rs`: + +```rust +#[derive(Debug, Default, Deserialize)] +pub struct CustomThemeConfig { + pub fg: Option, + pub fg_dim: Option, + pub accent: Option, + pub accent_dim: Option, + pub border: Option, + pub error: Option, + pub visualizer: Option>, +} +``` + +Add field to `TuiConfig`: + +```rust +#[serde(default)] +pub custom_theme: Option, +``` + +- [ ] **Step 4: Add theme to App and wire in main.rs** + +Add `pub theme: Theme` to `App` struct. Initialize from config in `main.rs`: + +```rust +let theme = theme::Theme::from_config(&config); +app.theme = theme; +``` + +Add `mod theme;` to main.rs. + +- [ ] **Step 5: Replace hardcoded colors in all UI modules** + +Update `sidebar.rs`, `main_panel.rs`, `now_playing.rs`, and `ui/mod.rs`: +- `Color::Magenta` → `app.theme.accent` +- `Color::DarkGray` → `app.theme.border` +- `Color::White` → `app.theme.fg` +- `Color::Gray` → `app.theme.fg_dim` +- `Color::Yellow` (status/error) → `app.theme.error` + +- [ ] **Step 6: Run tests, commit** + +Run: `cargo test --workspace` +Commit: `git commit -m "feat(tui): add color theme system with 5 presets and custom TOML themes"` + +--- + +## Task 2: Fuzzy Search + +**Files:** +- Modify: `crates/rustify-tui/Cargo.toml` (add `nucleo-matcher`) +- Modify: `crates/rustify-tui/src/library.rs` +- Modify: `crates/rustify-tui/src/ui/main_panel.rs` + +- [ ] **Step 1: Add nucleo-matcher dependency** + +Add to `crates/rustify-tui/Cargo.toml`: + +```toml +nucleo-matcher = "0.3" +``` + +- [ ] **Step 2: Write tests for fuzzy search** + +Add to tests in `library.rs`: + +```rust + #[test] + fn fuzzy_search_finds_exact_match() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search("Midnight City"); + assert!(!results.is_empty()); + assert_eq!(results[0].track.name, "Midnight City"); + } + + #[test] + fn fuzzy_search_partial_match() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search("mid cit"); + assert!(!results.is_empty()); + assert_eq!(results[0].track.name, "Midnight City"); + } + + #[test] + fn fuzzy_search_empty_query_returns_empty() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search(""); + assert!(results.is_empty()); + } + + #[test] + fn fuzzy_search_no_match() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search("zzzzzzz"); + assert!(results.is_empty()); + } + + #[test] + fn fuzzy_search_returns_matched_indices() { + let lib = Library::from_tracks(make_tracks()); + let results = lib.fuzzy_search("Midnight"); + assert!(!results[0].matched_indices.is_empty()); + } +``` + +- [ ] **Step 3: Implement fuzzy_search** + +Replace the `search()` method in `library.rs` with `fuzzy_search()`: + +```rust +use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern}; +use nucleo_matcher::{Config, Matcher, Utf32Str}; + +pub struct SearchResult<'a> { + pub track: &'a Track, + pub score: u32, + pub matched_indices: Vec, +} + +impl Library { + pub fn fuzzy_search(&self, query: &str) -> Vec> { + if query.is_empty() { + return Vec::new(); + } + + let mut matcher = Matcher::new(Config::DEFAULT); + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + + let mut results: Vec> = self.tracks + .iter() + .filter_map(|track| { + let haystack = format!( + "{} {} {}", + track.name, + track.artists.first().unwrap_or(&String::new()), + track.album + ); + let mut indices = Vec::new(); + let mut buf = Vec::new(); + let utf32 = Utf32Str::new(&haystack, &mut buf); + let score = pattern.score(utf32, &mut matcher)?; + pattern.indices(utf32, &mut matcher, &mut indices); + Some(SearchResult { + track, + score, + matched_indices: indices, + }) + }) + .collect(); + + results.sort_by(|a, b| b.score.cmp(&a.score)); + results.truncate(50); + results + } +} +``` + +- [ ] **Step 4: Update main_panel.rs search rendering** + +Update `draw_search()` in `main_panel.rs` to use `fuzzy_search()` and highlight matched characters using `matched_indices` with the theme's accent color. + +- [ ] **Step 5: Remove old search() method** + +Delete the old `Library::search()` method and its tests. Update any remaining call sites. + +- [ ] **Step 6: Run tests, commit** + +Run: `cargo test --workspace` +Commit: `git commit -m "feat(tui): replace substring search with fuzzy matching via nucleo-matcher"` + +--- + +## Task 3: Sample Buffer (Core) + +**Files:** +- Modify: `crates/rustify-core/src/player.rs` + +- [ ] **Step 1: Add sample buffer to Player** + +Add a shared sample buffer alongside the existing `SharedState`: + +```rust +// In Player struct +sample_buffer: Arc>>, +``` + +- [ ] **Step 2: Write samples in cpal callback** + +In the cpal output callback, after computing each output sample, push it into the shared buffer (capped at 4096 samples): + +```rust +// After writing to frame +if let Ok(mut buf) = sample_buffer.try_lock() { + buf.push_back(left); + buf.push_back(right); + while buf.len() > 4096 { + buf.pop_front(); + } +} +``` + +- [ ] **Step 3: Add get_samples() API** + +```rust +impl Player { + pub fn get_samples(&self) -> Vec { + self.sample_buffer.lock().unwrap().iter().copied().collect() + } +} +``` + +- [ ] **Step 4: Run tests, commit** + +Run: `cargo test -p rustify-core` +Commit: `git commit -m "feat(core): add sample buffer for audio visualization"` + +--- + +## Task 4: Audio Visualizer + +**Files:** +- Create: `crates/rustify-tui/src/ui/visualizer.rs` +- Modify: `crates/rustify-tui/Cargo.toml` (add `rustfft`) +- Modify: `crates/rustify-tui/src/app.rs` (add visualizer mode state) +- Modify: `crates/rustify-tui/src/ui/now_playing.rs` (expand layout, integrate visualizer) +- Modify: `crates/rustify-tui/src/main.rs` (pass player reference for samples) + +- [ ] **Step 1: Add rustfft dependency** + +```toml +rustfft = "6" +``` + +- [ ] **Step 2: Write tests for visualizer** + +Create `crates/rustify-tui/src/ui/visualizer.rs` with tests: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fft_produces_24_bars() { + let samples = vec![0.0f32; 1024]; + let bars = compute_spectrum_bars(&samples); + assert_eq!(bars.len(), 24); + } + + #[test] + fn silent_input_produces_zero_bars() { + let samples = vec![0.0f32; 1024]; + let bars = compute_spectrum_bars(&samples); + assert!(bars.iter().all(|&b| b == 0.0)); + } + + #[test] + fn smoothing_decays() { + let mut state = VisualizerState::new(); + state.bars = vec![1.0; 24]; + let new_bars = vec![0.0; 24]; + state.apply_smoothing(&new_bars); + // After one smoothing step, bars should be 0.85 (decay factor) + assert!(state.bars[0] > 0.8 && state.bars[0] < 0.9); + } +} +``` + +- [ ] **Step 3: Implement visualizer module** + +Full implementation of `visualizer.rs`: + +- `VisualizerMode` enum: `Spectrum`, `Waveform` +- `VisualizerState` struct: holds previous bar values for smoothing +- `compute_spectrum_bars(samples: &[f32]) -> Vec`: downmix mono, Hann window, 1024-pt FFT, log-frequency binning into 24 bars, normalize to 0.0..1.0 +- `apply_smoothing(&mut self, new_bars: &[f32])`: exponential decay `max(new, old * 0.85)` +- `draw_spectrum(frame, area, state, theme)`: render bars with unicode blocks `▁▂▃▄▅▆▇█` +- `draw_waveform(frame, area, samples, theme)`: render braille-dot oscilloscope line + +- [ ] **Step 4: Add VisualizerMode and state to App** + +In `app.rs`: +```rust +pub visualizer_mode: ui::visualizer::VisualizerMode, +pub visualizer_state: ui::visualizer::VisualizerState, +``` + +Add `Shift+v` keybinding to toggle mode. + +- [ ] **Step 5: Expand now-playing bar to 6 rows** + +Update `ui/mod.rs` layout: now-playing bar grows from `Constraint::Length(3)` to `Constraint::Length(6)` when a track is playing. Top 3 rows render the visualizer, bottom 3 rows keep existing track info/progress/modes. + +Update `now_playing.rs` to split its area and call `visualizer::draw_spectrum()` or `visualizer::draw_waveform()` based on mode. + +- [ ] **Step 6: Wire sample buffer in main.rs** + +Pass player's `get_samples()` result into the app on each tick: + +```rust +Ok(AppEvent::Tick) => { + app.handle_tick(); + // Feed visualizer with fresh samples + let samples = player.get_samples(); + app.update_visualizer(&samples); +} +``` + +- [ ] **Step 7: Run tests, commit** + +Run: `cargo test --workspace` +Commit: `git commit -m "feat(tui): add audio spectrum visualizer with FFT and waveform modes"` + +--- + +## Summary + +| Task | Feature | Files touched | +|------|---------|---------------| +| 1 | Color themes | theme.rs (new), config.rs, app.rs, main.rs, all ui/*.rs | +| 2 | Fuzzy search | library.rs, main_panel.rs, Cargo.toml | +| 3 | Sample buffer | player.rs (core) | +| 4 | Visualizer | visualizer.rs (new), now_playing.rs, app.rs, main.rs, Cargo.toml | + +Order matters: themes first (changes all UI modules), then fuzzy search (independent), then sample buffer + visualizer (depends on themes for colors). + +Run full test suite after each task: `cargo test --workspace` diff --git a/docs/superpowers/specs/2026-04-08-rustify-tui-design.md b/docs/superpowers/specs/2026-04-08-rustify-tui-design.md new file mode 100644 index 0000000..dc5e7f5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-rustify-tui-design.md @@ -0,0 +1,288 @@ +# Rustify TUI — Design Spec + +**Date:** 2026-04-08 +**Status:** Draft + +## Overview + +A rich terminal music player built on `rustify-core`. Serves two use cases: SSH remote control into a YoyoPod Pi, and a standalone desktop terminal player. Ships as a new binary crate (`crates/rustify-tui`) in the existing workspace. + +## Architecture + +### Crate Structure + +New workspace member: `crates/rustify-tui` (binary crate). + +Dependencies: +- `rustify-core` — playback, decoding, scanning, metadata, playlists +- `ratatui` — immediate-mode terminal UI rendering +- `crossterm` — terminal input/output backend +- `crossbeam` — channels for unified event loop (already in workspace) +- `ratatui-image` — album art rendering (sixel/kitty protocol with braille/half-block fallback) +- `serde` + `toml` — config file parsing + +### Event Loop + +A single `crossbeam::select!` loop on the main thread multiplexes all input sources: + +``` + ┌─────────────┐ + Keyboard/Mouse ──► │ │ + │ AppEvent │ ┌───────────┐ + Player callbacks ─►│ Channel │────►│ Main │──► ratatui render + │ (crossbeam)│ │ Thread │ + Tick timer ───────►│ │ └───────────┘ + └─────────────┘ +``` + +**AppEvent enum:** +- `AppEvent::Key(KeyEvent)` — keyboard input from crossterm +- `AppEvent::Mouse(MouseEvent)` — mouse clicks/scroll from crossterm +- `AppEvent::Player(PlayerEvent)` — state/track/position changes from rustify-core callbacks +- `AppEvent::Tick` — ~4Hz timer for progress bar smoothing and spinner animation +- `AppEvent::ScanComplete(Library)` — background library scan finished +- `AppEvent::Error(String)` — non-player errors (scan failures, config issues) + +### Thread Model + +| Thread | Purpose | Lifetime | +|--------|---------|----------| +| Main | Event loop + ratatui rendering | App lifetime | +| Input | Polls crossterm events, sends `AppEvent::Key`/`Mouse` | App lifetime | +| Tick | Sends `AppEvent::Tick` at ~4Hz | App lifetime | +| Scanner | Runs `rustify-core::scanner`, builds library index | On-demand, startup | +| rustify-core command | Processes `PlayerCommand`s (unchanged) | Player lifetime | +| rustify-core decode | Decodes audio packets (unchanged) | Per-track | + +The input thread calls `crossterm::event::poll()` with a timeout, then `crossterm::event::read()`, and forwards events into the shared `AppEvent` channel. The tick thread sleeps 250ms and sends `AppEvent::Tick`. + +Player callbacks (registered via `player.on_state_change()`, etc.) push `AppEvent::Player(...)` into the same channel from rustify-core's internal threads. + +## UI Layout + +Sidebar + Main layout. Three persistent regions: + +``` +┌──────────────┬────────────────────────────────┐ +│ │ │ +│ SIDEBAR │ MAIN PANEL │ +│ │ │ +│ Library Nav │ (content changes by nav │ +│ ────────── │ selection: Artists, Albums, │ +│ Queue │ Songs, Playlists, Detail) │ +│ │ │ +│ │ │ +│ │ │ +├──────────────┴────────────────────────────────┤ +│ NOW PLAYING BAR │ +│ [art] Title — Artist ◂◂ ▶ ▸▸ 1:42/4:03 │ +│ Album ━━━━━━━━━━━░░░░░ Vol: 80 │ +└───────────────────────────────────────────────┘ +``` + +### Sidebar (always visible, left ~30% width) + +**Library Nav** — four selectable entries: +- Artists +- Albums +- Songs +- Playlists + +Selecting one swaps the main panel content. Active entry is highlighted. + +**Queue** — below the nav, separated by a divider. Shows the current tracklist with the active track highlighted. Scrollable. Supports reorder (`Shift+j/k`) and remove (`d`). + +### Main Panel (right ~70% width) + +Content determined by sidebar nav selection: + +| View | Content | Interactions | +|------|---------|-------------| +| Artists | Alphabetical artist list | `Enter` → show albums by artist | +| Albums | Album list (all, or filtered by selected artist) | `Enter` → show album tracks | +| Songs | Flat list of all tracks, sortable by name/artist/album | `Enter` → play, `a` → add to queue | +| Playlists | Saved M3U playlists | `Enter` → load, `n` → new, `d` → delete | +| Album Detail | Track listing for a single album with album header | `Enter` → play all from track, `a` → enqueue | +| Search | Fuzzy search overlay triggered by `/` | Filters across artists/albums/tracks | + +### Now-Playing Bar (always visible, bottom 3-4 rows) + +- Album art thumbnail (braille/half-block rendering; sixel/kitty when terminal supports it via `ratatui-image`) +- Track title, artist name, album name +- Progress bar with elapsed / total time +- Transport state indicator (play/pause icon) +- Volume level indicator + +## Features + +### Playback Controls + +All transport commands delegate to `rustify-core::Player`: +- Play / Pause (toggle) +- Stop +- Next / Previous track +- Seek (left/right arrow in now-playing focus) +- Volume up / down + +### Library Browser + +On startup, `rustify-core::scanner::scan_directory()` scans configured music directories. The TUI reads metadata via `rustify-core::metadata::read_metadata_from_path()` for each discovered file and builds an in-memory index: + +``` +Library +├── artists: BTreeMap +│ └── Artist { name, albums: Vec } +├── albums: Vec +│ └── Album { name, artist, tracks: Vec, art_path: Option } +└── tracks: Vec // rustify-core::types::Track +``` + +The scan runs on a background thread. The UI shows a loading spinner until `AppEvent::ScanComplete` arrives. Rescanning is triggered manually via a keybinding (`R`). + +### Playlist Management + +- **Load** — select an M3U file from the Playlists view, loads into queue via `player.load_track_uris()` +- **Create** — `Ctrl+S` saves the current queue as a new M3U file; prompts for name via inline text input +- **Delete** — `d` on a playlist in the list view, with confirmation +- M3U files are discovered by scanning music directories for `.m3u` / `.m3u8` extensions (already supported by `rustify-core::playlist`) + +### Search + +`/` opens a search overlay — a text input at the top of the main panel with live-filtered results below. Searches across track names, artist names, and album names. Case-insensitive substring matching for v1 (full fuzzy/fzf-style can be added later). `Esc` closes the overlay. `Enter` on a result navigates to it. + +### Album Art + +Uses `ratatui-image` crate: +- **Sixel / Kitty protocol** — high-fidelity rendering when the terminal supports it (iTerm2, Kitty, WezTerm, foot) +- **Braille / half-block fallback** — works in any terminal including over SSH +- Art source: embedded cover art from audio file tags (via lofty), or `cover.jpg` / `folder.jpg` in the album directory + +## Input + +### Keyboard (primary) + +| Key | Action | +|-----|--------| +| `j` / `↓` | Navigate down in lists | +| `k` / `↑` | Navigate up in lists | +| `Enter` | Select / play | +| `Space` | Toggle play / pause | +| `n` | Next track | +| `p` | Previous track | +| `+` / `=` | Volume up | +| `-` | Volume down | +| `Tab` | Cycle focus: sidebar → main panel → sidebar | +| `/` | Open search overlay | +| `Esc` | Close overlay / go back | +| `a` | Add selected track to queue | +| `d` | Remove from queue / delete playlist (with confirm) | +| `Shift+j/k` | Reorder queue items | +| `Ctrl+S` | Save queue as M3U playlist | +| `R` | Rescan library | +| `1`-`4` | Jump to Artists / Albums / Songs / Playlists | +| `q` | Quit | + +### Mouse (optional, desktop convenience) + +- Click on sidebar nav items to switch views +- Click on list items to select +- Click on now-playing transport icons +- Scroll wheel on lists + +Mouse is purely additive — every interaction has a keyboard equivalent. + +## Configuration + +Config file: `~/.config/rustify/tui.toml` + +```toml +# Music directories to scan +music_dirs = ["/home/pi/Music", "/mnt/usb/music"] + +# Audio device (passed to rustify-core) +alsa_device = "default" + +# Theme preset: "default", "light", "nord", "dracula" +theme = "default" + +# Custom keybinding overrides (optional) +[keys] +quit = "q" +play_pause = " " +next = "n" +previous = "p" +``` + +On Pi: `/home/pi/.config/rustify/tui.toml`. On Linux/macOS desktop: `~/.config/rustify/tui.toml` (XDG). On Windows: `%APPDATA%\rustify\tui.toml`. The app uses `dirs` crate to resolve the platform-appropriate config directory. + +## App State + +A single `App` struct owns all mutable UI state: + +```rust +struct App { + player: Player, // rustify-core player handle + library: Option, // None until scan completes + focus: Focus, // Sidebar | Main | Search + sidebar: SidebarState, // selected nav item, queue scroll/selection + main_view: MainView, // active view enum + per-view list state + now_playing: NowPlayingState, // cached track, position, playback state + search: SearchState, // query string, filtered results + config: TuiConfig, // parsed config file + should_quit: bool, // exit flag +} +``` + +The event loop calls `app.handle_event(event)` which mutates state, then `ui::draw(&app, &mut frame)` which reads state to render. + +## Error Handling + +Errors surface in the UI — the app never panics on recoverable errors. + +- **Player errors** (decode failure, missing audio device) — displayed in a dismissable status line above the now-playing bar. Auto-dismiss after 5 seconds. Decode failures cause the player to skip to the next track (already handled by rustify-core's `DecodeFailed` → `TrackEnded` flow). +- **Scan errors** (permission denied, unreadable files) — skipped individually. After scan completes, a summary is shown: "Scanned 342 tracks (3 errors)". Detailed errors logged to stderr. +- **Config errors** (missing file, parse error) — fall back to defaults, show a one-time warning on startup. +- **Terminal errors** (resize) — crossterm emits resize events; the UI re-renders at the new size. All layout uses ratatui's constraint-based system, so it adapts automatically. +- **Lost SSH connection** — process receives SIGHUP, cleanup runs (restore terminal state via crossterm). + +## Testing + +### Unit Tests + +`App` state transitions tested without a terminal: +- Key handling: given a state + key event, assert the resulting state change +- View switching: sidebar selection changes main panel view +- Queue manipulation: add, remove, reorder + +### Snapshot Tests + +Render to `ratatui::Terminal`, assert buffer contents: +- Layout renders correctly at various terminal sizes (80x24, 120x40, minimal 60x20) +- Now-playing bar shows correct track info +- List scrolling and selection highlighting + +### Integration + +Playback correctness is covered by existing `rustify-core` tests. TUI integration (play a file, verify it renders, interact via keys) is manual testing. + +## Dependencies Summary + +| Crate | Version | Purpose | +|-------|---------|---------| +| `rustify-core` | workspace | Playback, scanning, metadata, playlists | +| `ratatui` | latest | Terminal UI framework | +| `crossterm` | latest | Terminal backend (input, raw mode, alternate screen) | +| `crossbeam` | 0.8 | Event channels (already in workspace) | +| `ratatui-image` | latest | Album art rendering (sixel/kitty/braille) | +| `serde` | 1 | Config deserialization (already in workspace) | +| `toml` | latest | Config file parsing | +| `dirs` | latest | Platform-appropriate config/data directories | + +## Out of Scope (for v1) + +- Network streaming (Spotify, HTTP streams) +- MPRIS D-Bus integration +- Lyrics display +- Equalizer / audio effects +- Multi-user / server mode +- Themes beyond built-in presets diff --git a/docs/superpowers/specs/2026-04-08-tier1-playback-essentials-design.md b/docs/superpowers/specs/2026-04-08-tier1-playback-essentials-design.md new file mode 100644 index 0000000..e3d51b4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-08-tier1-playback-essentials-design.md @@ -0,0 +1,335 @@ +# Tier 1: Playback Essentials — Design Spec + +**Date:** 2026-04-08 +**Status:** Draft +**Depends on:** rustify-tui v0.1 (implemented) + +## Overview + +Four features that make the player usable for daily listening: shuffle/repeat modes, seek keybindings, gapless playback via a dual-decode mixer, and album art rendering. This is the first of three tiers — Tier 2 (visual polish) and Tier 3 (power user) follow as separate specs. + +## 1. Shuffle & Repeat + +### Repeat Modes + +Three-state cycle toggled by `r`: + +| Mode | Behavior | +|------|----------| +| `RepeatMode::Off` | Queue plays through once and stops at the end | +| `RepeatMode::All` | After the last track, wrap to the first and continue | +| `RepeatMode::One` | Replay the current track endlessly | + +`RepeatMode` is a new enum in `rustify-core::types`, alongside `PlaybackState`. + +### Shuffle + +Toggled by `s`: + +- **On toggle-on:** Generate a Fisher-Yates permutation of queue indices. The currently-playing track stays as the current position; shuffle affects what `next()` and `previous()` return. +- **On toggle-off:** Restore original order. The currently-playing track remains current — the position is recalculated to match its original index. +- Shuffle state is deterministic per-toggle — calling `next()` repeatedly in shuffle mode walks through the same permutation. + +### Core Changes (rustify-core) + +**New types in `types.rs`:** + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RepeatMode { + Off, + All, + One, +} +``` + +**New `PlayerCommand` variants:** + +```rust +pub enum PlayerCommand { + // ... existing variants ... + SetShuffle(bool), + SetRepeat(RepeatMode), +} +``` + +**New `PlayerEvent` variant:** + +```rust +pub enum PlayerEvent { + // ... existing variants ... + ModeChanged { shuffle: bool, repeat: RepeatMode }, +} +``` + +**Changes to `Tracklist`:** + +New fields: +- `shuffle: bool` +- `repeat: RepeatMode` +- `shuffle_order: Vec` — permuted indices into `tracks` +- `shuffle_position: Option` — current position within `shuffle_order` + +Modified methods: +- `next()` — when shuffle is on, advance through `shuffle_order`. When repeat-all, wrap around. When repeat-one, return `current()`. +- `previous()` — walk backward through `shuffle_order` when shuffled. + +New methods: +- `set_shuffle(bool)` — generates or clears the permutation +- `set_repeat(RepeatMode)` +- `get_shuffle() -> bool` +- `get_repeat() -> RepeatMode` + +**Changes to `Player`:** + +New public methods: +- `player.set_shuffle(bool)` +- `player.set_repeat(RepeatMode)` +- `player.get_shuffle() -> bool` +- `player.get_repeat() -> RepeatMode` + +These send `PlayerCommand::SetShuffle` / `SetRepeat` to the command thread. The command loop calls `tracklist.set_shuffle()` / `tracklist.set_repeat()` and emits `PlayerEvent::ModeChanged`. + +### TUI Changes + +**New `PlayerAction` variants:** +- `PlayerAction::ToggleShuffle` +- `PlayerAction::CycleRepeat` + +**Keybindings in `app.rs`:** +- `s` → `PlayerAction::ToggleShuffle` +- `r` → `PlayerAction::CycleRepeat` + +**Now-playing bar indicators** in `ui/now_playing.rs`: +- Shuffle on: display `[S]` in accent color +- Repeat All: display `[R]` +- Repeat One: display `[R1]` +- Modes off: no indicator + +**App state:** +- `app.now_playing.shuffle: bool` +- `app.now_playing.repeat: RepeatMode` +- Updated on `PlayerEvent::ModeChanged` + +## 2. Gapless Playback (Dual-Decode Mixer) + +### Architecture + +The current architecture has one decode thread feeding audio into a single bounded channel, consumed by the cpal output callback. For gapless playback (and future crossfade in Tier 3), we introduce a dual-decode architecture with a mix stage. + +``` + ┌──────────────┐ + Current track ──────► │ Decode A │──► audio_tx_a ──┐ + └──────────────┘ │ + ▼ + ┌───────────┐ + │ MixStage │──► cpal output + └───────────┘ + ┌──────────────┐ ▲ + Next track ─────────► │ Decode B │──► audio_tx_b ──┘ + (pre-buffered) └──────────────┘ +``` + +### Flow + +1. Decode thread A decodes the current track. When remaining audio drops below ~3 seconds (calculated from packet timestamps and total duration), it sends `InternalEvent::TrackEnding { remaining_ms: u64 }`. +2. The command loop receives `TrackEnding` and checks the tracklist for a next track. If one exists, it starts Decode B, which pre-buffers into its own channel (`pending_audio_tx`). +3. When Decode A sends `TrackEnded`, the command loop tells the `MixStage` to swap: `active_rx` switches from A's channel to B's channel. Decode B becomes the new current decode. +4. Decode A's thread exits naturally. +5. The cycle repeats — the next `TrackEnding` from the now-current Decode B starts a new pending decode. + +### MixStage + +The mix stage lives in the cpal output callback. It manages two receive channels: + +- `active_rx: Receiver>` — current track's audio +- `pending_rx: Option>>` — next track's pre-buffered audio (set by command loop via atomic swap) + +**Gapless behavior:** When `active_rx` is fully drained (returns `Err` or track has ended) and `pending_rx` is `Some`, atomically promote `pending_rx` to `active_rx`. No overlap, no gap. + +**Crossfade behavior (Tier 3, not implemented now):** The MixStage struct gains a `crossfade_ms: u64` field. When nonzero, both channels are read simultaneously during the overlap window and mixed with linear fade curves. The infrastructure supports this — Tier 3 just sets the parameter. + +### Core Changes + +**New `InternalEvent` variant:** + +```rust +enum InternalEvent { + // ... existing ... + TrackEnding { remaining_ms: u64 }, +} +``` + +**Changes to `CommandLoop`:** + +New field: +- `pending_decode: Option` — the pre-started next decode +- `pending_audio_tx: Option>>` — channel for pending decode's audio +- `pending_audio_rx_slot: Arc>>>>` — shared with cpal callback for atomic swap + +Modified event handling: +- `TrackEnding` → start pending decode if next track exists +- `TrackEnded` → promote pending decode to current, signal MixStage to swap channels + +**Changes to decode thread:** + +The decode thread gains remaining-time awareness: +- Compute total duration from codec params (sample count / sample rate) +- Track decoded sample count +- When `(total_samples - decoded_samples) / sample_rate * 1000 < PRE_BUFFER_MS`, send `InternalEvent::TrackEnding` +- `PRE_BUFFER_MS` is a constant, initially 3000 (3 seconds) + +**Changes to `create_output_stream`:** + +The cpal callback reads from an `active_rx` and checks a `pending_rx_slot` (behind `Arc>`) on each buffer fill. When `active_rx` is drained and the slot has a receiver, it swaps. + +### Public API + +No changes to the `Player` public API. Gapless is automatic and internal. The `PlayerEvent::TrackChanged` still fires when the next track starts playing. + +## 3. Seek Keybindings + +### Keybindings + +| Key | Action | Delta | +|-----|--------|-------| +| `Left` / `h` | Seek backward | -5 seconds | +| `Right` / `l` | Seek forward | +5 seconds | +| `Shift+Left` / `Shift+H` | Seek backward (large) | -30 seconds | +| `Shift+Right` / `Shift+L` | Seek forward (large) | +30 seconds | + +### Implementation + +In `app.rs` `handle_key()`, these keys return `PlayerAction::Seek(delta_ms)` where `delta_ms` is a signed i64. The `main.rs` event loop computes the absolute position: + +``` +new_position = (current_position as i64 + delta_ms).clamp(0, track_length as i64) as u64 +``` + +Then calls `player.seek(new_position)`. + +**Optimistic UI update:** After sending the seek command, the TUI immediately updates `app.now_playing.position_ms` to the new position for instant visual feedback. The next `PositionUpdate` callback from core will correct any drift. + +## 4. Album Art + +### Art Extraction (rustify-core) + +New module: `crates/rustify-core/src/art.rs` + +```rust +/// Extract album art for a track. +/// Tries embedded cover art first (via lofty), then sidecar files. +/// Returns raw image bytes (JPEG or PNG) or None. +pub fn extract_art(path: &Path) -> Option> +``` + +**Embedded art:** Uses lofty's `Tag::pictures()` to find `PictureType::CoverFront` (or any picture if no front cover tagged). Returns the picture's `data` bytes. + +**Sidecar fallback:** Searches the track file's parent directory for (case-insensitive): `cover.jpg`, `cover.png`, `folder.jpg`, `folder.png`, `album.jpg`, `album.png`. Returns the first match's file contents. + +**Re-export:** `pub mod art;` added to `lib.rs`, `pub use art::extract_art;` for convenience. + +### Rendering (rustify-tui) + +**Dependencies:** `ratatui-image` (already in Cargo.toml) and `image` (already in Cargo.toml). + +**Art state in `App`:** + +```rust +pub struct ArtState { + /// Current track's URI (to detect changes and avoid re-extraction) + pub current_uri: Option, + /// Decoded image ready for rendering + pub image: Option>, +} +``` + +**On `TrackChanged` event:** +1. Compare `track.uri` to `art_state.current_uri`. If same, skip. +2. Call `rustify_core::art::extract_art(&uri_to_path(&track.uri))` on a background thread (don't block the event loop). +3. On result, decode bytes via `image::load_from_memory(&bytes)`, create protocol with `ratatui_image::picker::Picker`, store in `art_state.image`. +4. If no art found, set `art_state.image = None`. + +**Now-playing bar layout update:** + +``` +┌──────────────────────────────────────────────────────────┐ +│ [ART ] >> Midnight City [S] [R] 1:42/4:03 │ +│ [ART ] M83 — Hurry Up, We're D... ━━━━░░░░ Vol: 80 │ +└──────────────────────────────────────────────────────────┘ +``` + +Art area: 6 columns wide on the left of the now-playing bar. When art is available, render via `ratatui_image::StatefulImage`. When not available, render a centered `♪` glyph in a bordered box as placeholder. + +The now-playing bar height increases from 3 to 4 rows to give the art more vertical space. + +## Error Handling + +- **Shuffle on empty queue:** No-op. Shuffle toggles but permutation is empty. +- **Gapless pre-buffer failure:** If the next track fails to open/decode, `DecodeFailed` fires as usual. The current track finishes normally, then the player skips to the track after the failed one (or stops if none). +- **Art extraction failure:** Gracefully returns `None`. Placeholder glyph shown. No error propagated to UI — missing art is silent. +- **Seek beyond bounds:** Clamped to `0..track_length`. Seeking past the end triggers `TrackEnded` naturally from the decode thread. + +## Testing + +### Core Unit Tests + +**Tracklist shuffle/repeat:** +- `next()` with `RepeatMode::All` wraps from last to first +- `next()` with `RepeatMode::One` returns same track +- `next()` with `RepeatMode::Off` returns `None` at end +- `set_shuffle(true)` produces a permutation covering all indices +- `set_shuffle(false)` restores original order, current track stays current +- `previous()` in shuffle mode walks backward through shuffle order + +**Art extraction:** +- Extract embedded art from a tagged WAV/MP3 test fixture +- Sidecar fallback finds `cover.jpg` in track directory +- Returns `None` when no art exists + +**MixStage channel swap:** +- Feed channel A with samples, verify output matches +- Set pending channel B, drain A, verify output switches to B seamlessly +- Verify no samples lost or duplicated during swap + +### TUI Unit Tests + +**Keybindings:** +- `s` returns `PlayerAction::ToggleShuffle` +- `r` returns `PlayerAction::CycleRepeat` +- `Left` returns `PlayerAction::Seek(-5000)` +- `Shift+Right` returns `PlayerAction::Seek(30000)` + +**Now-playing bar:** +- Snapshot: shuffle indicator `[S]` visible when `shuffle = true` +- Snapshot: repeat indicator `[R1]` visible when `repeat = RepeatMode::One` +- Snapshot: art placeholder renders when no image available + +### Integration Test (manual) + +- Play a multi-track album, verify no silence gap between tracks +- Toggle shuffle mid-playback, verify next track comes from shuffled order +- Cycle through repeat modes, verify end-of-queue behavior for each +- Seek with arrow keys, verify position jumps in progress bar +- Play a track with embedded cover art, verify art renders + +## Dependencies + +| Crate | Change | Purpose | +|-------|--------|---------| +| `rustify-core` | existing | Shuffle/repeat in Tracklist, gapless in player, art extraction | +| `lofty` | existing | Extract embedded cover art pictures | +| `ratatui-image` | existing (in rustify-tui) | Render album art in terminal | +| `image` | existing (in rustify-tui) | Decode art bytes to DynamicImage | +| `rand` | **new** (in rustify-core) | Fisher-Yates shuffle RNG | + +## Out of Scope (deferred to later tiers) + +- Crossfade (Tier 3 — MixStage supports it, just needs fade parameter) +- Audio visualizer (Tier 2) +- Color themes (Tier 2) +- Fuzzy search (Tier 2) +- MPRIS / media keys (Tier 3) +- Scrobbling (Tier 3) +- Replay gain (Tier 3) +- Lyrics (Tier 3) diff --git a/docs/superpowers/specs/2026-04-09-tier2-rich-experience-design.md b/docs/superpowers/specs/2026-04-09-tier2-rich-experience-design.md new file mode 100644 index 0000000..975b589 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-tier2-rich-experience-design.md @@ -0,0 +1,268 @@ +# Tier 2: Rich Experience — Design Spec + +**Date:** 2026-04-09 +**Status:** Draft +**Depends on:** Tier 1 (shuffle/repeat, seek, gapless, album art) + +## Overview + +Three features that make the player visually impressive and pleasant to use: an audio spectrum visualizer in the now-playing bar, a color theme system with 5 presets and custom TOML themes, and fuzzy search with ranked results and match highlighting. All changes are TUI-only except one small core addition (sample buffer for the visualizer). + +## 1. Audio Spectrum Visualizer + +### Layout + +The now-playing bar grows from 3 rows to 6 rows when a track is playing. The top 3 rows are the visualizer; the bottom 3 rows are the existing track info, progress bar, mode indicators, and volume. + +When stopped (no track playing), the bar shrinks back to 3 rows showing "No track playing." + +### Two Modes + +Toggled by `Shift+v` (to avoid conflict with lowercase `v` for future use): + +**Spectrum bars (default):** +- FFT on recent audio samples +- ~24 vertical bars spanning bass to treble +- Rendered with unicode block characters: `▁▂▃▄▅▆▇█` +- Bars colored using the theme's `visualizer` gradient (low bars get base color, tall bars get bright color) +- Logarithmic frequency mapping: bass frequencies get more bars than treble, matching human perception + +**Waveform:** +- Raw audio signal shape rendered as a braille-dot line using unicode braille characters (`⠁⠂⠄⡀⢀⠠⠐⠈` etc.) +- Scrolls left-to-right like an oscilloscope +- Single accent color from the active theme + +### Data Source + +**Core change (small):** The cpal output callback already processes `f32` samples. Add a shared ring buffer that the callback writes into: + +```rust +// In player.rs — shared between cpal callback and Player API +sample_buffer: Arc>> +``` + +The callback copies the last ~2048 samples (at 44.1kHz stereo, this is ~23ms of audio) into this buffer on each callback invocation. The buffer is a fixed-capacity ring — old samples are discarded when full. + +**New Player API method:** + +```rust +impl Player { + /// Get a snapshot of recent audio samples for visualization. + /// Returns up to 2048 interleaved stereo f32 samples. + pub fn get_samples(&self) -> Vec; +} +``` + +This clones the current buffer contents. Called by the TUI on each tick (4Hz). + +### FFT Processing (TUI-only) + +**Dependency:** `rustfft` crate added to `crates/rustify-tui/Cargo.toml`. + +Processing pipeline (runs on each tick when visualizer is visible): +1. Call `player.get_samples()` — get ~2048 stereo samples +2. Downmix to mono: `(left + right) / 2.0` — yields ~1024 mono samples +3. Apply Hann window to reduce spectral leakage +4. Run 1024-point real FFT via `rustfft` +5. Compute magnitude of each frequency bin: `sqrt(re² + im²)` +6. Group 512 output bins into 24 display bars using logarithmic mapping +7. Scale magnitudes to 0.0..1.0 range (with smoothing: bars decay slowly for visual appeal) +8. Map each bar's value to a unicode block character height + +**New TUI module:** `crates/rustify-tui/src/ui/visualizer.rs` + +Responsible for FFT computation and both rendering modes. Called from `now_playing.rs` when the visualizer area is being drawn. + +### Smoothing + +To avoid jittery bars, apply exponential decay: `displayed = max(new_value, displayed * 0.85)`. This makes bars fall smoothly rather than snapping to zero. + +## 2. Color Themes + +### Theme Struct + +```rust +pub struct Theme { + pub name: String, + pub fg: Color, // primary text + pub fg_dim: Color, // secondary/muted text + pub accent: Color, // highlights, selected items, progress bar, focused borders + pub accent_dim: Color, // focused border dim variant + pub border: Color, // unfocused borders + pub error: Color, // error/warning status messages + pub visualizer: Vec, // gradient for spectrum bars (2-4 colors, low→high) +} +``` + +`Color` is `ratatui::style::Color`. Presets use named colors; custom themes use `Color::Rgb(r, g, b)` parsed from hex strings. + +### Built-in Presets + +| Name | accent | fg | fg_dim | border | visualizer gradient | +|------|--------|----|--------|--------|---------------------| +| `default` | Magenta | White | Gray | DarkGray | DarkGray → Magenta | +| `nord` | Cyan | #D8DEE9 | #4C566A | #3B4252 | #5E81AC → #88C0D0 | +| `dracula` | #BD93F9 | #F8F8F2 | #6272A4 | #44475A | #6272A4 → #BD93F9 | +| `gruvbox` | #FABD2F | #EBDBB2 | #928374 | #3C3836 | #689D6A → #FABD2F | +| `catppuccin` | #CBA6F7 | #CDD6F4 | #585B70 | #313244 | #585B70 → #CBA6F7 | + +### Custom Themes via TOML + +Users define a custom theme in `~/.config/rustify/tui.toml`: + +```toml +theme = "custom" + +[theme.custom] +fg = "#CDD6F4" +fg_dim = "#585B70" +accent = "#F38BA8" +accent_dim = "#A6476E" +border = "#313244" +error = "#F38BA8" +visualizer = ["#585B70", "#F38BA8"] +``` + +Missing fields fall back to the `default` preset values. The `theme.custom` section is optional — if `theme = "nord"`, no custom section is needed. + +### New TUI Module + +`crates/rustify-tui/src/theme.rs` + +Responsible for: +- `Theme` struct definition +- 5 built-in preset functions: `Theme::default_theme()`, `Theme::nord()`, etc. +- `Theme::from_config(config: &TuiConfig) -> Theme` — resolves preset name or parses custom section +- Helper: `parse_hex_color(hex: &str) -> Color` for `#RRGGBB` strings + +### Wiring + +- `App` gains a `pub theme: Theme` field, set once at startup +- All UI modules read colors from `app.theme` instead of hardcoded values +- Every `Color::Magenta` → `app.theme.accent`, every `Color::DarkGray` → `app.theme.border`, etc. +- Config field `theme: String` already exists in `TuiConfig` — just needs wiring to `Theme::from_config()` +- Theme selection requires app restart (no hot-reload — YAGNI) + +### Config Extension + +Add to the `TuiConfig` struct: + +```rust +#[serde(default)] +pub custom_theme: Option, +``` + +Where `CustomThemeConfig` is a struct with optional hex color fields matching the `Theme` fields. + +## 3. Fuzzy Search + +### Behavior + +The existing `/` search overlay is upgraded from substring matching to ranked fuzzy matching. + +- As the user types, results update live +- Results ranked by match score (best match first), single flat list +- Each result shows: `track name — artist` +- Matched characters highlighted in the theme's accent color +- `j`/`k` or arrows navigate results, `Enter` plays the selected track, `Esc` closes + +### Dependency + +`nucleo-matcher` crate added to `crates/rustify-tui/Cargo.toml`. Lightweight, same engine as the helix editor's fuzzy picker. Pure Rust, no async. + +### Changes to Library + +Replace the existing `search()` method in `crates/rustify-tui/src/library.rs`: + +```rust +pub struct SearchResult<'a> { + pub track: &'a Track, + pub score: u32, + pub matched_indices: Vec, +} + +impl Library { + /// Fuzzy search across track names, artist names, and album names. + /// Returns results ranked by match quality (best first). + pub fn fuzzy_search(&self, query: &str) -> Vec>; +} +``` + +**Implementation:** +1. For each track, build a search string: `"{name} {artist} {album}"` +2. Run `nucleo_matcher::Matcher::fuzzy_match()` on each search string against the query +3. Collect results with score > 0 +4. Sort by score descending +5. Return top 50 results (cap to avoid rendering thousands) + +### Rendering + +In `crates/rustify-tui/src/ui/main_panel.rs`, update the `draw_search()` function: + +- Use `matched_indices` from `SearchResult` to apply accent-colored style to matched characters in each result line +- Unmatched characters use `theme.fg` +- The search input line shows the query with a blinking cursor + +### Migration + +The old `Library::search()` method is removed and replaced by `fuzzy_search()`. All call sites (just the search overlay in `main_panel.rs`) are updated. + +## Error Handling + +- **Visualizer with no samples:** Show flat bars (all zero height). Happens when stopped or during buffering. +- **FFT on too few samples:** Pad with zeros if buffer has fewer than 1024 samples. +- **Invalid theme hex color:** Log warning, fall back to `default` theme's value for that field. +- **Custom theme missing fields:** Fall back to `default` preset values per-field. +- **Fuzzy search with empty query:** Show no results (empty list), same as current behavior. +- **nucleo-matcher returns no matches:** Show "No results" text. + +## Testing + +### Core Unit Tests + +**Sample buffer:** +- `player.get_samples()` returns empty vec when no audio playing +- Buffer is bounded (doesn't grow unbounded) + +### TUI Unit Tests + +**Visualizer:** +- FFT pipeline produces correct number of bars (24) from 1024 input samples +- Smoothing decays values correctly +- Spectrum and waveform render without panic at various widths + +**Theme:** +- Each preset loads without error +- `parse_hex_color("#FF00FF")` returns `Color::Rgb(255, 0, 255)` +- Custom theme from TOML with partial fields merges with defaults +- Invalid hex gracefully falls back to default + +**Fuzzy search:** +- Exact match ranks highest +- Prefix match ranks above middle match +- Query "mid cit" matches "Midnight City" with correct indices +- Empty query returns no results +- Results capped at 50 + +### Snapshot Tests + +- Now-playing bar renders with visualizer area at 80x24 +- Theme colors applied to sidebar, main panel, now-playing bar +- Search results show highlighted matched characters + +## Dependencies + +| Crate | Where | Purpose | +|-------|-------|---------| +| `rustfft` | rustify-tui | FFT for spectrum visualizer | +| `nucleo-matcher` | rustify-tui | Fuzzy string matching | + +No new core dependencies. The sample buffer uses existing `Arc>` and `VecDeque`. + +## Out of Scope + +- Visualizer audio effects (reverb, echo) — not a visualizer concern +- Theme hot-reload — restart required +- Search across playlists — tracks/artists/albums only +- Visualizer in album detail view — only in now-playing bar +- Custom visualizer bar count — fixed at 24 diff --git a/docs/superpowers/specs/2026-04-09-tier3-power-user-design.md b/docs/superpowers/specs/2026-04-09-tier3-power-user-design.md new file mode 100644 index 0000000..40d7774 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-tier3-power-user-design.md @@ -0,0 +1,353 @@ +# Tier 3: Power User — Design Spec + +**Date:** 2026-04-09 +**Status:** Draft +**Depends on:** Tier 1 (gapless/dual-decode mixer), Tier 2 (themes, visualizer) + +## Overview + +Five features that make the player a daily driver: crossfade between tracks, MPRIS media key support on Linux, ListenBrainz scrobbling, replay gain normalization, and lyrics display. These span core engine changes, a new optional crate, external API integration, and TUI additions. + +## 1. Crossfade + +### Architecture + +The Tier 1 gapless architecture already supports two simultaneous decode channels with a MixStage in the cpal callback. Crossfade extends this: instead of a hard switch from active to pending channel, both are read simultaneously during an overlap window and mixed with linear fade curves. + +### Config + +New field in `tui.toml`: + +```toml +# Crossfade duration in milliseconds. 0 = gapless (no overlap). +crossfade_ms = 3000 +``` + +### Core Changes + +**New `PlayerCommand` variant:** + +```rust +SetCrossfade(u64), // duration in ms +``` + +**New `Player` API:** + +```rust +pub fn set_crossfade(&self, ms: u64); +``` + +**MixStage changes in `create_output_stream()`:** + +Add shared state for crossfade: +- `crossfade_ms: Arc` — crossfade duration, 0 = gapless +- `crossfade_progress: f32` — 0.0 to 1.0, tracks current position in the fade + +When `crossfade_ms > 0` and both `active_rx` and `pending_rx` have audio: +- Read from both channels +- Outgoing: `sample * (1.0 - progress)` +- Incoming: `sample * progress` +- Increment progress based on sample rate and crossfade duration +- When progress reaches 1.0, complete the swap (same as gapless) + +**TrackEnding timing:** The `PRE_BUFFER_MS` constant (currently 3000) should be `max(3000, crossfade_ms)` so the pending decode starts early enough for the full crossfade window. + +### TUI Changes + +None — crossfade is automatic. The config value is read at startup and passed to the player. + +## 2. MPRIS (Linux Media Key Support) + +### Architecture + +New optional workspace crate: `crates/rustify-mpris`. Feature-gated behind a `mpris` Cargo feature so it compiles to a no-op on non-Linux platforms. + +Uses `zbus` crate for D-Bus communication on Linux. + +### MPRIS2 Interfaces Implemented + +**`org.mpris.MediaPlayer2`:** +- `Identity` — "Rustify" +- `Raise` / `Quit` — no-op / send shutdown + +**`org.mpris.MediaPlayer2.Player`:** +- `PlaybackStatus` — "Playing" / "Paused" / "Stopped" +- `Metadata` — track title, artist, album, art URI, length +- `Play` / `Pause` / `PlayPause` / `Stop` / `Next` / `Previous` — forward to `Player` +- `Position` — current playback position in microseconds +- `Volume` — 0.0 to 1.0 (mapped from 0-100) + +### Integration + +The MPRIS module receives a clone of the `Player` handle and the event channel sender. It: +1. Spawns a D-Bus event loop thread (`zbus::Connection::session()`) +2. Registers the MPRIS2 object at `/org/mpris/MediaPlayer2` +3. Listens for incoming D-Bus method calls (media keys) and translates to `PlayerCommand`s +4. Subscribes to `PlayerEvent`s to update MPRIS properties (metadata, playback status) + +### Crate Structure + +``` +crates/rustify-mpris/ +├── Cargo.toml +└── src/ + └── lib.rs # MPRIS2 D-Bus interface + event loop +``` + +**Cargo.toml dependencies:** +- `zbus = "5"` (Linux only, via `[target.'cfg(target_os = "linux")'.dependencies]`) +- `rustify-core` (for Player, PlayerEvent types) + +### Feature Gate + +In `rustify-tui/Cargo.toml`: + +```toml +[features] +default = ["mpris"] +mpris = ["rustify-mpris"] + +[dependencies] +rustify-mpris = { path = "../rustify-mpris", optional = true } +``` + +In `main.rs`, conditionally start MPRIS: + +```rust +#[cfg(feature = "mpris")] +{ + rustify_mpris::start(&player, tx.clone()); +} +``` + +### Non-Linux + +On non-Linux platforms, the `rustify-mpris` crate either doesn't compile (feature not enabled) or provides a no-op `start()` function. No conditional compilation in the TUI beyond the feature gate. + +## 3. Scrobbling (ListenBrainz) + +### Config + +```toml +# ListenBrainz user token (from https://listenbrainz.org/settings/) +listenbrainz_token = "" +``` + +Empty token = scrobbling disabled. + +### Scrobble Rules (ListenBrainz standard) + +A track is scrobbled when: +- It has played for at least **50% of its duration**, OR +- It has played for at least **4 minutes** (240,000 ms) +- AND the track is longer than 30 seconds + +### Architecture + +New TUI module: `crates/rustify-tui/src/scrobble.rs` + +**State:** +- `current_track: Option` — the track being monitored +- `play_start_ms: u64` — timestamp when the track started playing +- `accumulated_ms: u64` — total play time (pauses don't count) +- `scrobbled: bool` — whether this track has already been scrobbled + +**Events handled:** +- `TrackChanged` — submit scrobble for previous track if eligible, reset state for new track, send "now playing" notification +- `PositionUpdate` — update accumulated play time, check scrobble threshold +- `StateChanged(Paused)` — pause accumulation +- `StateChanged(Playing)` — resume accumulation + +**HTTP requests** (via `ureq` crate, blocking, on background thread): + +1. **Now Playing:** POST to `api.listenbrainz.org/1/submit-listens` with `listen_type: "playing_now"` +2. **Scrobble:** POST with `listen_type: "single"` and `listened_at` timestamp + +**Error handling:** Log failures to stderr. Don't retry — if the network is down, the scrobble is lost (standard scrobbler behavior). + +### Dependencies + +Add `ureq = "3"` to `crates/rustify-tui/Cargo.toml`. + +## 4. Replay Gain + +### Tag Reading + +lofty (already a dependency) reads ReplayGain tags: +- ID3v2: `TXXX:replaygain_track_gain` — e.g. `"-6.5 dB"` +- Vorbis/FLAC: `REPLAYGAIN_TRACK_GAIN` — e.g. `"-6.5 dB"` + +### Core Addition + +New function in `crates/rustify-core/src/metadata.rs`: + +```rust +/// Read ReplayGain track gain from audio file tags. +/// Returns the gain adjustment in dB, or None if no tag found. +pub fn read_replay_gain(path: &Path) -> Option +``` + +Parses the tag value, strips " dB" suffix, parses as `f32`. + +### Volume Adjustment + +The TUI handles replay gain at the application level: + +On `TrackChanged`: +1. Read `replay_gain_db` from the new track's tags +2. Compute gain factor: `10.0_f32.powf(rg_db / 20.0)` +3. Adjust effective volume: `player.set_volume((base_volume as f32 * gain_factor).clamp(0.0, 100.0) as u8)` + +The `base_volume` is the user's chosen volume (from +/- keys). Replay gain adjusts on top of it. + +### Config + +```toml +# Apply ReplayGain normalization (true/false) +replay_gain = true +``` + +### TUI State + +- `app.replay_gain_enabled: bool` — from config +- `app.base_volume: u8` — user's chosen volume level +- `app.now_playing.volume` — effective volume after replay gain adjustment + +## 5. Lyrics + +### Lyrics Extraction (Core) + +New module: `crates/rustify-core/src/lyrics.rs` + +```rust +/// Lyrics content — either synced (timestamped) or unsynced (plain text). +#[derive(Debug, Clone)] +pub enum Lyrics { + /// Timestamped lines from .lrc file: (timestamp_ms, line_text) + Synced(Vec<(u64, String)>), + /// Plain text lyrics from audio tags + Unsynced(String), +} + +/// Extract lyrics for a track. +/// Tries embedded tags first, then .lrc sidecar file. +pub fn extract_lyrics(path: &Path) -> Option +``` + +**Embedded lyrics:** lofty reads USLT (unsynced) and SYLT (synced) frames from ID3v2, and `LYRICS` from Vorbis comments. Most embedded lyrics are unsynced plain text. + +**Sidecar .lrc:** Look for `{filename_without_ext}.lrc` in the same directory as the track. Parse LRC format: +- `[mm:ss.xx]Line of lyrics` — synced line +- Lines without timestamps — treated as unsynced + +### TUI Display + +**Toggle:** `L` key toggles lyrics overlay in the main panel (replaces current view content). + +**Synced lyrics rendering:** +- Show 5-7 lines centered on the current line +- Current line highlighted in accent color +- Past lines in dim color, future lines in normal color +- Auto-scrolls as `PositionUpdate` events arrive — find the line whose timestamp is closest to (but not exceeding) current position + +**Unsynced lyrics rendering:** +- Static scrollable text in the main panel +- User scrolls with `j`/`k` as usual +- No auto-scroll (no timing information) + +**No lyrics:** Show "No lyrics found" in dim text. + +### Lyrics State in App + +```rust +pub struct LyricsState { + pub active: bool, // overlay visible + pub lyrics: Option, + pub current_line: usize, // for synced lyrics + pub scroll_offset: usize, // for unsynced lyrics +} +``` + +Loaded on background thread (like album art) on `TrackChanged`. + +## Error Handling + +- **Crossfade with decode failure:** If pending decode fails, crossfade aborts and the outgoing track finishes normally (fades to silence). Same `DecodeFailed` path as gapless. +- **MPRIS D-Bus unavailable:** Log warning, continue without media keys. Common on headless Pi or non-Linux. +- **Scrobble HTTP failure:** Log to stderr, don't retry. No user-visible error — scrobbling is best-effort. +- **ReplayGain tag missing:** No adjustment applied, plays at user-set volume. Silent — no error. +- **ReplayGain extreme values:** Clamp gain factor to 0.1x–2.0x (±20dB) to prevent blowing out speakers. +- **LRC parse failure:** Skip malformed lines, use what we can parse. Log warning. +- **No lyrics found:** Show placeholder text, not an error. + +## Testing + +### Core Unit Tests + +**Crossfade MixStage:** +- Two channels with known samples, verify linear mix at 50% crossfade point +- Verify crossfade completes and fully swaps to new channel + +**Replay gain parsing:** +- Parse "-6.5 dB" → -6.5f32 +- Parse "+3.2 dB" → 3.2f32 +- Parse missing tag → None +- Gain factor: -6dB → ~0.5, +6dB → ~2.0 + +**Lyrics extraction:** +- Parse LRC file with timestamps → Synced +- Read embedded USLT tag → Unsynced +- No lyrics → None +- Malformed LRC lines skipped + +### TUI Unit Tests + +**Scrobbler:** +- Track played 51% → scrobble eligible +- Track played 49% but > 4 min → scrobble eligible +- Track played 10% and < 4 min → not eligible +- Track < 30 seconds → never scrobble + +**Lyrics display:** +- Synced: correct current line for given position_ms +- Synced: auto-scrolls on position update +- Toggle on/off with L key + +### Integration Tests + +**MPRIS:** Manual — play a track, verify media keys work, verify desktop shows metadata. +**Scrobbling:** Manual — play a track > 50%, verify submission on ListenBrainz profile. +**Crossfade:** Manual — play album, verify smooth transitions. + +## Dependencies + +| Crate | Where | Purpose | +|-------|-------|---------| +| `zbus` 5 | rustify-mpris (new crate) | D-Bus MPRIS2 on Linux | +| `ureq` 3 | rustify-tui | HTTP POST for ListenBrainz scrobbling | + +Existing crates used: `lofty` (replay gain tags, lyrics tags), core mixer (volume adjustment). + +## Config Summary + +All new config fields in `tui.toml`: + +```toml +# Crossfade duration (0 = gapless, no overlap) +crossfade_ms = 0 + +# ListenBrainz scrobbling token (empty = disabled) +listenbrainz_token = "" + +# Replay gain normalization +replay_gain = true +``` + +## Out of Scope + +- Last.fm scrobbling (can be added later behind same interface) +- Online lyrics fetching (network lyrics services) +- MPRIS on Windows/macOS (no D-Bus) +- Crossfade with different sample rates between tracks +- Album-level replay gain (track gain only for v1) +- SYLT (synced lyrics) from ID3v2 tags (complex binary format — LRC sidecar covers synced use case) diff --git a/rules/CLAUDE.md b/rules/CLAUDE.md new file mode 100644 index 0000000..f71fb2c --- /dev/null +++ b/rules/CLAUDE.md @@ -0,0 +1,34 @@ +# Rules for Claude Sessions on Rustify + +## Session Context + +When starting a new session on this project, read: +1. `docs/TUI.md` — what exists, architecture, what's next +2. `rules/DEVELOPMENT.md` — code patterns, file organization, testing approach +3. The relevant spec in `docs/superpowers/specs/` if implementing a specific tier + +## How This User Works + +- Prefers bottom-up development: types first, then modules, then integration +- Writes design specs before implementation +- Wants concise answers — don't over-explain +- Approves designs with "yes" or "yeah" — take that as full approval and proceed +- When told "go ahead" — proceed with implementation, don't ask more questions +- Prefers Fisher-Yates shuffle, three-mode repeat cycle (Off/All/One), and standard UX patterns + +## Implementation Approach + +- Implement directly in the main repo at `C:\Users\att1a\WS\rustify` — do NOT use git worktrees for implementation (agents that spawn subprocesses may default to the worktree path instead of the main repo) +- When using subagents, always specify `Work from: C:\Users\att1a\WS\rustify` explicitly +- Build and test with `cargo test --workspace` after each change +- Commit frequently with conventional commit messages +- All core changes need tests. TUI snapshot tests for visual changes. + +## What NOT to Do + +- Don't add features beyond what was asked +- Don't refactor code you're not changing +- Don't add comments, docstrings, or type annotations to untouched code +- Don't create README.md files unless asked +- Don't use async/tokio — the project is deliberately sync with crossbeam channels +- Don't add MPRIS implementation on Windows — it's a Linux-only feature diff --git a/rules/DEVELOPMENT.md b/rules/DEVELOPMENT.md new file mode 100644 index 0000000..d723c1d --- /dev/null +++ b/rules/DEVELOPMENT.md @@ -0,0 +1,117 @@ +# Rustify Development Rules + +Guidelines established during the initial TUI development session. Follow these in future sessions. + +## Architecture Principles + +### Crate Boundaries +- `rustify-core` is a pure library — no TUI, no terminal, no config files. Exposed via PyO3 and consumed by the TUI binary. +- `rustify-tui` is the binary crate. It owns all UI state, config loading, and user interaction. +- `rustify-mpris` is optional and feature-gated. No-op on non-Linux. +- New features go in the crate closest to their responsibility. Audio processing = core. Display = TUI. + +### Event Architecture +- Core uses crossbeam channels: `PlayerCommand` (in) and `PlayerEvent` (out via callbacks). +- TUI has a unified `AppEvent` channel multiplexing keyboard, mouse, player callbacks, and tick timer. +- Player callbacks run on core's internal threads — they push `AppEvent::Player(...)` into the TUI's channel. +- Background work (scanning, art extraction, lyrics loading) spawns threads that send results via the same channel. + +### State Management +- Single `App` struct owns all mutable UI state. +- Rendering is a pure function: `ui::draw(&app, &mut frame)` reads state, never mutates it. +- State updates happen in the event loop, before the next draw call. +- Player state (track, position, playback state) is cached in `app.now_playing` from callbacks. + +## Code Patterns + +### Adding a New Player Feature +1. Add the type/enum to `types.rs` (commands, events) +2. Implement the logic in the relevant core module (tracklist, player, new module) +3. Add `Player` API method that sends a `PlayerCommand` +4. Add callback registration if the feature emits events +5. Wire in TUI: `PlayerAction` variant, keybinding in `app.rs`, `main.rs` action handler + +### Adding a New TUI View/Panel +1. Add state struct to `app.rs` +2. Add rendering function in the appropriate `ui/*.rs` module +3. Wire keybinding to toggle/navigate +4. Add snapshot test with `TestBackend` + +### Adding Config Fields +1. Add field to `TuiConfig` in `config.rs` with `#[serde(default)]` +2. Add to the `Default` impl +3. Wire in `main.rs` at startup + +## Testing + +### What to Test +- Core: unit tests for every public method. State transitions, edge cases. +- TUI: snapshot tests (render to `TestBackend`, assert buffer contents). Key handling tests (given state + key, assert resulting state). +- No integration tests requiring audio hardware — those are manual. + +### Test Patterns +- Use `TestBackend::new(width, height)` for snapshot tests +- Use helper functions: `make_key(KeyCode)`, `make_app()`, `make_track()` +- Test actual behavior, not mocks + +## Commit Style + +Format: `type(scope): description` + +- `feat(core):` — new core library feature +- `feat(tui):` — new TUI feature +- `feat:` — spans both crates +- `fix(tui):` — bug fix +- `docs:` — documentation only + +## File Organization + +### Core modules +| Module | Responsibility | +|--------|---------------| +| `types.rs` | Shared types: Track, PlaybackState, PlayerCommand, PlayerEvent, RepeatMode | +| `player.rs` | Playback engine: command loop, decode thread, cpal output, gapless mixer | +| `tracklist.rs` | Queue with shuffle/repeat | +| `mixer.rs` | Atomic volume control | +| `metadata.rs` | Tag reading, replay gain | +| `scanner.rs` | Directory scanning | +| `playlist.rs` | M3U parsing | +| `art.rs` | Album art extraction | +| `lyrics.rs` | Lyrics extraction (tags + LRC) | +| `error.rs` | Error types | + +### TUI modules +| Module | Responsibility | +|--------|---------------| +| `main.rs` | Entry point, event loop, player wiring | +| `app.rs` | All state, key/mouse handling, PlayerAction dispatch | +| `config.rs` | TOML config parsing | +| `event.rs` | AppEvent enum, input/tick threads | +| `library.rs` | In-memory index, fuzzy search | +| `theme.rs` | Color themes, presets, hex parsing | +| `scrobble.rs` | ListenBrainz scrobbling | +| `ui/mod.rs` | Top-level layout | +| `ui/sidebar.rs` | Nav + queue | +| `ui/main_panel.rs` | Content views (artists/albums/songs/playlists/search) | +| `ui/now_playing.rs` | Track info, progress, visualizer integration | +| `ui/visualizer.rs` | FFT spectrum + waveform rendering | + +## Design Specs and Plans + +Design specs go in `docs/superpowers/specs/`. Implementation plans in `docs/superpowers/plans/`. + +Each tier of features follows the cycle: spec -> plan -> implement -> test -> commit. + +Existing specs: +- `2026-04-08-rustify-tui-design.md` — original TUI design +- `2026-04-08-tier1-playback-essentials-design.md` — shuffle, repeat, seek, gapless, album art +- `2026-04-09-tier2-rich-experience-design.md` — visualizer, themes, fuzzy search +- `2026-04-09-tier3-power-user-design.md` — crossfade, MPRIS, scrobbling, replay gain, lyrics + +## Pi Constraints + +- Target: Raspberry Pi Zero 2W (aarch64, 512MB RAM) +- ALSA-only audio output +- Memory efficiency matters — avoid unbounded buffers +- SSH use case: no mouse expected, braille art fallback, keyboard-only +- Binary size target: <10MB (vs 70-80MB for Mopidy+GStreamer)