diff --git a/Assets/.DS_Store b/Assets/.DS_Store new file mode 100644 index 0000000..8cafa7c Binary files /dev/null and b/Assets/.DS_Store differ diff --git a/Assets/Demo.mov b/Assets/Demo.mov new file mode 100644 index 0000000..2ce1990 Binary files /dev/null and b/Assets/Demo.mov differ diff --git a/Assets/audioleaf-iOS-Default-1024x1024@1x.png b/Assets/audioleaf-iOS-Default-1024x1024@1x.png new file mode 100644 index 0000000..e2d6b04 Binary files /dev/null and b/Assets/audioleaf-iOS-Default-1024x1024@1x.png differ diff --git a/Assets/audioleaf-icon.svg b/Assets/audioleaf-icon.svg new file mode 100644 index 0000000..9afc716 --- /dev/null +++ b/Assets/audioleaf-icon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Assets/audioleaf.icon/Assets/audioleaf-icon.svg b/Assets/audioleaf.icon/Assets/audioleaf-icon.svg new file mode 100644 index 0000000..9afc716 --- /dev/null +++ b/Assets/audioleaf.icon/Assets/audioleaf-icon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Assets/audioleaf.icon/icon.json b/Assets/audioleaf.icon/icon.json new file mode 100644 index 0000000..011195b --- /dev/null +++ b/Assets/audioleaf.icon/icon.json @@ -0,0 +1,84 @@ +{ + "fill-specializations" : [ + { + "value" : { + "linear-gradient" : [ + "display-p3:0.07092,1.00000,0.12784,1.00000", + "display-p3:0.23472,0.23753,0.96783,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + } + }, + { + "appearance" : "dark", + "value" : { + "linear-gradient" : [ + "display-p3:0.93358,0.26816,1.00000,1.00000", + "display-p3:0.26882,0.96783,0.82718,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.5, + "y" : 0 + }, + "stop" : { + "x" : 0.5, + "y" : 0.7 + } + } + } + } + ], + "groups" : [ + { + "layers" : [ + { + "blend-mode-specializations" : [ + { + "appearance" : "tinted", + "value" : "normal" + } + ], + "fill-specializations" : [ + { + "appearance" : "tinted", + "value" : "automatic" + } + ], + "image-name" : "audioleaf-icon.svg", + "name" : "audioleaf-icon", + "position" : { + "scale" : 2.75, + "translation-in-points" : [ + -38.89999999999998, + -14.71875 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/Assets/demo.gif b/Assets/demo.gif new file mode 100644 index 0000000..bb0dbd6 Binary files /dev/null and b/Assets/demo.gif differ diff --git a/Assets/gui.png b/Assets/gui.png new file mode 100644 index 0000000..229e7fe Binary files /dev/null and b/Assets/gui.png differ diff --git a/Assets/icon_16.png b/Assets/icon_16.png new file mode 100644 index 0000000..3f41b8d Binary files /dev/null and b/Assets/icon_16.png differ diff --git a/Assets/icon_32.png b/Assets/icon_32.png new file mode 100644 index 0000000..b21f57b Binary files /dev/null and b/Assets/icon_32.png differ diff --git a/Assets/icon_64.png b/Assets/icon_64.png new file mode 100644 index 0000000..866aaca Binary files /dev/null and b/Assets/icon_64.png differ diff --git a/Cargo.lock b/Cargo.lock index 8387c67..e40080d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,15 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -110,15 +101,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -127,7 +109,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "audioleaf" -version = "3.5.0" +version = "4.0.0" dependencies = [ "anyhow", "auto-palette", @@ -142,7 +124,6 @@ dependencies = [ "objc2-foundation", "palette", "pollster", - "ratatui", "reqwest", "serde", "serde_json", @@ -195,21 +176,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bitflags" version = "1.3.2" @@ -222,15 +188,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "block2" version = "0.6.2" @@ -276,15 +233,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[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.57" @@ -346,7 +294,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -386,29 +334,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -479,15 +404,6 @@ dependencies = [ "windows", ] -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -497,140 +413,12 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.11.0", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf", -] - -[[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 2.0.117", -] - -[[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 2.0.117", -] - [[package]] name = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "dirs" version = "6.0.0" @@ -670,16 +458,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", + "syn", ] [[package]] @@ -688,12 +467,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -709,35 +482,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "euclid" -version = "0.22.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - [[package]] name = "fast-srgb8" version = "1.0.0" @@ -753,35 +497,12 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flate2" version = "1.1.9" @@ -804,12 +525,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "fontdue" version = "0.9.3" @@ -884,16 +599,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -916,24 +621,11 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 5.3.0", + "r-efi", "wasip2", "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - [[package]] name = "glam" version = "0.27.0" @@ -967,7 +659,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -975,11 +667,6 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "heck" @@ -987,12 +674,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "http" version = "1.4.0" @@ -1176,18 +857,6 @@ dependencies = [ "zerovec", ] -[[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 = "idna" version = "1.1.0" @@ -1256,30 +925,6 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", ] [[package]] @@ -1304,15 +949,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" @@ -1361,35 +997,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.183" @@ -1406,40 +1013,10 @@ dependencies = [ ] [[package]] -name = "line-clipping" -version = "0.3.5" +name = "litemap" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "log" @@ -1447,31 +1024,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix", - "winapi", -] - [[package]] name = "mach2" version = "0.5.0" @@ -1516,33 +1074,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniquad" version = "0.4.8" @@ -1572,7 +1109,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "log", "wasi", "windows-sys 0.61.2", ] @@ -1622,29 +1158,6 @@ dependencies = [ "jni-sys", ] -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -1654,12 +1167,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - [[package]] name = "num-derive" version = "0.4.2" @@ -1668,7 +1175,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1699,16 +1206,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", + "syn", ] [[package]] @@ -1833,15 +1331,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - [[package]] name = "palette" version = "0.7.6" @@ -1863,30 +1352,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.117", -] - -[[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", + "syn", ] [[package]] @@ -1895,49 +1361,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2", -] - [[package]] name = "phf" version = "0.11.3" @@ -1948,16 +1371,6 @@ dependencies = [ "phf_shared", ] -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - [[package]] name = "phf_generator" version = "0.11.3" @@ -1978,7 +1391,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2040,12 +1453,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "potential_utf" version = "0.1.4" @@ -2055,12 +1462,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2070,16 +1471,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2187,12 +1578,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "rand" version = "0.8.5" @@ -2237,100 +1622,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "ratatui" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termwiz", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" -dependencies = [ - "bitflags 2.11.0", - "compact_str", - "hashbrown 0.16.1", - "indoc", - "itertools", - "kasuari", - "lru", - "strum", - "thiserror 2.0.18", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.16.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "strum", - "time", - "unicode-segmentation", - "unicode-width", -] - -[[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" @@ -2342,35 +1633,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - [[package]] name = "reqwest" version = "0.13.2" @@ -2433,28 +1695,6 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.23.37" @@ -2536,12 +1776,6 @@ 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" @@ -2560,12 +1794,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "security-framework" version = "3.7.0" @@ -2589,12 +1817,6 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - [[package]] name = "serde" version = "1.0.228" @@ -2622,7 +1844,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2647,54 +1869,12 @@ dependencies = [ "serde_core", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[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.8" @@ -2735,56 +1915,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -2813,7 +1955,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2837,69 +1979,6 @@ dependencies = [ "libc", ] -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom", - "phf", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.11.0", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf", - "sha2", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -2926,7 +2005,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2937,30 +2016,9 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - [[package]] name = "tinystr" version = "0.8.2" @@ -3150,53 +2208,12 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-truncate" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "untrusted" version = "0.9.0" @@ -3227,33 +2244,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" -dependencies = [ - "atomic", - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -3288,15 +2278,6 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -3343,7 +2324,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -3356,40 +2337,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" version = "0.3.91" @@ -3419,78 +2366,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - [[package]] name = "winapi" version = "0.3.9" @@ -3575,7 +2450,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3586,7 +2461,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3879,88 +2754,6 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] [[package]] name = "writeable" @@ -3987,7 +2780,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4008,7 +2801,7 @@ checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4028,7 +2821,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4068,7 +2861,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3fb1038..7f742e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "audioleaf" -version = "3.5.0" +version = "4.0.0" edition = "2024" authors = ["Antoni Zasada", "weekendsuperhero"] description = "Manage your Nanoleaf devices (Canvas, Shapes, Elements, Light Panels) and visualize music straight from the terminal" @@ -23,7 +23,6 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png", num-complex = "0.4.6" palette = "0.7.6" pollster = "0.4" -ratatui = "0.30" reqwest = { version = "0.13", features = ["blocking", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index a00e4b7..e86e9aa 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,43 @@ # Audioleaf -A TUI for managing and visualizing music on your Nanoleaf devices (Canvas, Shapes, Elements, and Light Panels) +A real-time music visualizer for Nanoleaf devices (Shapes, Canvas, Elements, and Light Panels). Audioleaf listens to your system audio, analyzes it, and drives your Nanoleaf panels with reactive color animations — all rendered in a graphical window that mirrors your physical panel layout. -![audioleaf tui view](https://github.com/alfazet/audioleaf/blob/main/images/tui.png) +![Audioleaf GUI](Assets/gui.png) -> **Note:** This is a fork with macOS compatibility fixes and support for all Nanoleaf device types. See [CHANGELOG.md](CHANGELOG.md) for details. +![Audioleaf Demo](Assets/demo.gif) + +> **Note:** This is a fork with macOS compatibility fixes, a graphical UI, album art integration, and support for all Nanoleaf device types. See [CHANGELOG.md](CHANGELOG.md) for details. + +## Features + +- **Real-time audio visualization** — Three effects (Spectrum, Energy Wave, Pulse) that react to your music +- **Graphical panel preview** — See your exact Nanoleaf layout rendered on screen with live color preview +- **Album art integration** — Automatically extract color palettes from the currently playing track's album artwork (Spotify & Apple Music) +- **11 built-in color palettes** — From ocean-nightclub to neon-rainbow, plus custom RGB palettes +- **Panel sorting controls** — Adjust how colors map to your physical layout +- **Cross-platform** — macOS and Linux ## Installation -Install from cargo with `cargo install audioleaf`. Make sure that the directory with cargo binaries (by default `$HOME/.cargo/bin`) is added to your `$PATH`. +Install from cargo: + +```bash +cargo install audioleaf +``` + +Make sure `$HOME/.cargo/bin` is in your `$PATH`. -For users of Arch-based distros, audioleaf is also available as a [package in the AUR](https://aur.archlinux.org/packages/audioleaf). You can install it with your AUR helper of choice, for example with yay: `yay -S audioleaf`. +For Arch-based distros, audioleaf is also available in the [AUR](https://aur.archlinux.org/packages/audioleaf): + +```bash +yay -S audioleaf +``` ## Usage ### First-Time Setup -At first launch, `audioleaf` will automatically discover Nanoleaf devices on your local network and prompt you to choose one: +At first launch, audioleaf discovers Nanoleaf devices on your local network: ```bash audioleaf -n @@ -24,20 +45,20 @@ audioleaf -n This will: -1. Scan your network for Nanoleaf devices (Canvas, Shapes, Elements, Light Panels) -2. Display a list of discovered devices -3. Prompt you to put your chosen device in pairing mode (hold power button until LEDs flash) +1. Scan your network for Nanoleaf devices +2. Display discovered devices +3. Prompt you to put your device in pairing mode (hold power button until LEDs flash) 4. Save the device configuration **Device data location:** - **macOS**: `~/Library/Application Support/audioleaf/nl_devices.toml` - **Linux**: `~/.config/audioleaf/nl_devices.toml` -- **Custom**: Use `--devices /path/to/devices.toml` to specify a custom location +- **Custom**: Use `--devices /path/to/devices.toml` -### Running Audioleaf +### Running -After initial setup, simply run: +After setup, simply run: ```bash audioleaf @@ -49,363 +70,146 @@ To connect to a specific device: audioleaf -d "Shapes AC01" ``` -### TUI Controls +### Controls + +Press ? in the app to see all keybinds. -Lost in the TUI? Press ? to see the list of keybinds. +| Key | Action | +| --- | --- | +| Esc / Q | Quit | +| ? | Toggle help overlay | +| Space | Toggle live panel color preview | +| - / + | Decrease / increase gain (visual sensitivity) | +| 1-9, 0 | Switch color palette | +| E | Cycle effect: Spectrum / Energy Wave / Pulse | +| A | Toggle primary sort axis (X / Y) | +| P | Toggle primary sort (Asc / Desc) | +| S | Toggle secondary sort (Asc / Desc) | +| N | Use album art colors from current track | +| R | Reset all panels to black | -**Main shortcuts:** +### Effects -- Enter - Play selected effect -- V - Toggle visualizer mode -- j/k or ↓/↑ - Navigate effect list -- +/- - Increase/decrease visualizer gain (sensitivity) -- Q or Esc - Quit -- ? - Show help +- **Spectrum** — Each panel tracks a frequency band. Bass on one end, treble on the other. +- **Energy Wave** — Audio energy cascades across panels as a traveling ripple. +- **Pulse** — All panels pulse together, driven by audio transients. Snaps to the beat. ## Configuration -All configuration is done through the `config.toml` file. The location depends on your operating system: +Configuration lives in `config.toml`: - **macOS**: `~/Library/Application Support/audioleaf/config.toml` - **Linux**: `~/.config/audioleaf/config.toml` -- **Custom**: Use `--config /path/to/config.toml` to override +- **Custom**: Use `--config /path/to/config.toml` -At first launch, a default config file will be automatically generated after connecting to your Nanoleaf device. +A default config file is generated on first launch. -### Complete Configuration Reference +### Example Configuration ```toml -# Specify which Nanoleaf device to use by default default_nl_device_name = "Shapes AC01" -[tui_config] -# Display effect names in colors matching their palettes -colorful_effect_names = true - [visualizer_config] -# Audio input device name (see Audio Setup section below) +# Audio input device (see Audio Setup below) audio_backend = "BlackHole 2ch" -# Frequency range to visualize (Hz) - [min, max] -# Lower range = bass-focused, higher range = treble-focused +# Frequency range to visualize [min_hz, max_hz] freq_range = [20, 4500] -# Color palette - use a named palette OR custom hue array -# Named palette (easiest - see Color Palettes section for all options): -hues = "ocean-nightclub" -# OR custom hue array (0-360 on HSB color wheel, 360 = white): -# hues = [195, 210, 240, 270, 285, 300, 180] -# hues = [0, 120, 360] # Red, Green, White +# Color palette — named palette or custom RGB array +# Named: "ocean-nightclub", "sunset", "fire", "forest", "neon-rainbow", etc. +colors = "ocean-nightclub" +# Or custom RGB: colors = [[255, 0, 128], [0, 128, 255], [128, 255, 0]] -# Audio sensitivity multiplier (default: 1.0) -# BlackHole 2ch directly: 1 (recommended) -# VB Cable or other virtual devices: may need 200-500 -# Physical microphones: typically 1-10 -default_gain = 1 +# Audio sensitivity (doesn't affect playback volume) +default_gain = 1.0 -# Panel transition speed in 100ms units (1 = 100ms, 10 = 1s) +# Panel transition speed in 100ms units (2 = 200ms) transition_time = 2 # Audio sampling window in seconds -# Smaller = more responsive, larger = smoother but laggy time_window = 0.1875 -# Panel layout sorting (affects visualization direction) -primary_axis = "Y" # "X" (left→right) or "Y" (bottom→top) +# Panel sorting +primary_axis = "Y" # "X" or "Y" sort_primary = "Asc" # "Asc" or "Desc" sort_secondary = "Asc" # "Asc" or "Desc" -``` - -### Configuration Options Explained - -#### TUI Configuration - -| Option | Type | Default | Description | -| ----------------------- | ------- | ------- | -------------------------------------------------------- | -| `colorful_effect_names` | boolean | `true` | Display effect names with colors matching their palettes | - -#### Visualizer Configuration - -| Option | Type | Default | Description | -| ----------------- | -------------------- | --------------- | ----------------------------------------------------------------------------------- | -| `audio_backend` | string | (auto-detected) | Name of audio input device to visualize | -| `freq_range` | [int, int] | `[20, 4500]` | Min and max frequencies (Hz) to include in visualization | -| `hues` | string or [int, ...] | varies | Named palette (e.g., `"sunset"`) OR custom HSB hue array (0-360, use 360 for white) | -| `default_gain` | int/float | `1.0` | Audio amplification multiplier (doesn't affect playback volume) | -| `transition_time` | int | `2` | Panel color transition time in 100ms units | -| `time_window` | float | `0.1875` | Audio sampling window length in seconds | -| `primary_axis` | string | `"Y"` | Primary sort direction: `"X"` or `"Y"` | -| `sort_primary` | string | `"Asc"` | Primary sort order: `"Asc"` or `"Desc"` | -| `sort_secondary` | string | `"Asc"` | Secondary sort order: `"Asc"` or `"Desc"` | - -### Color Palettes - -Colors are specified using **HSB (Hue, Saturation, Brightness)** hue values ranging from **0-360**. You can use either: - -1. **Named palettes** (easiest): `hues = "palette-name"` -2. **Custom hue arrays**: `hues = [0, 60, 120, 180, 240, 300]` - - Use values 0-359 for colors on the HSB color wheel - - Use **360** for white/near-white colors - -#### Using Named Palettes - -Simply set `hues` to one of the palette names below: - -```toml -[visualizer_config] -hues = "sunset" -``` -#### Available Named Palettes - -**ocean-nightclub** - Deep blues, purples, and teals - -```toml -hues = "ocean-nightclub" -# Equivalent to: hues = [195, 210, 240, 270, 285, 300, 180] -``` - -**sunset** - Warm oranges, reds, pinks, and purples - -```toml -hues = "sunset" -# Equivalent to: hues = [15, 25, 340, 350, 0, 10, 310, 280] +# Visualization effect +effect = "Spectrum" # "Spectrum", "EnergyWave", or "Pulse" ``` -**house-music-party** - Energetic magentas, purples, blues, and cyans +### Available Palettes -```toml -hues = "house-music-party" -# Equivalent to: hues = [300, 285, 270, 255, 240, 195, 180] -``` +| Palette | Description | +| --- | --- | +| `ocean-nightclub` | Deep blues, purples, teals | +| `sunset` | Warm oranges, reds, pinks | +| `house-music-party` | Energetic magentas, purples, cyans | +| `tropical-beach` | Turquoise, aqua, lime | +| `fire` | Reds, oranges, yellows | +| `forest` | Deep greens, yellow-green | +| `neon-rainbow` | Full spectrum | +| `pink-dreams` | Soft pinks through magentas | +| `cool-blues` | Ice blues to navy | +| `tmnt` | Turtle green + bandana colors | +| `christmas` | Red, green, white | -**tropical-beach** - Turquoise, aqua, lime, and sunny yellows +## Audio Setup -```toml -hues = "tropical-beach" -# Equivalent to: hues = [180, 175, 170, 160, 90, 75, 60] -``` - -**fire** - Intense reds, oranges, and yellows - -```toml -hues = "fire" -# Equivalent to: hues = [0, 10, 20, 30, 40, 50, 60] -``` - -**forest** - Deep greens with yellow-green highlights - -```toml -hues = "forest" -# Equivalent to: hues = [90, 100, 110, 120, 130, 140, 150] -``` - -**neon-rainbow** - Full spectrum bright colors - -```toml -hues = "neon-rainbow" -# Equivalent to: hues = [0, 60, 120, 180, 240, 300] -``` - -**pink-dreams** - Soft pinks through magentas - -```toml -hues = "pink-dreams" -# Equivalent to: hues = [320, 325, 330, 335, 340, 345, 350] -``` - -**cool-blues** - Ice blues to deep navy - -```toml -hues = "cool-blues" -# Equivalent to: hues = [190, 200, 210, 220, 230, 240, 250] -``` - -**tmnt** - Turtle green with Leonardo, Michelangelo, Raphael, and Donatello bandana colors - -```toml -hues = "tmnt" -# Equivalent to: hues = [125, 130, 240, 245, 25, 30, 0, 5, 280, 285] -``` - -**christmas** - Festive red, green, and white - -```toml -hues = "christmas" -# Equivalent to: hues = [0, 5, 120, 125, 360, 360] -# Red (0, 5), Green (120, 125), White (360, 360) -``` - -### HSB Color Wheel Reference - -Hue values map to colors on the standard HSB color wheel (0-359 degrees): - -- **0° - Red** -- 15° - Red-Orange -- **30° - Orange** -- 45° - Yellow-Orange -- **60° - Yellow** -- 90° - Yellow-Green -- **120° - Green** -- 150° - Blue-Green -- **180° - Cyan** -- 210° - Sky Blue -- **240° - Blue** -- 270° - Blue-Purple -- **300° - Magenta** -- 330° - Pink-Red -- **359° - Back to Red** - -The Nanoleaf API uses **HSB (Hue, Saturation, Brightness)** color space where: - -- **Hue**: 0-359 (color position on the wheel) -- **Saturation**: 0-100 (color intensity, controlled by device) -- **Brightness**: 0-100 (lightness, controlled by device settings) - -In audioleaf configuration, you specify hue values (0-359). The visualizer dynamically adjusts brightness based on audio input. - -**Special white value**: Use **360** to create white/near-white colors. This adds high whiteness to the color, making it appear white or silvery when bright. - -Example with white: - -```toml -hues = [0, 120, 360] # Red, Green, White -``` - -For more details, see the [HSB Color System guide](https://www.learnui.design/blog/the-hsb-color-system-practicioners-primer.html) or the [Nanoleaf API documentation](https://forum.nanoleaf.me/docs). - -### Audio Setup - -#### macOS +### macOS 1. Install [BlackHole](https://existential.audio/blackhole/) (free virtual audio device) -2. Open **Audio MIDI Setup** (Applications → Utilities) -3. Create a **Multi-Output Device**: - - Include your speakers/headphones - - Include BlackHole 2ch +2. Open **Audio MIDI Setup** (Applications > Utilities) +3. Create a **Multi-Output Device** including your speakers + BlackHole 2ch 4. Set the Multi-Output Device as your system output 5. Set `audio_backend = "BlackHole 2ch"` in config.toml -6. Set `default_gain = 1` (normal gain when targeting BlackHole directly) -**Important**: Target `"BlackHole 2ch"` directly, not the Multi-Output Device aggregate. This provides proper audio levels without requiring extreme gain values. +**Tip**: Target `"BlackHole 2ch"` directly, not the Multi-Output Device aggregate. This provides proper audio levels with `default_gain = 1`. -#### Linux (PulseAudio/PipeWire) +### Linux (PulseAudio/PipeWire) -1. Run audioleaf once to see available devices -2. Use `pavucontrol` (PulseAudio Volume Control) -3. Go to **Recording** tab while audioleaf is running -4. Set audioleaf's input to your media player's monitor -5. Set `audio_backend` to match the device name -6. Adjust `default_gain` as needed (typically 1-10) +1. Run audioleaf +2. Open `pavucontrol` (PulseAudio Volume Control) +3. In the **Recording** tab, set audioleaf's input to your media player's monitor +4. Set `audio_backend` in config.toml to match -#### Windows +## Dump Commands -1. Install [VB Cable](https://vb-audio.com/Cable) (free virtual audio cable) -2. Set VB Cable as your default playback device -3. Route VB Cable output to your speakers using audio software -4. Set `audio_backend = "VB Cable"` in config.toml -5. Set `default_gain = 200` or higher +Inspect your device without launching the full app: -### Brightness Adjustment +```bash +# Show panel layout info +audioleaf dump layout -Brightness is controlled by your Nanoleaf device settings, not by audioleaf: +# Interactive graphical layout view (click panels to flash them) +audioleaf dump layout-graphical -1. Open the Nanoleaf mobile app -2. Select your device -3. Adjust the brightness slider -4. The visualizer will maintain this brightness level +# List available color palettes +audioleaf dump palettes -Alternatively, you can adjust brightness directly on the Nanoleaf controller using the physical buttons. +# Show raw device info +audioleaf dump info +``` ## Troubleshooting -### Visualizer Not Responding to Music - -**Symptom**: Panels don't react to audio, or react very weakly. - -**Solutions**: - -1. **Check audio routing**: Ensure audioleaf is receiving audio input - - On Linux: Use `pavucontrol` → Recording tab to verify audio source - - On macOS: Verify Multi-Output Device is selected in System Settings - - On Windows: Confirm VB Cable routing is correct - -2. **Adjust gain**: Different audio sources need different gain levels - - Press +/- in audioleaf to adjust gain in real-time - - BlackHole 2ch (macOS): `default_gain = 1` when targeted directly - - VB Cable (Windows): may need `default_gain = 200-500` - - Physical microphones: typically `default_gain = 1-10` - -3. **Adjust frequency range**: Try different frequency ranges - - Bass-heavy: `freq_range = [20, 500]` - - Full range: `freq_range = [20, 4500]` - - Treble-focused: `freq_range = [1000, 8000]` - -### Only One Panel Lights Up +### Visualizer Not Responding -**Symptom**: Only a single panel shows colors during visualization. - -**Solution**: This is fixed in this fork. The original version included the controller unit in the panel list. If you're still seeing this: - -- Update to the latest version of this fork -- Run `audioleaf -n` to re-discover your device -- Delete the devices file and reconnect +1. **Check audio routing** — Verify audioleaf receives audio input (use `pavucontrol` on Linux, check Multi-Output Device on macOS) +2. **Adjust gain** — Press +/- in the app, or set `default_gain` in config +3. **Adjust frequency range** — Try `freq_range = [20, 500]` for bass-heavy, `[20, 4500]` for full range ### Device Not Discovered -**Symptom**: "No Nanoleaf devices found on the local network" - -**Solutions**: - -1. Ensure your device is powered on and connected to the same network -2. This fork supports all device types: Canvas (nl29), Shapes (nl42), Elements (nl52), and Light Panels (aurora) -3. Check your firewall isn't blocking SSDP/UDP multicast -4. Try running with `sudo` on Linux if permission issues exist -5. Manually specify device in config: `default_nl_device_name = "Your Device Name"` - -### Enter Key Not Working in TUI - -**Symptom**: Pressing Enter doesn't select effects. +1. Ensure device is powered on and on the same network +2. Check firewall isn't blocking SSDP/UDP multicast +3. Try `audioleaf -n` to re-discover -**Solution**: This is fixed in this fork. If still experiencing issues: +### Brightness -- Ensure you're running the latest version -- Try different terminal emulators (tested with Ghostty, iTerm2, Alacritty) -- Check terminal supports standard key event handling - -### Config Changes Not Taking Effect - -**Symptom**: Changing config.toml has no effect. - -**Solutions**: - -1. **Verify correct config location**: - - macOS: `~/Library/Application Support/audioleaf/config.toml` - - Linux: `~/.config/audioleaf/config.toml` - - Not in `~/.config/` on macOS! - -2. **Check TOML syntax**: Ensure proper formatting - - Use integers OR floats for `default_gain` (both work) - - Arrays use square brackets: `hues = [0, 60, 120]` - - Strings use quotes: `audio_backend = "BlackHole 2ch"` - -3. **Restart audioleaf**: Changes only apply on launch - -### Audio Routing (Linux) - -Make sure that audioleaf's audio input is set to be your media player's output. Use any audio mixer software, for example [pavucontrol](https://freedesktop.org/software/pulseaudio/pavucontrol) (for PulseAudio or PipeWire): - -1. Go to the **Recording** tab while audioleaf is running -2. Set the device in the dropdown menu to your media player's monitor - -![pavucontrol](https://github.com/alfazet/audioleaf/blob/main/images/pavucontrol.png) - -### Audio Routing (Windows) - -Windows doesn't have a built-in way to route one program's audio output to another's input. Use third-party software such as [VB Cable](https://vb-audio.com/Cable). +Brightness is controlled by your Nanoleaf device settings (mobile app or physical buttons), not by audioleaf. The visualizer dynamically adjusts color intensity based on audio. ## Contributing -Audioleaf is a project made mainly in my spare time as a way to become familiar with Rust, making TUIs, and some basics of audio processing. - -Therefore, there are surely many ways to make it more robust, performant and nicer to use - feel free to open a pull request or start a Github issue if you see any potential for audioleaf's improvement. Thank you! +Feel free to open a pull request or start a GitHub issue. Contributions welcome! diff --git a/images/pavucontrol.png b/images/pavucontrol.png deleted file mode 100644 index 6b146ab..0000000 Binary files a/images/pavucontrol.png and /dev/null differ diff --git a/images/tui.png b/images/tui.png deleted file mode 100644 index 17b42f0..0000000 Binary files a/images/tui.png and /dev/null differ diff --git a/src/app.rs b/src/app.rs index f93dde7..ab0a6c3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,26 +1,15 @@ use crate::audio; use crate::constants; -use crate::event_handler::{self, Event}; -use crate::utils; -use crate::visualizer::VisualizerMsg; +use crate::layout_visualizer::PanelInfo; +use crate::visualizer::{self, VisualizerMsg}; use crate::{ - config::{Axis, Effect, Sort, TuiConfig, VisualizerConfig}, - nanoleaf::{NlDevice, NlEffect}, - visualizer, + config::{Axis, Effect, Sort, VisualizerConfig}, + nanoleaf::NlDevice, }; use anyhow::Result; -use ratatui::{ - Frame, Terminal, - crossterm::event::KeyCode, - layout::Margin, - prelude::Backend, - style::{Color, Style, Stylize}, - text::{Line, Span}, - widgets::{ - Block, Borders, HighlightSpacing, List, ListDirection, ListItem, ListState, Paragraph, - Scrollbar, ScrollbarOrientation, ScrollbarState, - }, -}; +use macroquad::prelude::*; +use std::collections::HashMap; +use std::f32::consts::PI; use std::sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, @@ -28,142 +17,57 @@ use std::sync::{ }; use std::time::Duration; -#[derive(Debug, Default)] -enum AppState { - #[default] - RunningEffectList, - RunningVisualizer, - Done, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum AppView { - #[default] - EffectList, - Visualizer, - HelpScreen, -} - -#[derive(Debug)] -enum AppMsg { - NoOp, - Quit, - ChangeView(AppView), - PlayEffect(usize), - ScrollDown(u16), - ScrollUp(u16), - ScrollToBottom, - ScrollToTop, - ChangeGain(f32), - ChangePalette(usize), - CycleEffect, - ResetPanels, - UseAlbumArtPalette, - ToggleAxis, - TogglePrimarySort, - ToggleSecondarySort, -} - /// Display state shared between the main thread and the album art watcher thread. -#[derive(Debug)] struct VizState { - /// The RGB colors currently driving the visualizer palette. colors: Vec<[u8; 3]>, - /// Set when album art mode is active; cleared when switching to a named palette. track_title: Option, + artwork_bytes: Option>, } -#[derive(Debug)] -struct Scroll { - pos: u16, - state: ScrollbarState, -} +pub struct App { + // Device + nl_device: NlDevice, + panels: Vec, + global_orientation: u16, -#[derive(Debug)] -struct EffectList { - list: Vec, - state: ListState, - scroll: Scroll, -} + // Visualizer + visualizer_tx: mpsc::Sender, + shared_colors: Arc>>, -#[derive(Debug)] -struct Visualizer { - tx: mpsc::Sender, + // Settings gain: f32, current_palette_index: usize, palette_names: Vec, - viz_state: Arc>, - album_art_stop: Option>, effect: Effect, primary_axis: Axis, sort_primary: Sort, sort_secondary: Sort, - global_orientation: u16, -} -#[derive(Debug)] -pub struct App { - state: AppState, - prev_view: AppView, - view: AppView, - nl_device: NlDevice, - effect_list: EffectList, - visualizer: Visualizer, - display_colors: bool, + // UI state + show_visualization: bool, + show_help: bool, + + // Album art + viz_state: Arc>, + album_art_stop: Option>, + album_art_texture: Option, + /// Tracks which artwork bytes we've already loaded into the texture + loaded_artwork_len: usize, } impl App { - /// Constructs a new `App` for TUI-based Nanoleaf effect selection and audio visualizer. - /// - /// Initializes: - /// - Effect list from device API, selects current effect if running. - /// - Visualizer with audio stream, UDP, config params, starts processing thread via `init()`. - /// - State to EffectList view, running effects mode. - /// - Palette names from predefined for switching. - /// - Colorful names from TUI config. - /// - Fetches device global orientation for panel sorting. - /// - /// # Arguments + /// Constructs a new `App` with macroquad-based graphical UI. /// - /// * `nl_device` - Connected Nanoleaf device. - /// * `tui_config` - UI settings like colorful effect names. - /// * `visualizer_config` - Audio/viz params like gain, hues, sorting. - /// - /// # Errors - /// - /// From device API, visualizer new/init, or effect list fetch. - pub fn new( - nl_device: NlDevice, - tui_config: TuiConfig, - visualizer_config: VisualizerConfig, - ) -> Result { - let state = AppState::default(); - let prev_view = AppView::default(); - let view = AppView::default(); - let list = nl_device.get_effect_list()?; - let list_pos = if let Some(cur_effect_name) = nl_device.cur_effect_name.as_deref() { - list.iter() - .position(|effect| effect.name == cur_effect_name) - .unwrap_or(0) - } else { - 0 - }; - let scroll = Scroll { - pos: 0, - state: ScrollbarState::default(), - }; - let effect_list = EffectList { - list, - state: ListState::default().with_selected(Some(list_pos)), - scroll, - }; + /// Initializes the audio visualizer thread, fetches panel layout from the device, + /// and prepares all settings state. Requests UDP control immediately. + pub fn new(nl_device: NlDevice, visualizer_config: VisualizerConfig) -> Result { let audio_stream = audio::AudioStream::new(visualizer_config.audio_backend.as_deref())?; let gain = visualizer_config .default_gain .unwrap_or(constants::DEFAULT_GAIN); + #[cfg(debug_assertions)] eprintln!("INFO: Starting with gain: {}", gain); - // Get global orientation let global_orientation = nl_device .get_global_orientation() .ok() @@ -180,299 +84,619 @@ impl App { .clone() .unwrap_or_else(|| Vec::from(constants::DEFAULT_COLORS)); - let tx = visualizer::Visualizer::new(visualizer_config, audio_stream, &nl_device)?.init(); + // Fetch panel layout for graphical rendering + let layout = nl_device.get_panel_layout()?; + let panels = crate::layout_visualizer::parse_layout(&layout)?; + + // Shared color state: visualizer thread writes panel colors, UI reads them + let shared_colors: Arc>> = Arc::new(Mutex::new(HashMap::new())); + + let tx = visualizer::Visualizer::new( + visualizer_config, + audio_stream, + &nl_device, + Arc::clone(&shared_colors), + )? + .init(); - // Initialize palette list let mut palette_names = crate::palettes::get_palette_names(); palette_names.sort(); + let viz_state = Arc::new(Mutex::new(VizState { colors: initial_colors, track_title: None, + artwork_bytes: None, })); - let visualizer = Visualizer { - tx, + nl_device.request_udp_control()?; + + Ok(App { + nl_device, + panels, + global_orientation, + visualizer_tx: tx, + shared_colors, gain, current_palette_index: 0, palette_names, - viz_state, - album_art_stop: None, effect, primary_axis, sort_primary, sort_secondary, - global_orientation, - }; - let display_colors = tui_config - .colorful_effect_names - .unwrap_or(constants::DEFAULT_COLORFUL_EFFECT_NAMES); - - Ok(App { - state, - prev_view, - view, - nl_device, - effect_list, - visualizer, - display_colors, + show_visualization: false, + show_help: false, + viz_state, + album_art_stop: None, + album_art_texture: None, + loaded_artwork_len: 0, }) } - /// Executes the main TUI application loop. - /// - /// Creates event handler for key/tick events. - /// Loop: draw current view, receive event, map to AppMsg, update app state. - /// Breaks when state=Done (quit). - /// On exit, sends End msg to visualizer to stop audio/UDP thread. - /// - /// # Arguments - /// - /// * `self` - Mutable app state. - /// * `terminal` - Ratatui terminal for rendering frames. - /// - /// # Errors - /// - /// From event recv, update logic, or draw. - pub fn run(&mut self, terminal: &mut Terminal) -> Result<()> - where - B::Error: Send + Sync + 'static, - { - let event_handler = event_handler::EventHandler::new(); + /// Launches the macroquad window and runs the main graphical event loop. + /// Blocks until the window is closed. + pub fn run(self) { + let title = format!("Audioleaf - {}", self.nl_device.name); + macroquad::Window::from_config( + Conf { + window_title: title, + window_width: 1200, + window_height: 800, + window_resizable: true, + icon: Some(load_icon()), + ..Default::default() + }, + async move { + let mut app = self; + app.main_loop().await; + }, + ); + } + + async fn main_loop(&mut self) { loop { - terminal.draw(|frame| self.render_view(frame))?; - let event = event_handler.next()?; - let msg = self.event_to_msg(event); - self.update(msg)?; - if let AppState::Done = self.state { + clear_background(Color::from_rgba(20, 20, 30, 255)); + + if self.handle_input() { break; } + + // Check if artwork bytes have changed and reload texture + self.update_album_art_texture(); + + self.draw_panels(); + self.draw_hud(); + + if self.show_help { + self.draw_help_overlay(); + } + + next_frame().await; } - self.visualizer.tx.send(VisualizerMsg::End)?; - self.shutdown() - } - /// Shuts down the device by turning it off and setting brightness to 0. - /// - /// Called after the main TUI loop exits to restore the device to an off state. - pub fn shutdown(&self) -> Result<()> { - self.nl_device.set_state(Some(false), Some(0))?; - Ok(()) + // Shutdown + let _ = self.visualizer_tx.send(VisualizerMsg::End); + self.stop_album_art_watcher(); + let _ = self.nl_device.set_state(Some(false), Some(0)); } - /// Converts raw terminal events to `AppMsg` for state updates. - /// - /// Ignores ticks (NoOp). - /// Maps KeyEvent codes to actions: - /// - ESC/Q: Quit - /// - Enter: Play selected effect in list view - /// - Up/Down/j/k: Scroll list - /// - g/G: Scroll top/bottom - /// - V: Toggle EffectList <-> Visualizer view - /// - ?: Toggle help screen - /// - +/-=_ : Adjust visualizer gain by ±0.05 - /// - 0-9: Switch to numbered palette - /// - a/A: Toggle primary axis X/Y - /// - p/P: Toggle primary sort Asc/Desc - /// - s/S: Toggle secondary sort Asc/Desc - /// - e/E: Cycle visual effect (Spectrum / Energy Wave) - /// - Defaults to NoOp for unhandled. - /// - /// View-specific logic, e.g., scroll only in EffectList. - fn event_to_msg(&self, event: Event) -> AppMsg { - match event { - Event::Tick => AppMsg::NoOp, - Event::Key(e) => match e.code { - KeyCode::Esc | KeyCode::Char('Q') => AppMsg::Quit, - KeyCode::Enter => { - if let AppView::EffectList = self.view { - if let Some(selected) = self.effect_list.state.selected() { - AppMsg::PlayEffect(selected) - } else { - AppMsg::NoOp - } - } else { - AppMsg::NoOp - } - } - KeyCode::Down | KeyCode::Char('j') => { - if let AppView::EffectList = self.view { - AppMsg::ScrollDown(1) - } else { - AppMsg::NoOp - } - } - KeyCode::Up | KeyCode::Char('k') => { - if let AppView::EffectList = self.view { - AppMsg::ScrollUp(1) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('g') => AppMsg::ScrollToTop, - KeyCode::Char('G') => AppMsg::ScrollToBottom, - KeyCode::Char('V') | KeyCode::Char('v') => match self.view { - AppView::HelpScreen => AppMsg::NoOp, - AppView::EffectList => AppMsg::ChangeView(AppView::Visualizer), - AppView::Visualizer => AppMsg::ChangeView(AppView::EffectList), - }, - KeyCode::Char('?') => { - if let AppView::HelpScreen = self.view { - AppMsg::ChangeView(self.prev_view) - } else { - AppMsg::ChangeView(AppView::HelpScreen) - } - } - KeyCode::Char('-') | KeyCode::Char('_') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangeGain(-0.05) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('=') | KeyCode::Char('+') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangeGain(0.05) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('1') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangePalette(0) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('2') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangePalette(1) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('3') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangePalette(2) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('4') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangePalette(3) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('5') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangePalette(4) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('6') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangePalette(5) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('7') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangePalette(6) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('8') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangePalette(7) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('9') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangePalette(8) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('0') => { - if let AppView::Visualizer = self.view { - AppMsg::ChangePalette(9) - } else { - AppMsg::NoOp - } - } - KeyCode::Char('a') | KeyCode::Char('A') => { - if let AppView::Visualizer = self.view { - AppMsg::ToggleAxis - } else { - AppMsg::NoOp - } + /// Process keyboard input. Returns true if quit was requested. + fn handle_input(&mut self) -> bool { + if is_key_pressed(KeyCode::Escape) { + return true; + } + + while let Some(ch) = get_char_pressed() { + match ch { + 'Q' => return true, + '?' => self.show_help = !self.show_help, + _ if self.show_help => {} // Swallow other keys while help is shown + '-' | '_' => { + self.gain -= 0.05; + let _ = self.visualizer_tx.send(VisualizerMsg::SetGain(self.gain)); } - KeyCode::Char('p') | KeyCode::Char('P') => { - if let AppView::Visualizer = self.view { - AppMsg::TogglePrimarySort - } else { - AppMsg::NoOp - } + '=' | '+' => { + self.gain += 0.05; + let _ = self.visualizer_tx.send(VisualizerMsg::SetGain(self.gain)); } - KeyCode::Char('s') | KeyCode::Char('S') => { - if let AppView::Visualizer = self.view { - AppMsg::ToggleSecondarySort - } else { - AppMsg::NoOp - } + '1'..='9' => { + let index = (ch as usize) - ('1' as usize); + self.change_palette(index); } - KeyCode::Char('e') | KeyCode::Char('E') => { - if let AppView::Visualizer = self.view { - AppMsg::CycleEffect - } else { - AppMsg::NoOp - } + '0' => self.change_palette(9), + 'a' | 'A' => self.toggle_axis(), + 'p' | 'P' => self.toggle_primary_sort(), + 's' | 'S' => self.toggle_secondary_sort(), + 'e' | 'E' => self.cycle_effect(), + 'n' | 'N' => self.use_album_art_palette(), + 'r' | 'R' => { + let _ = self.visualizer_tx.send(VisualizerMsg::ResetPanels); } - KeyCode::Char('n') | KeyCode::Char('N') => { - if let AppView::Visualizer = self.view { - AppMsg::UseAlbumArtPalette - } else { - AppMsg::NoOp - } + ' ' => self.show_visualization = !self.show_visualization, + _ => {} + } + } + + false + } + + // ── Panel rendering ────────────────────────────────────────────────── + + fn draw_panels(&self) { + let sw = screen_width(); + let sh = screen_height(); + + // Find the largest panel radius (in layout units) so we can include + // the full extent of edge panels in the bounding box, not just centers. + let max_panel_radius = self + .panels + .iter() + .filter(|p| p.shape_type.side_length >= 1.0) + .map(|p| { + let s = p.shape_type.side_length; + match p.shape_type.num_sides() { + 3 => s / f32::sqrt(3.0), + 4 => s / f32::sqrt(2.0), + _ => s, } - KeyCode::Char('r') | KeyCode::Char('R') => { - if let AppView::Visualizer = self.view { - AppMsg::ResetPanels - } else { - AppMsg::NoOp - } + }) + .fold(0.0_f32, f32::max); + + let min_x = self.panels.iter().map(|p| p.x).min().unwrap_or(0) as f32 - max_panel_radius; + let max_x = self.panels.iter().map(|p| p.x).max().unwrap_or(0) as f32 + max_panel_radius; + let min_y = self.panels.iter().map(|p| p.y).min().unwrap_or(0) as f32 - max_panel_radius; + let max_y = self.panels.iter().map(|p| p.y).max().unwrap_or(0) as f32 + max_panel_radius; + + let layout_width = (max_x - min_x).max(1.0); + let layout_height = (max_y - min_y).max(1.0); + + let padding_top = 40.0; + let padding_bottom = 40.0; + let padding_sides = 40.0; + let available_width = sw - 2.0 * padding_sides; + let available_height = sh - padding_top - padding_bottom; + + let scale = (available_width / layout_width).min(available_height / layout_height); + + let offset_x = (sw - layout_width * scale) / 2.0; + let offset_y = padding_top + (available_height - layout_height * scale) / 2.0; + + // Snapshot visualization colors once per frame + let vis_colors = if self.show_visualization { + self.shared_colors.lock().ok().map(|map| map.clone()) + } else { + None + }; + + // First pass: compute rotated screen positions + let transformed: Vec<(f32, f32)> = self + .panels + .iter() + .map(|panel| { + let rel_x = (panel.x as f32 - min_x) - layout_width / 2.0; + let rel_y = (panel.y as f32 - min_y) - layout_height / 2.0; + let angle = -(self.global_orientation as f32).to_radians(); + let rotated_x = rel_x * angle.cos() - rel_y * angle.sin(); + let rotated_y = rel_x * angle.sin() + rel_y * angle.cos(); + let screen_x = offset_x + (rotated_x + layout_width / 2.0) * scale; + let screen_y = offset_y + (layout_height / 2.0 - rotated_y) * scale; + (screen_x, screen_y) + }) + .collect(); + + // Second pass: draw + for (i, panel) in self.panels.iter().enumerate() { + let (x, y) = transformed[i]; + if panel.shape_type.side_length < 1.0 { + draw_controller(x, y, panel, scale, &self.panels, &transformed); + } else { + self.draw_light_panel(x, y, panel, scale, &vis_colors); + } + } + } + + fn draw_light_panel( + &self, + x: f32, + y: f32, + panel: &PanelInfo, + scale: f32, + vis_colors: &Option>, + ) { + let num_sides = panel.shape_type.num_sides(); + let side_length = panel.shape_type.side_length * scale; + + let radius = match num_sides { + 3 => side_length / f32::sqrt(3.0), + 4 => side_length / f32::sqrt(2.0), + _ => side_length, + }; + + let start_angle = (panel.orientation as f32).to_radians(); + let vertices: Vec = (0..num_sides) + .map(|i| { + let angle = start_angle + (i as f32 * 2.0 * PI / num_sides as f32); + Vec2::new(x + radius * angle.cos(), y + radius * angle.sin()) + }) + .collect(); + + // Determine fill color: live visualization or static shape-type color + let color = if let Some(colors_map) = vis_colors { + if let Some(&[r, g, b]) = colors_map.get(&panel.panel_id) { + Color::from_rgba(r, g, b, 255) + } else { + Color::from_rgba(30, 30, 40, 200) + } + } else { + match panel.shape_type.id { + 0 | 8 | 9 => Color::from_rgba(255, 100, 100, 200), + 2..=4 => Color::from_rgba(100, 255, 100, 200), + 7 | 14 | 15 => Color::from_rgba(100, 150, 255, 200), + 30..=32 => Color::from_rgba(255, 255, 100, 200), + _ => Color::from_rgba(150, 150, 150, 200), + } + }; + + // Fill polygon (triangle fan) + for i in 1..(num_sides - 1) { + draw_triangle(vertices[0], vertices[i], vertices[i + 1], color); + } + + // Outline + let outline = if vis_colors.is_some() { + Color::from_rgba(60, 60, 80, 255) + } else { + WHITE + }; + for i in 0..num_sides { + let next = (i + 1) % num_sides; + draw_line( + vertices[i].x, + vertices[i].y, + vertices[next].x, + vertices[next].y, + 2.0, + outline, + ); + } + } + + // ── Album art texture ───────────────────────────────────────────────── + + fn update_album_art_texture(&mut self) { + let bytes_opt = self + .viz_state + .lock() + .ok() + .and_then(|s| s.artwork_bytes.clone()); + if let Some(bytes) = bytes_opt { + // Only reload if the bytes actually changed + if bytes.len() != self.loaded_artwork_len { + if let Ok(img) = image::load_from_memory(&bytes) { + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width() as u16, rgba.height() as u16); + let tex = Texture2D::from_rgba8(w, h, rgba.as_raw()); + tex.set_filter(FilterMode::Linear); + self.album_art_texture = Some(tex); } - _ => AppMsg::NoOp, - }, + self.loaded_artwork_len = bytes.len(); + } + } else if self.album_art_texture.is_some() { + // Artwork cleared (switched to named palette) + self.album_art_texture = None; + self.loaded_artwork_len = 0; } } - /// Signals the album art watcher thread to stop, if one is running. - fn stop_album_art_watcher(&mut self) { - if let Some(stop) = self.visualizer.album_art_stop.take() { - stop.store(true, Ordering::Relaxed); + // ── HUD ────────────────────────────────────────────────────────────── + + fn draw_hud(&self) { + let sw = screen_width(); + let sh = screen_height(); + + // ── Top-left: device name ── + let name_text = format!("Connected to {}", self.nl_device.name); + let name_size = 28.0; + let nm = sharp_measure(name_size, &name_text); + draw_rectangle( + 5.0, + 2.0, + nm.width + 14.0, + nm.height + 10.0, + Color::from_rgba(0, 0, 0, 160), + ); + sharp_text( + &name_text, + 12.0, + nm.height + 5.0, + name_size, + Color::from_rgba(220, 130, 255, 255), + ); + + // ── Top-right: preview toggle ── + let vis_text = if self.show_visualization { + "Preview: ON [Space]" + } else { + "Preview: OFF [Space]" + }; + let vis_color = if self.show_visualization { + Color::from_rgba(100, 255, 100, 255) + } else { + Color::from_rgba(150, 150, 150, 255) + }; + let vm = sharp_measure(22.0, vis_text); + draw_rectangle( + sw - vm.width - 19.0, + 2.0, + vm.width + 14.0, + vm.height + 10.0, + Color::from_rgba(0, 0, 0, 160), + ); + sharp_text( + vis_text, + sw - vm.width - 12.0, + vm.height + 5.0, + 22.0, + vis_color, + ); + + // ── Bottom-left: effect + palette + colors ── + let effect_str = match self.effect { + Effect::Spectrum => "Spectrum", + Effect::EnergyWave => "Energy Wave", + Effect::Pulse => "Pulse", + }; + let (palette_name, track_title) = { + let state = self.viz_state.lock().unwrap(); + let name = if state.track_title.is_some() { + "album-art".to_string() + } else if self.current_palette_index < self.palette_names.len() { + self.palette_names[self.current_palette_index].clone() + } else { + "Unknown".to_string() + }; + (name, state.track_title.clone()) + }; + + let effect_text = format!("Effect: {} | Gain: {:.2}", effect_str, self.gain); + let mut palette_text = format!("Palette: {}", palette_name); + if let Some(title) = &track_title { + palette_text.push_str(&format!(" | Now playing: {}", title)); } - if let Ok(mut state) = self.visualizer.viz_state.lock() { - state.track_title = None; + + let big = 26.0; + let em = sharp_measure(big, &effect_text); + let pm = sharp_measure(big, &palette_text); + let box_w = em.width.max(pm.width) + 14.0; + + // Color swatches measurement + let colors = self.viz_state.lock().unwrap().colors.clone(); + let swatch_total_w = 22.0 * colors.len() as f32; + let box_w = box_w.max(swatch_total_w + 80.0); + + let box_h = big * 2.0 + 30.0 + 18.0; // two text lines + swatch row + padding + let box_y = sh - box_h - 5.0; + + draw_rectangle(5.0, box_y, box_w, box_h, Color::from_rgba(0, 0, 0, 160)); + + let line1_y = box_y + big + 4.0; + sharp_text(&effect_text, 12.0, line1_y, big, WHITE); + + let line2_y = line1_y + big + 4.0; + sharp_text( + &palette_text, + 12.0, + line2_y, + big, + Color::from_rgba(100, 255, 100, 255), + ); + + // Color swatches + let swatch_y = line2_y + 8.0; + let mut sx = 12.0; + for [r, g, b] in &colors { + draw_rectangle(sx, swatch_y, 18.0, 14.0, Color::from_rgba(*r, *g, *b, 255)); + sx += 22.0; + } + + // ── Bottom-right: album art + sorting ── + let mut art_bottom = 0.0_f32; + if let Some(tex) = &self.album_art_texture { + let art_size = 140.0; + let ax = sw - art_size - 10.0; + let ay = sh - art_size - 35.0; + // Semi-transparent background behind art + draw_rectangle( + ax - 4.0, + ay - 4.0, + art_size + 8.0, + art_size + 8.0, + Color::from_rgba(0, 0, 0, 160), + ); + draw_texture_ex( + tex, + ax, + ay, + WHITE, + DrawTextureParams { + dest_size: Some(Vec2::new(art_size, art_size)), + ..Default::default() + }, + ); + art_bottom = ay + art_size + 4.0; + } + let axis_str = match self.primary_axis { + Axis::X => "X", + Axis::Y => "Y", + }; + let pri_str = match self.sort_primary { + Sort::Asc => "Asc", + Sort::Desc => "Desc", + }; + let sec_str = match self.sort_secondary { + Sort::Asc => "Asc", + Sort::Desc => "Desc", + }; + let sort_text = format!( + "Sort: Axis={} Primary={} Secondary={}", + axis_str, pri_str, sec_str + ); + let sm = sharp_measure(18.0, &sort_text); + let sort_y = if art_bottom > 0.0 { + art_bottom + } else { + sh - sm.height - 15.0 + }; + draw_rectangle( + sw - sm.width - 19.0, + sort_y, + sm.width + 14.0, + sm.height + 10.0, + Color::from_rgba(0, 0, 0, 160), + ); + sharp_text( + &sort_text, + sw - sm.width - 12.0, + sort_y + sm.height + 3.0, + 18.0, + Color::from_rgba(255, 255, 100, 255), + ); + + // ── Bottom-center: controls hint ── + let hint = "? help | ESC quit | Space preview | -/+ gain | 1-0 palette | E effect | N album art | R reset"; + let hm = sharp_measure(14.0, hint); + let hx = (sw - hm.width) / 2.0; + sharp_text( + hint, + hx, + sh - 10.0, + 14.0, + Color::from_rgba(100, 100, 100, 200), + ); + } + + fn draw_help_overlay(&self) { + let sw = screen_width(); + let sh = screen_height(); + + draw_rectangle(0.0, 0.0, sw, sh, Color::from_rgba(0, 0, 0, 200)); + + let x = sw / 2.0 - 280.0; + let mut y = sh / 2.0 - 180.0; + + sharp_text("Keybinds", x, y, 28.0, WHITE); + y += 40.0; + + let binds = [ + ("ESC / Q", "Quit"), + ("?", "Toggle this help"), + ("Space", "Toggle panel visualization preview"), + ("- / +", "Decrease / increase gain"), + ("1-9, 0", "Switch color palette"), + ("E", "Cycle effect: Spectrum / Energy Wave / Pulse"), + ("A", "Toggle primary axis (X / Y)"), + ("P", "Toggle primary sort (Asc / Desc)"), + ("S", "Toggle secondary sort (Asc / Desc)"), + ("N", "Use album art colors from current track"), + ("R", "Reset all panels to black"), + ]; + + for (key, desc) in &binds { + sharp_text(key, x, y, 20.0, Color::from_rgba(255, 255, 100, 255)); + sharp_text(desc, x + 120.0, y, 20.0, WHITE); + y += 26.0; } + + sharp_text( + "(Gain only affects visuals, not your music volume)", + x, + y + 14.0, + 16.0, + GRAY, + ); + } + + // ── Settings changes ───────────────────────────────────────────────── + + fn change_palette(&mut self, index: usize) { + if index < self.palette_names.len() { + let palette_name = &self.palette_names[index]; + if let Some(colors) = crate::palettes::get_palette(palette_name) { + self.current_palette_index = index; + self.stop_album_art_watcher(); + if let Ok(mut state) = self.viz_state.lock() { + state.colors = colors.clone(); + state.track_title = None; + } + let _ = self.visualizer_tx.send(VisualizerMsg::SetPalette(colors)); + } + } + } + + fn toggle_axis(&mut self) { + self.primary_axis = match self.primary_axis { + Axis::X => Axis::Y, + Axis::Y => Axis::X, + }; + self.send_sorting(); + } + + fn toggle_primary_sort(&mut self) { + self.sort_primary = match self.sort_primary { + Sort::Asc => Sort::Desc, + Sort::Desc => Sort::Asc, + }; + self.send_sorting(); + } + + fn toggle_secondary_sort(&mut self) { + self.sort_secondary = match self.sort_secondary { + Sort::Asc => Sort::Desc, + Sort::Desc => Sort::Asc, + }; + self.send_sorting(); + } + + fn send_sorting(&self) { + let _ = self.visualizer_tx.send(VisualizerMsg::SetSorting { + primary_axis: self.primary_axis, + sort_primary: self.sort_primary, + sort_secondary: self.sort_secondary, + global_orientation: self.global_orientation, + }); + } + + fn cycle_effect(&mut self) { + self.effect = match self.effect { + Effect::Spectrum => Effect::EnergyWave, + Effect::EnergyWave => Effect::Pulse, + Effect::Pulse => Effect::Spectrum, + }; + let _ = self + .visualizer_tx + .send(VisualizerMsg::SetEffect(self.effect)); } - /// Spawns a background thread that polls the current track title every 3 seconds. - /// When the title changes it fetches the new album art and sends SetPalette directly - /// to the visualizer thread. Stopped by setting the AtomicBool stop flag. - fn start_album_art_watcher(&mut self) { + /// Fetches album art + palette on a background thread so the render loop + /// never blocks on HTTP downloads or color extraction. + fn use_album_art_palette(&mut self) { self.stop_album_art_watcher(); let stop = Arc::new(AtomicBool::new(false)); - self.visualizer.album_art_stop = Some(Arc::clone(&stop)); - let tx = self.visualizer.tx.clone(); - let viz_state = Arc::clone(&self.visualizer.viz_state); + self.album_art_stop = Some(Arc::clone(&stop)); + let tx = self.visualizer_tx.clone(); + let viz_state = Arc::clone(&self.viz_state); std::thread::spawn(move || { + // Initial fetch + if let Some((artwork, colors)) = crate::now_playing::fetch_artwork_and_palette() { + let title = crate::now_playing::get_track_title(); + if let Ok(mut state) = viz_state.lock() { + state.colors = colors.clone(); + state.track_title = title; + state.artwork_bytes = Some(artwork); + } + let _ = tx.send(VisualizerMsg::SetPalette(colors)); + } + + // Then poll for track changes let mut last_title = crate::now_playing::get_track_title(); loop { std::thread::sleep(Duration::from_secs(3)); @@ -482,10 +706,12 @@ impl App { let title = crate::now_playing::get_track_title(); if title != last_title { last_title = title.clone(); - if let Some(colors) = crate::now_playing::fetch_palette() { + if let Some((artwork, colors)) = crate::now_playing::fetch_artwork_and_palette() + { if let Ok(mut state) = viz_state.lock() { state.colors = colors.clone(); state.track_title = title; + state.artwork_bytes = Some(artwork); } let _ = tx.send(VisualizerMsg::SetPalette(colors)); } @@ -494,375 +720,165 @@ impl App { }); } - /// Applies an `AppMsg` to update application state, views, or external components. - /// - /// Match on msg type: - /// - NoOp/Quit: Idle or set Done state. - /// - Scroll: Adjust effect list selection and scrollbar position. - /// - View change: Switch views, pause/resume visualizer or effects mode, enable UDP if needed. - /// - PlayEffect: Calls device.play_effect by selected name. - /// - ChangeGain/Palette: Send SetGain/SetPalette to visualizer tx. - /// - Toggles (Axis/Sorts): Flip enum, send SetSorting with current params to visualizer. - /// - /// Syncs list state with scrollbar for rendering. - /// Ensures state transitions (e.g., resume viz only if switching to it). - /// - /// # Errors - /// - /// From device API calls or visualizer msg send. - fn update(&mut self, msg: AppMsg) -> Result<()> { - match msg { - AppMsg::NoOp => Ok(()), - AppMsg::Quit => { - self.stop_album_art_watcher(); - self.state = AppState::Done; - Ok(()) - } - AppMsg::ScrollDown(k) => { - self.effect_list.state.scroll_down_by(k); - self.effect_list.scroll.pos = self.effect_list.scroll.pos.saturating_add(k); - self.effect_list.scroll.state = self - .effect_list - .scroll - .state - .position(self.effect_list.scroll.pos as usize); - Ok(()) - } - AppMsg::ScrollUp(k) => { - self.effect_list.state.scroll_up_by(k); - self.effect_list.scroll.pos = self.effect_list.scroll.pos.saturating_sub(k); - self.effect_list.scroll.state = self - .effect_list - .scroll - .state - .position(self.effect_list.scroll.pos as usize); - Ok(()) - } - AppMsg::ScrollToBottom => { - self.effect_list.state.select_last(); - self.effect_list.scroll.pos = self.effect_list.list.len() as u16 - 1; - self.effect_list.scroll.state = self - .effect_list - .scroll - .state - .position(self.effect_list.scroll.pos as usize); - Ok(()) - } - AppMsg::ScrollToTop => { - self.effect_list.state.select_first(); - self.effect_list.scroll.pos = 0; - self.effect_list.scroll.state = self - .effect_list - .scroll - .state - .position(self.effect_list.scroll.pos as usize); - Ok(()) - } - AppMsg::ChangeView(view) => { - self.prev_view = self.view; - self.view = view; - match self.view { - AppView::Visualizer => { - if !matches!(self.state, AppState::RunningVisualizer) { - self.nl_device.request_udp_control()?; - self.visualizer.tx.send(VisualizerMsg::Resume)?; - } - self.state = AppState::RunningVisualizer; - } - AppView::EffectList => { - if !matches!(self.state, AppState::RunningEffectList) { - self.visualizer.tx.send(VisualizerMsg::Pause)?; - } - self.state = AppState::RunningEffectList; - } - AppView::HelpScreen => (), - }; - Ok(()) - } - AppMsg::PlayEffect(i) => { - let effect_name = self.effect_list.list[i].name.as_str(); - self.nl_device.play_effect(effect_name)?; - Ok(()) - } - AppMsg::ChangeGain(delta) => { - self.visualizer.gain += delta; - self.visualizer - .tx - .send(VisualizerMsg::SetGain(self.visualizer.gain))?; - Ok(()) - } - AppMsg::ChangePalette(index) => { - if index < self.visualizer.palette_names.len() { - let palette_name = &self.visualizer.palette_names[index]; - if let Some(colors) = crate::palettes::get_palette(palette_name) { - self.visualizer.current_palette_index = index; - self.stop_album_art_watcher(); - if let Ok(mut state) = self.visualizer.viz_state.lock() { - state.colors = colors.clone(); - state.track_title = None; - } - self.visualizer.tx.send(VisualizerMsg::SetPalette(colors))?; - } - } - Ok(()) - } - AppMsg::UseAlbumArtPalette => { - eprintln!("DEBUG app: UseAlbumArtPalette triggered"); - if let Some(colors) = crate::now_playing::fetch_palette() { - let title = crate::now_playing::get_track_title(); - if let Ok(mut state) = self.visualizer.viz_state.lock() { - state.colors = colors.clone(); - state.track_title = title; - } - self.visualizer.tx.send(VisualizerMsg::SetPalette(colors))?; - self.start_album_art_watcher(); - } - Ok(()) - } - AppMsg::CycleEffect => { - self.visualizer.effect = match self.visualizer.effect { - Effect::Spectrum => Effect::EnergyWave, - Effect::EnergyWave => Effect::Pulse, - Effect::Pulse => Effect::Spectrum, - }; - self.visualizer - .tx - .send(VisualizerMsg::SetEffect(self.visualizer.effect))?; - Ok(()) - } - AppMsg::ResetPanels => { - self.visualizer.tx.send(VisualizerMsg::ResetPanels)?; - Ok(()) - } - AppMsg::ToggleAxis => { - self.visualizer.primary_axis = match self.visualizer.primary_axis { - Axis::X => Axis::Y, - Axis::Y => Axis::X, - }; - self.visualizer.tx.send(VisualizerMsg::SetSorting { - primary_axis: self.visualizer.primary_axis, - sort_primary: self.visualizer.sort_primary, - sort_secondary: self.visualizer.sort_secondary, - global_orientation: self.visualizer.global_orientation, - })?; - Ok(()) - } - AppMsg::TogglePrimarySort => { - self.visualizer.sort_primary = match self.visualizer.sort_primary { - Sort::Asc => Sort::Desc, - Sort::Desc => Sort::Asc, - }; - self.visualizer.tx.send(VisualizerMsg::SetSorting { - primary_axis: self.visualizer.primary_axis, - sort_primary: self.visualizer.sort_primary, - sort_secondary: self.visualizer.sort_secondary, - global_orientation: self.visualizer.global_orientation, - })?; - Ok(()) - } - AppMsg::ToggleSecondarySort => { - self.visualizer.sort_secondary = match self.visualizer.sort_secondary { - Sort::Asc => Sort::Desc, - Sort::Desc => Sort::Asc, - }; - self.visualizer.tx.send(VisualizerMsg::SetSorting { - primary_axis: self.visualizer.primary_axis, - sort_primary: self.visualizer.sort_primary, - sort_secondary: self.visualizer.sort_secondary, - global_orientation: self.visualizer.global_orientation, - })?; - Ok(()) - } + fn stop_album_art_watcher(&mut self) { + if let Some(stop) = self.album_art_stop.take() { + stop.store(true, Ordering::Relaxed); + } + if let Ok(mut state) = self.viz_state.lock() { + state.track_title = None; + state.artwork_bytes = None; } } +} - /// Renders the active view (effect list, visualizer, or help) into the terminal frame. - /// - /// Creates bordered main block with device name in magenta left title, right-aligned "? for help". - /// Dispatches to view-specific rendering: - /// - `EffectList`: Stateful list of NlEffect items, optional colorful char styling via palette, - /// highlight with >> symbol, synced scrollbar with padding. - /// - Other views (Visualizer/HelpScreen): Implementation details for spectrum display or key bindings. - /// - Updates scrollbar state post-render if needed. - fn render_view(&mut self, frame: &mut Frame) { - let main_block = Block::new() - .borders(Borders::ALL) - .title_top( - Line::from(vec![ - "Connected to ".into(), - self.nl_device.name.as_str().magenta(), - ]) - .left_aligned(), - ) - .title_top( - Line::from(vec!["Press ".into(), "?".magenta(), " for help".into()]) - .right_aligned(), - ); - match self.view { - AppView::EffectList => { - frame.render_stateful_widget( - List::new(self.effect_list.list.iter().map(|x| { - let name = x.name.as_str(); - if self.display_colors { - ListItem::new(utils::colorful_effect_name(name, &x.palette)) - } else { - ListItem::new(name) - } - })) - .scroll_padding(2) - .block(main_block) - .highlight_style(Style::new().bold()) - .highlight_symbol(">> ") - .highlight_spacing(HighlightSpacing::Always) - .direction(ListDirection::TopToBottom), - frame.area(), - &mut self.effect_list.state, - ); - self.effect_list.scroll.state = self - .effect_list - .scroll - .state - .content_length(self.effect_list.list.len()); - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .track_symbol(Some("│")) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")), - frame.area().inner(Margin { - vertical: 1, - horizontal: 0, - }), - &mut self.effect_list.scroll.state, - ); +// ── Free-standing controller drawing ───────────────────────────────────── + +fn draw_controller( + x: f32, + y: f32, + _panel: &PanelInfo, + scale: f32, + all_panels: &[PanelInfo], + transformed_positions: &[(f32, f32)], +) { + // Find nearest parent light panel + let mut min_dist = f32::MAX; + let mut nearest_idx = 0; + for (i, other) in all_panels.iter().enumerate() { + if other.shape_type.side_length >= 1.0 { + let (ox, oy) = transformed_positions[i]; + let dist = ((x - ox).powi(2) + (y - oy).powi(2)).sqrt(); + if dist < min_dist { + min_dist = dist; + nearest_idx = i; } - AppView::Visualizer => { - // Snapshot display state from shared VizState (also written by watcher thread). - let (current_palette_name, track_title, palette_colors) = { - let state = self.visualizer.viz_state.lock().unwrap(); - let name = if state.track_title.is_some() { - "album-art".to_string() - } else if self.visualizer.current_palette_index - < self.visualizer.palette_names.len() - { - self.visualizer.palette_names[self.visualizer.current_palette_index].clone() - } else { - "Unknown".to_string() - }; - (name, state.track_title.clone(), state.colors.clone()) - }; - let current_palette = current_palette_name.as_str(); - - let effect_str = match self.visualizer.effect { - Effect::Spectrum => "Spectrum", - Effect::EnergyWave => "Energy Wave", - Effect::Pulse => "Pulse", - }; - - let axis_str = match self.visualizer.primary_axis { - Axis::X => "X", - Axis::Y => "Y", - }; - let primary_str = match self.visualizer.sort_primary { - Sort::Asc => "Asc", - Sort::Desc => "Desc", - }; - let secondary_str = match self.visualizer.sort_secondary { - Sort::Asc => "Asc", - Sort::Desc => "Desc", - }; - - // Build color swatch line: two spaces per color with background set. - let mut swatch_spans: Vec = vec!["Colors: ".into()]; - for [r, g, b] in &palette_colors { - swatch_spans.push(Span::styled( - " ", - Style::default().bg(Color::Rgb(*r, *g, *b)), - )); - swatch_spans.push(" ".into()); - } + } + } - let mut lines = vec![ - Line::from("Music Visualizer".bold().cyan()), - Line::from(""), - Line::from(vec![ - "Amplitude gain: ".into(), - format!("{:.2}", self.visualizer.gain).blue(), - ]), - Line::from(vec!["Current palette: ".into(), current_palette.green()]), - ]; - if let Some(title) = &track_title { - lines.push(Line::from(vec![ - "Now playing: ".into(), - title.as_str().cyan().italic(), - ])); - } - lines.push(Line::from(swatch_spans)); - lines.extend([ - Line::from(vec!["Effect [E]: ".into(), effect_str.magenta()]), - Line::from(""), - Line::from("Panel Sorting:".bold()), - Line::from(vec![ - " Primary Axis [A]: ".into(), - axis_str.yellow(), - " | Primary [P]: ".into(), - primary_str.yellow(), - " | Secondary [S]: ".into(), - secondary_str.yellow(), - ]), - Line::from(""), - Line::from("Available Palettes (press number to switch):".bold()), - ]); - - for (i, palette_name) in self.visualizer.palette_names.iter().enumerate().take(10) { - let key = if i == 9 { - "0".to_string() - } else { - (i + 1).to_string() - }; - let is_current = i == self.visualizer.current_palette_index; - let line = if is_current { - Line::from(vec![ - key.bold().yellow(), - " - ".into(), - palette_name.as_str().green().bold(), - " ◀".green(), - ]) - } else { - Line::from(vec![key.bold(), " - ".into(), palette_name.as_str().into()]) - }; - lines.push(line); - } + let (parent_x, parent_y) = transformed_positions[nearest_idx]; + let parent = &all_panels[nearest_idx]; + let dx = x - parent_x; + let dy = y - parent_y; + let angle_to_ctrl = dy.atan2(dx); + let num_sides = parent.shape_type.num_sides(); + let parent_side = parent.shape_type.side_length * scale; - frame.render_widget( - Paragraph::new(lines).block(main_block).centered(), - frame.area(), - ); - } - AppView::HelpScreen => { - frame.render_widget( - Paragraph::new(vec![ - Line::from("Keybinds:".bold()), - Line::from(vec!["?".bold(), " - toggle help".into()]), - Line::from(vec!["Q/Esc".bold(), " - quit".into()]), - Line::from(vec!["g/G".bold(), " - go to the top/bottom of the list".into()]), - Line::from(vec!["j/Down, k/Up".bold(), " - scroll down and up".into()]), - Line::from(vec!["Enter".bold(), " - play selected effect".into()]), - Line::from(vec!["V/v".bold(), " - toggle music visualizer mode".into()]), - Line::from(vec!["-/+".bold(), " - decrease/increase gain (in visualizer mode)".into()]), - Line::from(vec!["1-9, 0".bold(), " - switch color palette (in visualizer mode)".into()]), - Line::from(vec!["A".bold(), " - toggle primary axis X/Y (in visualizer mode)".into()]), - Line::from(vec!["P".bold(), " - toggle primary sort Asc/Desc (in visualizer mode)".into()]), - Line::from(vec!["S".bold(), " - toggle secondary sort Asc/Desc (in visualizer mode)".into()]), - Line::from(vec!["E".bold(), " - cycle visual effect: Spectrum / Energy Wave / Pulse (in visualizer mode)".into()]), - Line::from(vec!["N".bold(), " - use album art colors from current track (in visualizer mode)".into()]), - Line::from(vec!["R".bold(), " - reset all panels to black (in visualizer mode)".into()]), - Line::from(vec!["(note that gain doesn't affect your music volume, only the visuals are amplified)".italic()]), - ]) - .block(main_block) - .centered(), - frame.area(), - ); - } - }; + let parent_radius = match num_sides { + 3 => parent_side / f32::sqrt(3.0), + 4 => parent_side / f32::sqrt(2.0), + _ => parent_side, + }; + + let parent_ori = (parent.orientation as f32).to_radians(); + let angle_per_side = 2.0 * PI / num_sides as f32; + + let mut closest_edge = 0; + let mut min_angle_diff = f32::MAX; + for i in 0..num_sides { + let va = parent_ori + (i as f32 * angle_per_side); + let raw = (angle_to_ctrl - va).abs() % (2.0 * PI); + let diff = raw.min((2.0 * PI) - raw); + if diff < min_angle_diff { + min_angle_diff = diff; + closest_edge = i; + } + } + + let v1a = parent_ori + (closest_edge as f32 * angle_per_side); + let v2a = parent_ori + ((closest_edge + 1) as f32 * angle_per_side); + let v1x = parent_x + parent_radius * v1a.cos(); + let v1y = parent_y + parent_radius * v1a.sin(); + let v2x = parent_x + parent_radius * v2a.cos(); + let v2y = parent_y + parent_radius * v2a.sin(); + + let trap_h = 20.0; + let mid_x = (v1x + v2x) / 2.0; + let mid_y = (v1y + v2y) / 2.0; + let pdx = mid_x - parent_x; + let pdy = mid_y - parent_y; + let plen = (pdx * pdx + pdy * pdy).sqrt(); + let pnx = pdx / plen; + let pny = pdy / plen; + let nr = 0.6; + + let verts = [ + Vec2::new(v1x, v1y), + Vec2::new(v2x, v2y), + Vec2::new( + v2x + pnx * trap_h - (v2x - mid_x) * (1.0 - nr), + v2y + pny * trap_h - (v2y - mid_y) * (1.0 - nr), + ), + Vec2::new( + v1x + pnx * trap_h - (v1x - mid_x) * (1.0 - nr), + v1y + pny * trap_h - (v1y - mid_y) * (1.0 - nr), + ), + ]; + + let fill = Color::from_rgba(255, 200, 0, 255); + draw_triangle(verts[0], verts[1], verts[2], fill); + draw_triangle(verts[0], verts[2], verts[3], fill); + + let outline = Color::from_rgba(200, 150, 0, 255); + for i in 0..4 { + let next = (i + 1) % 4; + draw_line( + verts[i].x, + verts[i].y, + verts[next].x, + verts[next].y, + 2.0, + outline, + ); + } + + let ts = 10.0; + let td = sharp_measure(ts, "C"); + let lx = (verts[0].x + verts[1].x + verts[2].x + verts[3].x) / 4.0; + let ly = (verts[0].y + verts[1].y + verts[2].y + verts[3].y) / 4.0; + sharp_text("C", lx - td.width / 2.0, ly + ts / 3.0, ts, BLACK); +} + +// ── Sharp text helpers (DPI-aware via camera_font_scale) ───────────────── + +fn sharp_text(text: &str, x: f32, y: f32, logical_size: f32, color: Color) { + let (fs, sx, sy) = camera_font_scale(logical_size); + draw_text_ex( + text, + x, + y, + TextParams { + font_size: fs, + font_scale: sx, + font_scale_aspect: sy / sx, + color, + ..Default::default() + }, + ); +} + +fn sharp_measure(logical_size: f32, text: &str) -> TextDimensions { + let (fs, sx, sy) = camera_font_scale(logical_size); + measure_text(text, None, fs, sx * (sy / sx)) +} + +// ── Window icon ────────────────────────────────────────────────────────── + +fn load_icon() -> miniquad::conf::Icon { + fn decode_rgba(png_bytes: &[u8], size: u32) -> Vec { + let img = image::load_from_memory(png_bytes) + .expect("embedded icon PNG is valid") + .resize_exact(size, size, image::imageops::FilterType::Lanczos3) + .into_rgba8(); + img.into_raw() + } + + let small = decode_rgba(include_bytes!("../Assets/icon_16.png"), 16); + let medium = decode_rgba(include_bytes!("../Assets/icon_32.png"), 32); + let big = decode_rgba(include_bytes!("../Assets/icon_64.png"), 64); + + miniquad::conf::Icon { + small: small.try_into().expect("16x16 RGBA = 1024 bytes"), + medium: medium.try_into().expect("32x32 RGBA = 4096 bytes"), + big: big.try_into().expect("64x64 RGBA = 16384 bytes"), } } diff --git a/src/audio.rs b/src/audio.rs index 92e4fa5..d1e5f76 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -55,6 +55,7 @@ impl AudioStream { if let Ok(name) = device.description().map(|d| d.name().to_string()) && loopback_names.iter().any(|lb| name.contains(lb)) { + #[cfg(debug_assertions)] eprintln!("INFO: Found loopback device: {}", name); loopback_device = Some(device); break; diff --git a/src/config.rs b/src/config.rs index 9ccb250..0e8c89c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,23 +54,6 @@ pub enum DumpType { LayoutGraphical, } -#[derive(Debug, Serialize)] -pub struct TuiConfig { - pub colorful_effect_names: Option, -} - -impl TuiConfig { - /// Returns the default TUI configuration. - /// - /// Sets `colorful_effect_names` to the constant `DEFAULT_COLORFUL_EFFECT_NAMES` (typically false, - /// meaning effect names in the effect list are displayed without per-character coloring based on palette). - pub fn default() -> Self { - TuiConfig { - colorful_effect_names: Some(constants::DEFAULT_COLORFUL_EFFECT_NAMES), - } - } -} - #[derive(Copy, Clone, Debug, Default, Serialize)] pub enum Axis { X, @@ -155,7 +138,6 @@ impl VisualizerConfig { #[derive(Debug, Serialize)] pub struct Config { pub default_nl_device_name: Option, - pub tui_config: TuiConfig, pub visualizer_config: VisualizerConfig, } @@ -167,48 +149,17 @@ impl Config { /// # Arguments /// /// * `default_nl_device_name` - Optional default Nanoleaf device name for quick selection. - /// * `tui_config` - Optional TUI settings; defaults to `TuiConfig::default()`. /// * `visualizer_config` - Optional visualizer params; defaults to `VisualizerConfig::default()`. pub fn new( default_nl_device_name: Option, - tui_config: Option, visualizer_config: Option, ) -> Self { Config { default_nl_device_name, - tui_config: tui_config.unwrap_or(TuiConfig::default()), visualizer_config: visualizer_config.unwrap_or(VisualizerConfig::default()), } } - /// Parses a TOML table into the fields of a mutable `TuiConfig`. - /// - /// Supports key "colorful_effect_names" as boolean value. - /// Ignores unknown keys? No, bails with error on invalid keys. - /// Updates `tui_config` in place. - /// - /// # Arguments - /// - /// * `tui_config` - Mutable reference to populate. - /// * `t` - TOML table from config section. - /// - /// # Errors - /// - /// `anyhow::Error` for invalid key types or unknown keys. - pub fn parse_tui_config(tui_config: &mut TuiConfig, t: toml::Table) -> Result<()> { - for (key, val) in t { - match (key.as_str(), val) { - ("colorful_effect_names", Value::Boolean(b)) => { - tui_config.colorful_effect_names = Some(b); - } - (key, _) => { - bail!(format!("invalid key `{}`", key)); - } - } - } - Ok(()) - } - /// Parses a TOML table into the fields of a mutable `VisualizerConfig`. /// /// Supports comprehensive field validation and type conversion: @@ -316,10 +267,12 @@ impl Config { } } ("default_gain", Value::Float(x)) => { + #[cfg(debug_assertions)] eprintln!("DEBUG: Parsed default_gain as Float: {}", x); visualizer_config.default_gain = Some(x as f32); } ("default_gain", Value::Integer(x)) => { + #[cfg(debug_assertions)] eprintln!("DEBUG: Parsed default_gain as Integer: {}", x); visualizer_config.default_gain = Some(x as f32); } @@ -413,24 +366,24 @@ impl Config { /// /// File I/O errors, TOML deserialization failures, or validation bails. pub fn parse_from_file(path: &Path) -> Result { + #[cfg(debug_assertions)] eprintln!("DEBUG: Reading config from: {}", path.display()); let mut config_file = File::open(path)?; let mut contents = String::new(); config_file.read_to_string(&mut contents)?; + #[cfg(debug_assertions)] eprintln!("DEBUG: Config file contents:\n{}", contents); let data = contents.parse::()?; let mut default_nl_device_name = None; - let mut tui_config = TuiConfig::default(); let mut visualizer_config = VisualizerConfig::default(); for (key, val) in data { match (key.as_str(), val) { ("default_nl_device_name", Value::String(s)) => { default_nl_device_name = Some(s); } - ("tui_config", Value::Table(t)) => { - Self::parse_tui_config(&mut tui_config, t)?; - } + // Silently ignore legacy tui_config section for backwards compatibility + ("tui_config", Value::Table(_)) => {} ("visualizer_config", Value::Table(t)) => { Self::parse_visualizer_config(&mut visualizer_config, t)?; } @@ -439,11 +392,7 @@ impl Config { } } } - Ok(Config::new( - default_nl_device_name, - Some(tui_config), - Some(visualizer_config), - )) + Ok(Config::new(default_nl_device_name, Some(visualizer_config))) } /// Serializes and writes the configuration to a TOML file at the given path. diff --git a/src/constants.rs b/src/constants.rs index f0e4fb6..5ce3069 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,6 +1,3 @@ -// tui config -pub const DEFAULT_COLORFUL_EFFECT_NAMES: bool = false; - // visualizer config pub const DEFAULT_AUDIO_BACKEND: &str = "default"; pub const DEFAULT_FREQ_RANGE: (u16, u16) = (20, 4500); @@ -31,4 +28,3 @@ pub const DEFAULT_DEVICES_FILE: &str = "nl_devices.toml"; pub const DEFAULT_BACKTRACE_FILE: &str = "audioleaf_backtrace.log"; pub const NL_API_PORT: u16 = 16021; pub const NL_UDP_PORT: u16 = 60222; -pub const TICKRATE: u64 = 32; diff --git a/src/event_handler.rs b/src/event_handler.rs deleted file mode 100644 index f6eb1ae..0000000 --- a/src/event_handler.rs +++ /dev/null @@ -1,58 +0,0 @@ -use crate::{constants, panic}; -use anyhow::Result; -use ratatui::crossterm::event; -use std::{sync::mpsc, thread, time::Duration}; - -pub enum Event { - Key(event::KeyEvent), - Tick, -} - -pub struct EventHandler { - rx: mpsc::Receiver, -} - -impl EventHandler { - /// Creates a new `EventHandler` that polls for terminal events and ticks. - /// - /// Spawns a background thread to: - /// - Poll for key events (only processes KeyEventKind::Press to avoid repeats). - /// - Send `Event::Key` or `Event::Tick` via mpsc channel at `constants::TICKRATE` ms intervals. - /// - Registers panic handler in the thread for crash logging. - /// - /// # Returns - /// - /// `EventHandler` with receiver channel for events. - pub fn new() -> Self { - let tickrate = Duration::from_millis(constants::TICKRATE); - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - panic::register_backtrace_panic_handler(); - loop { - if event::poll(tickrate).expect("event poll failed") { - let event = event::read().expect("event read failed"); - if let event::Event::Key(key_event) = event { - // Only process Press events to avoid duplicate triggers - if key_event.kind == event::KeyEventKind::Press { - tx.send(Event::Key(key_event)).expect("event send failed"); - } - } - } - tx.send(Event::Tick).expect("tick send failed"); - } - }); - - EventHandler { rx } - } - - /// Blocks and receives the next event from the event handler channel. - /// - /// Used in the main loop to process keyboard input or ticks for UI updates. - /// - /// # Returns - /// - /// `Result` - The received event, or error if channel recv fails (e.g., sender dropped). - pub fn next(&self) -> Result { - Ok(self.rx.recv()?) - } -} diff --git a/src/main.rs b/src/main.rs index 365ec7a..a0df67d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,6 @@ mod app; mod audio; mod config; mod constants; -mod event_handler; mod graphical_layout; mod layout_visualizer; mod nanoleaf; @@ -50,7 +49,7 @@ async fn main_async() -> Result<()> { } = cli_options; let ((config_file_path, config_file_exists), (devices_file_path, devices_file_exists)) = config::resolve_paths(config_file_path, devices_file_path)?; - let (nl_device, tui_config, visualizer_config) = if !add_new && devices_file_exists { + let (nl_device, visualizer_config) = if !add_new && devices_file_exists { if config_file_exists { let config = config::Config::parse_from_file(&config_file_path)?; let name_to_search = if device_name.is_some() { @@ -60,13 +59,13 @@ async fn main_async() -> Result<()> { }; let nl_device = nanoleaf::NlDevice::find_in_file(&devices_file_path, name_to_search.as_deref())?; - (nl_device, config.tui_config, config.visualizer_config) + (nl_device, config.visualizer_config) } else { let nl_device = nanoleaf::NlDevice::find_in_file(&devices_file_path, device_name.as_deref())?; - let config = config::Config::new(Some(nl_device.name.clone()), None, None); + let config = config::Config::new(Some(nl_device.name.clone()), None); config.write_to_file(&config_file_path)?; - (nl_device, config.tui_config, config.visualizer_config) + (nl_device, config.visualizer_config) } } else { let ip = config::get_ip()?; @@ -77,19 +76,17 @@ async fn main_async() -> Result<()> { config.default_nl_device_name = Some(nl_device.name.clone()); config } else { - config::Config::new(Some(nl_device.name.clone()), None, None) + config::Config::new(Some(nl_device.name.clone()), None) }; config.write_to_file(&config_file_path)?; - (nl_device, config.tui_config, config.visualizer_config) + (nl_device, config.visualizer_config) }; // Ensure device is powered on and has brightness set nl_device.ensure_device_ready()?; - let mut app = app::App::new(nl_device, tui_config, visualizer_config)?; - let mut terminal = utils::init_tui()?; - app.run(&mut terminal)?; - utils::destroy_tui()?; + let app = app::App::new(nl_device, visualizer_config)?; + app.run(); Ok(()) } diff --git a/src/nanoleaf.rs b/src/nanoleaf.rs index 37b5280..f05010d 100644 --- a/src/nanoleaf.rs +++ b/src/nanoleaf.rs @@ -3,7 +3,7 @@ use crate::{ constants, utils, }; use anyhow::{Result, bail}; -use palette::{FromColor, Hsv, Oklch, Srgb}; +use palette::{FromColor, Oklch, Srgb}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::{ @@ -13,20 +13,11 @@ use std::{ path::Path, }; -#[derive(Debug)] -pub struct NlEffect { - pub name: String, - pub palette: Vec>, -} - #[derive(Clone, Debug, Serialize, Deserialize)] pub struct NlDevice { pub name: String, pub ip: Ipv4Addr, pub token: String, - #[serde(skip)] - #[serde(default)] - pub cur_effect_name: Option, } /// wrapper struct for TOML serialization @@ -58,7 +49,7 @@ impl NlDevice { /// /// # Returns /// - /// `Result` with name, ip, token, optional cur_effect_name. + /// `Result` with name, ip, and token. /// /// # Errors /// @@ -66,13 +57,7 @@ impl NlDevice { pub fn new(ip: Ipv4Addr) -> Result { let token = Self::get_token(&ip)?; let name = Self::get_name(&ip, &token)?; - let cur_effect_name = Self::get_cur_effect_name(&ip, &token)?; - Ok(NlDevice { - name, - ip, - token, - cur_effect_name, - }) + Ok(NlDevice { name, ip, token }) } fn get_token(ip: &Ipv4Addr) -> Result { @@ -101,27 +86,6 @@ impl NlDevice { Ok(String::from(res_json["name"].as_str().unwrap())) } - pub fn get_cur_effect_name(ip: &Ipv4Addr, token: &str) -> Result> { - let Ok(res) = utils::request_get(&format!( - "http://{}:{}/api/v1/{}/effects/select", - ip, - constants::NL_API_PORT, - token - )) else { - bail!(utils::generate_connection_error_msg(ip)); - }; - let res_text: String = serde_json::from_str(&res)?; - if res_text == "*Solid*" - || res_text == "*Dynamic*" - || res_text == "*Static*" - || res_text == "*ExtControl*" - { - Ok(None) - } else { - Ok(Some(res_text)) - } - } - /// Retrieves the panel layout configuration from the device API. /// /// GET /api/v1/{token}/panelLayout/layout returns JSON with "positionData" array of panel positions/shapes. @@ -263,75 +227,6 @@ impl NlDevice { Ok(panels) } - pub fn get_effect_list(&self) -> Result> { - let Ok(res) = utils::request_get(&format!( - "http://{}:{}/api/v1/{}/effects/effectsList", - self.ip, - constants::NL_API_PORT, - self.token - )) else { - bail!(utils::generate_connection_error_msg(&self.ip)); - }; - let res_list: Vec = serde_json::from_str(&res)?; - let mut palettes = Vec::with_capacity(res_list.len()); - for effect_name in res_list.iter() { - let data = json!({ - "write": { - "command": "request", - "animName": effect_name, - } - }); - let Ok(res) = utils::request_put( - &format!( - "http://{}:{}/api/v1/{}/effects/effectsList", - self.ip, - constants::NL_API_PORT, - self.token - ), - Some(&data), - ) else { - bail!(utils::generate_connection_error_msg(&self.ip)); - }; - let res_json: serde_json::Value = serde_json::from_str(&res)?; - let palette_json = res_json["palette"].as_array().unwrap(); - let mut palette: Vec> = Vec::new(); - for color_json in palette_json.iter() { - let h = color_json["hue"].as_u64().unwrap() as f32; - let s = (color_json["saturation"].as_u64().unwrap() as f32) / 100.0; - let b = (color_json["brightness"].as_u64().unwrap() as f32) / 100.0; - palette.push(Srgb::from_color(Hsv::new_srgb(h, s, b)).into_format::()); - } - palettes.push(palette); - } - - Ok(res_list - .into_iter() - .zip(palettes) - .map(|x| NlEffect { - name: x.0, - palette: x.1, - }) - .collect::>()) - } - - pub fn play_effect(&self, effect_name: &str) -> Result<()> { - let data = json!({ - "select": effect_name - }); - let Ok(_) = utils::request_put( - &format!( - "http://{}:{}/api/v1/{}/effects", - self.ip, - constants::NL_API_PORT, - self.token - ), - Some(&data), - ) else { - bail!(utils::generate_connection_error_msg(&self.ip)); - }; - Ok(()) - } - pub fn get_udp_socket(&self) -> Result { let socket = UdpSocket::bind("0.0.0.0:0")?; socket.connect(SocketAddrV4::new(self.ip, constants::NL_UDP_PORT))?; diff --git a/src/now_playing.rs b/src/now_playing.rs index d17ca34..0f05086 100644 --- a/src/now_playing.rs +++ b/src/now_playing.rs @@ -2,17 +2,25 @@ //! //! macOS: Uses ScriptingBridge.framework (via objc2) to query running media //! players directly through the ObjC bridge — no subprocess spawning. -//! - Spotify: title, artist, artwork URL (downloaded via reqwest). -//! - Apple Music: title, artist, raw artwork bytes from MusicArtwork.rawData. -//! - Falls back to osascript if ScriptingBridge fails. +//! - Spotify: title, artwork URL (downloaded via reqwest). +//! - Apple Music: title, raw artwork bytes from MusicArtwork.rawData. //! //! Linux: Uses `playerctl` subprocess. +/// Debug-only logging (stripped from release builds). +#[allow(unused_macros)] +macro_rules! debug_log { + ($($arg:tt)*) => { + #[cfg(debug_assertions)] + eprintln!($($arg)*); + }; +} + /// Returns the title of the currently playing track. pub fn get_track_title() -> Option { #[cfg(target_os = "macos")] { - macos::get_track_info().map(|info| info.title) + macos::get_track_title() } #[cfg(target_os = "linux")] { @@ -24,15 +32,20 @@ pub fn get_track_title() -> Option { } } -/// Returns dominant RGB colors extracted from the current track's album artwork. -pub fn fetch_palette() -> Option> { +/// Fetches artwork bytes once and returns both the raw image and the extracted palette. +/// Avoids double-fetch race conditions where the track could change between calls. +pub fn fetch_artwork_and_palette() -> Option<(Vec, Vec<[u8; 3]>)> { #[cfg(target_os = "macos")] { - macos::fetch_palette() + let bytes = macos::fetch_artwork_bytes()?; + let colors = macos::extract_colors(&bytes)?; + Some((bytes, colors)) } #[cfg(target_os = "linux")] { - linux::fetch_palette() + let bytes = linux::fetch_artwork_bytes()?; + let colors = linux::extract_colors_from_bytes(&bytes)?; + Some((bytes, colors)) } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { @@ -40,42 +53,31 @@ pub fn fetch_palette() -> Option> { } } -// ── macOS — ScriptingBridge + osascript fallback ────────────────────────────── +// ── macOS — ScriptingBridge ────────────────────────────────────────────────── #[cfg(target_os = "macos")] mod macos { use objc2::msg_send; use objc2::rc::Retained; use objc2::runtime::{AnyClass, AnyObject}; - use objc2_foundation::{NSData, NSString}; + use objc2_foundation::NSString; #[link(name = "ScriptingBridge", kind = "framework")] unsafe extern "C" {} - // Four-char code for "playing" state (shared by Spotify and Apple Music). - const EPLAYER_PLAYING: u32 = 0x6b505350; // 'kPSP' - - pub struct TrackInfo { - pub title: String, - } - - pub fn get_track_info() -> Option { - sb_spotify_title() - .or_else(sb_apple_music_title) - .or_else(osascript_title) - .map(|title| TrackInfo { title }) + pub fn get_track_title() -> Option { + sb_spotify_title().or_else(sb_apple_music_title) } - pub fn fetch_palette() -> Option> { - let bytes = sb_spotify_artwork() - .or_else(sb_apple_music_artwork) - .or_else(osascript_artwork)?; - extract_colors(&bytes) + pub fn fetch_artwork_bytes() -> Option> { + sb_spotify_artwork().or_else(sb_apple_music_artwork) } // ── ScriptingBridge helpers ─────────────────────────────────────────────── - /// Open a scripting bridge to a running application. Returns None if not running. + /// Open a scripting bridge to a running application. + /// Returns the app AND whether it is actively playing. + /// Separated from player-state check so callers can decide if they need playing state. fn sb_app(bundle_id: &str) -> Option> { unsafe { let cls = AnyClass::get(c"SBApplication")?; @@ -85,36 +87,53 @@ mod macos { let app = app?; let running: bool = msg_send![&*app, isRunning]; if !running { - return None; - } - let state: u32 = msg_send![&*app, playerState]; - if state != EPLAYER_PLAYING { + debug_log!("DEBUG now_playing: {} not running", bundle_id); return None; } Some(app) } } + /// Check if an app's player state is "playing" (four-char code 'kPSP'). + fn is_playing(app: &AnyObject) -> bool { + unsafe { + let state: u32 = msg_send![app, playerState]; + debug_log!("DEBUG now_playing: playerState = 0x{:08X}", state); + // 'kPSP' = playing + state == 0x6b505350 + } + } + // ── Spotify via ScriptingBridge ─────────────────────────────────────────── fn sb_spotify_title() -> Option { let app = sb_app("com.spotify.client")?; + if !is_playing(&app) { + return None; + } unsafe { let track: Option> = msg_send![&*app, currentTrack]; let track = track?; let name: Option> = msg_send![&*track, name]; - name.map(|s| s.to_string()) + let result = name.map(|s| s.to_string()); + debug_log!("DEBUG now_playing: Spotify title = {:?}", result); + result } } fn sb_spotify_artwork() -> Option> { let app = sb_app("com.spotify.client")?; + if !is_playing(&app) { + return None; + } unsafe { let track: Option> = msg_send![&*app, currentTrack]; let track = track?; let url: Option> = msg_send![&*track, artworkUrl]; let url = url?; - reqwest::blocking::get(&*url.to_string()) + let url_str = url.to_string(); + debug_log!("DEBUG now_playing: Spotify artwork URL = {}", url_str); + reqwest::blocking::get(&url_str) .ok()? .bytes() .ok() @@ -126,119 +145,129 @@ mod macos { fn sb_apple_music_title() -> Option { let app = sb_app("com.apple.Music")?; + if !is_playing(&app) { + return None; + } unsafe { let track: Option> = msg_send![&*app, currentTrack]; + eprintln!( + "DEBUG now_playing: Apple Music currentTrack = {:?}", + track.is_some() + ); let track = track?; let name: Option> = msg_send![&*track, name]; - name.map(|s| s.to_string()) + let result = name.map(|s| s.to_string()); + debug_log!("DEBUG now_playing: Apple Music title = {:?}", result); + result } } fn sb_apple_music_artwork() -> Option> { let app = sb_app("com.apple.Music")?; + if !is_playing(&app) { + return None; + } unsafe { let track: Option> = msg_send![&*app, currentTrack]; let track = track?; - let artworks: Option> = msg_send![&*track, artworks]; - let artworks = artworks?; - let count: usize = msg_send![&*artworks, count]; - if count == 0 { - return None; - } - let artwork: Option> = msg_send![&*artworks, objectAtIndex: 0usize]; - let artwork = artwork?; - let raw: Option> = msg_send![&*artwork, rawData]; - let raw = raw?; - // rawData returns NSData with JPEG/PNG image bytes. - raw.downcast_ref::().map(|d| d.to_vec()) - } - } - // ── osascript fallback ─────────────────────────────────────────────────── + // Get the artworks SBElementArray from the track. + let artworks: *mut AnyObject = msg_send![&*track, artworks]; + eprintln!( + "DEBUG now_playing: artworks ptr null = {}", + artworks.is_null() + ); + if !artworks.is_null() { + // Don't trust count — go straight to objectAtIndex:0. + // SBElementArray sends a targeted Apple Event for the specific + // element, which can succeed even when count reports 0. + let artwork: *mut AnyObject = msg_send![artworks, objectAtIndex: 0usize]; + eprintln!( + "DEBUG now_playing: artwork[0] ptr null = {}", + artwork.is_null() + ); + if !artwork.is_null() { + // Properties on SBObject return lazy proxies — call `get` + // to force the Apple Event and materialize the real object. - fn osascript_title() -> Option { - for script in [ - r#"tell application "System Events" - if not (exists process "Spotify") then return "NOT_RUNNING" - end tell - tell application "Spotify" - if player state is not playing then return "NOT_PLAYING" - return name of current track - end tell"#, - r#"tell application "System Events" - if not (exists process "Music") then return "NOT_RUNNING" - end tell - tell application "Music" - if player state is not playing then return "NOT_PLAYING" - return name of current track - end tell"#, - ] { - if let Some(title) = run_osascript(script) { - return Some(title); - } - } - None - } + // Try rawData first + let raw_proxy: *mut AnyObject = msg_send![artwork, rawData]; + if !raw_proxy.is_null() { + let raw: *mut AnyObject = msg_send![raw_proxy, get]; + debug_log!("DEBUG now_playing: rawData.get null = {}", raw.is_null()); + if !raw.is_null() { + let len: usize = msg_send![raw, length]; + debug_log!("DEBUG now_playing: rawData length = {}", len); + if len > 0 { + let ptr: *const u8 = msg_send![raw, bytes]; + if !ptr.is_null() { + let bytes = std::slice::from_raw_parts(ptr, len).to_vec(); + eprintln!( + "DEBUG now_playing: rawData artwork {} bytes", + bytes.len() + ); + return Some(bytes); + } + } + } + } - fn osascript_artwork() -> Option> { - // Spotify: artwork URL. - let script = r#"tell application "System Events" - if not (exists process "Spotify") then return "NOT_RUNNING" - end tell - tell application "Spotify" - if player state is not playing then return "NOT_PLAYING" - return artwork url of current track - end tell"#; - if let Some(url) = run_osascript(script) - && let Some(bytes) = reqwest::blocking::get(&url) - .ok() - .and_then(|r| r.bytes().ok()) - .map(|b| b.to_vec()) - { - return Some(bytes); - } - // Apple Music: use iTunes Search API. - let script = r#"tell application "System Events" - if not (exists process "Music") then return "NOT_RUNNING" - end tell - tell application "Music" - if player state is not playing then return "NOT_PLAYING" - return (name of current track) & " " & (artist of current track) - end tell"#; - if let Some(query) = run_osascript(script) - && let Some(url) = itunes_artwork_url(&query) - { - return reqwest::blocking::get(&url) - .ok() - .and_then(|r| r.bytes().ok()) - .map(|b| b.to_vec()); - } - None - } + // Try data property (MusicPicture) + let data_proxy: *mut AnyObject = msg_send![artwork, data]; + if !data_proxy.is_null() { + let data: *mut AnyObject = msg_send![data_proxy, get]; + debug_log!("DEBUG now_playing: data.get null = {}", data.is_null()); + if !data.is_null() { + let len: usize = msg_send![data, length]; + debug_log!("DEBUG now_playing: data length = {}", len); + if len > 0 { + let ptr: *const u8 = msg_send![data, bytes]; + if !ptr.is_null() { + let bytes = std::slice::from_raw_parts(ptr, len).to_vec(); + eprintln!( + "DEBUG now_playing: data artwork {} bytes", + bytes.len() + ); + return Some(bytes); + } + } + } + } + } + } - fn run_osascript(script: &str) -> Option { - let output = std::process::Command::new("osascript") - .args(["-e", script]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if text.is_empty() || text == "NOT_RUNNING" || text == "NOT_PLAYING" { - return None; + // iTunes Search API using artist + album. + debug_log!("DEBUG now_playing: falling back to iTunes Search API"); + let name: Option> = msg_send![&*track, name]; + let artist: Option> = msg_send![&*track, artist]; + let album: Option> = msg_send![&*track, album]; + let query = match (&artist, &album, &name) { + (Some(a), Some(al), _) => format!("{} {}", a, al), + (Some(a), None, Some(n)) => format!("{} {}", a, n), + (_, _, Some(n)) => n.to_string(), + _ => return None, + }; + debug_log!("DEBUG now_playing: iTunes Search API query = {:?}", query); + itunes_search_artwork(&query) } - Some(text) } - fn itunes_artwork_url(query: &str) -> Option { + /// Look up album artwork via the public iTunes Search API. + fn itunes_search_artwork(query: &str) -> Option> { let url = format!( "https://itunes.apple.com/search?term={}&media=music&limit=1", urlencoded(query) ); let resp: serde_json::Value = reqwest::blocking::get(&url).ok()?.json().ok()?; - let art = resp["results"][0]["artworkUrl100"].as_str()?; - Some(art.replace("100x100", "600x600")) + let art_url = resp["results"][0]["artworkUrl100"].as_str()?; + // Request a larger image (600x600 instead of 100x100) + let art_url = art_url.replace("100x100", "600x600"); + debug_log!("DEBUG now_playing: iTunes artwork URL = {}", art_url); + reqwest::blocking::get(&art_url) + .ok()? + .bytes() + .ok() + .map(|b| b.to_vec()) } fn urlencoded(s: &str) -> String { @@ -258,16 +287,13 @@ mod macos { out } - fn extract_colors(image_bytes: &[u8]) -> Option> { + pub fn extract_colors(image_bytes: &[u8]) -> Option> { use auto_palette::{ImageData, Palette}; let img = image::load_from_memory(image_bytes).ok()?; let rgba = img.to_rgba8(); let image_data = ImageData::new(rgba.width(), rgba.height(), rgba.as_raw()).ok()?; let palette: Palette = Palette::extract(&image_data).ok()?; - // Sort by population (most prevalent colors first) and take the - // top 4 that aren't near-black — these are the actual dominant - // colors of the album art, not theme-scored "interesting" picks. let mut swatches = palette.swatches().to_vec(); swatches.sort_by_key(|s| std::cmp::Reverse(s.population())); let colors: Vec<[u8; 3]> = swatches @@ -305,7 +331,7 @@ mod linux { None } - pub fn fetch_palette() -> Option> { + pub fn fetch_artwork_bytes() -> Option> { let output = std::process::Command::new("playerctl") .args(["metadata", "mpris:artUrl"]) .output() @@ -317,16 +343,21 @@ mod linux { if url.is_empty() { return None; } - - let bytes: Vec = if url.starts_with("file://") { - std::fs::read(url.trim_start_matches("file://")).ok()? + if url.starts_with("file://") { + std::fs::read(url.trim_start_matches("file://")).ok() } else { - reqwest::blocking::get(&url).ok()?.bytes().ok()?.to_vec() - }; + reqwest::blocking::get(&url) + .ok()? + .bytes() + .ok() + .map(|b| b.to_vec()) + } + } + pub fn extract_colors_from_bytes(bytes: &[u8]) -> Option> { use auto_palette::{ImageData, Palette}; - let img = image::load_from_memory(&bytes).ok()?; + let img = image::load_from_memory(bytes).ok()?; let rgba = img.to_rgba8(); let image_data = ImageData::new(rgba.width(), rgba.height(), rgba.as_raw()).ok()?; let palette: Palette = Palette::extract(&image_data).ok()?; diff --git a/src/panic.rs b/src/panic.rs index 4da3f2c..2ac6f66 100644 --- a/src/panic.rs +++ b/src/panic.rs @@ -1,26 +1,18 @@ use crate::constants; -use ratatui::crossterm::{execute, terminal}; -use std::{ - backtrace, fs, - io::{Write, stdout}, -}; +use std::{backtrace, fs, io::Write}; /// Registers a custom panic hook to handle application crashes gracefully. /// -/// Disables raw mode and leaves alternate screen before printing crash message. /// Captures and saves backtrace to cache/audioleaf_backtrace.log if possible. -/// Ensures TUI state is restored on panic in threads like event handler. pub fn register_backtrace_panic_handler() { std::panic::set_hook(Box::new(|panic_info| { - let _ = terminal::disable_raw_mode(); - let _ = execute!(stdout(), terminal::LeaveAlternateScreen); - println!("Audioleaf crashed unexpectedly!"); + eprintln!("Audioleaf crashed unexpectedly!"); if let Some(path) = dirs::cache_dir() { let path = path.join(constants::DEFAULT_BACKTRACE_FILE); if let Ok(mut file) = fs::File::create(&path) { writeln!(file, "{}", backtrace::Backtrace::force_capture()).unwrap_or_default(); writeln!(file, "{}", panic_info).unwrap_or_default(); - println!("The backtrace has been saved to {}", path.to_string_lossy()); + eprintln!("The backtrace has been saved to {}", path.to_string_lossy()); } } })); diff --git a/src/utils.rs b/src/utils.rs index decb73e..ce31768 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,20 +1,9 @@ use anyhow::{Result, bail}; use palette::{IntoColor, Oklch, Srgb}; -use ratatui::{ - Terminal, - backend::CrosstermBackend, - crossterm::{ - event::{self, Event}, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, - }, - style::{Color, Stylize}, - text::Line, -}; use reqwest::blocking::Client; use std::{ fmt::Display, - io::{self, Stdout, Write, stdout}, + io::{self, Write}, net::Ipv4Addr, }; @@ -97,55 +86,12 @@ pub fn get_ip_from_stdin() -> Result> { } } -/// Pauses execution until any key is pressed, with TUI-friendly handling. +/// Pauses execution until the user presses Enter. /// -/// Temporarily enables raw mode for crossterm event polling. -/// Loops reading events until Key event received (any key). -/// Disables raw mode, prints newline, used for user confirmation prompts (e.g., pairing mode). +/// Used for user confirmation prompts (e.g., pairing mode). pub fn wait_for_any_key() -> Result<()> { - // Enable raw mode temporarily to detect key presses - enable_raw_mode()?; - - // Wait for any key press using crossterm event handling - loop { - if let Event::Key(_) = event::read()? { - break; - } - } - - // Disable raw mode and return to normal terminal - disable_raw_mode()?; - println!(); // Add newline after key press - Ok(()) -} - -/// Initializes the terminal user interface (TUI) environment. -/// -/// Enables raw mode for input handling, enters alternate screen buffer, -/// creates a new ratatui Terminal with CrosstermBackend on stdout. -/// -/// Called before running the app TUI loop. -/// -/// # Returns -/// -/// `Result>>`. -pub fn init_tui() -> Result>> { - enable_raw_mode()?; - execute!(stdout(), EnterAlternateScreen)?; - Terminal::new(CrosstermBackend::new(stdout())).map_err(anyhow::Error::from) -} - -/// Cleans up the TUI environment after app exit. -/// -/// Disables raw mode and leaves alternate screen to restore normal terminal state. -/// Called after app run to prevent hanging or corrupted display. -/// -/// # Errors -/// -/// Propagates crossterm execution errors. -pub fn destroy_tui() -> Result<(), anyhow::Error> { - disable_raw_mode()?; - execute!(stdout(), LeaveAlternateScreen)?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; Ok(()) } @@ -296,21 +242,3 @@ pub fn equalize(a: f32, f: u32) -> f32 { pub fn split_into_bytes(x: u16) -> (u8, u8) { ((x / 256) as u8, (x % 256) as u8) } - -/// Creates a ratatui `Line` with effect name characters styled in cycling colors from a palette. -/// -/// Splits string into individual chars, applies foreground color from `colors` array cycling by index. -/// Used when `config.tui_config.colorful_effect_names` is true to highlight effect list items. -pub fn colorful_effect_name<'a>(effect_name: &'a str, colors: &'a [Srgb]) -> Line<'a> { - let chars = effect_name.chars().map(|c| c.to_string()); - Line::from( - chars - .into_iter() - .enumerate() - .map(|(i, c)| { - let color = colors[i % colors.len()]; - c.fg(Color::Rgb(color.red, color.green, color.blue)) - }) - .collect::>(), - ) -} diff --git a/src/visualizer.rs b/src/visualizer.rs index c6da7e0..d9142fc 100644 --- a/src/visualizer.rs +++ b/src/visualizer.rs @@ -8,24 +8,25 @@ use crate::{ use anyhow::Result; use cpal::{InputCallbackInfo, SampleFormat, SizedSample, traits::*}; use dasp_sample::conv::ToSample; -use palette::Oklch; +use palette::{FromColor, Oklch, Srgb}; use std::{ - sync::mpsc::{self, TryRecvError}, + collections::HashMap, + sync::{ + Arc, Mutex, + mpsc::{self, TryRecvError}, + }, thread, }; #[derive(Debug, Default)] enum VisualizerState { #[default] - Paused, Running, Done, } #[derive(Debug)] pub enum VisualizerMsg { - Pause, - Resume, End, SetGain(f32), SetPalette(Vec<[u8; 3]>), @@ -51,6 +52,7 @@ pub struct Visualizer { max_freq: u16, hues: Vec<[u8; 3]>, effect: Effect, + shared_colors: Arc>>, } impl Visualizer { @@ -76,6 +78,7 @@ impl Visualizer { config: VisualizerConfig, audio_stream: AudioStream, nl_device: &NlDevice, + shared_colors: Arc>>, ) -> Result { let state = VisualizerState::default(); let mut nl_udp = nanoleaf::NlUdp::new(nl_device)?; @@ -115,6 +118,7 @@ impl Visualizer { max_freq, hues, effect, + shared_colors, }) } @@ -150,8 +154,6 @@ impl Visualizer { speed: &mut [f32], ) { match event { - VisualizerMsg::Resume => self.state = VisualizerState::Running, - VisualizerMsg::Pause => self.state = VisualizerState::Paused, VisualizerMsg::End => self.state = VisualizerState::Done, VisualizerMsg::SetGain(gain) => self.gain = gain, VisualizerMsg::SetEffect(effect) => { @@ -301,32 +303,22 @@ impl Visualizer { // Clear any colors left over from a previous Nanoleaf scene or effect self.send_black_frame(n); loop { - match self.state { - VisualizerState::Done => break, - VisualizerState::Paused => { - let event = rx_events.recv().expect("events sender disconnected"); - self.update_state( - event, - &mut base_colors, - &mut brightness, - &mut prev_max, - &mut speed, - ); - } - VisualizerState::Running => match rx_events.try_recv() { - Ok(event) => self.update_state( - event, - &mut base_colors, - &mut brightness, - &mut prev_max, - &mut speed, - ), - Err(err) => { - if err == TryRecvError::Disconnected { - panic!("events sender disconnected"); - } + if matches!(self.state, VisualizerState::Done) { + break; + } + match rx_events.try_recv() { + Ok(event) => self.update_state( + event, + &mut base_colors, + &mut brightness, + &mut prev_max, + &mut speed, + ), + Err(err) => { + if err == TryRecvError::Disconnected { + panic!("events sender disconnected"); } - }, + } } let to_collect = ((sample_rate as f32) * self.time_window).round() as usize; let mut samples = Vec::with_capacity(2 * to_collect); @@ -382,6 +374,19 @@ impl Visualizer { let _ = self.nl_udp.update_panels(&display_colors, self.trans_time); } } + // Share display colors with the graphical UI for panel preview + // Clamp to sRGB gamut before converting to u8 to avoid + // wrap-around artifacts from out-of-gamut Oklch values + if let Ok(mut map) = self.shared_colors.lock() { + map.clear(); + for (i, color) in display_colors.iter().enumerate() { + let srgb: Srgb = Srgb::from_color(*color); + let r = (srgb.red.clamp(0.0, 1.0) * 255.0) as u8; + let g = (srgb.green.clamp(0.0, 1.0) * 255.0) as u8; + let b = (srgb.blue.clamp(0.0, 1.0) * 255.0) as u8; + map.insert(self.nl_udp.panels[i].id, [r, g, b]); + } + } } });