diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..933475a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +target +node_modules +web/node_modules +web/dist +Assets +test.wav +*.md +LICENSE +.DS_Store +.idea +.vscode diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 291600c..e891a4f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest] rust: [stable] steps: @@ -64,8 +64,8 @@ jobs: - name: Run clippy run: cargo clippy --all-targets --all-features -- -D warnings - - name: Build - if: matrix.os != 'macos-latest' + - name: Build (Linux) + if: matrix.os == 'ubuntu-latest' run: cargo build --release --verbose - name: Add macOS targets @@ -78,9 +78,9 @@ jobs: cargo build --release --target x86_64-apple-darwin cargo build --release --target aarch64-apple-darwin lipo -create \ - target/x86_64-apple-darwin/release/audioleaf \ - target/aarch64-apple-darwin/release/audioleaf \ - -output target/release/audioleaf-universal + target/x86_64-apple-darwin/release/nanoviz \ + target/aarch64-apple-darwin/release/nanoviz \ + -output target/release/nanoviz-universal - name: Run tests run: cargo test --verbose diff --git a/.github/workflows/container.yaml b/.github/workflows/container.yaml new file mode 100644 index 0000000..88a1057 --- /dev/null +++ b/.github/workflows/container.yaml @@ -0,0 +1,151 @@ +name: Container + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + paths: + - "Dockerfile" + - ".dockerignore" + - "containers/**" + - ".github/workflows/container.yaml" + - "Cargo.toml" + - "Cargo.lock" + - "src/**" + - "web/**" + - "piWebServer/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !github.ref_protected }} + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + build: + name: Build (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + id-token: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Resolve image name (lowercase) + run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/nanoviz" >> "$GITHUB_ENV" + + - name: Prepare platform pair + id: platform + run: | + platform="${{ matrix.platform }}" + echo "pair=${platform//\//-}" >> "$GITHUB_OUTPUT" + + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image labels + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.IMAGE }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ steps.platform.outputs.pair }} + cache-to: type=gha,mode=max,scope=${{ steps.platform.outputs.pair }} + + - name: Export digest + run: | + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v7 + with: + name: digests-${{ steps.platform.outputs.pair }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Publish manifest + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + id-token: write + steps: + - name: Resolve image name (lowercase) + run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/nanoviz" >> "$GITHUB_ENV" + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute image metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.IMAGE }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=dev,enable=${{ github.event_name == 'pull_request' }} + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }} diff --git a/.gitignore b/.gitignore index 0592392..5509743 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ /target +/node_modules .DS_Store +web/node_modules +web/dist +web/*.tsbuildinfo +.claude +*.wav +.pnpm-store + +# Stakpak session files +.stakpak/session* diff --git a/Assets/audioleaf-iOS-Default-1024x1024@1x.png b/Assets/nanoviz-iOS-Default-1024x1024@1x.png similarity index 100% rename from Assets/audioleaf-iOS-Default-1024x1024@1x.png rename to Assets/nanoviz-iOS-Default-1024x1024@1x.png diff --git a/Assets/audioleaf-icon.svg b/Assets/nanoviz-icon.svg similarity index 100% rename from Assets/audioleaf-icon.svg rename to Assets/nanoviz-icon.svg diff --git a/Assets/audioleaf.icon/Assets/audioleaf-icon.svg b/Assets/nanoviz.icon/Assets/audioleaf-icon.svg similarity index 100% rename from Assets/audioleaf.icon/Assets/audioleaf-icon.svg rename to Assets/nanoviz.icon/Assets/audioleaf-icon.svg diff --git a/Assets/audioleaf.icon/icon.json b/Assets/nanoviz.icon/icon.json similarity index 100% rename from Assets/audioleaf.icon/icon.json rename to Assets/nanoviz.icon/icon.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1332236..03b9063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this fork of audioleaf are documented in this file. This fork focuses on macOS compatibility, support for all Nanoleaf device types, and enhanced color palette features. +## [4.0.0] — Renamed to NanoViz + +The project was renamed from **audioleaf** to **NanoViz** to reflect its +shift from a terminal-only macOS tool to a Pi-first AirPlay appliance with +a web control panel. Original audioleaf credit preserved in [NOTICE](NOTICE) +and the README's Credits section. Breaking changes for existing installs: + +- Crate / binary / container image are now `nanoviz`. Old image path + `ghcr.io/weekendsuperhero-io/audioleaf` is dead; pull + `ghcr.io/weekendsuperhero-io/nanoviz` instead. +- Env vars renamed: `AUDIOLEAF_*` → `NANOVIZ_*` (including + `NANOVIZ_AIRPLAY_NAME`, `NANOVIZ_FRONTEND_DIR`, + `NANOVIZ_SHAIRPORT_METADATA_PIPE`, `NANOVIZ_LOG_METADATA`). +- Default config dir is now `~/.config/nanoviz/` (was + `~/.config/audioleaf/`). On Linux you can `mv` the old dir or pass + `--config-dir=~/.config/audioleaf` to keep using the old layout. +- systemd unit, polkit rule, Quadlet filename, and container name all + default to `nanoviz` instead of `audioleaf`. +- Default AirPlay receiver name is now `nanoviz`; override with + `pi/setup.sh --airplay-name="..."` as before. + ## [3.5.0] - 2026-03-17 ### Added @@ -70,8 +91,8 @@ This fork focuses on macOS compatibility, support for all Nanoleaf device types, - **SSDP deduplication**: Prevent duplicate device entries when multiple SSDP responses are received for the same device - **macOS config directory support**: Properly handle macOS Application Support directory - - Config location: `~/Library/Application Support/audioleaf/` - - Device data location: `~/Library/Application Support/audioleaf/` + - Config location: `~/Library/Application Support/nanoviz/` + - Device data location: `~/Library/Application Support/nanoviz/` - Previous version assumed Linux-style `~/.config/` on all platforms - **Flexible config parsing**: Accept both integer and float values for `default_gain` in config.toml @@ -80,7 +101,7 @@ This fork focuses on macOS compatibility, support for all Nanoleaf device types, - **Comprehensive documentation**: - Complete configuration reference with all options explained - - Platform-specific audio setup guides (macOS, Linux, Windows) + - Platform-specific audio setup guides (macOS, Linux) - Pre-made color palette examples using HSB color system - HSB color wheel reference (0-359 hue values) - Detailed troubleshooting section @@ -125,7 +146,6 @@ This fork focuses on macOS compatibility, support for all Nanoleaf device types, - **Recommended gain values**: Updated documentation for audio devices - macOS BlackHole 2ch (targeted directly): `default_gain = 1` - - Windows VB Cable: `default_gain = 200-500` (may vary) - Physical microphones: `default_gain = 1-10` - Important: Target BlackHole 2ch directly, not the Multi-Output aggregate device @@ -155,9 +175,8 @@ Panel filtering logic excludes controller units: | Platform | Config Path | Device Data Path | | -------- | ----------------------------------------------------- | --------------------------------------------------------- | -| macOS | `~/Library/Application Support/audioleaf/config.toml` | `~/Library/Application Support/audioleaf/nl_devices.toml` | -| Linux | `~/.config/audioleaf/config.toml` | `~/.config/audioleaf/nl_devices.toml` | -| Windows | `%APPDATA%\audioleaf\config.toml` | `%APPDATA%\audioleaf\nl_devices.toml` | +| macOS | `~/Library/Application Support/nanoviz/config.toml` | `~/Library/Application Support/nanoviz/nl_devices.toml` | +| Linux | `~/.config/nanoviz/config.toml` | `~/.config/nanoviz/nl_devices.toml` | ### Audio Gain Values @@ -166,7 +185,6 @@ Recommended gain settings based on audio source: | Audio Source | Configuration | Recommended Gain | | ----------------------------- | --------------------------------- | ---------------- | | BlackHole 2ch (macOS, direct) | `audio_backend = "BlackHole 2ch"` | 1 | -| VB Cable (Windows) | Varies by setup | 200-500 | | Physical microphone | Direct input | 1-10 | **Important for macOS users**: Set `audio_backend` to `"BlackHole 2ch"` (the virtual device itself), not the Multi-Output Device aggregate. This provides proper audio levels with `default_gain = 1`. diff --git a/Cargo.lock b/Cargo.lock index e40080d..44c88c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,24 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -21,7 +39,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.11.0", + "bitflags", "cfg-if", "libc", ] @@ -102,41 +120,50 @@ dependencies = [ ] [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] -name = "audioleaf" -version = "4.0.0" +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ - "anyhow", - "auto-palette", - "clap", - "cpal", - "dasp_sample", - "dirs", - "image 0.25.10", - "macroquad", - "num-complex", - "objc2", - "objc2-foundation", - "palette", - "pollster", - "reqwest", - "serde", - "serde_json", - "toml", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "auto-palette" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3a3dd8bb9a7414943e5ccc56d75083721bf2286f2b25e6e2eb9eda8ac97d7f" dependencies = [ - "image 0.25.10", + "image", "num-traits", "rustc-hash", "thiserror 2.0.18", @@ -144,15 +171,58 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -160,9 +230,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -170,6 +240,61 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" @@ -177,16 +302,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bitflags" -version = "1.3.2" +name = "bit_field" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitstream-io" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[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" @@ -197,11 +340,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "by_address" @@ -215,12 +364,6 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "byteorder-lite" version = "0.1.0" @@ -235,9 +378,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.57" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -265,9 +408,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -287,9 +430,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -305,9 +448,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -362,11 +505,11 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "coreaudio-rs" -version = "0.14.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d15c3c3cee7c087938f7ad1c3098840b3ef1f1bdc7f6e496336c3b1e7a6f3914" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 2.11.0", + "bitflags", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -383,7 +526,7 @@ dependencies = [ "alsa", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2", @@ -404,6 +547,15 @@ 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" @@ -413,12 +565,69 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[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 = "dasp_sample" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[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" @@ -440,13 +649,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a0d569e003ff27784e0e14e4a594048698e0c0f0b66cabcb51511be55a7caa0" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + [[package]] name = "dispatch2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags", "objc2", ] @@ -467,6 +688,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -476,18 +703,78 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fast-srgb8" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -497,6 +784,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -513,6 +810,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -526,14 +835,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "fontdue" -version = "0.9.3" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" -dependencies = [ - "hashbrown 0.15.5", - "ttf-parser", -] +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" @@ -599,6 +904,16 @@ 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" @@ -621,22 +936,39 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] -name = "glam" -version = "0.27.0" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -651,22 +983,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -707,17 +1053,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -727,9 +1085,9 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -737,15 +1095,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -778,12 +1135,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -791,9 +1149,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -804,9 +1162,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -818,15 +1176,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -838,15 +1196,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -857,6 +1215,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -870,27 +1234,14 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] -[[package]] -name = "image" -version = "0.24.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" -dependencies = [ - "bytemuck", - "byteorder", - "color_quant", - "num-traits", - "png 0.17.16", -] - [[package]] name = "image" version = "0.25.10" @@ -899,10 +1250,18 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", "image-webp", "moxcms", "num-traits", - "png 0.18.1", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", "zune-core", "zune-jpeg", ] @@ -917,31 +1276,40 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] -name = "ipnet" -version = "2.12.0" +name = "interpolate_name" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "iri-string" -version = "0.7.10" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -949,11 +1317,20 @@ 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" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" @@ -964,18 +1341,70 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] [[package]] name = "jobserver" @@ -989,34 +1418,73 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] +[[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" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] [[package]] name = "log" @@ -1024,6 +1492,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1040,32 +1517,39 @@ dependencies = [ ] [[package]] -name = "macroquad" -version = "0.4.14" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2befbae373456143ef55aa93a73594d080adfb111dc32ec96a1123a3e4ff4ae" -dependencies = [ - "fontdue", - "glam", - "image 0.24.9", - "macroquad_macro", - "miniquad", - "quad-rand", -] +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] -name = "macroquad_macro" -version = "0.1.8" +name = "maybe-rayon" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64b1d96218903768c1ce078b657c0d5965465c95a60d2682fd97443c9d2483dd" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] [[package]] -name = "malloc_buf" -version = "0.0.6" +name = "media-remote" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +checksum = "e38122ff1e806f27d4f5780635f05eddf64ffd92a17299d1e86212765639dbbe" dependencies = [ - "libc", + "base64", + "block2", + "dispatch2 0.2.0", + "flate2", + "image", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "serde_json", + "tar", + "tempfile", ] [[package]] @@ -1081,15 +1565,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "miniquad" -version = "0.4.8" +name = "mime_guess" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fb3e758e46dbc45716a8a49ca9edc54b15bcca826277e80b1f690708f67f9e3" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ - "libc", - "ndk-sys 0.2.2", - "objc-rs", - "winapi", + "mime", + "unicase", ] [[package]] @@ -1104,9 +1586,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -1124,38 +1606,101 @@ dependencies = [ ] [[package]] -name = "ndk" -version = "0.9.0" +name = "nanoviz" +version = "4.0.0" +dependencies = [ + "anyhow", + "auto-palette", + "axum", + "base64", + "clap", + "cpal", + "dasp_sample", + "dirs", + "flume", + "hashbrown 0.17.1", + "image", + "media-remote", + "num-complex", + "palette", + "parking_lot", + "quick-xml", + "reqwest", + "serde", + "serde_json", + "tokio", + "toml", + "toml_edit", + "tower-http", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ - "bitflags 2.11.0", - "jni-sys", - "log", - "ndk-sys 0.6.0+11769913", - "num_enum", - "thiserror 1.0.69", + "memchr", ] [[package]] -name = "ndk-context" -version = "0.1.1" +name = "nom" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] [[package]] -name = "ndk-sys" -version = "0.2.2" +name = "noop_proc_macro" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] -name = "ndk-sys" -version = "0.6.0+11769913" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "jni-sys", + "num-integer", + "num-traits", ] [[package]] @@ -1178,6 +1723,26 @@ dependencies = [ "syn", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1210,21 +1775,33 @@ dependencies = [ ] [[package]] -name = "objc-rs" -version = "0.2.8" +name = "objc2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a1e7069a2525126bf12a9f1f7916835fafade384fb27cabf698e745e2a1eb8" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "malloc_buf", + "objc2-encode", ] [[package]] -name = "objc2" -version = "0.6.4" +name = "objc2-app-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "objc2-encode", + "bitflags", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -1233,7 +1810,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.11.0", + "bitflags", "libc", "objc2", "objc2-core-audio", @@ -1252,13 +1829,24 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-audio" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ - "dispatch2", + "dispatch2 0.3.1", "objc2", "objc2-core-audio-types", "objc2-core-foundation", @@ -1271,8 +1859,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.11.0", + "bitflags", + "objc2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags", "objc2", + "objc2-foundation", ] [[package]] @@ -1281,13 +1880,61 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags", "block2", - "dispatch2", + "dispatch2 0.3.1", "libc", "objc2", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2 0.3.1", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -1300,13 +1947,35 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags", "block2", "libc", "objc2", "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1355,6 +2024,41 @@ dependencies = [ "syn", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1378,7 +2082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -1409,30 +2113,11 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "png" -version = "0.17.16" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "png" @@ -1440,24 +2125,18 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.0", + "bitflags", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1471,6 +2150,16 @@ 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", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1489,17 +2178,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] -name = "quad-rand" -version = "0.2.3" +name = "qoi" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] [[package]] name = "quick-error" @@ -1507,6 +2218,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1537,7 +2257,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1578,20 +2298,26 @@ 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" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -1622,6 +2348,85 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1635,9 +2440,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", @@ -1675,6 +2480,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -1691,15 +2502,37 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[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 = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -1723,9 +2556,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -1733,13 +2566,13 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -1760,9 +2593,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -1776,6 +2609,12 @@ 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" @@ -1794,13 +2633,19 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1817,6 +2662,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1849,9 +2700,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1860,32 +2711,101 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -1909,6 +2829,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1964,7 +2893,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1979,6 +2908,30 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2019,11 +2972,25 @@ dependencies = [ "syn", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2046,18 +3013,31 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2068,6 +3048,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2083,9 +3075,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", @@ -2098,39 +3090,40 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -2145,24 +3138,34 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", - "iri-string", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2183,6 +3186,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] @@ -2203,10 +3207,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "ttf-parser" -version = "0.21.1" +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicase" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" @@ -2214,6 +3240,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[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" @@ -2244,6 +3276,23 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -2271,18 +3320,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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 = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -2293,23 +3351,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2317,9 +3371,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -2330,18 +3384,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" 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", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -2359,28 +3447,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] [[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "weezl" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "winapi-util" @@ -2391,12 +3469,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows" version = "0.62.2" @@ -2742,9 +3814,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -2754,18 +3826,122 @@ 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" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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", + "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", + "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", + "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" -version = "0.6.2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "y4m" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2774,9 +3950,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -2786,18 +3962,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -2806,18 +3982,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2833,9 +4009,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2844,9 +4020,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2855,9 +4031,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -2876,11 +4052,20 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 7f742e1..6853a1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,36 +1,44 @@ [package] -name = "audioleaf" +name = "nanoviz" version = "4.0.0" edition = "2024" +# Forked from audioleaf by Antoni Zasada (https://github.com/AntoniZasada/audioleaf). +# See NOTICE for fork lineage. authors = ["Antoni Zasada", "weekendsuperhero"] -description = "Manage your Nanoleaf devices (Canvas, Shapes, Elements, Light Panels) and visualize music straight from the terminal" -keywords = ["audio", "music", "nanoleaf", "visualizer"] +description = "Raspberry Pi AirPlay receiver that drives Nanoleaf panels as a music visualizer (forked from audioleaf)" +keywords = ["audio", "music", "nanoleaf", "visualizer", "airplay"] license = "MIT" readme = "README.md" -repository = "https://github.com/weekendsuperhero-io/audioleaf" +repository = "https://github.com/weekendsuperhero-io/nanoviz" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1.0.102" -clap = { version = "4.6.0", features = ["derive"] } +base64 = "0.22.1" +clap = { version = "4.6.1", features = ["derive"] } auto-palette = "0.9" +axum = { version = "0.8.9", features = ["ws"] } cpal = "0.17.3" dasp_sample = "0.11.0" dirs = "6.0.0" -macroquad = "0.4" -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } +flume = "0.12" +hashbrown = "0.17" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "bmp", "tiff", "gif"] } num-complex = "0.4.6" palette = "0.7.6" -pollster = "0.4" +parking_lot = "0.12" +quick-xml = "0.39" reqwest = { version = "0.13", features = ["blocking", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1.52", features = ["macros", "rt-multi-thread", "net", "time", "io-util", "signal"] } toml = "1" +toml_edit = "0.25" +tower-http = { version = "0.6.10", features = ["cors", "fs"] } [target.'cfg(target_os = "macos")'.dependencies] -objc2 = "0.6" -objc2-foundation = { version = "0.3", features = ["NSString", "NSData"] } +media-remote = "0.3" [profile.dev] panic = "abort" diff --git a/DEBUG.md b/DEBUG.md new file mode 100644 index 0000000..7c90b41 --- /dev/null +++ b/DEBUG.md @@ -0,0 +1,175 @@ +# Debug runbook: AirPlay receiver disappears after long pause + +## Symptom + +On the Raspberry Pi, the **"nanoviz"** AirPlay receiver disappears from the +iPhone/Mac AirPlay picker after audio is paused for a long time. Even +`podman compose down && podman compose up -d` does not bring it back. + +The fact that a full container restart doesn't fix it is the surprising part — +it points at host-side state (snd-aloop kernel module, host avahi conflict on +UDP 5353, source-device caches) or a startup-order problem rather than a +process that simply crashed. + +## Why we can't diagnose it yet + +Out of the box, `podman logs nanoviz` only shows nanoviz's own output: + +- `shairport-sync` defaults to syslog (no syslog daemon inside the container). +- `nqptp` is started in the background but its stderr was inherited silently. +- `avahi-daemon` is started with `--daemonize`, which detaches its log. +- The `diagnostics` block in `piWebServer/shairport-sync.conf` is commented out. + +Phase 1 (this runbook) makes the failure observable. Phase 2 (designed after we +have a captured failure) is the actual fix. + +## Phase 1 changes already applied + +| File | Change | +|---|---| +| `piWebServer/shairport-sync.conf` | `diagnostics` block uncommented, `log_verbosity=2`, file/line + time-since-startup on | +| `containers/entrypoint.sh` | `avahi-daemon` foreground (no `--daemonize`), `shairport-sync -u -vv` (stderr, verbose), all services' stderr redirected, PIDs logged at startup, `cleanup` trap also kills avahi | +| `containers/diag.sh` *(new)* | One-shot state capture: processes, mDNS browse, UDP listeners, `/proc/asound/Loopback/*`, dbus socket | +| `containers/compose.yaml` | Bind-mounts the three files above on top of the published image so we can iterate without rebuild | + +To revert: drop the three `:ro,Z` mounts from `containers/compose.yaml` and +re-comment the `diagnostics` block. + +## Reproduction & capture protocol + +Run on the Pi. + +### 1. Start with the diagnostic build and confirm logs + +```sh +cd /path/to/nanoviz +podman compose -f containers/compose.yaml down +podman compose -f containers/compose.yaml up -d +podman logs -f nanoviz > /tmp/nanoviz-baseline.log & +``` + +Within ~5 seconds you should see entrypoint lines plus `shairport-sync`, +`avahi`, and `nqptp` output in the log. If you don't, the bind-mount didn't +land — re-check the volume paths in `containers/compose.yaml`. + +### 2. Capture a healthy baseline + +```sh +podman exec nanoviz /usr/local/bin/diag.sh > /tmp/healthy-baseline.txt 2>&1 +``` + +This is what "working" looks like. We diff it against the failure capture. + +### 3. Reproduce the failure + +1. From iPhone/Mac, select **"nanoviz"** in the AirPlay picker. +2. Play a track for ~30 seconds. +3. **Pause.** Note the timestamp. +4. Walk away. Check the picker every ~15 minutes. + +### 4. The instant "nanoviz" disappears from the picker + +Don't fix anything yet. Capture state first: + +```sh +# Container-side state +podman exec nanoviz /usr/local/bin/diag.sh > /tmp/failure-diag.txt 2>&1 + +# Snapshot the streamed log up to the failure +cp /tmp/nanoviz-baseline.log /tmp/failure-podman.log + +# Optional: from a SECOND device on the same network, confirm it's not just +# the source device's mDNS cache lying: +avahi-browse -ar -t | grep -i 'nanoviz' +``` + +### 5. Now test whether down/up recovers + +```sh +podman compose -f containers/compose.yaml down +podman compose -f containers/compose.yaml up -d +sleep 15 +podman exec nanoviz /usr/local/bin/diag.sh > /tmp/failure-after-restart.txt 2>&1 +``` + +If `failure-after-restart.txt` shows shairport/avahi/nqptp running and mDNS +advertising "nanoviz" but the iPhone/Mac picker still doesn't show it → it's +a source-device cache issue, not a server issue. If it still doesn't show in +`avahi-browse` from inside the container after restart → host-side state is +poisoned (most likely). + +## What each output tells us + +Diff `failure-diag.txt` against `healthy-baseline.txt`: + +- **`shairport-sync` missing from `ps -ef`** → it crashed silently. The + entrypoint runs it with `&` and has no supervisor; tini reaps it but doesn't + restart. Phase 2 = process supervisor (s6-overlay or simple restart loop) + + read the stderr in `failure-podman.log` to find the crash reason. +- **`shairport-sync` alive, but `avahi-browse` from inside the container + doesn't show "nanoviz"** → mDNS deregistration. Look at `avahi` lines in + `failure-podman.log` for "withdrawing" / "Server disappeared" / dbus errors. + Phase 2 = avahi/dbus startup ordering or shairport mDNS renewal. +- **`avahi-browse` shows it from inside the container, but the second device's + `avahi-browse` doesn't** → multicast/IGMP membership lost on the host. + Phase 2 = network/host-side, not container. +- **Both `avahi-browse` outputs show it, but iPhone/Mac picker doesn't** → + source-device cache. Phase 2 = bump shairport's mDNS TXT record on a + heartbeat to force re-advertise. +- **`/proc/asound/Loopback/pcm*c/sub*/status` shows `state: XRUN` or + `closed` on the writer side** → the standby-mode workaround + (`shairport-sync.conf:42–44`, `disable_standby_mode = "always"`) failed. + Phase 2 = ALSA loopback config tuning. +- **After `down/up`, shairport's stderr shows ALSA `device busy` or PTP + bind error** → host-side state (kernel module stuck, host `avahi-daemon` + on UDP 5353, leftover `nqptp` on UDP 319/320). This is the most likely + explanation for "compose down/up doesn't fix it." Phase 2 = on the host: + `sudo systemctl stop avahi-daemon` (if running on host), + `sudo modprobe -r snd_aloop && sudo modprobe snd_aloop`, then start + the container. + +## Files to send back for Phase 2 design + +- `/tmp/healthy-baseline.txt` +- `/tmp/failure-diag.txt` +- `/tmp/failure-podman.log` — most important; this is where shairport's actual + death/error message lives now that diagnostics are on +- `/tmp/failure-after-restart.txt` +- The output of these on the Pi host (not the container): + ```sh + systemctl is-active avahi-daemon dbus + pgrep -fa nqptp + pgrep -fa shairport-sync + lsmod | grep snd_aloop + ``` + +## Avahi "name collision, renaming to NAME #N" loop + +If the journal shows shairport-sync rapidly incrementing its mDNS service +name (`nanoviz #2`, `#3`, `#4`, ...), the host avahi-daemon has a stale +registration cached from a previous container run that didn't deregister. +Fix in one shot: + +```sh +sudo systemctl restart avahi-daemon +sudo systemctl restart nanoviz +``` + +The container's entrypoint now traps SIGTERM and forwards it to +shairport-sync before exiting, so this should stop recurring after a clean +restart. If you still see the loop, check that `/var/run/avahi-daemon` is +bind-mounted (`Volume=/var/run/avahi-daemon:/var/run/avahi-daemon` in the +Quadlet) — without that, shairport-sync starts its *own* avahi client and +collides with the host's daemon every time. + +## Reverting Phase 1 + +Once root cause is captured: + +1. In `containers/compose.yaml`, remove the three `:ro,Z` bind-mounts under `volumes:`. +2. In `piWebServer/shairport-sync.conf`, re-comment the `diagnostics` block + (or leave at `log_verbosity = 1` for ongoing visibility). +3. In `containers/entrypoint.sh`, drop `-vv` from `shairport-sync` (keep `-u` + so logs continue to reach `podman logs`). +4. Rebuild and republish the image so changes go into `:latest` rather than + relying on host bind-mounts. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6a60ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,92 @@ +# syntax=docker/dockerfile:1.7 +# +# Multi-arch container image for nanoviz on Raspberry Pi (and any Linux host +# with ALSA loopback). Bundles nqptp + shairport-sync (built from source for +# AirPlay 2 support) + nanoviz API server + prebuilt React frontend. +# +# Build: +# docker buildx build --platform linux/arm64,linux/amd64 -t nanoviz:test . +# +# Run on a Pi (snd-aloop kernel module must be loaded on the host): +# podman compose -f containers/compose.yaml up -d + +ARG DEBIAN_RELEASE=bookworm +ARG RUST_VERSION=1 +ARG NODE_VERSION=22 + +# ---------- Stage 1: Rust binary ---------- +FROM rust:${RUST_VERSION}-${DEBIAN_RELEASE} AS nanoviz-builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + libasound2-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +RUN cargo build --release --locked --bin nanoviz + +# ---------- Stage 2: React frontend (pnpm) ---------- +FROM node:${NODE_VERSION}-${DEBIAN_RELEASE}-slim AS web-builder +RUN npm install -g pnpm@11.0.8 +WORKDIR /build/web +COPY web/package.json web/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY web/ ./ +RUN pnpm run build + +# ---------- Stage 3: nqptp + shairport-sync from source (AirPlay 2) ---------- +FROM debian:${DEBIAN_RELEASE}-slim AS airplay-builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential git autoconf automake libtool ca-certificates \ + libpopt-dev libconfig-dev libasound2-dev \ + libavahi-client-dev libssl-dev libsoxr-dev \ + libplist-dev libsodium-dev uuid-dev libgcrypt-dev xxd libplist-utils \ + libavutil-dev libavcodec-dev libavformat-dev libswresample-dev \ + && rm -rf /var/lib/apt/lists/* + +# Pin to upstream main at build time. Bump these when you want a refresh. +# Pinned to specific tags so the Docker layer cache invalidates only when we +# explicitly bump these values. Override at build time with --build-arg if you +# need to test a different revision. +ARG NQPTP_REF=1.2.6 +ARG SHAIRPORT_REF=5.0.4 + +WORKDIR /src +RUN git clone --depth 1 --branch ${NQPTP_REF} https://github.com/mikebrady/nqptp.git +RUN cd nqptp && autoreconf -fi && ./configure && make -j"$(nproc)" \ + && make install DESTDIR=/out + +RUN git clone --depth 1 --branch ${SHAIRPORT_REF} https://github.com/mikebrady/shairport-sync.git +RUN cd shairport-sync && autoreconf -fi \ + && ./configure --sysconfdir=/etc \ + --with-alsa --with-soxr --with-avahi \ + --with-ssl=openssl --with-airplay-2 --with-metadata \ + && make -j"$(nproc)" \ + && make install DESTDIR=/out + +# ---------- Stage 4: runtime ---------- +FROM debian:${DEBIAN_RELEASE}-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + tini ca-certificates \ + libasound2 alsa-utils \ + avahi-daemon dbus \ + libavcodec59 libavformat59 libavutil57 libswresample4 \ + libsoxr0 libplist3 libsodium23 libgcrypt20 libssl3 \ + libpopt0 libconfig9 libavahi-client3 \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /var/run/dbus /root/.config/nanoviz /usr/local/share/nanoviz + +COPY --from=airplay-builder /out/usr/local/ /usr/local/ +COPY --from=nanoviz-builder /build/target/release/nanoviz /usr/local/bin/nanoviz +COPY --from=web-builder /build/web/dist/ /usr/local/share/nanoviz/web/ + +COPY piWebServer/shairport-sync.conf /etc/shairport-sync.conf +COPY piWebServer/asound-default-loopback.conf /etc/asound.conf +COPY containers/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENV NANOVIZ_FRONTEND_DIR=/usr/local/share/nanoviz/web \ + NANOVIZ_SHAIRPORT_METADATA_PIPE=/tmp/shairport-sync-metadata + +EXPOSE 8787 + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..c74e995 --- /dev/null +++ b/NOTICE @@ -0,0 +1,34 @@ +NanoViz +======= + +NanoViz is a Raspberry Pi AirPlay receiver that drives Nanoleaf panels as a +music visualizer. + +Fork lineage +------------ + +NanoViz is a fork of audioleaf: + + audioleaf — https://github.com/alfazet/audioleaf + Copyright (c) Antoni Zasada + Licensed under the MIT License (see LICENSE). + +The audioleaf project provided the original Nanoleaf SSDP discovery, ALSA +audio capture, FFT analysis, panel sorting / layout, and named-palette +visualizer effects that NanoViz still uses today. The project was renamed +to NanoViz when the focus shifted from a terminal-only macOS visualizer to +a Raspberry Pi AirPlay appliance with a web control panel; the rename also +reflects substantial new functionality (AirPlay 2 receiver via +shairport-sync + nqptp, podman/Quadlet container deployment, web-UI device +pairing, album-art palette extraction, AirPlay-volume-driven brightness, +and the React control panel). + +Third-party components shipped in the published container image +--------------------------------------------------------------- + +- shairport-sync (https://github.com/mikebrady/shairport-sync) — GPL-2.0+ +- nqptp (https://github.com/mikebrady/nqptp) — GPL-3.0 +- avahi-daemon, dbus, ALSA, FFmpeg libs — see Debian package licenses. + +Audioleaf and NanoViz are released under the MIT License; see LICENSE for +the full text. diff --git a/README.md b/README.md index e86e9aa..5d4cc2b 100644 --- a/README.md +++ b/README.md @@ -1,215 +1,272 @@ -# Audioleaf +# NanoViz -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. +A Raspberry Pi AirPlay receiver that lights up your Nanoleaf panels in time with +the music. Stream from any iOS/macOS device, and your Shapes / Canvas / +Elements / Light Panels become a reactive visualizer driven by what's playing. -![Audioleaf GUI](Assets/gui.png) +![NanoViz Demo](Assets/demo.gif) -![Audioleaf Demo](Assets/demo.gif) +The Pi runs a single container that bundles `shairport-sync` (AirPlay 2 via +`nqptp`), the audio visualizer, and a web control panel. Pair your Nanoleaf +device from the browser, then play to the AirPlay receiver — that's it. -> **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. +> **Note:** This is a fork of the original audioleaf that pivoted from a +> terminal-only macOS tool into a Pi-first AirPlay appliance with a web UI. +> Most of the original TUI features (keybinds, dump commands, native GUI) have +> been replaced by the web app. See [CHANGELOG.md](CHANGELOG.md) for history. ## 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 +- **AirPlay 2 receiver** — Stream from any Apple device; appears as a regular + AirPlay target on your network. +- **Volume slider drives brightness** — The iOS/macOS volume slider dims and + brightens your panels. Audio stays at full volume; only the lights move. +- **Three visualizer effects** — Spectrum (bass→treble across panels), Energy + Wave (cascading ripples), and Ripple (transient-driven pulses). +- **Album art palette** — Optionally pull palette colors from the current + track's cover art instead of a fixed palette. +- **Device palettes** — Use any palette saved as a Nanoleaf "effect" on the + device itself; no static catalog to maintain. +- **Web UI** — Pair devices, switch effects/palettes, see now-playing, live + panel preview, and a layout visualizer. +- **Multi-instance** — Run more than one receiver per Pi with `--name=...`. -## Installation +## Quick start (Raspberry Pi) -Install from cargo: +One-shot install from a fresh Pi: ```bash -cargo install audioleaf +curl -fsSL https://raw.githubusercontent.com/Weekendsuperhero-io/nanoviz/main/pi/setup.sh \ + | sudo bash ``` -Make sure `$HOME/.cargo/bin` is in your `$PATH`. - -For Arch-based distros, audioleaf is also available in the [AUR](https://aur.archlinux.org/packages/audioleaf): +Or from a local clone: ```bash -yay -S audioleaf +sudo ./pi/setup.sh ``` -## Usage - -### First-Time Setup +The installer: -At first launch, audioleaf discovers Nanoleaf devices on your local network: +1. Installs `podman` and the `snd-aloop` kernel module. +2. Adds your user to `audio`, `render`, and `systemd-journal` groups. +3. Stages `~//.config/nanoviz/` (config + compose file). +4. Drops a Quadlet at `/etc/containers/systemd/nanoviz.container` and + starts `nanoviz.service`. +5. Installs a polkit rule so the `audio` group can `systemctl start/stop/restart` + without sudo. -```bash -audioleaf -n -``` +After install: -This will: +- Web UI: `http://:8787` +- Pair your Nanoleaf from the **Devices → Pair new device** card. +- AirPlay to the receiver (default name **nanoviz**). +- Log out and back in once if the script added you to new groups. -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 +### Install flags -**Device data location:** +``` +--name=NAME Instance name (default "nanoviz"). Controls the + systemd unit, container name, Quadlet filename, + polkit rule, and config dir. Use a distinct name to + run multiple receivers on one host. +--airplay-name=NAME AirPlay display name advertised on mDNS (the name + that appears in the iOS/macOS AirPlay picker). + Spaces are allowed; quote the value. +--image-tag=TAG Container image tag (default: "dev" on non-main git + branches, "latest" otherwise). +--config-dir=DIR Override the default config dir + (~/.config/ for sudo'd users, + /etc/ for raw-root installs). +--no-systemd Skip the systemd unit; just `podman compose up -d`. +--no-deploy Host prep only — don't pull or start the container. +--force-compose Overwrite the staged compose.yaml if it exists. +``` -- **macOS**: `~/Library/Application Support/audioleaf/nl_devices.toml` -- **Linux**: `~/.config/audioleaf/nl_devices.toml` -- **Custom**: Use `--devices /path/to/devices.toml` +Example — second receiver named "Bedroom": -### Running +```bash +sudo ./pi/setup.sh --name=nanoviz-bedroom --airplay-name="Bedroom Speaker" +``` -After setup, simply run: +### Updating ```bash -audioleaf +sudo podman pull ghcr.io/weekendsuperhero-io/nanoviz:latest +sudo systemctl restart nanoviz ``` -To connect to a specific device: +(or `:dev` if you installed from a non-`main` branch). + +### Uninstall ```bash -audioleaf -d "Shapes AC01" +sudo ./pi/uninstall.sh # keep config +sudo ./pi/uninstall.sh --purge --remove-image # full wipe +sudo ./pi/uninstall.sh --name=nanoviz-bedroom # named instance ``` -### Controls +Removes the systemd service, Quadlet, container, and polkit rule. +`~/.config//` (with your `nl_devices.toml` pairing tokens) is kept +unless you pass `--purge`. -Press ? in the app to see all keybinds. +## Daily use -| 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 | +1. Open the AirPlay menu on your iPhone / Mac and pick **nanoviz** (or + whatever you set via `--airplay-name`). +2. Start playback. Panels react. +3. Slide the volume bar — panels dim and brighten. Audio is unaffected + (`ignore_volume_control = "yes"` in shairport-sync.conf). +4. Open `http://:8787` to change effect, palette, sort axis, or + audio sensitivity (`default_gain`) at runtime. -### Effects +### Visualizer effects -- **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. +- **Spectrum** — Each panel tracks a frequency band. Bass on one end, + treble on the other. +- **Energy Wave** — Per-panel band like Spectrum, but brightness cascades + across the layout as a traveling wave. +- **Ripple** — All panels pulse together, driven by audio transients. -## Configuration +### Color source -Configuration lives in `config.toml`: +Pick one in the web UI: -- **macOS**: `~/Library/Application Support/audioleaf/config.toml` -- **Linux**: `~/.config/audioleaf/config.toml` -- **Custom**: Use `--config /path/to/config.toml` +- **Palette** — Use a named palette stored as an effect on your Nanoleaf + device. The dropdown lists every palette the device knows about and + shows the swatches inline. +- **Artwork** — Extract colors from the current track's cover art. Falls + back to the configured palette when nothing's playing. -A default config file is generated on first launch. +## Configuration -### Example Configuration +Config lives in `~/.config//config/config.toml` on the host +(bind-mounted into the container as `/root/.config/nanoviz/`). The web +UI edits the running config in memory; click **Save** to persist. + +### Example `config.toml` ```toml default_nl_device_name = "Shapes AC01" [visualizer_config] -# Audio input device (see Audio Setup below) -audio_backend = "BlackHole 2ch" - -# Frequency range to visualize [min_hz, max_hz] +audio_backend = "default" # ALSA loopback on the Pi freq_range = [20, 4500] - -# 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 (doesn't affect playback volume) +color_source = "palette" # "palette" or "artwork" +palette_name = "Sunset" # any palette name saved on the device default_gain = 1.0 - -# Panel transition speed in 100ms units (2 = 200ms) -transition_time = 2 - -# Audio sampling window in seconds +transition_time = 2 # in 100ms units time_window = 0.1875 - -# Panel sorting -primary_axis = "Y" # "X" or "Y" -sort_primary = "Asc" # "Asc" or "Desc" -sort_secondary = "Asc" # "Asc" or "Desc" - -# Visualization effect -effect = "Spectrum" # "Spectrum", "EnergyWave", or "Pulse" +primary_axis = "Y" # "X" or "Y" +sort_primary = "Asc" # "Asc" or "Desc" +sort_secondary = "Asc" +effect = "Spectrum" # "Spectrum" | "EnergyWave" | "Ripple" ``` -### Available Palettes +### Devices file -| 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 | +`nl_devices.toml` (next to `config.toml`) holds your Nanoleaf pairing +tokens. The web UI writes this automatically when you pair from the +**Devices** card. Keep it backed up if you reinstall. -## Audio Setup +## HTTP API -### macOS +The container exposes a REST + WebSocket API on port `8787`. Selected +routes: -1. Install [BlackHole](https://existential.audio/blackhole/) (free virtual audio device) -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 +- `GET /api/health` — version + the configured AirPlay name +- `GET /api/config` — current runtime config +- `POST /api/config/save` — persist runtime config to `config.toml` +- `PUT /api/config/visualizer/{effect,palette,color-source,sort,settings}` +- `GET /api/now-playing` — current track + AirPlay volume +- `GET /api/now-playing/artwork` — current cover art bytes +- `GET /api/visualizer/{preview,status}` and `WS /api/visualizer/ws` +- `GET /api/palettes` — palettes from the active device +- `GET /api/audio/backends` +- `GET /api/devices`, `POST /api/devices/discover`, + `POST /api/devices/pair` +- `GET /api/devices/{name}/info`, `/layout` +- `PUT /api/devices/{name}/state` — manual power / brightness override -**Tip**: Target `"BlackHole 2ch"` directly, not the Multi-Output Device aggregate. This provides proper audio levels with `default_gain = 1`. +## Running outside the container (development) -### Linux (PulseAudio/PipeWire) +The repo builds and runs as a regular Rust binary plus a Vite frontend. +Useful on macOS or Linux dev machines where you don't want to deal with +the container. -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 +```bash +# 1. Build the frontend +cd web && pnpm install && pnpm build && cd .. -## Dump Commands +# 2. Run the API + visualizer +cargo run --bin nanoviz +``` -Inspect your device without launching the full app: +Then visit `http://127.0.0.1:8787`. -```bash -# Show panel layout info -audioleaf dump layout +Useful flags: -# Interactive graphical layout view (click panels to flash them) -audioleaf dump layout-graphical +- `--host 0.0.0.0` — bind interface (default `0.0.0.0`). +- `--port 8787` — HTTP port. +- `--config /path/to/config.toml` +- `--devices /path/to/nl_devices.toml` +- `NANOVIZ_FRONTEND_DIR=/path/to/web/dist` — serve a different build. +- `NANOVIZ_AIRPLAY_NAME="Living Room"` — override AirPlay name (also + surfaced in the web UI header). -# List available color palettes -audioleaf dump palettes +For the Vite dev server with hot reload: -# Show raw device info -audioleaf dump info +```bash +cd web && pnpm dev # http://127.0.0.1:5173 (proxies /api → 8787) ``` -## Troubleshooting +### macOS audio capture -### Visualizer Not Responding +The CoreAudio backend can't intercept system audio without a loopback +device. -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 +1. Install [BlackHole](https://existential.audio/blackhole/) (free). +2. **Audio MIDI Setup** → Multi-Output Device with your speakers + + BlackHole 2ch; set as system output. +3. In the web UI, switch the audio backend to `BlackHole 2ch`. -### Device Not Discovered +### Linux audio capture (dev mode) -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 +In `pavucontrol` → **Recording** tab, point nanoviz at your player's +monitor source. Pick the matching backend in the web UI. -### Brightness +## Troubleshooting -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. +- **AirPlay name keeps incrementing (`nanoviz #2`, `#3`, ...)** — A + stale mDNS registration is cached on the host's avahi-daemon. Run + `sudo systemctl restart avahi-daemon` once. See + [DEBUG.md](DEBUG.md#avahi-name-collision-renaming-to-name-n-loop). +- **Web UI says "No known devices yet"** — Click **Pair new device → + Scan network**, hold the Nanoleaf power button until the LEDs flash, + then click **Pair**. +- **Panels don't react** — Check the visualizer status badge in the web + UI; if it's `Restarting`, the audio backend may be missing. + `default_gain` controls audio sensitivity; bump it in the web UI. +- **Container won't start with "Unit is transient or generated"** — + Older installs; pull the latest `pi/setup.sh`. The Quadlet generator + wires `[Install] WantedBy=` itself; never call `systemctl enable` on + it. ## Contributing -Feel free to open a pull request or start a GitHub issue. Contributions welcome! +Issues and PRs welcome. Run `cargo clippy --bin nanoviz -- -D warnings` +and `pnpm build` (in `web/`) before sending. + +## Credits + +NanoViz is a fork of [**audioleaf**](https://github.com/alfazet/audioleaf) +by **Antoni Zasada**, which provided the original Nanoleaf SSDP discovery, +ALSA audio capture, FFT analysis, panel sorting/layout, and palette +visualizer effects. The rename to NanoViz coincided with the shift from a +terminal-only macOS tool to a Pi-first AirPlay appliance with a web UI +(AirPlay 2 via shairport-sync + nqptp, podman/Quadlet deployment, +web-based device pairing, album-art palette extraction, AirPlay-volume +brightness, and the React control panel). + +See [NOTICE](NOTICE) for the fork lineage and third-party component +attributions. MIT licensed; see [LICENSE](LICENSE). diff --git a/containers/compose.yaml b/containers/compose.yaml new file mode 100644 index 0000000..fd0ae5b --- /dev/null +++ b/containers/compose.yaml @@ -0,0 +1,42 @@ +# Podman compose for nanoviz on Raspberry Pi. +# +# For systemd integration we ship a Quadlet at containers/nanoviz.container — +# this compose file is for ad-hoc / dev runs without systemd. +# +# Pi prerequisites are handled by pi/setup.sh, but if you're doing it by hand: +# echo snd-aloop | sudo tee /etc/modules-load.d/snd-aloop.conf +# echo "options snd-aloop id=Loopback index=2 pcm_substreams=8" \ +# | sudo tee /etc/modprobe.d/snd-aloop.conf +# sudo modprobe snd-aloop +# +# Run: +# podman compose -f containers/compose.yaml up -d + +services: + nanoviz: + image: ghcr.io/weekendsuperhero-io/nanoviz:latest + container_name: nanoviz + network_mode: host + restart: unless-stopped + # nqptp binds UDP 319/320 for PTP; shairport needs NET_ADMIN for RT scheduling + cap_add: + - NET_BIND_SERVICE + - NET_ADMIN + devices: + - /dev/snd:/dev/snd + group_add: + - audio + volumes: + - ${NANOVIZ_CONFIG_DIR:-./config}:/root/.config/nanoviz + # Share the host's dbus + avahi so shairport-sync registers AirPlay via + # the host's avahi-daemon instead of running its own. With network_mode: + # host, two avahi-daemons on the same network collide on the mDNS + # hostname, producing a "Host name conflict, retrying with HOST-N" loop. + - /var/run/dbus:/var/run/dbus + - /var/run/avahi-daemon:/var/run/avahi-daemon + # Diagnostic overrides — mount host copies on top of the published image so + # we can iterate on entrypoint/shairport config + run diag.sh without rebuild. + # Remove this block once root cause is captured. + - ./entrypoint.sh:/usr/local/bin/entrypoint.sh:ro,Z + - ./diag.sh:/usr/local/bin/diag.sh:ro,Z + - ../piWebServer/shairport-sync.conf:/etc/shairport-sync.conf:ro,Z diff --git a/containers/diag.sh b/containers/diag.sh new file mode 100755 index 0000000..aec61e1 --- /dev/null +++ b/containers/diag.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Captures container-side state at the moment AirPlay misbehaves. +# Invoke from the host: podman exec nanoviz /usr/local/bin/diag.sh > capture.txt 2>&1 + +set -u + +section() { printf '\n=== %s ===\n' "$*"; } + +section "date / uptime" +date -u +%FT%TZ +uptime || true + +section "processes" +ps -ef 2>/dev/null || ps aux + +section "shairport-sync alive?" +pgrep -fa shairport-sync || echo "NO shairport-sync process" +section "nqptp alive?" +pgrep -fa nqptp || echo "NO nqptp process" +section "avahi-daemon alive?" +pgrep -fa avahi-daemon || echo "NO avahi-daemon process" + +section "mDNS browse (5s, _airplay._tcp + _raop._tcp)" +timeout 5 avahi-browse -tr _airplay._tcp 2>&1 || true +timeout 5 avahi-browse -tr _raop._tcp 2>&1 || true + +section "mDNS resolve nanoviz" +avahi-resolve -n "nanoviz._airplay._tcp.local" 2>&1 || true + +section "UDP listeners (mDNS 5353, PTP 319/320, AirPlay 7000)" +if command -v ss >/dev/null 2>&1; then + ss -lun 2>/dev/null | grep -E ':(5353|319|320|7000|6001|6002)\b' || ss -lun +else + netstat -lun 2>/dev/null | grep -E ':(5353|319|320|7000|6001|6002)\b' || netstat -lun +fi + +section "ALSA loopback state" +for f in /proc/asound/Loopback/pcm*c/sub*/status \ + /proc/asound/Loopback/pcm*c/sub*/hw_params \ + /proc/asound/Loopback/pcm*p/sub*/status \ + /proc/asound/Loopback/pcm*p/sub*/hw_params; do + [ -e "$f" ] || continue + printf -- '--- %s ---\n' "$f" + cat "$f" 2>&1 +done + +section "ALSA cards" +cat /proc/asound/cards 2>&1 || true + +section "dbus system socket" +ls -la /var/run/dbus/ 2>&1 || true + +section "metadata pipe" +ls -la /tmp/shairport-sync-metadata 2>&1 || true + +section "done" +date -u +%FT%TZ diff --git a/containers/entrypoint.sh b/containers/entrypoint.sh new file mode 100755 index 0000000..9fd57c2 --- /dev/null +++ b/containers/entrypoint.sh @@ -0,0 +1,67 @@ +#!/bin/sh +set -eu + +PIPE="${NANOVIZ_SHAIRPORT_METADATA_PIPE:-/tmp/shairport-sync-metadata}" + +if [ ! -p "$PIPE" ]; then + rm -f "$PIPE" + mkfifo "$PIPE" + chmod 0666 "$PIPE" +fi + +log() { printf '[entrypoint %s] %s\n' "$(date -u +%FT%TZ)" "$*"; } + +# We rely on the HOST's dbus + avahi-daemon (bind-mounted via the compose +# file / Quadlet). Running our own here would race the host for the mDNS +# hostname and produce a "Host name conflict, retrying with HOST-N" loop. +if [ ! -S /var/run/dbus/system_bus_socket ]; then + log "FATAL: /var/run/dbus/system_bus_socket missing — bind-mount the host's /var/run/dbus into the container" + exit 1 +fi + +nqptp >&2 & +NQPTP_PID=$! +log "nqptp pid=$NQPTP_PID" + +# -u: log to stderr instead of syslog. -vv: verbose (paired with diagnostics +# block in shairport-sync.conf). Drop -vv once we've captured the failure. +# -a NAME overrides the `general.name` in shairport-sync.conf with the user's +# chosen AirPlay display name. Set NANOVIZ_AIRPLAY_NAME in the Quadlet +# (Environment=NANOVIZ_AIRPLAY_NAME="My Speaker") or compose env to change +# what appears in the AirPlay picker; unset means the conf-file default. +if [ -n "${NANOVIZ_AIRPLAY_NAME:-}" ]; then + log "AirPlay name override: $NANOVIZ_AIRPLAY_NAME" + shairport-sync -u -vv -a "$NANOVIZ_AIRPLAY_NAME" >&2 & +else + shairport-sync -u -vv >&2 & +fi +SHAIRPORT_PID=$! +log "shairport-sync pid=$SHAIRPORT_PID" + +# Start nanoviz as a child (NOT exec'd) so this shell stays as PID 1 and +# the trap below can forward SIGTERM to shairport-sync on container stop. +# Without that forwarding, shairport-sync dies without deregistering from +# avahi-daemon, leaving a stale mDNS entry that causes "name collision, +# renaming to NAME #N" loops on the next container start. +nanoviz --host 0.0.0.0 --port 8787 "$@" & +NANOVIZ_PID=$! +log "nanoviz pid=$NANOVIZ_PID" + +cleanup() { + log "cleanup: stopping nanoviz=$NANOVIZ_PID shairport=$SHAIRPORT_PID nqptp=$NQPTP_PID" + # SIGTERM shairport first so it gets a chance to call avahi_entry_group_free. + kill -TERM "$SHAIRPORT_PID" 2>/dev/null || true + wait "$SHAIRPORT_PID" 2>/dev/null || true + kill -TERM "$NANOVIZ_PID" "$NQPTP_PID" 2>/dev/null || true + wait "$NANOVIZ_PID" "$NQPTP_PID" 2>/dev/null || true +} +trap cleanup TERM INT + +# Wait on nanoviz — its exit drives the container exit code. Tolerate +# non-zero exits and signal interrupts (set -e would otherwise abort here) +# so the cleanup below always runs and shairport gets a chance to +# deregister from avahi. +EXIT_CODE=0 +wait "$NANOVIZ_PID" || EXIT_CODE=$? +cleanup +exit "$EXIT_CODE" diff --git a/containers/nanoviz.container b/containers/nanoviz.container new file mode 100644 index 0000000..1eaabce --- /dev/null +++ b/containers/nanoviz.container @@ -0,0 +1,55 @@ +# Podman Quadlet for nanoviz. +# +# Drop this at /etc/containers/systemd/nanoviz.container, then: +# sudo systemctl daemon-reload +# sudo systemctl start nanoviz.service +# (Do not `systemctl enable` — Quadlet generates a transient unit in +# /run/systemd/generator/ and wires [Install] WantedBy= itself.) +# +# pi/setup.sh installs this for you and substitutes the volume path if you +# use --config-dir. Requires podman >= 4.4 (Quadlet generator). + +[Unit] +Description=NanoViz (Nanoleaf audio visualizer) +After=network-online.target sound.target +Wants=network-online.target + +[Container] +Image=ghcr.io/weekendsuperhero-io/nanoviz:latest +ContainerName=nanoviz +Network=host +# nqptp binds UDP 319/320 for PTP; shairport needs NET_ADMIN for RT scheduling. +AddCapability=NET_BIND_SERVICE +AddCapability=NET_ADMIN +AddDevice=/dev/snd:/dev/snd +GroupAdd=audio +# Persistent config (config.toml, nl_devices.toml). Path is rewritten by +# pi/setup.sh to match --config-dir. +Volume=/etc/nanoviz/config:/root/.config/nanoviz +# Share the host's dbus + avahi so shairport-sync registers AirPlay via the +# host's avahi-daemon instead of running its own. With Network=host, two +# avahi-daemons on the same network collide on the mDNS hostname, producing +# a "Host name conflict, retrying with HOST-N" loop. +Volume=/var/run/dbus:/var/run/dbus +Volume=/var/run/avahi-daemon:/var/run/avahi-daemon +# Pull updated image when 'systemctl start' fires after upstream tags move. +AutoUpdate=registry +# Override the AirPlay display name that appears in the iPhone/Mac picker. +# Default (baked into shairport-sync.conf) is "nanoviz". Edit + uncomment +# and run `sudo systemctl daemon-reload && sudo systemctl restart nanoviz`. +# pi/setup.sh --airplay-name="..." can manage this line for you. +#Environment=NANOVIZ_AIRPLAY_NAME=nanoviz + +# Uncomment to dump every parsed shairport metadata item to journalctl +# (decoded fourcc/code, length, UTF-8 or hex preview). After editing: +# sudo systemctl daemon-reload && sudo systemctl restart nanoviz +# Then: journalctl -fu nanoviz | grep META +#Environment=NANOVIZ_LOG_METADATA=1 + +[Service] +Restart=on-failure +RestartSec=10 +TimeoutStartSec=900 + +[Install] +WantedBy=default.target diff --git a/install_share_port_sync.sh b/install_share_port_sync.sh new file mode 100755 index 0000000..d80a3ef --- /dev/null +++ b/install_share_port_sync.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +CONFIG_SOURCE="${SCRIPT_DIR}/piWebServer/shairport-sync.conf" +ASOUND_SOURCE="${SCRIPT_DIR}/piWebServer/asound-default-loopback.conf" + +if [[ ! -f "${CONFIG_SOURCE}" ]]; then + echo "ERROR: Expected config file not found: ${CONFIG_SOURCE}" >&2 + exit 1 +fi +if [[ ! -f "${ASOUND_SOURCE}" ]]; then + echo "ERROR: Expected ALSA config file not found: ${ASOUND_SOURCE}" >&2 + exit 1 +fi + +if ! command -v sudo >/dev/null 2>&1; then + echo "ERROR: sudo is required." >&2 + exit 1 +fi + +if ! command -v apt-get >/dev/null 2>&1; then + echo "ERROR: apt-get not found. This script targets Debian/Raspberry Pi OS." >&2 + exit 1 +fi + +if ! command -v systemctl >/dev/null 2>&1; then + echo "ERROR: systemctl not found. This script expects a systemd host." >&2 + exit 1 +fi + +echo "[0/11] Validate sudo access" +sudo -v + +echo "[1/11] Stop old services" +sudo systemctl disable --now shairport-sync >/dev/null 2>&1 || true +sudo systemctl disable --now nqptp >/dev/null 2>&1 || true + +echo "[2/11] Remove distro packages (if present)" +sudo apt-get remove -y shairport-sync nqptp || true +sudo apt-get autoremove -y + +echo "[3/11] Remove old manual binaries/service files" +for f in \ + /usr/local/bin/shairport-sync /usr/local/sbin/shairport-sync \ + /usr/local/bin/nqptp /usr/local/sbin/nqptp \ + /etc/systemd/system/shairport-sync.service /lib/systemd/system/shairport-sync.service \ + /etc/systemd/user/shairport-sync.service /lib/systemd/user/shairport-sync.service \ + /etc/systemd/system/nqptp.service /lib/systemd/system/nqptp.service \ + /etc/systemd/user/nqptp.service /lib/systemd/user/nqptp.service \ + /etc/init.d/shairport-sync /etc/init.d/nqptp \ + /etc/dbus-1/system.d/shairport-sync-dbus.conf /etc/dbus-1/system.d/shairport-sync-mpris.conf +do + sudo rm -f "$f" +done + +if [[ -f /etc/shairport-sync.conf ]]; then + sudo cp /etc/shairport-sync.conf "/etc/shairport-sync.conf.bak.$(date +%Y%m%d%H%M%S)" +fi + +sudo systemctl daemon-reload +sudo systemctl reset-failed || true + +echo "[4/11] Install build dependencies" +sudo apt-get update +sudo apt-get install -y --no-install-recommends \ + build-essential git autoconf automake libtool \ + libpopt-dev libconfig-dev libasound2-dev \ + avahi-daemon libavahi-client-dev libssl-dev libsoxr-dev \ + libplist-dev libsodium-dev uuid-dev libgcrypt-dev xxd libplist-utils \ + libavutil-dev libavcodec-dev libavformat-dev libswresample-dev ffmpeg +sudo apt-get install -y --no-install-recommends systemd-dev || true + +echo "[5/11] Configure ALSA loopback module" +echo "snd-aloop" | sudo tee /etc/modules-load.d/snd-aloop.conf >/dev/null +echo "options snd-aloop id=Loopback index=2 pcm_substreams=8" \ + | sudo tee /etc/modprobe.d/snd-aloop.conf >/dev/null +sudo modprobe snd-aloop || true + +echo "[6/11] Build/install NQPTP" +cd "$HOME" +if [[ ! -d nqptp ]]; then git clone https://github.com/mikebrady/nqptp.git; fi +cd nqptp +git pull --ff-only || true +autoreconf -fi +./configure --with-systemd-startup +make -j"$(nproc)" +sudo make install +sudo systemctl enable --now nqptp + +echo "[7/11] Build/install Shairport Sync (AirPlay 2)" +cd "$HOME" +if [[ ! -d shairport-sync ]]; then git clone https://github.com/mikebrady/shairport-sync.git; fi +cd shairport-sync +git pull --ff-only || true +autoreconf -fi +# AirPlay 2 requires Avahi + OpenSSL. +./configure --sysconfdir=/etc --with-alsa --with-soxr --with-avahi \ + --with-ssl=openssl --with-systemd-startup --with-airplay-2 --with-metadata +make -j"$(nproc)" +sudo make install + +echo "[8/11] Write Shairport configuration" +sudo install -m 0644 "${CONFIG_SOURCE}" /etc/shairport-sync.conf + +echo "[9/11] Configure ALSA default input mapping" +if [[ -f /etc/asound.conf ]]; then + sudo cp /etc/asound.conf "/etc/asound.conf.bak.$(date +%Y%m%d%H%M%S)" +fi +sudo install -m 0644 "${ASOUND_SOURCE}" /etc/asound.conf +if [[ -f "${HOME}/.asoundrc" ]]; then + cp "${HOME}/.asoundrc" "${HOME}/.asoundrc.bak.$(date +%Y%m%d%H%M%S)" +fi +install -m 0644 "${ASOUND_SOURCE}" "${HOME}/.asoundrc" + +echo "[10/11] Ensure metadata pipe exists at boot and now" +echo "p /tmp/shairport-sync-metadata 0666 root root -" \ + | sudo tee /etc/tmpfiles.d/shairport-sync-metadata.conf >/dev/null +sudo systemd-tmpfiles --create /etc/tmpfiles.d/shairport-sync-metadata.conf +sudo rm -f /tmp/shairport-sync-metadata +sudo mkfifo /tmp/shairport-sync-metadata +sudo chmod 0666 /tmp/shairport-sync-metadata + +echo "[11/11] Enable/start services and verify" +sudo systemctl enable --now avahi-daemon shairport-sync +sudo systemctl restart shairport-sync + +echo +echo "Versions:" +nqptp -V || true +shairport-sync -V || true + +echo +echo "Verify Shairport build capabilities (AirPlay2):" +SHAIRPORT_VERSION="$(shairport-sync -V 2>/dev/null || true)" +if ! echo "${SHAIRPORT_VERSION}" | grep -q "AirPlay2"; then + echo "ERROR: Installed shairport-sync does not report AirPlay2 support: ${SHAIRPORT_VERSION}" >&2 + exit 1 +fi +echo "OK: ${SHAIRPORT_VERSION}" + +echo +echo "Verify FFmpeg AAC decoder supports fltp (required for AirPlay 2 buffered AAC):" +AAC_DECODER_INFO="$(ffmpeg -hide_banner -h decoder=aac 2>/dev/null || true)" +if ! echo "${AAC_DECODER_INFO}" | grep -qi "Supported sample formats:.*fltp"; then + echo "ERROR: ffmpeg AAC decoder does not report fltp support." >&2 + echo "Install/repair FFmpeg so decoder=aac supports planar float." >&2 + exit 1 +fi +echo "OK: AAC decoder reports fltp support." + +echo +echo "Service status:" +systemctl --no-pager --full status nqptp shairport-sync | sed -n '1,120p' +echo +echo "ALSA devices:" +aplay -l | sed -n '1,120p' +arecord -l | sed -n '1,120p' +echo +echo "Metadata pipe:" +ls -l /tmp/shairport-sync-metadata || true diff --git a/pi/setup.sh b/pi/setup.sh new file mode 100755 index 0000000..9c83538 --- /dev/null +++ b/pi/setup.sh @@ -0,0 +1,444 @@ +#!/usr/bin/env bash +# nanoviz — Raspberry Pi container setup (podman only). +# +# Usage: +# sudo ./pi/setup.sh # full install + deploy +# curl -fsSL https://raw.githubusercontent.com/Weekendsuperhero-io/nanoviz/main/pi/setup.sh | sudo bash +# +# Flags: +# --name=NAME instance name (default "nanoviz"). Controls the +# systemd unit (NAME.service), container name, Quadlet +# filename (NAME.container), polkit rule, and the +# default config dir (~/.config/NAME). Use a distinct +# name to run multiple independent instances on one host. +# --no-systemd skip writing/enabling the systemd service +# --no-deploy host prep only (don't pull/start the container) +# --force-compose overwrite the staged compose.yaml if it exists +# --config-dir=DIR override the default config dir +# --image-tag=TAG container image tag (default: "dev" on non-main git +# branches, "latest" otherwise) +# --airplay-name=NAME AirPlay display name advertised on mDNS (the name +# that appears in the iPhone/Mac AirPlay picker). +# Default leaves the image's built-in value. Spaces +# are allowed; quote the value when calling. + +set -euo pipefail + +# ---------- defaults ---------- +ENABLE_SYSTEMD=1 +DEPLOY=1 +FORCE_COMPOSE=0 +CONFIG_DIR="" # resolved after preflight; "" means "pick default" +IMAGE_TAG="" +INSTANCE_NAME="nanoviz" +AIRPLAY_NAME="" +COMPOSE_URL="https://raw.githubusercontent.com/Weekendsuperhero-io/nanoviz/main/containers/compose.yaml" +QUADLET_URL="https://raw.githubusercontent.com/Weekendsuperhero-io/nanoviz/main/containers/nanoviz.container" + +TARGET_USER="${SUDO_USER:-${USER:-}}" +SCRIPT_DIR="" +if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then + SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +fi + +# ---------- arg parsing ---------- +for arg in "$@"; do + case "$arg" in + --no-systemd) ENABLE_SYSTEMD=0 ;; + --no-deploy) DEPLOY=0 ;; + --force-compose) FORCE_COMPOSE=1 ;; + --config-dir=*) CONFIG_DIR="${arg#*=}" ;; + --image-tag=*) IMAGE_TAG="${arg#*=}" ;; + --name=*) + INSTANCE_NAME="${arg#*=}" + if [[ ! "$INSTANCE_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "ERROR: --name must match [a-zA-Z0-9_-]+ (got: $INSTANCE_NAME)" >&2 + exit 2 + fi + ;; + --airplay-name=*) + AIRPLAY_NAME="${arg#*=}" + # mDNS rejects control characters and very long names. Spaces are + # fine. Strip leading/trailing whitespace; reject anything obvious. + AIRPLAY_NAME="${AIRPLAY_NAME#"${AIRPLAY_NAME%%[![:space:]]*}"}" + AIRPLAY_NAME="${AIRPLAY_NAME%"${AIRPLAY_NAME##*[![:space:]]}"}" + if [[ -z "$AIRPLAY_NAME" || ${#AIRPLAY_NAME} -gt 63 || "$AIRPLAY_NAME" == *$'\n'* ]]; then + echo "ERROR: --airplay-name must be 1-63 printable chars, no newlines." >&2 + exit 2 + fi + ;; + -h|--help) + sed -n '2,23p' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) + echo "ERROR: unknown flag: $arg" >&2 + exit 2 + ;; + esac +done + +# ---------- helpers ---------- +banner() { printf '\n[%s/9] %s\n' "$1" "$2"; } +log() { printf ' %s\n' "$*"; } +warn() { printf ' WARN: %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +# Pick the container image tag. Explicit --image-tag wins. Otherwise, if the +# script is running from a git checkout, default to "dev" on non-main branches +# so feature-branch installs pull the CI :dev tag instead of :latest (= main). +detect_image_tag() { + [[ -n "$IMAGE_TAG" ]] && { echo "$IMAGE_TAG"; return; } + if [[ -n "$SCRIPT_DIR" ]] && git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1; then + local branch + branch="$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo)" + if [[ -n "$branch" && "$branch" != "main" && "$branch" != "HEAD" ]]; then + echo "dev" + return + fi + fi + echo "latest" +} + +# ---------- preflight ---------- +[[ "$(uname -s)" == "Linux" ]] || die "This script targets Linux (Raspberry Pi OS / Debian)." +command -v apt-get >/dev/null || die "apt-get not found. This script targets Debian-based hosts." +command -v systemctl >/dev/null || die "systemctl not found. systemd is required." + +if [[ $EUID -ne 0 ]]; then + if command -v sudo >/dev/null; then + log "Re-executing under sudo..." + exec sudo -E bash "$0" "$@" + fi + die "Must run as root (or via sudo)." +fi + +TARGET_HOME="" +if [[ -z "$TARGET_USER" || "$TARGET_USER" == "root" ]]; then + warn "No non-root \$SUDO_USER detected; group memberships will be skipped." + TARGET_USER="" +else + # We were re-exec'd under sudo, so $HOME is root's. Look up the real user's + # home directly so config defaults land in their home, not /root. + TARGET_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)" + if [[ -z "$TARGET_HOME" || ! -d "$TARGET_HOME" ]]; then + warn "Could not resolve home directory for $TARGET_USER; falling back to /etc." + TARGET_HOME="" + fi +fi + +# Resolve CONFIG_DIR default: invoking user's XDG config when possible, +# system-wide /etc otherwise. Explicit --config-dir= always wins. The +# instance name is part of the path so multiple --name= installs don't share +# config and device files. +if [[ -z "$CONFIG_DIR" ]]; then + if [[ -n "$TARGET_HOME" ]]; then + CONFIG_DIR="$TARGET_HOME/.config/$INSTANCE_NAME" + else + CONFIG_DIR="/etc/$INSTANCE_NAME" + fi +fi +log "Instance name: $INSTANCE_NAME" +log "Config dir: $CONFIG_DIR" + +# Warn (but don't migrate) if an old /etc install would be left orphaned. +if [[ "$CONFIG_DIR" != "/etc/$INSTANCE_NAME" && -f "/etc/$INSTANCE_NAME/config/config.toml" ]]; then + warn "/etc/$INSTANCE_NAME/config/config.toml exists. New default is $CONFIG_DIR;" + warn "re-run with --config-dir=/etc/$INSTANCE_NAME to keep using the old layout." +fi + +IMAGE_TAG="$(detect_image_tag)" +log "Using container image tag: $IMAGE_TAG" + +# ---------- 1. install OS packages ---------- +banner 1 "Install OS packages" +NEED_INSTALL=() +for pkg in podman podman-compose alsa-utils ca-certificates curl; do + if ! dpkg -s "$pkg" >/dev/null 2>&1; then + NEED_INSTALL+=("$pkg") + fi +done +if (( ${#NEED_INSTALL[@]} )); then + log "Installing: ${NEED_INSTALL[*]}" + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${NEED_INSTALL[@]}" +else + log "All packages already installed." +fi + +# Decide which compose invocation to use. Prefer the v5+ plugin (`podman compose`) +# and fall back to the legacy standalone `podman-compose` binary. +if podman compose version >/dev/null 2>&1; then + COMPOSE_CMD=(podman compose) +elif command -v podman-compose >/dev/null 2>&1; then + COMPOSE_CMD=(podman-compose) +else + die "Neither 'podman compose' nor 'podman-compose' is available after install." +fi +log "Using compose: ${COMPOSE_CMD[*]}" + +# ---------- 2. group memberships ---------- +banner 2 "Group memberships" +if [[ -n "$TARGET_USER" ]]; then + current_groups="$(id -nG "$TARGET_USER" 2>/dev/null || echo "")" + # audio — ALSA device access for native runs + # render — some Pi GPU/audio paths use it + # systemd-journal — read system journal without sudo (`journalctl -fu nanoviz`) + for grp in audio render systemd-journal; do + if getent group "$grp" >/dev/null; then + if [[ " $current_groups " == *" $grp "* ]]; then + log "$TARGET_USER already in '$grp'." + else + usermod -aG "$grp" "$TARGET_USER" + log "Added $TARGET_USER to '$grp'." + fi + else + log "Group '$grp' not present on this host; skipping." + fi + done + log "Note: new group memberships take effect after next login." +else + log "Skipped (no target user)." +fi + +# ---------- 3. snd-aloop kernel module ---------- +banner 3 "Configure snd-aloop kernel module" +mkdir -p /etc/modules-load.d /etc/modprobe.d +echo "snd-aloop" > /etc/modules-load.d/snd-aloop.conf +cat > /etc/modprobe.d/snd-aloop.conf <<'EOF' +options snd-aloop id=Loopback index=2 pcm_substreams=8 +EOF + +needs_reload=0 +if lsmod | grep -q '^snd_aloop'; then + current_id="" + if [[ -r /sys/module/snd_aloop/parameters/id ]]; then + current_id="$(tr -d '\0\n ' /dev/null || true + if ! modprobe snd-aloop; then + warn "modprobe snd-aloop failed. The kernel module package may be missing." + fi +fi + +if grep -q Loopback /proc/asound/cards 2>/dev/null; then + log "Verified: 'Loopback' present in /proc/asound/cards." +else + warn "'Loopback' card not present in /proc/asound/cards. Audio capture will fail until this is resolved." +fi + +# ---------- 4. stage compose + config ---------- +banner 4 "Stage compose file + config dir" +mkdir -p "$CONFIG_DIR/config" +chmod 0755 "$CONFIG_DIR" "$CONFIG_DIR/config" + +# When the config lives under the invoking user's home, hand ownership back +# to them so they can edit config.toml without sudo. +if [[ -n "$TARGET_HOME" && "$CONFIG_DIR" == "$TARGET_HOME"/* ]]; then + chown -R "$TARGET_USER:" "$CONFIG_DIR" + log "Chowned $CONFIG_DIR to $TARGET_USER." +fi + +compose_dest="$CONFIG_DIR/compose.yaml" +local_compose="" +if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../containers/compose.yaml" ]]; then + local_compose="$SCRIPT_DIR/../containers/compose.yaml" +fi + +if [[ -f "$compose_dest" && $FORCE_COMPOSE -eq 0 ]]; then + log "$compose_dest exists; preserving (use --force-compose to overwrite)." +elif [[ -n "$local_compose" ]]; then + cp "$local_compose" "$compose_dest" + log "Copied compose.yaml from local clone." +else + if curl -fsSL "$COMPOSE_URL" -o "$compose_dest"; then + log "Fetched compose.yaml from $COMPOSE_URL." + else + die "Failed to fetch $COMPOSE_URL" + fi +fi + +# Rewrite the image: tag in the staged compose. The in-repo file always pins +# :latest; the installer swaps it for whatever detect_image_tag picked. +if [[ -f "$compose_dest" && "$IMAGE_TAG" != "latest" ]]; then + sed -i "s|\(image: ghcr.io/weekendsuperhero-io/nanoviz:\)latest|\1${IMAGE_TAG}|" \ + "$compose_dest" +fi + +# ---------- 5. pull image ---------- +banner 5 "Pull container image" +if (( DEPLOY )); then + ( cd "$CONFIG_DIR" && "${COMPOSE_CMD[@]}" pull ) +else + log "Skipped (--no-deploy)." +fi + +# ---------- 6. install Quadlet ---------- +banner 6 "Install Podman Quadlet" +if (( DEPLOY )) && (( ENABLE_SYSTEMD )); then + # Quadlets require podman >= 4.4 (the systemd generator that translates + # .container files into .service units). + podman_version="$(podman version --format '{{.Client.Version}}' 2>/dev/null || echo 0)" + podman_major="${podman_version%%.*}" + podman_minor_full="${podman_version#*.}" + podman_minor="${podman_minor_full%%.*}" + if [[ ! "$podman_major" =~ ^[0-9]+$ ]] || [[ ! "$podman_minor" =~ ^[0-9]+$ ]] \ + || (( podman_major < 4 )) \ + || (( podman_major == 4 && podman_minor < 4 )); then + die "Podman $podman_version is too old for Quadlets (need >= 4.4). Re-run with --no-systemd to use compose, or upgrade podman." + fi + log "Podman $podman_version supports Quadlets." + + # Source for the Quadlet template: prefer local clone, else fetch from main. + local_quadlet="" + if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../containers/nanoviz.container" ]]; then + local_quadlet="$SCRIPT_DIR/../containers/nanoviz.container" + fi + + quadlet_dir="/etc/containers/systemd" + quadlet_dest="$quadlet_dir/${INSTANCE_NAME}.container" + mkdir -p "$quadlet_dir" + + if [[ -n "$local_quadlet" ]]; then + quadlet_src="$local_quadlet" + log "Installing Quadlet from local clone." + else + quadlet_src="$(mktemp)" + if ! curl -fsSL "$QUADLET_URL" -o "$quadlet_src"; then + rm -f "$quadlet_src" + die "Failed to fetch $QUADLET_URL" + fi + log "Fetched Quadlet from $QUADLET_URL." + fi + + # Substitute the volume mount (template default: /etc/nanoviz/config), + # the image tag (template default: :latest), and the container name + # (template default: ContainerName=nanoviz) so multiple --name= + # instances don't collide on the host. + sed_args=() + if [[ "$CONFIG_DIR" != "/etc/nanoviz" ]]; then + sed_args+=(-e "s|^Volume=/etc/nanoviz/config:|Volume=${CONFIG_DIR}/config:|") + fi + if [[ "$IMAGE_TAG" != "latest" ]]; then + sed_args+=(-e "s|^\(Image=ghcr.io/weekendsuperhero-io/nanoviz:\)latest|\1${IMAGE_TAG}|") + fi + if [[ "$INSTANCE_NAME" != "nanoviz" ]]; then + sed_args+=(-e "s|^ContainerName=nanoviz$|ContainerName=${INSTANCE_NAME}|") + fi + if [[ -n "$AIRPLAY_NAME" ]]; then + # Uncomment the placeholder and set the value. Escape sed metacharacters + # in the name so spaces / regex chars survive cleanly. + escaped_airplay_name="$(printf '%s' "$AIRPLAY_NAME" | sed 's/[\\&|]/\\&/g')" + sed_args+=(-e "s|^#Environment=NANOVIZ_AIRPLAY_NAME=.*|Environment=NANOVIZ_AIRPLAY_NAME=${escaped_airplay_name}|") + fi + if (( ${#sed_args[@]} )); then + sed "${sed_args[@]}" "$quadlet_src" > "$quadlet_dest" + log "Rewrote Quadlet (name=$INSTANCE_NAME, config-dir=$CONFIG_DIR, image tag=$IMAGE_TAG${AIRPLAY_NAME:+, airplay-name=$AIRPLAY_NAME})." + else + cp "$quadlet_src" "$quadlet_dest" + fi + chmod 0644 "$quadlet_dest" + + # Clean up the temp file if we used one. + [[ -z "$local_quadlet" ]] && rm -f "$quadlet_src" + + # The Quadlet generator runs at daemon-reload and turns .container files + # into transient .service units in /run/systemd/generator/. The generator + # honors [Install] WantedBy= itself by writing the wants symlinks, so we + # must NOT call `systemctl enable` (which fails on generated units with + # "Unit ... is transient or generated."). + systemctl daemon-reload + + service_unit="${INSTANCE_NAME}.service" + if systemctl is-active --quiet "$service_unit"; then + systemctl restart "$service_unit" + log "$service_unit restarted via Quadlet." + else + systemctl start "$service_unit" + log "$service_unit started via Quadlet (auto-wired to default.target by the generator)." + fi +else + log "Skipped ($([[ $DEPLOY -eq 0 ]] && echo --no-deploy || echo --no-systemd))." +fi + +# ---------- 7. polkit rule (no-sudo systemctl) ---------- +banner 7 "polkit rule for no-sudo service control" +if (( ENABLE_SYSTEMD )); then + polkit_rules_dir="/etc/polkit-1/rules.d" + polkit_rule_file="$polkit_rules_dir/50-${INSTANCE_NAME}.rules" + if [[ -d "$polkit_rules_dir" ]]; then + cat >"$polkit_rule_file" <} + Web UI: http://${host_ip}:8787 + Config dir: $CONFIG_DIR/config + Devices file: $CONFIG_DIR/config/nl_devices.toml (host path; container sees /root/.config/nanoviz/nl_devices.toml) + Compose file: $compose_dest + Quadlet: /etc/containers/systemd/${INSTANCE_NAME}.container + +Useful commands (no sudo needed once you've logged out and back in): + journalctl -fu ${INSTANCE_NAME} # live logs + systemctl status ${INSTANCE_NAME} # service state + systemctl restart ${INSTANCE_NAME} # restart + sudo podman compose -f $CONFIG_DIR/compose.yaml pull # update image (still needs sudo for podman) + +To enable verbose shairport metadata logging: + edit /etc/containers/systemd/${INSTANCE_NAME}.container + uncomment the NANOVIZ_LOG_METADATA line, then + sudo systemctl daemon-reload && systemctl restart ${INSTANCE_NAME} + journalctl -fu ${INSTANCE_NAME} | grep META + +If you were just added to new groups, log out and log back in for them to apply. +EOF diff --git a/pi/uninstall.sh b/pi/uninstall.sh new file mode 100755 index 0000000..93d2414 --- /dev/null +++ b/pi/uninstall.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# nanoviz — Raspberry Pi teardown (reverses pi/setup.sh). +# +# Usage: +# sudo ./pi/uninstall.sh # remove service + Quadlet + polkit rule +# sudo ./pi/uninstall.sh --name=foo # uninstall a non-default instance +# sudo ./pi/uninstall.sh --remove-image # also `podman rmi` the nanoviz image +# sudo ./pi/uninstall.sh --remove-aloop # also remove snd-aloop module config +# sudo ./pi/uninstall.sh --purge # also delete the config dir (loses +# # nl_devices.toml pairing tokens) +# +# Always preserved unless --purge: +# ~/.config// (compose.yaml, config/config.toml, config/nl_devices.toml) +# +# Always removed: +# /etc/containers/systemd/.container +# /etc/polkit-1/rules.d/50-.rules +# the running .service + container + +set -euo pipefail + +# ---------- defaults ---------- +INSTANCE_NAME="nanoviz" +REMOVE_IMAGE=0 +REMOVE_ALOOP=0 +PURGE=0 +CONFIG_DIR="" + +TARGET_USER="${SUDO_USER:-${USER:-}}" + +# ---------- arg parsing ---------- +for arg in "$@"; do + case "$arg" in + --name=*) INSTANCE_NAME="${arg#*=}" ;; + --config-dir=*) CONFIG_DIR="${arg#*=}" ;; + --remove-image) REMOVE_IMAGE=1 ;; + --remove-aloop) REMOVE_ALOOP=1 ;; + --purge) PURGE=1 ;; + -h|--help) + sed -n '2,19p' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) + echo "ERROR: unknown flag: $arg" >&2 + exit 2 + ;; + esac +done + +if [[ ! "$INSTANCE_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "ERROR: --name must match [a-zA-Z0-9_-]+ (got: $INSTANCE_NAME)" >&2 + exit 2 +fi + +# ---------- helpers ---------- +banner() { printf '\n[%s/6] %s\n' "$1" "$2"; } +log() { printf ' %s\n' "$*"; } +warn() { printf ' WARN: %s\n' "$*" >&2; } + +# ---------- preflight ---------- +if [[ $EUID -ne 0 ]]; then + if command -v sudo >/dev/null; then + log "Re-executing under sudo..." + exec sudo -E bash "$0" "$@" + fi + echo "ERROR: must run as root (or via sudo)." >&2 + exit 1 +fi + +# Resolve a sensible default for CONFIG_DIR mirroring setup.sh, so --purge +# knows where to look. Skip the lookup unless we're actually purging. +if (( PURGE )) && [[ -z "$CONFIG_DIR" ]]; then + if [[ -n "$TARGET_USER" && "$TARGET_USER" != "root" ]]; then + target_home="$(getent passwd "$TARGET_USER" | cut -d: -f6)" + if [[ -n "$target_home" && -d "$target_home" ]]; then + CONFIG_DIR="$target_home/.config/$INSTANCE_NAME" + fi + fi + if [[ -z "$CONFIG_DIR" ]]; then + CONFIG_DIR="/etc/$INSTANCE_NAME" + fi +fi + +service_unit="${INSTANCE_NAME}.service" +quadlet_file="/etc/containers/systemd/${INSTANCE_NAME}.container" +polkit_rule="/etc/polkit-1/rules.d/50-${INSTANCE_NAME}.rules" + +log "Instance: $INSTANCE_NAME" +log "Service: $service_unit" +log "Quadlet: $quadlet_file" +log "Polkit rule: $polkit_rule" +(( PURGE )) && log "Will purge: $CONFIG_DIR" + +# ---------- 1. stop the systemd service ---------- +banner 1 "Stop systemd service" +if systemctl list-unit-files --type=service --all 2>/dev/null | grep -q "^${service_unit}" \ + || systemctl is-active --quiet "$service_unit" 2>/dev/null \ + || systemctl status "$service_unit" >/dev/null 2>&1; then + if systemctl is-active --quiet "$service_unit"; then + systemctl stop "$service_unit" || warn "systemctl stop $service_unit returned non-zero" + log "Stopped $service_unit." + else + log "$service_unit not active." + fi +else + log "$service_unit not present." +fi + +# ---------- 2. remove Quadlet drop-in + reload generator ---------- +banner 2 "Remove Quadlet drop-in" +if [[ -f "$quadlet_file" ]]; then + rm -f "$quadlet_file" + log "Removed $quadlet_file." +else + log "Already absent: $quadlet_file." +fi +systemctl daemon-reload +log "systemctl daemon-reload done (generator will no longer materialize the unit)." + +# ---------- 3. remove the container if still around ---------- +banner 3 "Remove container" +if command -v podman >/dev/null 2>&1; then + if podman container exists "$INSTANCE_NAME" 2>/dev/null; then + podman rm -f "$INSTANCE_NAME" >/dev/null + log "Removed container '$INSTANCE_NAME'." + else + log "No container named '$INSTANCE_NAME'." + fi +else + log "podman not installed; skipping container removal." +fi + +# ---------- 4. remove polkit rule ---------- +banner 4 "Remove polkit rule" +if [[ -f "$polkit_rule" ]]; then + rm -f "$polkit_rule" + log "Removed $polkit_rule." +else + log "Already absent: $polkit_rule." +fi + +# ---------- 5. optional: snd-aloop module config ---------- +banner 5 "snd-aloop module config" +if (( REMOVE_ALOOP )); then + rm -f /etc/modules-load.d/snd-aloop.conf /etc/modprobe.d/snd-aloop.conf + log "Removed /etc/modules-load.d/snd-aloop.conf and /etc/modprobe.d/snd-aloop.conf." + if lsmod | grep -q '^snd_aloop'; then + if modprobe -r snd-aloop 2>/dev/null; then + log "Unloaded snd-aloop." + else + warn "snd-aloop is in use; unload deferred until next reboot." + fi + fi +else + log "Kept (pass --remove-aloop to remove). Loopback ALSA card will still load at boot." +fi + +# ---------- 6. optional: image + config purge ---------- +banner 6 "Image and config" +if (( REMOVE_IMAGE )); then + if command -v podman >/dev/null 2>&1; then + # Match any tag of the nanoviz image; ignore failures (e.g. image absent). + for img in $(podman images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null \ + | grep -E 'nanoviz:(latest|dev|[A-Za-z0-9._-]+)$' || true); do + podman rmi -f "$img" >/dev/null 2>&1 && log "Removed image $img." \ + || warn "Could not remove $img (in use?)." + done + else + log "podman not installed; skipping image removal." + fi +else + log "Image kept (pass --remove-image to remove)." +fi + +if (( PURGE )); then + if [[ -n "$CONFIG_DIR" && -d "$CONFIG_DIR" ]]; then + rm -rf "$CONFIG_DIR" + log "Purged $CONFIG_DIR." + else + log "Config dir $CONFIG_DIR not present." + fi +else + log "Config kept at default location (pass --purge to remove)." +fi + +# Recommend an avahi-daemon restart so any stale mDNS registration from the +# now-removed service doesn't linger and collide with future installs. +echo +echo "Suggested follow-up:" +echo " sudo systemctl restart avahi-daemon # clear any leaked mDNS registration" +echo "Done. To reinstall: sudo ./pi/setup.sh ${INSTANCE_NAME:+--name=$INSTANCE_NAME}" diff --git a/piWebServer/asound-default-loopback.conf b/piWebServer/asound-default-loopback.conf new file mode 100644 index 0000000..834116f --- /dev/null +++ b/piWebServer/asound-default-loopback.conf @@ -0,0 +1,10 @@ +pcm.nanoviz_in { + type plug + slave.pcm "hw:Loopback,1,0" +} + +pcm.!default { + type plug + slave.pcm "hw:Loopback,1,0" +} + diff --git a/piWebServer/shairport-sync.conf b/piWebServer/shairport-sync.conf new file mode 100644 index 0000000..0dd4af5 --- /dev/null +++ b/piWebServer/shairport-sync.conf @@ -0,0 +1,62 @@ + general = { + // Default AirPlay name baked into the image. Override at runtime by + // setting the NANOVIZ_AIRPLAY_NAME env var (the entrypoint passes it + // to shairport-sync via `-a`, which takes precedence over this line). + name = "nanoviz"; + output_backend = "alsa"; + // Use the low-power interpolation path to avoid expensive soxr processing. + interpolation = "vernier"; + // NanoViz visualizer only needs stereo capture; disable multichannel AP2 paths. + six_channel_mode = "off"; + eight_channel_mode = "off"; + // Force any multichannel AirPlay 2 stream to a predictable stereo path. + mixdown = "stereo"; + output_channel_mapping = "auto"; + // Do not apply AirPlay volume changes on this endpoint. + // Useful when this receiver is only feeding a visualizer loopback path. + ignore_volume_control = "yes"; + // Sync trim versus other AirPlay endpoints/amplifiers. + // Positive delays this device; negative makes it earlier. + // Keep within about +/-0.25. + audio_backend_latency_offset_in_seconds = 0.0; + // Increase backend buffer modestly to reduce dropouts/XRUNs on busy Pi/network links. + audio_backend_buffer_desired_length_in_seconds = 0.4; + // AP2 decoded buffer target (default is 1.0s; keep explicit for clarity). + audio_decoded_buffer_desired_length_in_seconds = 1.0; + // Loosen drift correction slightly for visualizer-only usage. + drift_tolerance_in_seconds = 0.004; + resync_threshold_in_seconds = 0.100; + }; + + alsa = { + output_device = "hw:Loopback,0,0"; + // Pin output to one stable format so capture side doesn't see format/rate churn. + // NanoViz's loopback capture path prefers 44.1kHz / 16-bit / stereo on Linux. + output_rate = 44100; + output_format = "S16_LE"; + output_channels = 2; + use_mmap_if_available = "no"; + // Prefer compatibility over precision timing in loopback-only visualizer setups. + use_precision_timing = "no"; + // Keep the aloop writer side open at all times by sending silence during idle. + // Without this, shairport closes hw:Loopback,0,0 after active_state_timeout, + // which leaves nanoviz's capture (hw:Loopback,1,0) with no writer and ALSA + // returns POLLERR at ~200K/sec, starving the API's tokio runtime. + disable_standby_mode = "always"; + disable_standby_mode_silence_threshold = 0.040; + disable_standby_mode_silence_scan_interval = 0.030; + }; + + metadata = { + enabled = "yes"; + include_cover_art = "yes"; + pipe_name = "/tmp/shairport-sync-metadata"; + }; + + // Diagnostic logging — enabled while we hunt the long-pause AirPlay disappearance. + // Revert to the commented form once root cause is captured. + diagnostics = { + log_verbosity = 2; // 0=off, 1=basic, 2=detailed, 3=trace + log_show_file_and_line = "yes"; + log_show_time_since_startup = "yes"; + }; diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index ab0a6c3..0000000 --- a/src/app.rs +++ /dev/null @@ -1,884 +0,0 @@ -use crate::audio; -use crate::constants; -use crate::layout_visualizer::PanelInfo; -use crate::visualizer::{self, VisualizerMsg}; -use crate::{ - config::{Axis, Effect, Sort, VisualizerConfig}, - nanoleaf::NlDevice, -}; -use anyhow::Result; -use macroquad::prelude::*; -use std::collections::HashMap; -use std::f32::consts::PI; -use std::sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - mpsc, -}; -use std::time::Duration; - -/// Display state shared between the main thread and the album art watcher thread. -struct VizState { - colors: Vec<[u8; 3]>, - track_title: Option, - artwork_bytes: Option>, -} - -pub struct App { - // Device - nl_device: NlDevice, - panels: Vec, - global_orientation: u16, - - // Visualizer - visualizer_tx: mpsc::Sender, - shared_colors: Arc>>, - - // Settings - gain: f32, - current_palette_index: usize, - palette_names: Vec, - effect: Effect, - primary_axis: Axis, - sort_primary: Sort, - sort_secondary: Sort, - - // 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` with macroquad-based graphical UI. - /// - /// 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); - - let global_orientation = nl_device - .get_global_orientation() - .ok() - .and_then(|o| o["value"].as_u64()) - .unwrap_or(0) as u16; - - let primary_axis = visualizer_config.primary_axis.unwrap_or_default(); - let sort_primary = visualizer_config.sort_primary.unwrap_or_default(); - let sort_secondary = visualizer_config.sort_secondary.unwrap_or_default(); - let effect = visualizer_config.effect.unwrap_or_default(); - - let initial_colors = visualizer_config - .colors - .clone() - .unwrap_or_else(|| Vec::from(constants::DEFAULT_COLORS)); - - // 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(); - - 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, - })); - - nl_device.request_udp_control()?; - - Ok(App { - nl_device, - panels, - global_orientation, - visualizer_tx: tx, - shared_colors, - gain, - current_palette_index: 0, - palette_names, - effect, - primary_axis, - sort_primary, - sort_secondary, - show_visualization: false, - show_help: false, - viz_state, - album_art_stop: None, - album_art_texture: None, - loaded_artwork_len: 0, - }) - } - - /// 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 { - 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; - } - - // Shutdown - let _ = self.visualizer_tx.send(VisualizerMsg::End); - self.stop_album_art_watcher(); - let _ = self.nl_device.set_state(Some(false), Some(0)); - } - - /// 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)); - } - '=' | '+' => { - self.gain += 0.05; - let _ = self.visualizer_tx.send(VisualizerMsg::SetGain(self.gain)); - } - '1'..='9' => { - let index = (ch as usize) - ('1' as usize); - self.change_palette(index); - } - '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); - } - ' ' => 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, - } - }) - .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); - } - 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; - } - } - - // ── 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)); - } - - 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)); - } - - /// 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.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)); - if stop.load(Ordering::Relaxed) { - break; - } - let title = crate::now_playing::get_track_title(); - if title != last_title { - last_title = title.clone(); - 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)); - } - } - } - }); - } - - 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; - } - } -} - -// ── 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; - } - } - } - - 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; - - 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 d1e5f76..2718f30 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -1,6 +1,7 @@ use crate::constants; use anyhow::{Result, bail}; -use cpal::{Device, SampleFormat, StreamConfig, traits::*}; +use cpal::{BufferSize, Device, SampleFormat, StreamConfig, traits::*}; +use hashbrown::HashMap; pub struct AudioStream { pub device: Device, @@ -8,6 +9,14 @@ pub struct AudioStream { pub stream_config: StreamConfig, } +#[derive(Clone)] +struct InputDeviceEntry { + device: Device, + backend_name: String, + friendly_name: String, + legacy_display_name: String, +} + impl AudioStream { /// Creates a new `AudioStream` instance for capturing audio from an input device. /// @@ -28,14 +37,15 @@ impl AudioStream { /// /// Propagates `cpal` errors for device discovery or config retrieval. Bail with available devices list if none match. pub fn new(device_name: Option<&str>) -> Result { - let device_name = match device_name { + let requested_name = match device_name { Some(name) => name, None => constants::DEFAULT_AUDIO_BACKEND, }; let host = cpal::default_host(); + let input_devices = enumerate_input_devices(&host)?; // Try to find the device in input devices (for loopback/monitor devices) - let device = match device_name { + let device = match requested_name { constants::DEFAULT_AUDIO_BACKEND => { // Check for common loopback device names first let loopback_names = [ @@ -43,6 +53,8 @@ impl AudioStream { "BlackHole 2ch", "BlackHole 16ch", "Loopback Audio", + "Loopback", + "snd-aloop", "CABLE Output", "VB-Audio", "Monitor", @@ -50,16 +62,17 @@ impl AudioStream { ]; let mut loopback_device = None; - if let Ok(devices) = host.input_devices() { - for device in devices { - 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; - } + for entry in &input_devices { + if loopback_names.iter().any(|lb| { + entry.friendly_name.contains(lb) || entry.backend_name.contains(lb) + }) { + #[cfg(debug_assertions)] + eprintln!( + "INFO: Found loopback device: {} ({})", + entry.friendly_name, entry.backend_name + ); + loopback_device = Some(entry.device.clone()); + break; } } @@ -70,29 +83,17 @@ impl AudioStream { host.default_input_device() }) } - _ => host.input_devices()?.find(|x| { - x.description() - .map(|d| d.name() == device_name) - .unwrap_or(false) - }), + _ => select_input_device(&input_devices, requested_name).map(|entry| entry.device), }; let Some(device) = device else { bail!(format!( "Audio backend `{}` not found, available options: {}", - device_name, - host.input_devices()? - .map(|dev| dev - .description() - .map(|d| d.name().to_string()) - .unwrap_or_default()) - .collect::>() - .join(", ") + requested_name, + list_input_backend_names()?.join(", ") )); }; - let audio_config = device.default_input_config()?; - let sample_format = audio_config.sample_format(); - let stream_config: StreamConfig = audio_config.into(); + let (sample_format, stream_config) = select_input_stream_profile(&device, requested_name)?; Ok(AudioStream { device, @@ -101,3 +102,392 @@ impl AudioStream { }) } } + +/// Returns input backend names with stable disambiguation suffixes for duplicates. +/// +/// Example: +/// - "Loopback, Loopback PCM [#1]" +/// - "Loopback, Loopback PCM [#2]" +pub fn list_input_backend_names() -> Result> { + let host = cpal::default_host(); + let devices = enumerate_input_devices(&host)?; + Ok(devices + .into_iter() + .map(|entry| entry.backend_name) + .collect()) +} + +fn enumerate_input_devices(host: &cpal::Host) -> Result> { + let raw_devices: Vec<(Device, String, String)> = host + .input_devices()? + .filter_map(|device| { + let backend_name = device + .id() + .ok() + .map(|id| id.1.trim().to_string()) + .filter(|name| !name.is_empty()); + let friendly_name = device + .description() + .ok() + .map(|description| description.name().trim().to_string()) + .filter(|name| !name.is_empty()); + + let backend_name = match (backend_name, friendly_name.as_deref()) { + (Some(name), _) => name, + (None, Some(name)) => name.to_string(), + (None, None) => return None, + }; + let friendly_name = friendly_name.unwrap_or_else(|| backend_name.clone()); + Some((device, backend_name, friendly_name)) + }) + .collect(); + + let mut counts: HashMap = HashMap::new(); + for (_, _, friendly_name) in &raw_devices { + *counts.entry(friendly_name.clone()).or_default() += 1; + } + + let mut seen: HashMap = HashMap::new(); + let mut result = Vec::with_capacity(raw_devices.len()); + for (device, backend_name, friendly_name) in raw_devices { + let total = counts.get(&friendly_name).copied().unwrap_or(1); + let legacy_display_name = if total > 1 { + let next = seen.entry(friendly_name.clone()).or_default(); + *next += 1; + format!("{} [#{}]", friendly_name, *next) + } else { + friendly_name.clone() + }; + result.push(InputDeviceEntry { + device, + backend_name, + friendly_name, + legacy_display_name, + }); + } + + Ok(result) +} + +fn select_input_device( + devices: &[InputDeviceEntry], + selected_name: &str, +) -> Option { + let selected_name = selected_name.trim(); + if selected_name.is_empty() { + return None; + } + + if let Some(entry) = devices.iter().find(|entry| { + entry.backend_name == selected_name + || entry.friendly_name == selected_name + || entry.legacy_display_name == selected_name + }) { + return Some(entry.clone()); + } + + let selected_without_host_prefix = strip_host_prefix(selected_name); + if selected_without_host_prefix != selected_name + && let Some(entry) = devices.iter().find(|entry| { + entry.backend_name == selected_without_host_prefix + || entry.friendly_name == selected_without_host_prefix + || entry.legacy_display_name == selected_without_host_prefix + }) + { + return Some(entry.clone()); + } + + if let Some((base_name, duplicate_index)) = parse_indexed_name(selected_name) { + let mut matched_index = 0usize; + for entry in devices { + if entry.friendly_name == base_name { + matched_index += 1; + if matched_index == duplicate_index { + return Some(entry.clone()); + } + } + } + } + + let candidate_names = expand_selector_candidates(selected_name); + devices + .iter() + .find(|entry| candidate_names.contains(&entry.backend_name)) + .cloned() +} + +fn select_input_stream_profile( + device: &Device, + requested_name: &str, +) -> Result<(SampleFormat, StreamConfig)> { + if should_prefer_loopback_profile(device, requested_name) + && let Some(profile) = pick_loopback_44100_profile(device)? + { + let sample_format = profile.sample_format(); + let mut stream_config = profile.config(); + // CPAL's default buffer on ALSA aloop is ~1024 frames (~23ms). That's too small: + // the first timing hiccup causes an XRUN, after which CPAL prepares the stream + // and leaves it stuck in PREPARED state with no data flowing. 8192 frames (~186ms) + // matches the visualizer's time_window and survives normal jitter. + stream_config.buffer_size = BufferSize::Fixed(8192); + return Ok((sample_format, stream_config)); + } + + let audio_config = device.default_input_config()?; + Ok((audio_config.sample_format(), audio_config.into())) +} + +fn should_prefer_loopback_profile(device: &Device, requested_name: &str) -> bool { + let normalized = requested_name.to_ascii_lowercase(); + + if normalized.contains("loopback") || normalized.contains("aloop") { + return true; + } + + if let Ok(description) = device.description() + && description.name().to_ascii_lowercase().contains("loopback") + { + return true; + } + + if let Ok(device_id) = device.id() + && device_id.1.to_ascii_lowercase().contains("loopback") + { + return true; + } + + false +} + +fn pick_loopback_44100_profile(device: &Device) -> Result> { + let mut best: Option<(u8, cpal::SupportedStreamConfig)> = None; + for range in device.supported_input_configs()? { + if range.channels() != 2 { + continue; + } + if !(range.min_sample_rate() <= 44_100 && range.max_sample_rate() >= 44_100) { + continue; + } + + let score = match range.sample_format() { + // Prefer matching Shairport's common ALSA loopback format exactly. + SampleFormat::I16 => 0, + // F32 is typically robust if I16 is unavailable. + SampleFormat::F32 => 1, + _ => 2, + }; + let candidate = range.with_sample_rate(44_100); + let should_replace = match &best { + None => true, + Some((best_score, _)) => score < *best_score, + }; + if should_replace { + best = Some((score, candidate)); + } + } + + Ok(best.map(|(_, config)| config)) +} + +fn parse_indexed_name(name: &str) -> Option<(String, usize)> { + if !name.ends_with(']') { + return None; + } + let marker_start = name.rfind(" [#")?; + let index_part = &name[(marker_start + 3)..(name.len() - 1)]; + let parsed_index = index_part.parse::().ok()?; + if parsed_index == 0 { + return None; + } + let base_name = name[..marker_start].to_string(); + if base_name.is_empty() { + return None; + } + Some((base_name, parsed_index)) +} + +fn strip_host_prefix(name: &str) -> &str { + let Some((prefix, remainder)) = name.split_once(':') else { + return name; + }; + if prefix.eq_ignore_ascii_case("alsa") + || prefix.eq_ignore_ascii_case("coreaudio") + || prefix.eq_ignore_ascii_case("wasapi") + || prefix.eq_ignore_ascii_case("asio") + || prefix.eq_ignore_ascii_case("jack") + || prefix.eq_ignore_ascii_case("pipewire") + || prefix.eq_ignore_ascii_case("aaudio") + || prefix.eq_ignore_ascii_case("webaudio") + || prefix.eq_ignore_ascii_case("emscripten") + || prefix.eq_ignore_ascii_case("null") + { + return remainder.trim(); + } + name +} + +fn expand_selector_candidates(selected_name: &str) -> Vec { + let mut candidates = Vec::new(); + push_candidate(&mut candidates, selected_name); + + let Some((prefix_raw, remainder_raw)) = selected_name.split_once(':') else { + return candidates; + }; + let prefix = prefix_raw.trim().to_ascii_lowercase(); + if prefix != "hw" && prefix != "plughw" { + return candidates; + } + let remainder = remainder_raw.trim(); + if remainder.is_empty() { + return candidates; + } + + let Some((card, dev, subdev)) = parse_alsa_selector_parts(remainder) else { + return candidates; + }; + let normalized_prefix = prefix.as_str(); + push_candidate( + &mut candidates, + &format!("{}:{},{}", normalized_prefix, card, dev), + ); + push_candidate( + &mut candidates, + &format!("{}:CARD={},DEV={}", normalized_prefix, card, dev), + ); + + if let Some(subdev) = subdev.as_deref() { + push_candidate( + &mut candidates, + &format!("{}:{},{},{}", normalized_prefix, card, dev, subdev), + ); + if subdev == "0" { + push_candidate( + &mut candidates, + &format!("{}:{},{}", normalized_prefix, card, dev), + ); + } else { + push_candidate( + &mut candidates, + &format!( + "{}:CARD={},DEV={},SUBDEV={}", + normalized_prefix, card, dev, subdev + ), + ); + } + } else { + push_candidate( + &mut candidates, + &format!("{}:{},{},0", normalized_prefix, card, dev), + ); + } + + if !is_ascii_digits(&card) + && let Some(card_index) = lookup_alsa_card_index(&card) + { + push_candidate( + &mut candidates, + &format!("{}:{},{}", normalized_prefix, card_index, dev), + ); + push_candidate( + &mut candidates, + &format!("{}:CARD={},DEV={}", normalized_prefix, card_index, dev), + ); + } + + candidates +} + +fn push_candidate(candidates: &mut Vec, candidate: &str) { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return; + } + if candidates.iter().any(|existing| existing == trimmed) { + return; + } + candidates.push(trimmed.to_string()); +} + +fn parse_alsa_selector_parts(remainder: &str) -> Option<(String, String, Option)> { + if remainder.contains("CARD=") || remainder.contains("DEV=") { + parse_named_alsa_selector_parts(remainder) + } else { + parse_short_alsa_selector_parts(remainder) + } +} + +fn parse_short_alsa_selector_parts(remainder: &str) -> Option<(String, String, Option)> { + let mut parts = remainder.split(',').map(str::trim); + let card = parts.next()?.to_string(); + let dev = parts.next()?.to_string(); + if card.is_empty() || dev.is_empty() { + return None; + } + let subdev = parts + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + Some((card, dev, subdev)) +} + +fn parse_named_alsa_selector_parts(remainder: &str) -> Option<(String, String, Option)> { + let mut card: Option = None; + let mut dev: Option = None; + let mut subdev: Option = None; + + for token in remainder.split(',') { + let token = token.trim(); + let Some((key, value)) = token.split_once('=') else { + continue; + }; + let key = key.trim().to_ascii_uppercase(); + let value = value.trim().to_string(); + if value.is_empty() { + continue; + } + match key.as_str() { + "CARD" => card = Some(value), + "DEV" => dev = Some(value), + "SUBDEV" => subdev = Some(value), + _ => {} + } + } + + Some((card?, dev?, subdev)) +} + +fn is_ascii_digits(value: &str) -> bool { + !value.is_empty() && value.chars().all(|c| c.is_ascii_digit()) +} + +#[cfg(target_os = "linux")] +fn lookup_alsa_card_index(card_name: &str) -> Option { + let cards = std::fs::read_to_string("/proc/asound/cards").ok()?; + for line in cards.lines() { + let line = line.trim_start(); + let Some(bracket_start) = line.find('[') else { + continue; + }; + let Some(bracket_end_offset) = line[bracket_start + 1..].find(']') else { + continue; + }; + let bracket_end = bracket_start + 1 + bracket_end_offset; + let short_name = line[bracket_start + 1..bracket_end].trim(); + if !short_name.eq_ignore_ascii_case(card_name) { + continue; + } + let Some(index_str) = line.split_whitespace().next() else { + continue; + }; + if let Ok(index) = index_str.parse::() { + return Some(index); + } + } + None +} + +#[cfg(not(target_os = "linux"))] +fn lookup_alsa_card_index(_card_name: &str) -> Option { + None +} diff --git a/src/bin/nanoviz.rs b/src/bin/nanoviz.rs new file mode 100644 index 0000000..509c76a --- /dev/null +++ b/src/bin/nanoviz.rs @@ -0,0 +1,2572 @@ +use anyhow::Result; +use axum::{ + Json, Router, + body::Body, + extract::{ + Path, State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + http::{StatusCode, header}, + response::{IntoResponse, Response}, + routing::{get, post, put}, +}; +#[cfg(target_os = "linux")] +use base64::Engine; +use clap::Parser; +use hashbrown::HashMap; +use parking_lot::Mutex; +#[cfg(target_os = "linux")] +use quick_xml::Reader as XmlReader; +#[cfg(target_os = "linux")] +use quick_xml::events::Event as XmlEvent; +use serde::{Deserialize, Serialize}; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use std::thread; +#[cfg(target_os = "linux")] +use std::{ + fs::OpenOptions, + io::{BufRead, BufReader}, +}; +use std::{ + net::SocketAddr, + path::PathBuf, + sync::Arc, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tokio::task::JoinError; +use tower_http::{ + cors::{Any, CorsLayer}, + services::{ServeDir, ServeFile}, +}; + +#[derive(Parser, Debug)] +#[command(version, about = "Audioleaf — Nanoleaf music visualizer", author)] +struct ApiOptions { + /// Host interface to bind + #[arg(long, default_value = "0.0.0.0")] + host: String, + + /// HTTP port for the API server + #[arg(long, default_value_t = 8787)] + port: u16, + + /// Path to audioleaf's configuration file + #[arg(long = "config")] + config_file_path: Option, + + /// Path to audioleaf's database of known Nanoleaf devices + #[arg(long = "devices")] + devices_file_path: Option, +} + +#[derive(Clone)] +struct ApiState { + config_file_path: Option, + devices_file_path: Option, + runtime_config: Arc>, + live_visualizer: Arc>>, + live_visualizer_recovery: Arc>, + now_playing: Arc>, + /// 5-min in-memory cache of the active Nanoleaf device's saved palettes. + /// Replaces the static `src/palettes.rs` catalog. None until the first + /// successful fetch; refreshed lazily on read once stale. + palette_cache: Arc>, +} + +type PaletteCacheSlot = Option<(Instant, Vec)>; + +const PALETTE_CACHE_TTL: Duration = Duration::from_secs(300); + +#[derive(Clone, Debug)] +struct LiveVisualizerRuntime { + sender: flume::Sender, + global_orientation: u16, + device: DeviceSummary, + color_rx: flume::Receiver>, + latest_colors: Arc>>, + stream_health: Arc>, +} + +#[derive(Clone, Debug, Default)] +struct LiveVisualizerRecoveryState { + consecutive_restart_failures: u32, + auto_fallback_to_default_active: bool, + last_restart_at_ms: Option, + healthy_ping_streak: u8, +} + +#[cfg(target_os = "linux")] +const DEFAULT_SHAIRPORT_METADATA_PIPE: &str = "/tmp/shairport-sync-metadata"; +#[cfg(target_os = "linux")] +const NOW_PLAYING_RETRY_DELAY: Duration = Duration::from_secs(3); +const LIVE_VISUALIZER_WATCHDOG_INTERVAL: Duration = Duration::from_secs(2); +const LIVE_VISUALIZER_RESTART_FAILURE_LIMIT: u32 = 3; +const LIVE_VISUALIZER_RESTART_COOLDOWN: Duration = Duration::from_secs(1); +const LIVE_VISUALIZER_RESTART_COOLDOWN_MAX: Duration = Duration::from_secs(60); +const LIVE_VISUALIZER_HEALTHY_PINGS_TO_CLEAR_FAILURES: u8 = 2; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum PlaybackState { + #[default] + Stopped, + Playing, + Paused, +} + +#[derive(Clone, Debug, Default)] +struct NowPlayingTrackData { + title: Option, + artist: Option, + album: Option, + genre: Option, + composer: Option, + stream_url: Option, + source_name: Option, + source_ip: Option, + user_agent: Option, + /// Song data kind: 0 = timed track (has duration), 1 = untimed stream (radio) + song_data_kind: Option, + /// Track duration in milliseconds (from DMAP "astm" code) + duration_ms: Option, +} + +impl NowPlayingTrackData { + fn has_data(&self) -> bool { + self.title.as_deref().is_some_and(|value| !value.is_empty()) + || self + .artist + .as_deref() + .is_some_and(|value| !value.is_empty()) + || self.album.as_deref().is_some_and(|value| !value.is_empty()) + || self + .stream_url + .as_deref() + .is_some_and(|value| !value.is_empty()) + || self + .source_name + .as_deref() + .is_some_and(|value| !value.is_empty()) + || self + .source_ip + .as_deref() + .is_some_and(|value| !value.is_empty()) + || self + .user_agent + .as_deref() + .is_some_and(|value| !value.is_empty()) + } +} + +#[derive(Clone, Debug)] +struct NowPlayingRuntimeState { + metadata_pipe_path: String, + reader_running: bool, + last_error: Option, + track: NowPlayingTrackData, + /// Colors extracted from album artwork. Drives the visualizer when + /// `visualizer_config.color_source == Artwork`. Empty between tracks / + /// when no album cover is available. + palette_colors: Vec<[u8; 3]>, + artwork_bytes: Option>, + artwork_mime_type: Option, + artwork_generation: u64, + updated_at_ms: Option, + playback_state: PlaybackState, + /// Progress from "prgr": RTP timestamps at 44100 fps as (start, current, end) + progress_rtp: Option<(u64, u64, u64)>, + /// Wall-clock unix-ms timestamp when `progress_rtp` was last updated. + /// Used to extrapolate elapsed time between sparse `prgr` events + /// (shairport only emits one at stream start + on flushes). + progress_received_at_ms: Option, + /// AirPlay volume in dB (0.0 to -30.0, -144.0 = mute) + volume_db: Option, + /// Last brightness value we pushed to the device in response to a + /// `pvol` event. Used to suppress duplicate writes when the iOS slider + /// fires several pvols that map to the same integer brightness. Only + /// read on Linux (the metadata pipe is Linux-only); on macOS this is + /// initialized to None and never touched. + #[allow(dead_code)] + last_pvol_brightness: Option, +} + +impl NowPlayingRuntimeState { + fn new(metadata_pipe_path: String) -> Self { + Self { + metadata_pipe_path, + reader_running: false, + last_error: None, + track: NowPlayingTrackData::default(), + palette_colors: Vec::new(), + artwork_bytes: None, + artwork_mime_type: None, + artwork_generation: 0, + updated_at_ms: None, + playback_state: PlaybackState::default(), + progress_rtp: None, + progress_received_at_ms: None, + volume_db: None, + last_pvol_brightness: None, + } + } + + #[cfg(target_os = "linux")] + fn clear_session_data(&mut self) { + self.track = NowPlayingTrackData::default(); + self.palette_colors.clear(); + self.artwork_bytes = None; + self.artwork_mime_type = None; + self.artwork_generation = self.artwork_generation.saturating_add(1); + self.progress_rtp = None; + self.progress_received_at_ms = None; + self.updated_at_ms = Some(now_unix_ms()); + } + + /// Returns (elapsed_secs, total_secs) derived from RTP timestamps at 44100 Hz. + /// While `playback_state == Playing`, the elapsed value is extrapolated + /// forward from `progress_received_at_ms` since shairport only emits + /// `prgr` at stream start / flushes — the raw `current` would otherwise + /// stay frozen at whatever shairport last reported. + fn progress_seconds(&self) -> Option<(f64, f64)> { + let (start, current, end) = self.progress_rtp?; + let raw_elapsed = current.wrapping_sub(start) as f64 / 44100.0; + let total = end.wrapping_sub(start) as f64 / 44100.0; + let elapsed = match (self.progress_received_at_ms, &self.playback_state) { + (Some(anchor_ms), PlaybackState::Playing) => { + let now_ms = now_unix_ms(); + let drift = now_ms.saturating_sub(anchor_ms) as f64 / 1000.0; + (raw_elapsed + drift).min(total) + } + _ => raw_elapsed, + }; + Some((elapsed, total)) + } + + fn snapshot(&self) -> NowPlayingResponse { + let (progress_elapsed_secs, progress_total_secs) = self + .progress_seconds() + .map(|(e, t)| (Some(e), Some(t))) + .unwrap_or((None, None)); + NowPlayingResponse { + reader_running: self.reader_running, + metadata_pipe_path: self.metadata_pipe_path.clone(), + last_error: self.last_error.clone(), + track: self.track.has_data().then_some(NowPlayingTrackResponse { + title: self.track.title.clone(), + artist: self.track.artist.clone(), + album: self.track.album.clone(), + genre: self.track.genre.clone(), + composer: self.track.composer.clone(), + stream_url: self.track.stream_url.clone(), + source_name: self.track.source_name.clone(), + source_ip: self.track.source_ip.clone(), + user_agent: self.track.user_agent.clone(), + duration_ms: self.track.duration_ms, + song_data_kind: self.track.song_data_kind, + }), + palette_colors: self.palette_colors.clone(), + artwork_available: self.artwork_bytes.is_some(), + artwork_generation: self.artwork_generation, + updated_at_ms: self.updated_at_ms, + playback_state: self.playback_state.clone(), + progress_elapsed_secs, + progress_total_secs, + volume_db: self.volume_db, + } + } +} + +#[derive(Clone, Debug, Serialize)] +struct NowPlayingTrackResponse { + title: Option, + artist: Option, + album: Option, + genre: Option, + composer: Option, + stream_url: Option, + source_name: Option, + source_ip: Option, + user_agent: Option, + duration_ms: Option, + song_data_kind: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct NowPlayingResponse { + reader_running: bool, + metadata_pipe_path: String, + last_error: Option, + track: Option, + palette_colors: Vec<[u8; 3]>, + artwork_available: bool, + artwork_generation: u64, + updated_at_ms: Option, + playback_state: PlaybackState, + progress_elapsed_secs: Option, + progress_total_secs: Option, + volume_db: Option, +} + +#[derive(Debug, Serialize)] +struct ErrorResponse { + error: String, +} + +type ApiResult = Result, ApiError>; + +#[derive(Debug)] +struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn internal(err: E) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: err.to_string(), + } + } + + fn not_found(err: E) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: err.to_string(), + } + } + + fn bad_request(err: E) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: err.to_string(), + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + ( + self.status, + Json(ErrorResponse { + error: self.message, + }), + ) + .into_response() + } +} + +#[derive(Debug, Serialize)] +struct HealthResponse { + status: &'static str, + version: &'static str, + /// AirPlay receiver name advertised on mDNS. `None` when the + /// `NANOVIZ_AIRPLAY_NAME` env var is unset and shairport-sync falls + /// back to its baked-in conf default ("audioleaf"). + airplay_name: Option, +} + +#[derive(Debug, Serialize)] +struct PathsResponse { + config_file_path: String, + config_file_exists: bool, + devices_file_path: String, + devices_file_exists: bool, +} + +#[derive(Debug, Serialize)] +struct ConfigResponse { + paths: PathsResponse, + config: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct DeviceSummary { + name: String, + ip: String, +} + +#[derive(Debug, Serialize)] +struct DevicesResponse { + devices: Vec, + devices_file_path: String, + devices_file_exists: bool, +} + +#[derive(Debug, Serialize)] +struct DeviceInfoResponse { + device: DeviceSummary, + info: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct DeviceLayoutPanel { + panel_id: u16, + x: i16, + y: i16, + orientation: u16, + shape_type_id: u64, + shape_type_name: String, + num_sides: usize, + side_length: f32, +} + +#[derive(Debug, Serialize)] +struct DeviceLayoutResponse { + device: DeviceSummary, + global_orientation: u16, + panels: Vec, +} + +#[derive(Debug, Deserialize)] +struct VisualizerEffectUpdateRequest { + effect: String, +} + +#[derive(Debug, Deserialize)] +struct VisualizerPaletteUpdateRequest { + palette_name: String, +} + +#[derive(Debug, Deserialize)] +struct VisualizerColorSourceUpdateRequest { + kind: String, + /// Optional. Only honored when `kind == "palette"`. Leaving it unset (or + /// passing the same name) leaves the existing `palette_name` config alone. + palette_name: Option, +} + +#[derive(Debug, Deserialize)] +struct VisualizerSortUpdateRequest { + primary_axis: String, + sort_primary: String, + sort_secondary: String, +} + +#[derive(Debug, Deserialize)] +struct VisualizerSettingsUpdateRequest { + audio_backend: Option, + freq_range: Option<(u16, u16)>, + default_gain: Option, + transition_time: Option, + time_window: Option, +} + +#[derive(Debug, Serialize)] +struct DiscoverResponse { + devices: Vec, +} + +#[derive(Debug, Deserialize)] +struct PairRequest { + ip: String, +} + +#[derive(Debug, Serialize)] +struct PairResponse { + device: DeviceSummary, +} + +#[derive(Debug, Deserialize)] +struct DeviceStateUpdateRequest { + power_on: Option, + brightness: Option, +} + +#[derive(Debug, Serialize)] +struct DeviceStateUpdateResponse { + device: DeviceSummary, + power_on: Option, + brightness: Option, +} + +#[derive(Debug, Serialize)] +struct PaletteEntry { + name: String, + colors: Vec<[u8; 3]>, +} + +#[derive(Debug, Serialize)] +struct PalettesResponse { + palettes: Vec, +} + +#[derive(Debug, Serialize)] +struct AudioBackendsResponse { + current_audio_backend: Option, + available_audio_backends: Vec, +} + +#[derive(Debug, Serialize)] +struct VisualizerPreviewPanelColor { + panel_id: u16, + rgb: [u8; 3], +} + +#[derive(Debug, Serialize)] +struct VisualizerPreviewResponse { + enabled: bool, + device: Option, + panel_colors: Vec, +} + +#[derive(Debug, Serialize)] +struct VisualizerStatusResponse { + status: String, + stream_health: String, + live_visualizer_attached: bool, + restart_cooldown_active: bool, + consecutive_restart_failures: u32, + healthy_ping_streak: u8, + auto_fallback_to_default_active: bool, + current_audio_backend: Option, + device: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Install the panic hook before anything that can panic. With + // `panic = "abort"` in Cargo.toml, a panic kills the process via + // SIGABRT (container exit 134); the hook is our only chance to flush + // a useful message to journald. + nanoviz::panic::register_backtrace_panic_handler(); + + let options = ApiOptions::parse(); + let ((resolved_config_path, config_exists), _) = nanoviz::config::resolve_paths( + options.config_file_path.clone(), + options.devices_file_path.clone(), + )?; + let initial_config = if config_exists { + nanoviz::config::Config::parse_from_file(&resolved_config_path)? + } else { + nanoviz::config::Config::new(None, None) + }; + #[cfg(target_os = "linux")] + let metadata_pipe_path = std::env::var("NANOVIZ_SHAIRPORT_METADATA_PIPE") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_SHAIRPORT_METADATA_PIPE.to_string()); + #[cfg(not(target_os = "linux"))] + let metadata_pipe_path = String::new(); + + let state = ApiState { + config_file_path: options.config_file_path, + devices_file_path: options.devices_file_path, + runtime_config: Arc::new(Mutex::new(initial_config)), + live_visualizer: Arc::new(Mutex::new(None)), + live_visualizer_recovery: Arc::new(Mutex::new(LiveVisualizerRecoveryState::default())), + now_playing: Arc::new(Mutex::new(NowPlayingRuntimeState::new( + metadata_pipe_path.clone(), + ))), + palette_cache: Arc::new(Mutex::new(None)), + }; + + if let Err(err) = restart_live_visualizer(&state).await { + eprintln!( + "WARNING: Live visualizer startup failed. API will still run, but effect changes will not be applied live: {}", + err.message + ); + } else { + println!("Live visualizer initialized."); + } + start_now_playing_reader(&state); + start_live_visualizer_watchdog(&state); + + let app = Router::new() + .route("/api/health", get(get_health)) + .route("/api/config", get(get_config)) + .route("/api/config/save", post(post_config_save)) + .route("/api/config/visualizer/effect", put(put_visualizer_effect)) + .route( + "/api/config/visualizer/palette", + put(put_visualizer_palette), + ) + .route( + "/api/config/visualizer/color-source", + put(put_visualizer_color_source), + ) + .route("/api/config/visualizer/sort", put(put_visualizer_sort)) + .route( + "/api/config/visualizer/settings", + put(put_visualizer_settings), + ) + .route("/api/now-playing", get(get_now_playing)) + .route("/api/now-playing/artwork", get(get_now_playing_artwork)) + .route("/api/visualizer/preview", get(get_visualizer_preview)) + .route("/api/visualizer/ws", get(visualizer_ws)) + .route("/api/visualizer/status", get(get_visualizer_status)) + .route("/api/audio/backends", get(get_audio_backends)) + .route("/api/devices", get(get_devices)) + .route("/api/devices/discover", post(post_devices_discover)) + .route("/api/devices/pair", post(post_devices_pair)) + .route("/api/devices/{name}/info", get(get_device_info)) + .route("/api/devices/{name}/layout", get(get_device_layout)) + .route("/api/devices/{name}/state", put(put_device_state)) + .route("/api/palettes", get(get_palettes)) + .with_state(state) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ); + + // Serve the built frontend from web/dist/ (or NANOVIZ_FRONTEND_DIR override). + // Falls back to index.html for client-side SPA routing. + let frontend_dir = + std::env::var("NANOVIZ_FRONTEND_DIR").unwrap_or_else(|_| "./web/dist".to_string()); + let frontend_path = std::path::Path::new(&frontend_dir); + let app = if frontend_path.is_dir() { + let index = format!("{}/index.html", frontend_dir); + app.fallback_service(ServeDir::new(&frontend_dir).fallback(ServeFile::new(index))) + } else { + app + }; + + let addr: SocketAddr = format!("{}:{}", options.host, options.port).parse()?; + let listener = tokio::net::TcpListener::bind(addr).await?; + println!( + "Audioleaf API listening on http://{}", + listener.local_addr()? + ); + if frontend_path.is_dir() { + println!("Serving frontend from {}", frontend_dir); + } + axum::serve(listener, app.into_make_service()).await?; + Ok(()) +} + +async fn get_health() -> Json { + Json(HealthResponse { + status: "ok", + version: env!("CARGO_PKG_VERSION"), + airplay_name: std::env::var("NANOVIZ_AIRPLAY_NAME") + .ok() + .filter(|name| !name.trim().is_empty()), + }) +} + +async fn get_config(State(state): State) -> ApiResult { + let paths = resolve_paths(&state)?; + let config = Some(get_runtime_config_clone(&state)?); + + Ok(Json(ConfigResponse { paths, config })) +} + +async fn post_config_save(State(state): State) -> ApiResult { + let config = get_runtime_config_clone(&state)?; + let mut paths = resolve_paths(&state)?; + config + .write_to_file(PathBuf::from(&paths.config_file_path).as_path()) + .map_err(ApiError::internal)?; + paths.config_file_exists = true; + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn put_visualizer_effect( + State(state): State, + Json(payload): Json, +) -> ApiResult { + let effect = parse_effect(&payload.effect).ok_or_else(|| { + ApiError::bad_request("Invalid effect. Use Spectrum, EnergyWave, or Ripple.") + })?; + + let config = update_runtime_config(&state, |config| { + config.visualizer_config.effect = Some(effect); + })?; + let paths = resolve_paths(&state)?; + send_live_message_with_recovery( + &state, + nanoviz::visualizer::VisualizerMsg::SetEffect(effect), + ) + .await?; + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn put_visualizer_palette( + State(state): State, + Json(payload): Json, +) -> ApiResult { + let palettes = palettes_cached_async(&state).await; + if !palettes.iter().any(|p| p.name == payload.palette_name) { + let mut names: Vec = palettes.into_iter().map(|p| p.name).collect(); + names.sort(); + return Err(ApiError::bad_request(format!( + "Unknown palette '{}'. Available on device: {}", + payload.palette_name, + if names.is_empty() { + "(no palettes — Nanoleaf device may be offline)".to_string() + } else { + names.join(", ") + } + ))); + } + + let config = update_runtime_config(&state, |config| { + config.visualizer_config.color_source = Some(nanoviz::config::ColorSourceKind::Palette); + config.visualizer_config.palette_name = Some(payload.palette_name.clone()); + })?; + + let colors = resolve_palette_colors_async(&state, Some(payload.palette_name.clone())).await; + let paths = resolve_paths(&state)?; + send_live_message_with_recovery( + &state, + nanoviz::visualizer::VisualizerMsg::SetPalette(colors), + ) + .await?; + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn put_visualizer_color_source( + State(state): State, + Json(payload): Json, +) -> ApiResult { + let kind = match payload.kind.to_ascii_lowercase().as_str() { + "palette" => nanoviz::config::ColorSourceKind::Palette, + "artwork" => nanoviz::config::ColorSourceKind::Artwork, + other => { + return Err(ApiError::bad_request(format!( + "color_source kind must be `palette` or `artwork`, got `{}`", + other + ))); + } + }; + + let config = update_runtime_config(&state, |config| { + config.visualizer_config.color_source = Some(kind); + if kind == nanoviz::config::ColorSourceKind::Palette && payload.palette_name.is_some() { + config.visualizer_config.palette_name = payload.palette_name.clone(); + } + })?; + + let paths = resolve_paths(&state)?; + + match kind { + nanoviz::config::ColorSourceKind::Palette => { + let colors = + resolve_palette_colors_async(&state, config.visualizer_config.palette_name.clone()) + .await; + send_live_message_with_recovery( + &state, + nanoviz::visualizer::VisualizerMsg::SetPalette(colors), + ) + .await?; + } + nanoviz::config::ColorSourceKind::Artwork => { + // If we already have artwork colors cached, push them now. Otherwise + // leave the panels on whatever palette is current — the next now- + // playing update will swap in artwork colors via apply_artwork_palette. + let colors = state.now_playing.lock().palette_colors.clone(); + if !colors.is_empty() { + send_live_message_with_recovery( + &state, + nanoviz::visualizer::VisualizerMsg::SetPalette(colors), + ) + .await?; + } + } + } + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn put_visualizer_sort( + State(state): State, + Json(payload): Json, +) -> ApiResult { + let primary_axis = parse_axis(&payload.primary_axis) + .ok_or_else(|| ApiError::bad_request("Invalid primary_axis. Use X or Y."))?; + let sort_primary = parse_sort(&payload.sort_primary) + .ok_or_else(|| ApiError::bad_request("Invalid sort_primary. Use Asc or Desc."))?; + let sort_secondary = parse_sort(&payload.sort_secondary) + .ok_or_else(|| ApiError::bad_request("Invalid sort_secondary. Use Asc or Desc."))?; + + let config = update_runtime_config(&state, |config| { + config.visualizer_config.primary_axis = Some(primary_axis); + config.visualizer_config.sort_primary = Some(sort_primary); + config.visualizer_config.sort_secondary = Some(sort_secondary); + })?; + let paths = resolve_paths(&state)?; + let live = ensure_live_visualizer(&state).await?; + send_live_message_with_recovery( + &state, + nanoviz::visualizer::VisualizerMsg::SetSorting { + primary_axis, + sort_primary, + sort_secondary, + global_orientation: live.global_orientation, + }, + ) + .await?; + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn put_visualizer_settings( + State(state): State, + Json(payload): Json, +) -> ApiResult { + if payload.audio_backend.is_none() + && payload.freq_range.is_none() + && payload.default_gain.is_none() + && payload.transition_time.is_none() + && payload.time_window.is_none() + { + return Err(ApiError::bad_request( + "Request must include at least one visualizer setting.", + )); + } + + if let Some((low, high)) = payload.freq_range + && low >= high + { + return Err(ApiError::bad_request( + "freq_range must have min < max (e.g. [20, 4500]).", + )); + } + if let Some(default_gain) = payload.default_gain + && (!default_gain.is_finite() || default_gain < 0.0) + { + return Err(ApiError::bad_request( + "default_gain must be a finite number >= 0.", + )); + } + if let Some(transition_time) = payload.transition_time + && !(1..=10).contains(&transition_time) + { + return Err(ApiError::bad_request( + "transition_time must be between 1 and 10 (0.1s to 1.0s in 100ms units).", + )); + } + if let Some(time_window) = payload.time_window + && (!time_window.is_finite() || !(0.1..=1.0).contains(&time_window)) + { + return Err(ApiError::bad_request( + "time_window must be between 0.1 and 1.0 seconds.", + )); + } + + let audio_backend = payload.audio_backend.clone(); + let freq_range = payload.freq_range; + let default_gain = payload.default_gain; + let transition_time = payload.transition_time; + let time_window = payload.time_window; + + let config = update_runtime_config(&state, |config| { + if let Some(audio_backend) = audio_backend.clone() { + config.visualizer_config.audio_backend = Some(audio_backend); + } + if let Some(freq_range) = freq_range { + config.visualizer_config.freq_range = Some(freq_range); + } + if let Some(default_gain) = default_gain { + config.visualizer_config.default_gain = Some(default_gain); + } + if let Some(transition_time) = transition_time { + config.visualizer_config.transition_time = Some(transition_time); + } + if let Some(time_window) = time_window { + config.visualizer_config.time_window = Some(time_window); + } + })?; + let paths = resolve_paths(&state)?; + + if payload.audio_backend.is_some() { + restart_live_visualizer(&state).await?; + } else { + if let Some(freq_range) = payload.freq_range { + send_live_message_with_recovery( + &state, + nanoviz::visualizer::VisualizerMsg::SetFreqRange(freq_range.0, freq_range.1), + ) + .await?; + } + if let Some(default_gain) = payload.default_gain { + send_live_message_with_recovery( + &state, + nanoviz::visualizer::VisualizerMsg::SetGain(default_gain), + ) + .await?; + } + if let Some(transition_time) = payload.transition_time { + send_live_message_with_recovery( + &state, + nanoviz::visualizer::VisualizerMsg::SetTransitionTime(transition_time), + ) + .await?; + } + if let Some(time_window) = payload.time_window { + send_live_message_with_recovery( + &state, + nanoviz::visualizer::VisualizerMsg::SetTimeWindow(time_window), + ) + .await?; + } + } + + Ok(Json(ConfigResponse { + paths, + config: Some(config), + })) +} + +async fn get_now_playing(State(state): State) -> ApiResult { + let snapshot = current_now_playing_snapshot(&state)?; + Ok(Json(snapshot)) +} + +async fn get_now_playing_artwork( + State(state): State, +) -> std::result::Result { + let (bytes, mime_type) = { + let guard = state.now_playing.lock(); + let Some(bytes) = guard.artwork_bytes.clone() else { + return Err(ApiError::not_found("No album artwork available yet.")); + }; + let mime_type = guard + .artwork_mime_type + .clone() + .unwrap_or_else(|| "application/octet-stream".to_string()); + (bytes, mime_type) + }; + + let mut response = Response::new(Body::from(bytes)); + let content_type = header::HeaderValue::from_str(&mime_type) + .unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream")); + response + .headers_mut() + .insert(header::CONTENT_TYPE, content_type); + response.headers_mut().insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("no-store"), + ); + Ok(response) +} + +async fn get_devices(State(state): State) -> ApiResult { + let paths = resolve_paths(&state)?; + + let devices = if paths.devices_file_exists { + nanoviz::nanoleaf::NlDevice::all_from_file( + PathBuf::from(&paths.devices_file_path).as_path(), + ) + .map_err(ApiError::internal)? + .into_iter() + .map(|device| DeviceSummary { + name: device.name, + ip: device.ip.to_string(), + }) + .collect() + } else { + Vec::new() + }; + + Ok(Json(DevicesResponse { + devices, + devices_file_path: paths.devices_file_path, + devices_file_exists: paths.devices_file_exists, + })) +} + +async fn post_devices_discover(State(_state): State) -> ApiResult { + // ssdp_msearch is blocking UDP with a 10s timeout — keep it off the + // async runtime. + let (names, ips) = tokio::task::spawn_blocking(nanoviz::ssdp::ssdp_msearch) + .await + .map_err(handle_join_error)? + .map_err(ApiError::internal)?; + + let devices = names + .into_iter() + .zip(ips) + .map(|(name, ip)| DeviceSummary { + name, + ip: ip.to_string(), + }) + .collect(); + + Ok(Json(DiscoverResponse { devices })) +} + +async fn post_devices_pair( + State(state): State, + Json(payload): Json, +) -> ApiResult { + let ip: std::net::Ipv4Addr = payload + .ip + .parse() + .map_err(|_| ApiError::bad_request(format!("Invalid IPv4 address: {}", payload.ip)))?; + + let paths = resolve_paths(&state)?; + let devices_path = PathBuf::from(&paths.devices_file_path); + + let device = tokio::task::spawn_blocking(move || nanoviz::nanoleaf::NlDevice::new(ip)) + .await + .map_err(handle_join_error)? + .map_err(|err| { + // If the underlying reqwest error is HTTP 403, the device wasn't + // in pairing mode. Surface 409 + a stable error code so the UI + // can show the "hold the power button" hint. + for cause in err.chain() { + if let Some(req_err) = cause.downcast_ref::() + && req_err.status() == Some(reqwest::StatusCode::FORBIDDEN) + { + return ApiError { + status: StatusCode::CONFLICT, + message: "device_not_in_pairing_mode".to_string(), + }; + } + } + ApiError::bad_request(err.to_string()) + })?; + + let summary = DeviceSummary { + name: device.name.clone(), + ip: device.ip.to_string(), + }; + + device + .append_to_file(&devices_path) + .map_err(ApiError::internal)?; + + Ok(Json(PairResponse { device: summary })) +} + +async fn get_device_info( + Path(name): Path, + State(state): State, +) -> ApiResult { + let device = load_device_by_name(&state, &name)?; + + let summary = DeviceSummary { + name: device.name.clone(), + ip: device.ip.to_string(), + }; + + let info = run_nanoleaf_io(move || device.get_device_info()).await?; + + Ok(Json(DeviceInfoResponse { + device: summary, + info, + })) +} + +async fn get_device_layout( + Path(name): Path, + State(state): State, +) -> ApiResult { + let device = load_device_by_name(&state, &name)?; + let layout_device = device.clone(); + + let (layout_json, orientation_json) = run_nanoleaf_io(move || { + let layout = layout_device.get_panel_layout()?; + let orientation = layout_device.get_global_orientation()?; + Ok((layout, orientation)) + }) + .await?; + + let panels = nanoviz::layout_visualizer::parse_layout(&layout_json) + .map_err(ApiError::internal)? + .into_iter() + .map(|panel| DeviceLayoutPanel { + panel_id: panel.panel_id, + x: panel.x, + y: panel.y, + orientation: panel.orientation, + shape_type_id: panel.shape_type.id, + shape_type_name: panel.shape_type.name.to_string(), + num_sides: panel.shape_type.num_sides(), + side_length: panel.shape_type.side_length, + }) + .collect(); + + let global_orientation = orientation_json["value"].as_u64().unwrap_or(0) as u16; + + Ok(Json(DeviceLayoutResponse { + device: DeviceSummary { + name: device.name, + ip: device.ip.to_string(), + }, + global_orientation, + panels, + })) +} + +async fn put_device_state( + Path(name): Path, + State(state): State, + Json(payload): Json, +) -> ApiResult { + if payload.power_on.is_none() && payload.brightness.is_none() { + return Err(ApiError::bad_request( + "Request must include `power_on` and/or `brightness`.", + )); + } + if payload + .brightness + .is_some_and(|brightness| brightness > 100) + { + return Err(ApiError::bad_request( + "`brightness` must be between 0 and 100.", + )); + } + + let device = load_device_by_name(&state, &name)?; + let write_device = device.clone(); + run_nanoleaf_io(move || write_device.set_state(payload.power_on, payload.brightness)).await?; + + Ok(Json(DeviceStateUpdateResponse { + device: DeviceSummary { + name: device.name, + ip: device.ip.to_string(), + }, + power_on: payload.power_on, + brightness: payload.brightness, + })) +} + +async fn get_palettes(State(state): State) -> Json { + let mut palettes: Vec = palettes_cached_async(&state) + .await + .into_iter() + .map(|p| PaletteEntry { + name: p.name, + colors: p.colors, + }) + .collect(); + palettes.sort_by(|a, b| a.name.cmp(&b.name)); + Json(PalettesResponse { palettes }) +} + +async fn get_audio_backends(State(state): State) -> ApiResult { + let current_audio_backend = get_runtime_config_clone(&state)? + .visualizer_config + .audio_backend; + + let mut available_audio_backends = + nanoviz::audio::list_input_backend_names().unwrap_or_else(|_| Vec::new()); + + if !available_audio_backends + .iter() + .any(|name| name == nanoviz::constants::DEFAULT_AUDIO_BACKEND) + { + available_audio_backends.insert(0, nanoviz::constants::DEFAULT_AUDIO_BACKEND.to_string()); + } + + Ok(Json(AudioBackendsResponse { + current_audio_backend, + available_audio_backends, + })) +} + +fn latest_panel_colors(runtime: &LiveVisualizerRuntime) -> HashMap { + let mut latest = None; + while let Ok(frame) = runtime.color_rx.try_recv() { + latest = Some(frame); + } + if let Some(frame) = latest { + *runtime.latest_colors.lock() = frame; + } + runtime.latest_colors.lock().clone() +} + +async fn get_visualizer_preview( + State(state): State, +) -> ApiResult { + let Some(runtime) = current_live_visualizer(&state)? else { + return Ok(Json(VisualizerPreviewResponse { + enabled: false, + device: None, + panel_colors: Vec::new(), + })); + }; + + let colors_map = latest_panel_colors(&runtime); + let mut panel_colors: Vec = colors_map + .iter() + .map(|(panel_id, rgb)| VisualizerPreviewPanelColor { + panel_id: *panel_id, + rgb: *rgb, + }) + .collect(); + panel_colors.sort_by_key(|entry| entry.panel_id); + + Ok(Json(VisualizerPreviewResponse { + enabled: true, + device: Some(runtime.device), + panel_colors, + })) +} + +async fn visualizer_ws(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse { + ws.on_upgrade(|socket| handle_visualizer_ws(socket, state)) +} + +async fn handle_visualizer_ws(mut socket: WebSocket, state: ApiState) { + let mut interval = tokio::time::interval(Duration::from_millis(100)); + let mut last_frame: HashMap = HashMap::new(); + loop { + interval.tick().await; + let Ok(Some(runtime)) = current_live_visualizer(&state) else { + continue; + }; + let frame = latest_panel_colors(&runtime); + if frame == last_frame { + continue; + } + last_frame = frame.clone(); + let mut colors: Vec = frame + .iter() + .map(|(id, rgb)| VisualizerPreviewPanelColor { + panel_id: *id, + rgb: *rgb, + }) + .collect(); + colors.sort_by_key(|c| c.panel_id); + let Ok(msg) = serde_json::to_string(&colors) else { + continue; + }; + if socket.send(Message::Text(msg.into())).await.is_err() { + break; + } + } +} + +async fn get_visualizer_status( + State(state): State, +) -> ApiResult { + let live = current_live_visualizer(&state)?; + let live_visualizer_attached = live.is_some(); + let device = live.as_ref().map(|runtime| runtime.device.clone()); + let stream_health = match live { + Some(runtime) => *runtime.stream_health.lock(), + None => nanoviz::visualizer::StreamHealth::Restarting, + }; + + let restart_cooldown_active = live_visualizer_restart_cooldown_remaining(&state)?.is_some(); + let recovery = state.live_visualizer_recovery.lock(); + let current_audio_backend = get_runtime_config_clone(&state)? + .visualizer_config + .audio_backend; + + let status = summarize_visualizer_status( + live_visualizer_attached, + stream_health, + recovery.consecutive_restart_failures, + ); + + Ok(Json(VisualizerStatusResponse { + status: status.to_string(), + stream_health: stream_health_label(stream_health).to_string(), + live_visualizer_attached, + restart_cooldown_active, + consecutive_restart_failures: recovery.consecutive_restart_failures, + healthy_ping_streak: recovery.healthy_ping_streak, + auto_fallback_to_default_active: recovery.auto_fallback_to_default_active, + current_audio_backend, + device, + })) +} + +fn current_now_playing_snapshot(state: &ApiState) -> Result { + let guard = state.now_playing.lock(); + Ok(guard.snapshot()) +} + +#[cfg(target_os = "macos")] +fn start_now_playing_reader(state: &ApiState) { + let state = state.clone(); + thread::spawn(move || { + use media_remote::{NowPlayingInfo, NowPlayingPerl, Subscription}; + use std::sync::RwLockReadGuard; + + let now_playing = NowPlayingPerl::new(); + let state_clone = state.clone(); + now_playing.subscribe(move |guard: RwLockReadGuard<'_, Option>| { + let Some(info) = guard.as_ref() else { return }; + + { + let mut np = state_clone.now_playing.lock(); + np.reader_running = true; + np.last_error = None; + np.updated_at_ms = Some(now_unix_ms()); + + // Detect track change against previous state before overwriting. + // On change, drop stale artwork-derived state so the old track's + // colors don't linger if the new track arrives without a cover. + let track_changed = np.track.title != info.title + || np.track.artist != info.artist + || np.track.album != info.album; + + np.track.title = info.title.clone(); + np.track.artist = info.artist.clone(); + np.track.album = info.album.clone(); + np.playback_state = match info.is_playing { + Some(true) => PlaybackState::Playing, + Some(false) => PlaybackState::Paused, + None => PlaybackState::Stopped, + }; + if let (Some(elapsed), Some(duration)) = (info.elapsed_time, info.duration) { + let start = 0u64; + let current = (elapsed * 44100.0) as u64; + let end = (duration * 44100.0) as u64; + np.progress_rtp = Some((start, current, end)); + np.progress_received_at_ms = Some(now_unix_ms()); + } else { + np.progress_rtp = None; + np.progress_received_at_ms = None; + } + np.track.duration_ms = info.duration.map(|d| (d * 1000.0) as u64); + + if track_changed { + np.palette_colors.clear(); + np.artwork_bytes = None; + np.artwork_mime_type = None; + np.artwork_generation = np.artwork_generation.saturating_add(1); + } + + if let Some(ref cover) = info.album_cover { + let mut buf: std::io::Cursor> = std::io::Cursor::new(Vec::new()); + if cover.write_to(&mut buf, image::ImageFormat::Jpeg).is_ok() { + let bytes = buf.into_inner(); + let colors = extract_prominent_colors(&bytes).unwrap_or_default(); + np.artwork_mime_type = Some("image/jpeg".to_string()); + np.artwork_bytes = Some(bytes); + np.artwork_generation = np.artwork_generation.saturating_add(1); + np.palette_colors = colors; + } + } + } + + apply_artwork_palette(&state_clone); + }); + + // Block to keep the subscriber alive + loop { + thread::sleep(Duration::from_secs(3600)); + } + }); +} + +#[cfg(target_os = "linux")] +fn start_now_playing_reader(state: &ApiState) { + // Spawn the cover-art color-extraction worker once. The reader loop hands + // PICT bytes off via this channel so it can keep draining the FIFO. + let pict_tx = start_pict_worker(state.clone()); + let state = state.clone(); + thread::spawn(move || { + loop { + let metadata_pipe_path = state.now_playing.lock().metadata_pipe_path.clone(); + + match OpenOptions::new().read(true).open(&metadata_pipe_path) { + Ok(file) => { + { + let mut guard = state.now_playing.lock(); + guard.reader_running = true; + guard.last_error = None; + guard.updated_at_ms = Some(now_unix_ms()); + } + + let reader = BufReader::new(file); + let result = process_shairport_metadata_stream(&state, reader, &pict_tx); + + let mut guard = state.now_playing.lock(); + guard.reader_running = false; + guard.updated_at_ms = Some(now_unix_ms()); + if let Err(err) = &result { + guard.last_error = Some(err.clone()); + } + if let Err(err) = result { + eprintln!("WARNING: metadata stream error: {}", err); + } + } + Err(err) => { + let mut guard = state.now_playing.lock(); + guard.reader_running = false; + guard.last_error = Some(format!( + "Failed to open metadata pipe '{}': {}", + metadata_pipe_path, err + )); + guard.updated_at_ms = Some(now_unix_ms()); + } + } + + thread::sleep(NOW_PLAYING_RETRY_DELAY); + } + }); +} + +fn start_live_visualizer_watchdog(state: &ApiState) { + let state = state.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(LIVE_VISUALIZER_WATCHDOG_INTERVAL).await; + if let Err(err) = run_live_visualizer_watchdog_tick(&state).await { + eprintln!( + "WARNING: live visualizer watchdog tick failed: {}", + err.message + ); + } + } + }); +} + +async fn run_live_visualizer_watchdog_tick(state: &ApiState) -> Result<(), ApiError> { + let should_recover = match current_live_visualizer(state)? { + Some(runtime) => runtime + .sender + .send(nanoviz::visualizer::VisualizerMsg::Ping) + .is_err(), + None => true, + }; + if !should_recover { + mark_live_visualizer_watchdog_healthy(state)?; + return Ok(()); + } + + // Quiet idle: a fresh install with no paired devices has nothing to drive. + // Don't log a recovery warning every tick — wait until the user pairs a + // device via /api/devices/pair (which will then succeed). + if !has_paired_devices(state)? { + return Ok(()); + } + + recover_live_visualizer(state, "watchdog health check").await +} + +fn has_paired_devices(state: &ApiState) -> Result { + let paths = resolve_paths(state)?; + if !paths.devices_file_exists { + return Ok(false); + } + let devices_path = PathBuf::from(&paths.devices_file_path); + let devices = + nanoviz::nanoleaf::NlDevice::all_from_file(&devices_path).map_err(ApiError::internal)?; + Ok(!devices.is_empty()) +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy)] +enum MetadataDisposition { + /// Code is unknown / unhandled — don't even decode the base64 payload. + Skip, + /// Handler doesn't read the payload — dispatch with an empty Vec. + NoPayload, + /// Handler reads the payload — decode base64 and dispatch. + Decode, +} + +/// Decide whether an ``'s payload is worth decoding before we hand it to +/// `apply_metadata_item_to_state`. Shairport emits dozens of DMAP codes per +/// burst; only this whitelisted subset is consumed. Skipping the rest avoids +/// per-item base64 alloc/decode that would otherwise stall the reader thread +/// (and backpressure the FIFO into shairport). +#[cfg(target_os = "linux")] +fn metadata_code_disposition(item_type: &str, code: &str) -> MetadataDisposition { + match (item_type, code) { + ( + "core", + "minm" | "asar" | "asal" | "asgn" | "ascp" | "asul" | "astm" | "asdk" | "caps", + ) => MetadataDisposition::Decode, + ("ssnc", "snam" | "snua" | "clip" | "conn" | "PICT" | "prgr" | "pvol") => { + MetadataDisposition::Decode + } + ( + "ssnc", + "pbeg" | "prsm" | "pres" | "pfls" | "paus" | "pend" | "disc" | "mdst" | "mden" | "pcst" + | "pcen", + ) => MetadataDisposition::NoPayload, + _ => MetadataDisposition::Skip, + } +} + +/// Cover-art job sent from the metadata reader thread to the dedicated PICT +/// worker thread. `generation` tracks `NowPlayingRuntimeState::artwork_generation` +/// at the time the job was enqueued; the worker drops its result if the +/// generation has advanced (i.e. a newer cover arrived first). +#[cfg(target_os = "linux")] +struct PictJob { + generation: u64, + bytes: Vec, +} + +/// Spawn a single-purpose worker that runs `extract_prominent_colors_from_bytes` +/// off the metadata reader thread. The reader thread used to do this inline +/// inside the `now_playing` mutex critical section, which blocked both the FIFO +/// drain and `/api/now-playing` for the duration of the image work +/// (100–500 ms per cover on a Pi). +/// +/// The worker uses a drain-to-latest pattern: when extraction finishes, any +/// queued newer jobs are skipped over so we always work on the freshest cover. +#[cfg(target_os = "linux")] +fn start_pict_worker(state: ApiState) -> flume::Sender { + let (tx, rx) = flume::unbounded::(); + thread::spawn(move || { + while let Ok(mut job) = rx.recv() { + while let Ok(newer) = rx.try_recv() { + job = newer; + } + + let colors = nanoviz::now_playing::extract_prominent_colors_from_bytes(&job.bytes) + .unwrap_or_default(); + + let applied = { + let mut guard = state.now_playing.lock(); + if guard.artwork_generation == job.generation { + guard.palette_colors = colors.clone(); + true + } else { + false + } + }; + if applied { + // TODO: remove after song-change bug confirmed + eprintln!( + "INFO: PICT worker sending SetPalette gen={} colors={}", + job.generation, + colors.len() + ); + apply_artwork_palette(&state); + } else { + // TODO: remove after song-change bug confirmed + eprintln!( + "INFO: PICT worker dropped result (newer PICT arrived) gen={} colors={}", + job.generation, + colors.len() + ); + } + } + }); + tx +} + +/// Shairport Sync writes a stream of `` elements to the metadata pipe. +/// Each item looks like: +/// 636f72656173617226 +/// +/// RE1ORFMgJiBEYW5jZSBGcnVpdHMgTXVzaWM= +/// Large payloads (cover art) span many base64 lines before ``. +/// This parser uses quick-xml to handle all shapes robustly (inline or multi-line, +/// with or without ``). +#[cfg(target_os = "linux")] +fn process_shairport_metadata_stream( + state: &ApiState, + reader: R, + pict_tx: &flume::Sender, +) -> std::result::Result<(), String> { + let mut xml = XmlReader::from_reader(reader); + xml.config_mut().check_end_names = false; + xml.config_mut().trim_text(true); + + #[derive(Default)] + struct Cursor { + in_item: bool, + current_tag: Option, + type_hex: String, + code_hex: String, + length: usize, + encoded: String, + has_data: bool, + } + let mut cur = Cursor::default(); + let mut buf: Vec = Vec::new(); + + loop { + buf.clear(); + match xml.read_event_into(&mut buf) { + Ok(XmlEvent::Start(e)) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + match name.as_str() { + "item" => { + cur = Cursor { + in_item: true, + ..Cursor::default() + } + } + "type" | "code" | "length" | "data" if cur.in_item => { + cur.current_tag = Some(name.clone()); + if name == "data" { + cur.has_data = true; + } + } + _ => {} + } + } + Ok(XmlEvent::Text(e)) => { + if !cur.in_item { + continue; + } + // Shairport emits hex (type/code/length) and base64 (data) only, + // so plain UTF-8 decode is sufficient; no XML-entity unescaping needed. + let text = std::str::from_utf8(e.as_ref()).unwrap_or(""); + match cur.current_tag.as_deref() { + Some("type") => cur.type_hex.push_str(text.trim()), + Some("code") => cur.code_hex.push_str(text.trim()), + Some("length") => { + cur.length = text.trim().parse().unwrap_or(0); + } + Some("data") => { + for part in text.split_whitespace() { + cur.encoded.push_str(part); + } + } + _ => {} + } + } + Ok(XmlEvent::CData(e)) => { + if cur.current_tag.as_deref() == Some("data") { + let bytes = e.into_inner(); + for part in std::str::from_utf8(&bytes).unwrap_or("").split_whitespace() { + cur.encoded.push_str(part); + } + } + } + Ok(XmlEvent::End(e)) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + if name == "item" { + if !cur.type_hex.is_empty() && !cur.code_hex.is_empty() { + let item_type = decode_fourcc(&cur.type_hex) + .map(|s| s.to_ascii_lowercase()) + .unwrap_or_default(); + let code = decode_fourcc(&cur.code_hex).unwrap_or_default(); + if !item_type.is_empty() && !code.is_empty() { + match metadata_code_disposition(&item_type, &code) { + MetadataDisposition::Skip => {} + MetadataDisposition::NoPayload => { + apply_metadata_item_to_state( + state, + &item_type, + &code, + Vec::new(), + pict_tx, + ); + } + MetadataDisposition::Decode => { + let payload = if cur.has_data && !cur.encoded.is_empty() { + base64::engine::general_purpose::STANDARD + .decode(cur.encoded.as_bytes()) + .unwrap_or_default() + } else { + Vec::new() + }; + apply_metadata_item_to_state( + state, &item_type, &code, payload, pict_tx, + ); + } + } + } + } + cur = Cursor::default(); + } else if cur.current_tag.as_deref() == Some(name.as_str()) { + cur.current_tag = None; + } + } + Ok(XmlEvent::Eof) => return Ok(()), + Ok(_) => {} + Err(err) => { + // Non-fatal: the pipe can be joined mid-stream, producing + // orphaned close tags. Reset cursor and keep reading. + eprintln!("WARNING: metadata XML parse error (continuing): {err}"); + cur = Cursor::default(); + } + } + } +} + +#[cfg(target_os = "linux")] +fn apply_metadata_item_to_state( + state: &ApiState, + item_type: &str, + code: &str, + payload: Vec, + pict_tx: &flume::Sender, +) { + let payload_text = payload_bytes_to_string(&payload); + + let mut session_ended = false; + let mut guard = state.now_playing.lock(); + guard.reader_running = true; + guard.last_error = None; + guard.updated_at_ms = Some(now_unix_ms()); + + if metadata_logging_enabled() { + log_metadata_event( + &format!("{}/{} len={}", item_type, code, payload.len()), + Some(&payload), + ); + } + + match item_type { + "core" => match code { + "minm" => guard.track.title = payload_text, + "asar" => guard.track.artist = payload_text, + "asal" => guard.track.album = payload_text, + "asgn" => guard.track.genre = payload_text, + "ascp" => guard.track.composer = payload_text, + "asul" => guard.track.stream_url = payload_text, + "astm" => guard.track.duration_ms = dmap_payload_u64(&payload), + "asdk" => guard.track.song_data_kind = dmap_payload_u32(&payload), + // caps = DACP player state byte. + // Mapping varies across implementations (some use 3=playing, others 4=playing). + // Treat any value >= 2 as "active" — rely on prsm/pfls/paus for play vs pause. + "caps" => { + if let Some(v) = dmap_payload_u32(&payload) { + if v >= 2 { + // Don't downgrade from Playing to Paused based on caps alone — + // only prsm/pfls/paus should toggle between those states. + if guard.playback_state != PlaybackState::Playing { + guard.playback_state = PlaybackState::Playing; + } + } else { + guard.playback_state = PlaybackState::Stopped; + } + } + } + _ => {} + }, + "ssnc" => match code { + "snam" => guard.track.source_name = payload_text, + "snua" => guard.track.user_agent = payload_text, + "clip" | "conn" => guard.track.source_ip = payload_text, + "PICT" if !payload.is_empty() => { + guard.artwork_mime_type = detect_image_mime_type(&payload).map(str::to_string); + let new_generation = guard.artwork_generation.saturating_add(1); + guard.artwork_generation = new_generation; + // Clone for the worker; the state retains its own copy via + // `artwork_bytes` so /api/now-playing/artwork serves immediately. + // palette_colors is intentionally NOT cleared — old colors stay + // visible until the worker computes the new palette. + let job_bytes = payload.clone(); + guard.artwork_bytes = Some(payload); + let _ = pict_tx.send(PictJob { + generation: new_generation, + bytes: job_bytes, + }); + } + // Playback state transitions + "pbeg" => { + guard.clear_session_data(); + guard.playback_state = PlaybackState::Playing; + } + "prsm" | "pres" => guard.playback_state = PlaybackState::Playing, + "pfls" | "paus" => guard.playback_state = PlaybackState::Paused, + "pend" | "disc" => { + guard.playback_state = PlaybackState::Stopped; + guard.clear_session_data(); + session_ended = true; + } + // Progress: "start/current/end" RTP timestamps at 44100 Hz. + // shairport sends this at stream start and on flush events; we + // anchor wall-clock here so the snapshot can extrapolate elapsed. + "prgr" => { + let parsed = parse_prgr(&payload); + guard.progress_rtp = parsed; + if parsed.is_some() { + guard.progress_received_at_ms = Some(now_unix_ms()); + guard.playback_state = PlaybackState::Playing; + } else { + guard.progress_received_at_ms = None; + log_metadata_event("WARNING: failed to parse prgr payload", Some(&payload)); + } + } + // Volume: "airplay_vol,actual_vol,lowest,highest" in dB. + // We drive Nanoleaf brightness from this so the iOS AirPlay + // volume slider becomes the dimmer. shairport-sync.conf has + // `ignore_volume_control = "yes"`, so volume changes do NOT + // attenuate audio — the slider is brightness-only. + "pvol" => { + let db = parse_pvol(&payload); + guard.volume_db = db; + if let Some(db_value) = db + && let Some(brightness) = volume_db_to_brightness(db_value) + && guard.last_pvol_brightness != Some(brightness) + { + guard.last_pvol_brightness = Some(brightness); + // active_palette_device locks runtime_config + reads + // the devices file — both quick. The actual HTTP write + // is fire-and-forget on a plain OS thread so it doesn't + // stall metadata processing if the device is slow or + // unreachable. + // + // Important: this handler runs on the now-playing reader + // thread, which is spawned via std::thread::spawn (NOT + // inside the Tokio runtime). Calling + // tokio::task::spawn_blocking from here panics with + // "no reactor running". std::thread::spawn works + // regardless of runtime presence. + if let Ok(device) = active_palette_device(state) { + std::thread::spawn(move || { + if let Err(err) = device.set_state(None, Some(brightness)) { + eprintln!( + "WARNING: pvol → brightness write failed (brightness={}): {}", + brightness, err + ); + } + }); + } + } + } + // Metadata bundle boundaries (informational — no action needed) + "mdst" | "mden" | "pcst" | "pcen" => {} + _ => {} + }, + _ => {} + } + + drop(guard); + + // AirPlay session just ended — push panels to black and keep them there + // until a new SetPalette arrives (next session's first PICT). + if session_ended { + send_blackout(state); + return; + } + + // Cover art may have just landed (PICT). Push artwork colors live if + // color_source = Artwork; otherwise the previous palette persists. + apply_artwork_palette(state); +} + +#[cfg(target_os = "linux")] +fn dmap_payload_u32(payload: &[u8]) -> Option { + match payload.len() { + 1 => Some(payload[0] as u32), + 2 => Some(u16::from_be_bytes([payload[0], payload[1]]) as u32), + 4 => Some(u32::from_be_bytes([ + payload[0], payload[1], payload[2], payload[3], + ])), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn dmap_payload_u64(payload: &[u8]) -> Option { + match payload.len() { + 1..=4 => dmap_payload_u32(payload).map(|v| v as u64), + 8 => Some(u64::from_be_bytes(payload[..8].try_into().ok()?)), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn parse_prgr(payload: &[u8]) -> Option<(u64, u64, u64)> { + let text = std::str::from_utf8(payload).ok()?; + let mut parts = text.split('/'); + let start = parts.next()?.trim().parse().ok()?; + let current = parts.next()?.trim().parse().ok()?; + let end = parts.next()?.trim().parse().ok()?; + Some((start, current, end)) +} + +#[cfg(target_os = "linux")] +fn parse_pvol(payload: &[u8]) -> Option { + let text = std::str::from_utf8(payload).ok()?; + let first = text.split(',').next()?.trim(); + first.parse().ok() +} + +/// Map AirPlay volume (dB) to a Nanoleaf brightness value (1..=100). +/// +/// AirPlay reports volume on a 0.0 (loudest) to -30.0 (quietest) dB scale, +/// with -144.0 as a "muted" sentinel. We treat mute as "leave brightness +/// alone" (returning None) so a stray mute press doesn't kill the panels; +/// otherwise linearly map [-30, 0] dB → [1, 100] brightness. Clamping +/// keeps us inside the Nanoleaf API's accepted 0..=100 range while +/// avoiding 0 (which would turn the device off rather than dim it). +/// +/// Linux-only because the `pvol` consumer lives in the metadata pipe +/// handler which is Linux-gated. +#[cfg(target_os = "linux")] +fn volume_db_to_brightness(db: f32) -> Option { + if !db.is_finite() || db <= -144.0 { + return None; + } + let clamped = db.clamp(-30.0, 0.0); + let frac = 1.0 - (clamped / -30.0); + let raw = (frac * 100.0).round(); + Some(raw.clamp(1.0, 100.0) as u8) +} + +#[cfg(target_os = "linux")] +fn payload_bytes_to_string(payload: &[u8]) -> Option { + if payload.is_empty() { + return None; + } + let value = String::from_utf8_lossy(payload) + .trim_matches('\0') + .trim() + .to_string(); + if value.is_empty() { None } else { Some(value) } +} + +#[cfg(target_os = "linux")] +fn decode_fourcc(hex_value: &str) -> Option { + let raw = u32::from_str_radix(hex_value, 16).ok()?.to_be_bytes(); + Some(raw.iter().map(|byte| *byte as char).collect()) +} + +#[cfg(target_os = "linux")] +fn detect_image_mime_type(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some("image/jpeg"); + } + if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + return Some("image/png"); + } + if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + return Some("image/webp"); + } + // BMP + if bytes.starts_with(b"BM") && bytes.len() > 14 { + return Some("image/bmp"); + } + // TIFF (little-endian II or big-endian MM) + if bytes.len() >= 4 + && ((bytes[0..2] == [0x49, 0x49] && bytes[2..4] == [0x2A, 0x00]) + || (bytes[0..2] == [0x4D, 0x4D] && bytes[2..4] == [0x00, 0x2A])) + { + return Some("image/tiff"); + } + // GIF + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Some("image/gif"); + } + None +} + +#[cfg(target_os = "macos")] +fn extract_prominent_colors(image_bytes: &[u8]) -> Option> { + nanoviz::now_playing::extract_prominent_colors_from_bytes(image_bytes) +} + +fn now_unix_ms() -> u64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => duration.as_millis() as u64, + Err(_) => 0, + } +} + +/// True when NANOVIZ_LOG_METADATA is set to a truthy value. Cached on first +/// read — toggling at runtime requires a process restart. +#[allow(dead_code)] // Only consumed by the Linux shairport-metadata path. +fn metadata_logging_enabled() -> bool { + static FLAG: std::sync::OnceLock = std::sync::OnceLock::new(); + *FLAG.get_or_init(|| { + std::env::var("NANOVIZ_LOG_METADATA") + .ok() + .map(|v| !matches!(v.as_str(), "" | "0" | "false" | "no")) + .unwrap_or(false) + }) +} + +/// Log an ad-hoc metadata-stream event. Rendering a preview of the payload +/// (UTF-8 if printable, hex-bytes otherwise, truncated for large blobs). +/// No-op unless NANOVIZ_LOG_METADATA is set. +#[allow(dead_code)] // Only consumed by the Linux shairport-metadata path. +fn log_metadata_event(message: &str, payload: Option<&[u8]>) { + if !metadata_logging_enabled() { + return; + } + let preview = match payload { + None => String::new(), + Some([]) => " (empty)".to_string(), + Some(bytes) => { + let utf8 = std::str::from_utf8(bytes) + .ok() + .map(|s| s.trim_matches('\0')) + .filter(|s| s.chars().all(|c| !c.is_control() || c == '\n' || c == '\t')); + if let Some(text) = utf8 { + format!(" \"{}\"", text) + } else if bytes.len() <= 16 { + format!(" bytes={:02x?}", bytes) + } else { + format!(" <{} bytes>", bytes.len()) + } + } + }; + eprintln!("META {}{}", message, preview); +} + +fn resolve_paths(state: &ApiState) -> Result { + let ((config_file_path, config_file_exists), (devices_file_path, devices_file_exists)) = + nanoviz::config::resolve_paths( + state.config_file_path.clone(), + state.devices_file_path.clone(), + ) + .map_err(ApiError::internal)?; + + Ok(PathsResponse { + config_file_path: config_file_path.to_string_lossy().into_owned(), + config_file_exists, + devices_file_path: devices_file_path.to_string_lossy().into_owned(), + devices_file_exists, + }) +} + +fn load_device_by_name( + state: &ApiState, + name: &str, +) -> Result { + let paths = resolve_paths(state)?; + if !paths.devices_file_exists { + return Err(ApiError::not_found(format!( + "No devices file found at {}", + paths.devices_file_path + ))); + } + let devices_path = PathBuf::from(&paths.devices_file_path); + nanoviz::nanoleaf::NlDevice::find_in_file(&devices_path, Some(name)) + .map_err(|err| ApiError::not_found(err.to_string())) +} + +fn get_runtime_config_clone(state: &ApiState) -> Result { + let guard = state.runtime_config.lock(); + Ok(guard.clone()) +} + +/// Resolve the `NlDevice` we should pull palettes from. Uses +/// `default_nl_device_name` from config if set; otherwise the first device in +/// `nl_devices.toml`. +fn active_palette_device(state: &ApiState) -> Result { + let config = get_runtime_config_clone(state)?; + let paths = resolve_paths(state)?; + if !paths.devices_file_exists { + return Err(ApiError::not_found(format!( + "No devices file found at {}", + paths.devices_file_path + ))); + } + let devices_path = PathBuf::from(&paths.devices_file_path); + let preferred = config.default_nl_device_name.as_deref(); + nanoviz::nanoleaf::NlDevice::find_in_file(&devices_path, preferred) + .map_err(|err| ApiError::not_found(err.to_string())) +} + +/// Read palettes from cache. Refreshes from the device if missing or stale. +/// Returns an empty Vec on device-fetch failure rather than failing the +/// caller — palette lookups should degrade gracefully when the device is off. +fn palettes_cached(state: &ApiState) -> Vec { + { + let guard = state.palette_cache.lock(); + if let Some((fetched_at, palettes)) = guard.as_ref() + && fetched_at.elapsed() < PALETTE_CACHE_TTL + { + return palettes.clone(); + } + } + let device = match active_palette_device(state) { + Ok(d) => d, + Err(_) => return Vec::new(), + }; + match device.list_effect_palettes() { + Ok(palettes) => { + let mut guard = state.palette_cache.lock(); + *guard = Some((Instant::now(), palettes.clone())); + palettes + } + Err(err) => { + eprintln!( + "WARNING: failed to fetch palettes from Nanoleaf device '{}': {}", + device.name, err + ); + Vec::new() + } + } +} + +/// Resolve a palette name to its colors. Falls back to the device's +/// currently-selected effect when: +/// - the requested name doesn't exist (renamed/removed in the Nanoleaf app) +/// - no name was given (`name = None`) +/// Final fallback if nothing matches: `constants::DEFAULT_COLORS`, so the +/// visualizer always has something to animate. +fn resolve_palette_colors(state: &ApiState, name: Option<&str>) -> Vec<[u8; 3]> { + let palettes = palettes_cached(state); + + if let Some(want) = name + && let Some(found) = palettes.iter().find(|p| p.name == want) + { + return found.colors.clone(); + } + + // Try the device's currently-selected effect as the fallback. + if let Ok(device) = active_palette_device(state) + && let Ok(info) = device.get_device_info() + && let Some(selected) = info["effects"]["select"].as_str() + && let Some(found) = palettes.iter().find(|p| p.name == selected) + { + return found.colors.clone(); + } + + // Last resort: hard-coded gradient, used at startup if device is offline. + Vec::from(nanoviz::constants::DEFAULT_COLORS) +} + +/// Async wrapper around [`palettes_cached`] for use from `async fn` handlers. +/// The underlying call hits the Nanoleaf via blocking reqwest — without +/// `spawn_blocking` it would pin a tokio worker for the duration of the +/// device round-trip (often seconds when the Pi/Nanoleaf are slow), and +/// enough of those at once will starve the runtime. +async fn palettes_cached_async(state: &ApiState) -> Vec { + let state = state.clone(); + tokio::task::spawn_blocking(move || palettes_cached(&state)) + .await + .unwrap_or_default() +} + +/// Async wrapper around [`resolve_palette_colors`]. Same rationale. +async fn resolve_palette_colors_async(state: &ApiState, name: Option) -> Vec<[u8; 3]> { + let state = state.clone(); + tokio::task::spawn_blocking(move || resolve_palette_colors(&state, name.as_deref())) + .await + .unwrap_or_default() +} + +/// When color_source = Artwork and we have artwork colors cached, push them +/// as the live palette. Otherwise no-op — the visualizer keeps whatever +/// palette it had last (no flash to white between songs). +fn apply_artwork_palette(state: &ApiState) { + let config = match get_runtime_config_clone(state) { + Ok(c) => c, + Err(_) => return, + }; + if !matches!( + config.visualizer_config.color_source, + Some(nanoviz::config::ColorSourceKind::Artwork) + ) { + return; + } + let colors = state.now_playing.lock().palette_colors.clone(); + if colors.is_empty() { + return; + } + let Ok(Some(runtime)) = current_live_visualizer(state) else { + eprintln!( + "WARN: apply_artwork_palette: no live visualizer (colors={})", + colors.len() + ); + return; + }; + let n = colors.len(); + if let Err(err) = runtime + .sender + .send(nanoviz::visualizer::VisualizerMsg::SetPalette(colors)) + { + // TODO: remove after song-change bug confirmed + eprintln!("WARN: SetPalette send failed n={} err={}", n, err); + } else { + // TODO: remove after song-change bug confirmed + eprintln!("INFO: SetPalette sent n={}", n); + } +} + +/// Send a Blackout to the live visualizer. Panels go dark and stay dark until +/// the next SetPalette. Used on AirPlay session end (Linux/shairport only). +#[cfg(target_os = "linux")] +fn send_blackout(state: &ApiState) { + let Ok(Some(runtime)) = current_live_visualizer(state) else { + return; + }; + let _ = runtime + .sender + .send(nanoviz::visualizer::VisualizerMsg::Blackout); +} + +fn update_runtime_config( + state: &ApiState, + updater: F, +) -> Result +where + F: FnOnce(&mut nanoviz::config::Config), +{ + let mut guard = state.runtime_config.lock(); + updater(&mut guard); + Ok(guard.clone()) +} + +fn current_live_visualizer(state: &ApiState) -> Result, ApiError> { + let guard = state.live_visualizer.lock(); + Ok(guard.clone()) +} + +async fn ensure_live_visualizer(state: &ApiState) -> Result { + if let Some(runtime) = current_live_visualizer(state)? { + return Ok(runtime); + } + + recover_live_visualizer(state, "ensure_live_visualizer").await?; + current_live_visualizer(state)? + .ok_or_else(|| ApiError::internal("Live visualizer failed to initialize")) +} + +fn mark_live_visualizer_recovery_success( + state: &ApiState, + auto_fallback_to_default_active: bool, +) -> Result<(), ApiError> { + let mut guard = state.live_visualizer_recovery.lock(); + guard.auto_fallback_to_default_active = auto_fallback_to_default_active; + guard.last_restart_at_ms = Some(now_unix_ms()); + guard.healthy_ping_streak = 0; + Ok(()) +} + +fn mark_live_visualizer_recovery_failure(state: &ApiState) -> Result { + let mut guard = state.live_visualizer_recovery.lock(); + guard.consecutive_restart_failures = guard.consecutive_restart_failures.saturating_add(1); + guard.healthy_ping_streak = 0; + Ok(guard.consecutive_restart_failures) +} + +fn mark_live_visualizer_restart_attempt(state: &ApiState) -> Result<(), ApiError> { + let mut guard = state.live_visualizer_recovery.lock(); + guard.last_restart_at_ms = Some(now_unix_ms()); + guard.healthy_ping_streak = 0; + Ok(()) +} + +fn mark_live_visualizer_watchdog_healthy(state: &ApiState) -> Result<(), ApiError> { + let mut guard = state.live_visualizer_recovery.lock(); + + if guard.consecutive_restart_failures == 0 { + guard.healthy_ping_streak = 0; + return Ok(()); + } + + guard.healthy_ping_streak = guard.healthy_ping_streak.saturating_add(1); + if guard.healthy_ping_streak >= LIVE_VISUALIZER_HEALTHY_PINGS_TO_CLEAR_FAILURES { + guard.consecutive_restart_failures = 0; + guard.healthy_ping_streak = 0; + eprintln!( + "INFO: cleared live visualizer restart failure counter after {} healthy watchdog pings.", + LIVE_VISUALIZER_HEALTHY_PINGS_TO_CLEAR_FAILURES + ); + } + Ok(()) +} + +fn live_visualizer_restart_cooldown_remaining( + state: &ApiState, +) -> Result, ApiError> { + let guard = state.live_visualizer_recovery.lock(); + + let Some(last_restart_at_ms) = guard.last_restart_at_ms else { + return Ok(None); + }; + let base_ms = LIVE_VISUALIZER_RESTART_COOLDOWN.as_millis() as u64; + let max_ms = LIVE_VISUALIZER_RESTART_COOLDOWN_MAX.as_millis() as u64; + let shift = guard.consecutive_restart_failures.min(20); + let cooldown_ms = base_ms.checked_shl(shift).unwrap_or(max_ms).min(max_ms); + let now_ms = now_unix_ms(); + let elapsed_ms = now_ms.saturating_sub(last_restart_at_ms); + if elapsed_ms >= cooldown_ms { + Ok(None) + } else { + Ok(Some(Duration::from_millis(cooldown_ms - elapsed_ms))) + } +} + +async fn recover_live_visualizer(state: &ApiState, reason: &str) -> Result<(), ApiError> { + if let Some(delay) = live_visualizer_restart_cooldown_remaining(state)? { + tokio::time::sleep(delay).await; + } + mark_live_visualizer_restart_attempt(state)?; + + let configured_backend = get_runtime_config_clone(state)? + .visualizer_config + .audio_backend + .unwrap_or_else(|| nanoviz::constants::DEFAULT_AUDIO_BACKEND.to_string()); + + match restart_live_visualizer(state).await { + Ok(()) => { + mark_live_visualizer_recovery_success(state, false)?; + return Ok(()); + } + Err(primary_err) => { + let failure_count = mark_live_visualizer_recovery_failure(state)?; + eprintln!( + "WARNING: live visualizer restart failed (reason: {}, backend: {}, consecutive_failures: {}): {}", + reason, configured_backend, failure_count, primary_err.message + ); + + let should_try_default_fallback = configured_backend + != nanoviz::constants::DEFAULT_AUDIO_BACKEND + && failure_count >= LIVE_VISUALIZER_RESTART_FAILURE_LIMIT; + if !should_try_default_fallback { + return Err(primary_err); + } + } + } + + eprintln!( + "WARNING: falling back live visualizer backend to '{}' after repeated restart failures.", + nanoviz::constants::DEFAULT_AUDIO_BACKEND + ); + update_runtime_config(state, |config| { + config.visualizer_config.audio_backend = + Some(nanoviz::constants::DEFAULT_AUDIO_BACKEND.to_string()); + })?; + mark_live_visualizer_restart_attempt(state)?; + restart_live_visualizer(state).await?; + mark_live_visualizer_recovery_success(state, true)?; + Ok(()) +} + +async fn send_live_message_with_recovery( + state: &ApiState, + message: nanoviz::visualizer::VisualizerMsg, +) -> Result<(), ApiError> { + let live = ensure_live_visualizer(state).await?; + if live.sender.send(message.clone()).is_ok() { + return Ok(()); + } + + recover_live_visualizer(state, "control message send failure").await?; + let restarted = ensure_live_visualizer(state).await?; + restarted + .sender + .send(message) + .map_err(|_| ApiError::internal("Failed to send command to live visualizer")) +} + +async fn restart_live_visualizer(state: &ApiState) -> Result<(), ApiError> { + let state = state.clone(); + tokio::task::spawn_blocking(move || restart_live_visualizer_sync(&state)) + .await + .map_err(handle_join_error)? +} + +fn restart_live_visualizer_sync(state: &ApiState) -> Result<(), ApiError> { + let new_runtime = build_live_visualizer(state)?; + let old_runtime = { + let mut guard = state.live_visualizer.lock(); + guard.replace(new_runtime) + }; + + if let Some(old_runtime) = old_runtime { + let _ = old_runtime + .sender + .send(nanoviz::visualizer::VisualizerMsg::End); + } + Ok(()) +} + +fn build_live_visualizer(state: &ApiState) -> Result { + let config = get_runtime_config_clone(state)?; + let paths = resolve_paths(state)?; + if !paths.devices_file_exists { + return Err(ApiError::not_found(format!( + "No devices file found at {}", + paths.devices_file_path + ))); + } + + let devices_path = PathBuf::from(&paths.devices_file_path); + let known_devices = + nanoviz::nanoleaf::NlDevice::all_from_file(&devices_path).map_err(ApiError::internal)?; + if known_devices.is_empty() { + return Err(ApiError::not_found(format!( + "No Nanoleaf devices found in {}", + paths.devices_file_path + ))); + } + + let preferred_name = config.default_nl_device_name.clone(); + let nl_device = if let Some(default_name) = preferred_name.as_deref() { + match known_devices + .iter() + .find(|device| device.name == default_name) + { + Some(device) => device.clone(), + None => { + let fallback = known_devices[0].clone(); + eprintln!( + "WARNING: default_nl_device_name '{}' not found. Falling back to '{}'.", + default_name, fallback.name + ); + fallback + } + } + } else { + known_devices[0].clone() + }; + + nl_device + .ensure_device_ready() + .map_err(ApiError::internal)?; + nl_device + .request_udp_control() + .map_err(ApiError::internal)?; + + let global_orientation = nl_device + .get_global_orientation() + .ok() + .and_then(|orientation| orientation["value"].as_u64()) + .unwrap_or(0) as u16; + + let configured_backend = config.visualizer_config.audio_backend.clone(); + let audio_stream = match nanoviz::audio::AudioStream::new(configured_backend.as_deref()) { + Ok(stream) => stream, + Err(primary_err) => { + let should_try_default = configured_backend + .as_deref() + .is_some_and(|name| name != nanoviz::constants::DEFAULT_AUDIO_BACKEND); + if !should_try_default { + return Err(ApiError::internal(primary_err)); + } + + eprintln!( + "WARNING: Failed to initialize audio backend '{}': {}. Falling back to '{}'.", + configured_backend.as_deref().unwrap_or("unknown"), + primary_err, + nanoviz::constants::DEFAULT_AUDIO_BACKEND + ); + nanoviz::audio::AudioStream::new(Some(nanoviz::constants::DEFAULT_AUDIO_BACKEND)) + .map_err(ApiError::internal)? + } + }; + + let (color_tx, color_rx) = flume::bounded(1); + let stream_health = Arc::new(Mutex::new(nanoviz::visualizer::StreamHealth::Starting)); + + // Resolve initial hues from the configured color source. For Artwork mode + // and any case where the palette can't be looked up, the visualizer's + // own fallback (DEFAULT_COLORS) kicks in via empty input. + let initial_hues = match config.visualizer_config.color_source { + Some(nanoviz::config::ColorSourceKind::Artwork) => { + // Use any current artwork colors; otherwise an empty Vec triggers + // the visualizer's own DEFAULT_COLORS fallback. Subsequent now- + // playing updates push artwork colors live via apply_artwork_palette. + state.now_playing.lock().palette_colors.clone() + } + _ => resolve_palette_colors(state, config.visualizer_config.palette_name.as_deref()), + }; + + let visualizer = nanoviz::visualizer::Visualizer::new( + config.visualizer_config, + audio_stream, + &nl_device, + vec![color_tx], + initial_hues, + ) + .map_err(ApiError::internal)? + .with_stream_health(Arc::clone(&stream_health)); + let sender = visualizer.init(); + + println!( + "Live visualizer attached to '{}' at {}", + nl_device.name, nl_device.ip + ); + + Ok(LiveVisualizerRuntime { + sender, + global_orientation, + device: DeviceSummary { + name: nl_device.name, + ip: nl_device.ip.to_string(), + }, + color_rx, + latest_colors: Arc::new(Mutex::new(HashMap::new())), + stream_health, + }) +} + +fn parse_axis(input: &str) -> Option { + if input.eq_ignore_ascii_case("x") { + Some(nanoviz::config::Axis::X) + } else if input.eq_ignore_ascii_case("y") { + Some(nanoviz::config::Axis::Y) + } else { + None + } +} + +fn parse_sort(input: &str) -> Option { + if input.eq_ignore_ascii_case("asc") { + Some(nanoviz::config::Sort::Asc) + } else if input.eq_ignore_ascii_case("desc") { + Some(nanoviz::config::Sort::Desc) + } else { + None + } +} + +fn parse_effect(input: &str) -> Option { + match input { + x if x.eq_ignore_ascii_case("spectrum") => Some(nanoviz::config::Effect::Spectrum), + x if x.eq_ignore_ascii_case("energywave") + || x.eq_ignore_ascii_case("energy_wave") + || x.eq_ignore_ascii_case("energy-wave") => + { + Some(nanoviz::config::Effect::EnergyWave) + } + x if x.eq_ignore_ascii_case("ripple") => Some(nanoviz::config::Effect::Ripple), + _ => None, + } +} + +fn stream_health_label(stream_health: nanoviz::visualizer::StreamHealth) -> &'static str { + match stream_health { + nanoviz::visualizer::StreamHealth::Starting => "Starting", + nanoviz::visualizer::StreamHealth::Healthy => "Healthy", + nanoviz::visualizer::StreamHealth::Degraded => "Degraded", + nanoviz::visualizer::StreamHealth::Restarting => "Restarting", + nanoviz::visualizer::StreamHealth::Stopped => "Stopped", + } +} + +fn summarize_visualizer_status( + live_visualizer_attached: bool, + stream_health: nanoviz::visualizer::StreamHealth, + consecutive_restart_failures: u32, +) -> &'static str { + if !live_visualizer_attached { + return "Restarting"; + } + + match stream_health { + nanoviz::visualizer::StreamHealth::Healthy => { + if consecutive_restart_failures > 0 { + "Degraded" + } else { + "Healthy" + } + } + nanoviz::visualizer::StreamHealth::Degraded => "Degraded", + nanoviz::visualizer::StreamHealth::Starting + | nanoviz::visualizer::StreamHealth::Restarting + | nanoviz::visualizer::StreamHealth::Stopped => "Restarting", + } +} + +async fn run_nanoleaf_io(operation: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> anyhow::Result + Send + 'static, +{ + tokio::task::spawn_blocking(operation) + .await + .map_err(handle_join_error)? + .map_err(ApiError::internal) +} + +fn handle_join_error(err: JoinError) -> ApiError { + ApiError::internal(format!("Background I/O task failed: {err}")) +} diff --git a/src/config.rs b/src/config.rs index 0e8c89c..a8153f9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,5 @@ use crate::{constants, ssdp, utils}; use anyhow::{Result, bail}; -use clap::Parser; use serde::Serialize; use std::fs::File; use std::{ @@ -10,50 +9,6 @@ use std::{ }; use toml::{Table, Value}; -#[derive(Parser, Debug)] -#[command(version, about, author, long_about = None)] -pub struct CliOptions { - /// Path to audioleaf's configuration file - #[arg(long = "config")] - pub config_file_path: Option, - - /// Path to audioleaf's database of known Nanoleaf devices - #[arg(long = "devices")] - pub devices_file_path: Option, - - /// Name of the Nanoleaf device to connect to (e.g. Canvas 2E50) - #[arg(short = 'd', long = "device-name")] - pub device_name: Option, - - /// Explicitly add a new Nanoleaf device - #[arg(short = 'n', long = "new")] - pub add_new: bool, - - #[command(subcommand)] - pub command: Option, -} - -#[derive(Parser, Debug)] -pub enum Command { - /// Dump information from device or configuration - Dump { - #[command(subcommand)] - dump_type: DumpType, - }, -} - -#[derive(Parser, Debug)] -pub enum DumpType { - /// Dump panel layout information from the device - Layout, - /// Dump available color palettes - Palettes, - /// Dump device info from /api/v1/ endpoint (no auth required) - Info, - /// Show graphical panel layout visualization - LayoutGraphical, -} - #[derive(Copy, Clone, Debug, Default, Serialize)] pub enum Axis { X, @@ -77,20 +32,37 @@ pub enum Effect { /// Brightness pulses with audio energy in that band (fast attack, slow decay). #[default] Spectrum, - /// Audio energy enters from one end and cascades across panels as a traveling wave. - /// Creates a flowing ripple effect driven by overall audio amplitude. + /// Each panel tracks its own frequency band (like Spectrum) but brightness + /// bleeds into neighboring panels, creating a flowing wave across the array. EnergyWave, - /// All panels pulse together, driven directly by audio transients. - /// Very fast attack snaps to each beat; smooth exponential decay fades between hits. - /// The music's own rhythm drives the animation — no fixed oscillation. - Pulse, + /// Onset-triggered pulses propagate outward from panel 0 like ripples on water + /// or a starship jumping to warp — bright leading edge stretching into a fading trail. + Ripple, +} + +/// Where the visualizer pulls its panel colors from. +/// +/// `Palette { name }` looks up the named effect on the active Nanoleaf device +/// and uses its palette. `name = None` means "use the device's currently- +/// selected effect." `Artwork` drives colors from album cover art when audio +/// is playing, falling back to a static dim white when idle. +#[derive(Copy, Clone, Debug, Default, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ColorSourceKind { + #[default] + Palette, + Artwork, } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] pub struct VisualizerConfig { pub audio_backend: Option, pub freq_range: Option<(u16, u16)>, - pub colors: Option>, + pub color_source: Option, + /// Name of a Nanoleaf-side effect whose palette we use. `None` (with + /// `color_source = Palette`) means "use the device's currently-selected + /// effect." Ignored when `color_source = Artwork`. + pub palette_name: Option, pub default_gain: Option, pub transition_time: Option, pub time_window: Option, @@ -100,30 +72,15 @@ pub struct VisualizerConfig { pub effect: Option, } -impl VisualizerConfig { - /// Returns the default visualizer configuration. - /// - /// Initializes with constants: - /// - `audio_backend`: "default" - /// - `freq_range`: (20, 4500) Hz - /// - `colors`: RGB color array for panel visualization - /// - `default_gain`: 1.0 - /// - `transition_time`: 2 (200ms) - /// - `time_window`: 0.1875 s - /// - Sorting: Y axis ascending, secondary ascending - pub fn default() -> Self { +impl Default for VisualizerConfig { + fn default() -> Self { VisualizerConfig { audio_backend: Some("default".to_string()), freq_range: Some(constants::DEFAULT_FREQ_RANGE), - colors: Some(vec![ - [255, 128, 0], - [255, 0, 0], - [255, 0, 128], - [255, 0, 255], - [128, 0, 255], - [0, 0, 255], - [0, 128, 255], - ]), + // Defaults: pull palette from whatever effect the device is + // currently set to. Users override via the API. + color_source: Some(ColorSourceKind::Palette), + palette_name: None, default_gain: Some(constants::DEFAULT_GAIN), transition_time: Some(constants::DEFAULT_TRANSITION_TIME), time_window: Some(constants::DEFAULT_TIME_WINDOW), @@ -135,7 +92,7 @@ impl VisualizerConfig { } } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] pub struct Config { pub default_nl_device_name: Option, pub visualizer_config: VisualizerConfig, @@ -156,7 +113,7 @@ impl Config { ) -> Self { Config { default_nl_device_name, - visualizer_config: visualizer_config.unwrap_or(VisualizerConfig::default()), + visualizer_config: visualizer_config.unwrap_or_default(), } } @@ -202,70 +159,22 @@ impl Config { visualizer_config.freq_range = Some((u16::try_from(low)?, u16::try_from(high)?)); } - ("colors" | "hues", Value::String(s)) => { - // Named palette support - match crate::palettes::get_palette(&s) { - Some(colors) => visualizer_config.colors = Some(colors), - None => { - let available = crate::palettes::get_palette_names().join(", "); - bail!( - "Unknown palette name '{}'. Available palettes: {}", - s, - available - ); - } - } + ("color_source", Value::String(s)) => { + let kind = match s.to_ascii_lowercase().as_str() { + "palette" => ColorSourceKind::Palette, + "artwork" => ColorSourceKind::Artwork, + _ => bail!("color_source must be `palette` or `artwork`, got `{}`", s), + }; + visualizer_config.color_source = Some(kind); } - ("colors" | "hues", Value::Array(v)) => { - if v.is_empty() { - bail!("colors cannot be an empty array"); - } - // Detect format: if first element is an integer, treat as legacy hue array; - // if first element is an array, treat as RGB color array. - if v[0].is_integer() { - // Legacy hue format: [30, 0, 330, ...] → convert HSV hues to RGB - let mut colors = Vec::with_capacity(v.len()); - for (i, entry) in v.iter().enumerate() { - let Some(hue_val) = entry.as_integer() else { - bail!("hues[{}] must be an integer (0-360)", i); - }; - if !(0..=360).contains(&hue_val) { - bail!("hues[{}] must be 0-360, got {}", i, hue_val); - } - if hue_val == 360 { - colors.push([255, 255, 255]); // white - } else { - // Convert HSV hue (S=1, V=1) to RGB - let rgb = hsv_hue_to_rgb(hue_val as f32); - colors.push(rgb); - } - } - visualizer_config.colors = Some(colors); - } else { - // New RGB format: [[255, 0, 0], [0, 255, 0], ...] - let mut colors = Vec::with_capacity(v.len()); - for (i, entry) in v.iter().enumerate() { - let Some(rgb_arr) = entry.as_array() else { - bail!("colors[{}] must be a [R, G, B] array", i); - }; - if rgb_arr.len() != 3 { - bail!("colors[{}] must be a 3-element [R, G, B] array", i); - } - let mut rgb = [0u8; 3]; - for (j, component) in rgb_arr.iter().enumerate() { - let Some(val) = component.as_integer() else { - bail!("colors[{}][{}] must be an integer (0-255)", i, j); - }; - if !(0..=255).contains(&val) { - bail!("colors[{}][{}] must be 0-255, got {}", i, j, val); - } - rgb[j] = val as u8; - } - colors.push(rgb); - } - visualizer_config.colors = Some(colors); - } + ("palette_name", Value::String(s)) => { + visualizer_config.palette_name = Some(s); } + // Legacy keys removed by migrate_obsolete_fields() before parse. + // If they survive (e.g. external write), silently drop them + // rather than failing — we don't want a stale field to brick + // startup. + ("colors" | "hues", _) => {} ("default_gain", Value::Float(x)) => { #[cfg(debug_assertions)] eprintln!("DEBUG: Parsed default_gain as Float: {}", x); @@ -324,12 +233,12 @@ impl Config { let effect = match s.as_str() { "Spectrum" | "spectrum" => Some(Effect::Spectrum), "EnergyWave" | "energy_wave" | "energy-wave" => Some(Effect::EnergyWave), - "Pulse" | "pulse" => Some(Effect::Pulse), + "Ripple" | "ripple" => Some(Effect::Ripple), _ => None, }; if effect.is_none() { bail!( - "effect must be `Spectrum`, `EnergyWave`, or `Pulse`, got `{}`", + "effect must be `Spectrum`, `EnergyWave`, or `Ripple`, got `{}`", s ); }; @@ -365,9 +274,54 @@ impl Config { /// # Errors /// /// File I/O errors, TOML deserialization failures, or validation bails. + /// + /// One-shot migration: if the on-disk config still has obsolete `colors` + /// or `hues` fields under `[visualizer_config]`, strip them and rewrite + /// the file. Uses `toml_edit` so comments, key order, and whitespace + /// outside the removed lines are preserved. No-op if neither key is + /// present. + fn migrate_obsolete_fields(path: &Path) -> Result<()> { + let mut contents = String::new(); + File::open(path)?.read_to_string(&mut contents)?; + + let mut doc: toml_edit::DocumentMut = match contents.parse() { + Ok(d) => d, + // If the file isn't valid TOML, let the main parser produce a + // proper error — we don't want to mangle a half-broken file. + Err(_) => return Ok(()), + }; + + let mut removed = Vec::new(); + if let Some(item) = doc.get_mut("visualizer_config") + && let Some(table) = item.as_table_mut() + { + for legacy in ["colors", "hues"] { + if table.remove(legacy).is_some() { + removed.push(legacy); + } + } + } + + if !removed.is_empty() { + let new_contents = doc.to_string(); + std::fs::write(path, new_contents)?; + eprintln!( + "INFO: migrated {} — removed obsolete inline {}; palettes now come from the Nanoleaf device.", + path.display(), + removed.join(" and ") + ); + } + Ok(()) + } + pub fn parse_from_file(path: &Path) -> Result { #[cfg(debug_assertions)] eprintln!("DEBUG: Reading config from: {}", path.display()); + // Strip obsolete fields from the on-disk file before parse. After this + // returns, the file is guaranteed not to contain `colors` / `hues` in + // [visualizer_config], regardless of what was there before. + Self::migrate_obsolete_fields(path)?; + let mut config_file = File::open(path)?; let mut contents = String::new(); config_file.read_to_string(&mut contents)?; @@ -422,7 +376,7 @@ impl Config { /// Resolves absolute paths for configuration and devices TOML files. /// -/// Defaults to XDG config dir (~/.config/audioleaf/) + default filenames if not provided. +/// Defaults to XDG config dir (~/.config/nanoviz/) + default filenames if not provided. /// Checks file existence (returns bool in tuple) and permissions. /// /// # Arguments @@ -473,24 +427,6 @@ pub fn resolve_paths( )) } -/// Converts an HSV hue (with S=1, V=1) to an RGB triplet. -/// -/// Used for backwards compatibility with legacy config files that specify -/// colors as hue angles (0-360) instead of RGB arrays. -fn hsv_hue_to_rgb(hue: f32) -> [u8; 3] { - let h = hue / 60.0; - let x = 1.0 - (h % 2.0 - 1.0).abs(); - let (r, g, b) = match h as u32 { - 0 => (1.0, x, 0.0), - 1 => (x, 1.0, 0.0), - 2 => (0.0, 1.0, x), - 3 => (0.0, x, 1.0), - 4 => (x, 0.0, 1.0), - _ => (1.0, 0.0, x), - }; - [(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8] -} - /// Interactively discovers Nanoleaf devices via SSDP or accepts manual IP input. /// /// Performs SSDP M-SEARCH to find devices on network, lists names/IPs for user choice. diff --git a/src/constants.rs b/src/constants.rs index 5ce3069..6ad4626 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -21,10 +21,19 @@ pub const DEFAULT_GAIN: f32 = 1.0; pub const DEFAULT_TRANSITION_TIME: u16 = 2; pub const DEFAULT_TIME_WINDOW: f32 = 0.1875; +/// Frequency range presets for cycling with the F key +pub const FREQ_RANGE_PRESETS: [(u16, u16, &str); 5] = [ + (20, 4500, "Full"), + (20, 300, "Sub Bass"), + (60, 1000, "Low-Mid"), + (200, 4500, "Mid-High"), + (1000, 12000, "Treble"), +]; + // other -pub const DEFAULT_CONFIG_DIR: &str = "audioleaf"; +pub const DEFAULT_CONFIG_DIR: &str = "nanoviz"; pub const DEFAULT_CONFIG_FILE: &str = "config.toml"; pub const DEFAULT_DEVICES_FILE: &str = "nl_devices.toml"; -pub const DEFAULT_BACKTRACE_FILE: &str = "audioleaf_backtrace.log"; +pub const DEFAULT_BACKTRACE_FILE: &str = "nanoviz_backtrace.log"; pub const NL_API_PORT: u16 = 16021; pub const NL_UDP_PORT: u16 = 60222; diff --git a/src/graphical_layout.rs b/src/graphical_layout.rs deleted file mode 100644 index 189cc73..0000000 --- a/src/graphical_layout.rs +++ /dev/null @@ -1,558 +0,0 @@ -use crate::layout_visualizer::PanelInfo; -use crate::nanoleaf::NlDevice; -use macroquad::prelude::*; -use palette::Oklch; -use std::f32::consts::PI; -use std::thread; -use std::time::Duration; - -/// graphical_layout - Graphical Visualization of Nanoleaf Panel Layouts -/// -/// This module provides an interactive graphical interface to visualize and interact with -/// Nanoleaf panel layouts using the macroquad rendering engine. It renders panels as -/// polygons scaled to fit the window, applies global orientation rotations, and supports -/// mouse interaction for flashing individual panels via UDP commands. -/// -/// ## Main Components -/// -/// - `visualize_graphical`: Entry point function to launch the visualization window. -/// - `visualize_loop`: Core async loop handling rendering and input. -/// - `draw_panel`: Renders individual panels or controller trapezoids. -/// - `flash_panel`: Sends UDP color updates to flash a panel white briefly. -/// -/// ## Color Mapping for Panels -/// -/// Panels are colored by shape family: -/// - Triangles (IDs 0,8,9): Red-ish -/// - Squares (2-4): Green-ish -/// - Hexagons (7,14,15): Blue-ish -/// - Skylight panels (30-32): Yellow-ish -/// - Others: Gray -/// -/// Controllers are always yellow trapezoids labeled "C". -/// -/// ## Interaction -/// -/// - Left-click a panel to flash it. -/// - ESC to exit. -/// - Window auto-scales layout with padding. -/// -/// ## Error Handling -/// -/// Warns if UDP controller fails to initialize but continues without interaction. -/// Relies on `pollster::block_on` for async compatibility. -/// Visualizes the Nanoleaf panel layout in a graphical window using the macroquad game engine. -/// -/// This function creates an interactive window displaying the physical arrangement of Nanoleaf panels. -/// Panels are rendered as colored polygons based on their shape type (triangles, squares, hexagons). -/// Controller panels are depicted as yellow trapezoids attached to nearby light panels. -/// The layout can be rotated according to the global orientation. -/// Users can click on panels to briefly flash them white using UDP commands to the device. -/// -/// The window includes: -/// - Title showing global orientation -/// - Scaled and centered layout -/// - Panel IDs labeled in centers -/// - Instructions for interaction -/// -/// Press ESC to close the window. -/// -/// # Arguments -/// -/// * `panels` - Vector of `PanelInfo` structs describing each panel's position, orientation, and shape. -/// * `global_orientation` - The global rotation of the layout in degrees (u16). -/// * `device` - `NlDevice` containing IP and auth token for UDP communication. -/// -/// # Panics -/// -/// Panics if macroquad window creation or async runtime fails. -/// -/// # Examples -/// -/// ``` -/// // Assuming panels and device are obtained from layout parsing -/// visualize_graphical(panels, global_orientation, device); -/// ``` -/// -/// # Dependencies -/// -/// Requires `macroquad` and `palette` crates for rendering and color handling. -pub fn visualize_graphical(panels: Vec, global_orientation: u16, device: NlDevice) { - // Synchronously block on the async visualization routine using pollster::block_on, - // bridging the synchronous entry point to macroquad's async window and event loop. - pollster::block_on(visualize_async(panels, global_orientation, device)); -} - -/// Asynchronous function that sets up the macroquad window and runs the visualization loop. -/// -/// This private helper function is called by `visualize_graphical` to handle the async nature -/// of macroquad's event loop. It creates a window titled "Nanoleaf Panel Layout" and awaits -/// the main loop execution. -/// -/// # Arguments -/// -/// * `panels` - The panel layout data. -/// * `global_orientation` - Device global orientation. -/// * `device` - Nanoleaf device for interaction. -async fn visualize_async(panels: Vec, global_orientation: u16, device: NlDevice) { - // Create and configure the macroquad window with fixed size and title, - // then spawn the async block to run the main visualization loop. - macroquad::Window::new("Nanoleaf Panel Layout", async move { - visualize_loop(panels, global_orientation, device).await; - }); -} - -/// The core asynchronous loop that runs the interactive visualization. -/// -/// This function implements the main game loop: -/// - Clears background and draws title. -/// - Computes transformed positions applying global rotation. -/// - Draws all panels and controllers. -/// - Handles left mouse clicks to flash panels via UDP if controller available. -/// - Draws instructions and checks for ESC key to exit. -/// -/// # Arguments -/// -/// * `panels` - List of all panels including controllers. -/// * `global_orientation` - Applied as clockwise rotation to layout. -/// * `device` - Used to create UDP controller for flashing. -async fn visualize_loop(panels: Vec, global_orientation: u16, device: NlDevice) { - // Calculate the layout bounds by finding min/max coordinates of all panels, - // used for scaling and centering the visualization. - let min_x = panels.iter().map(|p| p.x).min().unwrap_or(0) as f32; - let max_x = panels.iter().map(|p| p.x).max().unwrap_or(0) as f32; - let min_y = panels.iter().map(|p| p.y).min().unwrap_or(0) as f32; - let max_y = panels.iter().map(|p| p.y).max().unwrap_or(0) as f32; - - let layout_width = max_x - min_x; - let layout_height = max_y - min_y; - - // Set fixed window size: 1200x800 pixels for optimal layout display. - let window_width = 1200.0; - let window_height = 800.0; - - // Calculate uniform scaling factor to fit the layout inside the window with padding, - // using the minimum of horizontal and vertical scales to avoid distortion. - let padding_top = 100.0; // Extra space at top for title - let padding_bottom = 50.0; - let padding_sides = 50.0; - let available_width = window_width - 2.0 * padding_sides; - let available_height = window_height - padding_top - padding_bottom; - - let scale_x = available_width / layout_width; - let scale_y = available_height / layout_height; - let scale = scale_x.min(scale_y); - - // Initialize optional UDP controller using device IP and token for flashing panels. - // Gracefully handles initialization failure by disabling clicks but continuing render. - let nl_controller = match crate::nanoleaf::NlUdp::new(&device) { - Ok(controller) => { - // Blank all panels to black so only clicked panels light up - let black_colors: Vec = panels - .iter() - .filter(|p| p.shape_type.side_length >= 1.0) - .map(|_| Oklch::new(0.0, 0.0, 0.0)) - .collect(); - let _ = controller.update_panels(&black_colors, 0); - Some(controller) - } - Err(e) => { - eprintln!("Warning: Could not initialize Nanoleaf controller: {}", e); - None - } - }; - - loop { - clear_background(Color::from_rgba(20, 20, 30, 255)); - - // Draw dynamic title displaying global orientation at top-left with white text. - draw_text( - &format!( - "Nanoleaf Panel Layout - Global Orientation: {}°", - global_orientation - ), - 10.0, - 30.0, - 30.0, - WHITE, - ); - - // Calculate screen offsets to center the scaled layout horizontally, - // vertically centered within available height below title padding. - let offset_x = (window_width - layout_width * scale) / 2.0; - let offset_y = padding_top + (available_height - layout_height * scale) / 2.0; - - // First pass: Precompute screen positions for all panels. - // - Translate to layout-relative coords centered at (0,0) - // - Rotate clockwise by -global_orientation radians around origin - // - Translate back and scale to screen coordinates with offsets - // This enables two-pass rendering: positions first, then drawing with full context. - let mut transformed_positions = Vec::new(); - for panel in &panels { - // Apply global orientation rotation to coordinates - 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 = -(global_orientation as f32).to_radians(); // Negative for clockwise - let rotated_x = rel_x * angle.cos() - rel_y * angle.sin(); - let rotated_y = rel_x * angle.sin() + rel_y * angle.cos(); - - // Convert to screen coordinates - let screen_x = offset_x + (rotated_x + layout_width / 2.0) * scale; - let screen_y = offset_y + (layout_height / 2.0 - rotated_y) * scale; // Flip Y - - transformed_positions.push((screen_x, screen_y)); - } - - // Second pass: Draw each panel using transformed positions, providing full layout - // context needed for controller attachment calculations. - for (i, panel) in panels.iter().enumerate() { - let (screen_x, screen_y) = transformed_positions[i]; - draw_panel( - screen_x, - screen_y, - panel, - scale, - &panels, - &transformed_positions, - ); - } - - // Handle user interaction: On left mouse press with valid controller, - // check distance from mouse to each panel center; if within ~1.2x radius, flash it. - if is_mouse_button_pressed(MouseButton::Left) && nl_controller.is_some() { - let (mouse_x, mouse_y) = mouse_position(); - - // Scan panels for mouse hit detection, skipping controllers (no side_length). - for (i, panel) in panels.iter().enumerate() { - if panel.shape_type.side_length < 1.0 { - continue; // Skip controllers - } - - let (screen_x, screen_y) = transformed_positions[i]; - let num_sides = panel.shape_type.num_sides(); - let side_length = panel.shape_type.side_length * scale; - - let radius = if num_sides == 3 { - side_length / f32::sqrt(3.0) - } else if num_sides == 4 { - side_length / f32::sqrt(2.0) - } else { - side_length - }; - - // Perform radial distance check: mouse within 120% of estimated panel radius - // (approximated from side_length and shape: tri/sqrt(3), sq/sqrt(2), else side). - let dist = ((mouse_x - screen_x).powi(2) + (mouse_y - screen_y).powi(2)).sqrt(); - if dist < radius * 1.2 { - // Panel hit confirmed: send flash command via UDP controller and exit loop. - if let Some(ref controller) = nl_controller { - flash_panel(controller, &panels, panel.panel_id); - } - break; - } - } - } - - // Render interaction instructions at bottom-left in smaller gray text. - draw_text( - "Press ESC to close | Click panels to flash them", - 10.0, - window_height - 20.0, - 20.0, - GRAY, - ); - - if is_key_pressed(KeyCode::Escape) { - break; - } - - next_frame().await - } -} - -/// Draws a single panel or controller at the specified screen position. -/// -/// Supports different shape types: -/// - Light panels (side_length >=1): Polygons (triangles=3 sides, squares=4, hex=6) with colors based on shape ID. -/// - Controllers (side_length <1): Yellow trapezoids attached to the nearest light panel's edge. -/// -/// For light panels: -/// - Vertices calculated from radius, orientation. -/// - Filled with semi-transparent color matching shape family. -/// - White outline. -/// - Panel ID text in center. -/// -/// For controllers: -/// - Finds nearest parent panel. -/// - Determines closest edge. -/// - Draws trapezoid protruding outward, narrower at tip. -/// - Outlined and labeled "C". -/// -/// # Arguments -/// -/// * `x` - Center x coordinate on screen. -/// * `y` - Center y coordinate on screen. -/// * `panel` - The `PanelInfo` to draw. -/// * `scale` - Scaling factor for sizes. -/// * `all_panels` - Full list for finding parent for controllers. -/// * `transformed_positions` - Precomputed screen positions of all panels. -fn draw_panel( - x: f32, - y: f32, - panel: &PanelInfo, - scale: f32, - all_panels: &[PanelInfo], - transformed_positions: &[(f32, f32)], -) { - // Branch for controllers: panels with side_length <1.0 are non-light controllers, - // visualized as yellow trapezoids attached to the nearest light panel's edge for realism. - if panel.shape_type.side_length < 1.0 { - // Select parent light panel: minimum distance to any valid (light) panel center. - let mut min_dist = f32::MAX; - let mut nearest_idx = 0; - - for (i, other_panel) in all_panels.iter().enumerate() { - if other_panel.shape_type.side_length >= 1.0 { - let (other_x, other_y) = transformed_positions[i]; - let dist = ((x - other_x).powi(2) + (y - other_y).powi(2)).sqrt(); - if dist < min_dist { - min_dist = dist; - nearest_idx = i; - } - } - } - - let (parent_x, parent_y) = transformed_positions[nearest_idx]; - let parent_panel = &all_panels[nearest_idx]; - - // Determine angular direction (atan2) from parent to controller for aligning with parent edges. - let dx = x - parent_x; - let dy = y - parent_y; - let angle_to_controller = dy.atan2(dx); - - // Extract parent's num_sides and scaled side_length for radius and vertex computation. - let num_sides = parent_panel.shape_type.num_sides(); - let parent_side_length = parent_panel.shape_type.side_length * scale; - - // Compute distance from center to vertex (circumradius) based on shape: - // triangle: side / sqrt(3), square: side / sqrt(2), default: side. - let parent_radius = if num_sides == 3 { - parent_side_length / f32::sqrt(3.0) - } else if num_sides == 4 { - parent_side_length / f32::sqrt(2.0) - } else { - parent_side_length - }; - - // Select closest parent edge: iterate over vertex angles (adjusted by parent orientation), - // find minimum angular difference to controller direction using shortest arc distance modulo 2π. - let parent_orientation = (parent_panel.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 vertex_angle = parent_orientation + (i as f32 * angle_per_side); - let angle_diff = ((angle_to_controller - vertex_angle).abs() % (2.0 * PI)) - .min((2.0 * PI) - ((angle_to_controller - vertex_angle).abs() % (2.0 * PI))); - if angle_diff < min_angle_diff { - min_angle_diff = angle_diff; - closest_edge = i; - } - } - - // Position the edge endpoints: v1 and v2 at angles from parent orientation + edge index * angle_per_side. - let v1_angle = parent_orientation + (closest_edge as f32 * angle_per_side); - let v2_angle = parent_orientation + ((closest_edge + 1) as f32 * angle_per_side); - - let v1_x = parent_x + parent_radius * v1_angle.cos(); - let v1_y = parent_y + parent_radius * v1_angle.sin(); - let v2_x = parent_x + parent_radius * v2_angle.cos(); - let v2_y = parent_y + parent_radius * v2_angle.sin(); - - // Define trapezoid vertices: top matches edge v1-v2, bottom parallel but shorter and offset - // perpendicular outward by fixed height; fill with yellow triangles, outline in darker yellow. - let trapezoid_height = 20.0; - - // Derive outward normal: vector from center to edge midpoint, normalized for extension. - let edge_mid_x = (v1_x + v2_x) / 2.0; - let edge_mid_y = (v1_y + v2_y) / 2.0; - let perp_dx = edge_mid_x - parent_x; - let perp_dy = edge_mid_y - parent_y; - let perp_len = (perp_dx * perp_dx + perp_dy * perp_dy).sqrt(); - let perp_norm_x = perp_dx / perp_len; - let perp_norm_y = perp_dy / perp_len; - - // Assemble trapezoid vertices array: - // - Top: parent edge endpoints v1, v2 - // - Bottom: inset towards midpoint by (1-0.6=0.4), extended along perpendicular normal by height=20px - let narrow_ratio = 0.6; // Bottom edge is 60% of top edge - - let vertices = [ - Vec2::new(v1_x, v1_y), // Top left (on parent edge) - Vec2::new(v2_x, v2_y), // Top right (on parent edge) - // Bottom right (narrower, extended outward) - Vec2::new( - v2_x + perp_norm_x * trapezoid_height - (v2_x - edge_mid_x) * (1.0 - narrow_ratio), - v2_y + perp_norm_y * trapezoid_height - (v2_y - edge_mid_y) * (1.0 - narrow_ratio), - ), - // Bottom left (narrower, extended outward) - Vec2::new( - v1_x + perp_norm_x * trapezoid_height - (v1_x - edge_mid_x) * (1.0 - narrow_ratio), - v1_y + perp_norm_y * trapezoid_height - (v1_y - edge_mid_y) * (1.0 - narrow_ratio), - ), - ]; - - // Render filled trapezoid by splitting into two triangles with solid yellow color. - draw_triangle( - vertices[0], - vertices[1], - vertices[2], - Color::from_rgba(255, 200, 0, 255), - ); - draw_triangle( - vertices[0], - vertices[2], - vertices[3], - Color::from_rgba(255, 200, 0, 255), - ); - - // Outline trapezoid edges with 2px thick darker yellow lines connecting vertices cyclically. - for i in 0..vertices.len() { - let next = (i + 1) % vertices.len(); - draw_line( - vertices[i].x, - vertices[i].y, - vertices[next].x, - vertices[next].y, - 2.0, - Color::from_rgba(200, 150, 0, 255), - ); - } - - // Add 'C' identifier: measure text, center at trapezoid centroid in black 10pt font. - let text_size = 10.0; - let text_dims = measure_text("C", None, text_size as u16, 1.0); - let label_x = (vertices[0].x + vertices[1].x + vertices[2].x + vertices[3].x) / 4.0; - let label_y = (vertices[0].y + vertices[1].y + vertices[2].y + vertices[3].y) / 4.0; - draw_text( - "C", - label_x - text_dims.width / 2.0, - label_y + text_size / 3.0, - text_size, - BLACK, - ); - return; - } - - // Light panel rendering: compute polygon sides and scaled side length. - let num_sides = panel.shape_type.num_sides(); - let side_length = panel.shape_type.side_length * scale; - - // Calculate circumradius (center to vertex) using geometry formulas per shape type. - let radius = if num_sides == 3 { - side_length / f32::sqrt(3.0) - } else if num_sides == 4 { - side_length / f32::sqrt(2.0) - } else { - side_length - }; - - // Compute vertex positions: for each side, angle = orientation_rad + i * (2π / n), offset from center by radius. - let start_angle = (panel.orientation as f32).to_radians(); - let mut vertices = Vec::new(); - - for i in 0..num_sides { - let angle = start_angle + (i as f32 * 2.0 * PI / num_sides as f32); - let vx = x + radius * angle.cos(); - let vy = y + radius * angle.sin(); - vertices.push(Vec2::new(vx, vy)); - } - - // Select panel fill color by shape ID groups for visual distinction (alpha=200 for transparency). - let color = 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 the convex polygon using triangle fan: vertex[0] to consecutive pairs. - for i in 1..(num_sides - 1) { - draw_triangle(vertices[0], vertices[i], vertices[i + 1], color); - } - - // Draw polygon boundary: connect consecutive vertices with white 2px lines. - 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, - WHITE, - ); - } - - // Render panel ID text: format as string, measure for centering at panel center position. - let id_text = format!("{}", panel.panel_id); - let text_size = 16.0; - let text_dims = measure_text(&id_text, None, text_size as u16, 1.0); - draw_text( - &id_text, - x - text_dims.width / 2.0, - y + text_size / 3.0, - text_size, - BLACK, - ); -} - -/// Flashes a specific panel white briefly by sending UDP color updates. -/// -/// Sets the clicked panel to white (Oklch L=1, C=0) and all other light panels to black (Oklch L=0, C=0). -/// Updates immediately (transition=1), waits 300ms, then sets all light panels back to black. -/// -/// Only affects panels with side_length >=1.0 (light panels, skips controllers). -/// -/// # Arguments -/// -/// * `controller` - Initialized `NlUdp` instance for sending commands. -/// * `all_panels` - Full panel list to determine which to color. -/// * `clicked_panel_id` - ID of the panel to flash white. -fn flash_panel( - controller: &crate::nanoleaf::NlUdp, - all_panels: &[PanelInfo], - clicked_panel_id: u16, -) { - // Construct per-panel Oklch colors for UDP update: white (L=1, C=0) for clicked, - // black (L=0, C=0) for other light panels; exclude controllers from array. - let colors: Vec = all_panels - .iter() - .filter(|panel| panel.shape_type.side_length >= 1.0) - .map(|panel| { - if panel.panel_id == clicked_panel_id { - Oklch::new(1.0, 0.0, 0.0) // White - } else { - Oklch::new(0.0, 0.0, 0.0) // Black - } - }) - .collect(); - - // Send 'on' state: set clicked panel white, others black; immediate transition (duration=1). - let _ = controller.update_panels(&colors, 1); - - // Sleep 300 milliseconds for visible flash duration. - thread::sleep(Duration::from_millis(300)); - - // Reset flash: update all light panels to black, effectively turning off the highlight - let black_colors: Vec = all_panels - .iter() - .filter(|panel| panel.shape_type.side_length >= 1.0) - .map(|_| Oklch::new(0.0, 0.0, 0.0)) - .collect(); - let _ = controller.update_panels(&black_colors, 1); -} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e7a5dc5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,18 @@ +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +compile_error!( + "nanoviz supports Linux (the prod target — Raspberry Pi container) and \ + macOS (dev). Other targets are unsupported." +); + +pub mod audio; +pub mod config; +pub mod constants; +pub mod layout_visualizer; +pub mod nanoleaf; +pub mod now_playing; +pub mod palettes; +pub mod panic; +pub mod processing; +pub mod ssdp; +pub mod utils; +pub mod visualizer; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index a0df67d..0000000 --- a/src/main.rs +++ /dev/null @@ -1,262 +0,0 @@ -use anyhow::Result; -use clap::Parser; - -mod app; -mod audio; -mod config; -mod constants; -mod graphical_layout; -mod layout_visualizer; -mod nanoleaf; -mod now_playing; -mod palettes; -mod panic; -mod processing; -mod ssdp; -mod utils; -mod visualizer; - -/// The main entry point of the Audioleaf application. -/// -/// This function blocks on the asynchronous main logic using `pollster::block_on`. -/// It sets up panic handling and parses CLI options before delegating to `main_async`. -fn main() -> Result<()> { - pollster::block_on(main_async()) -} - -/// Asynchronous main logic of the Audioleaf application. -/// -/// Parses CLI options and handles different modes: -/// - Dump commands (layout, palettes, info, graphical layout) without TUI. -/// - Normal mode: loads or discovers Nanoleaf device, sets up config, runs TUI visualizer/effect selector. -/// -/// Ensures device is ready (powered on, brightness set) before running the app. -async fn main_async() -> Result<()> { - panic::register_backtrace_panic_handler(); - let cli_options = config::CliOptions::parse(); - - // Handle dump commands separately - they don't need TUI - if let Some(config::Command::Dump { dump_type }) = &cli_options.command { - return handle_dump_command(dump_type, &cli_options).await; - } - - let config::CliOptions { - config_file_path, - devices_file_path, - device_name, - add_new, - .. - } = 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, 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() { - &device_name - } else { - &config.default_nl_device_name - }; - let nl_device = - nanoleaf::NlDevice::find_in_file(&devices_file_path, name_to_search.as_deref())?; - (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); - config.write_to_file(&config_file_path)?; - (nl_device, config.visualizer_config) - } - } else { - let ip = config::get_ip()?; - let nl_device = nanoleaf::NlDevice::new(ip)?; - nl_device.append_to_file(&devices_file_path)?; - let config = if config_file_exists { - let mut config = config::Config::parse_from_file(&config_file_path)?; - config.default_nl_device_name = Some(nl_device.name.clone()); - config - } else { - config::Config::new(Some(nl_device.name.clone()), None) - }; - config.write_to_file(&config_file_path)?; - (nl_device, config.visualizer_config) - }; - - // Ensure device is powered on and has brightness set - nl_device.ensure_device_ready()?; - - let app = app::App::new(nl_device, visualizer_config)?; - app.run(); - Ok(()) -} - -/// Handles 'dump' subcommands to display Nanoleaf device information or configuration without launching the TUI. -/// -/// Supported dump types: -/// - `Layout`: Fetches and prints panel layout data and global orientation. -/// - `Palettes`: Lists all predefined color palettes available in the application. -/// - `LayoutGraphical`: Renders an interactive graphical visualization of the panel layout using macroquad. -/// - `Info`: Retrieves and prints basic device information from the /api/v1/ endpoint. -/// -/// In all cases except `Palettes`, it connects to a known device or uses CLI-specified name. -/// -/// # Arguments -/// -/// * `dump_type` - Specifies which type of information to dump. -/// * `cli_options` - Parsed CLI options including config paths and device name. -/// -/// # Errors -/// -/// Returns `anyhow::Error` for issues like missing devices file, connection failures, or JSON parsing errors. -async fn handle_dump_command( - dump_type: &config::DumpType, - cli_options: &config::CliOptions, -) -> Result<()> { - match dump_type { - config::DumpType::Layout => { - // Need to connect to device for layout - let ((config_file_path, config_file_exists), (devices_file_path, devices_file_exists)) = - config::resolve_paths( - cli_options.config_file_path.clone(), - cli_options.devices_file_path.clone(), - )?; - - if !devices_file_exists { - anyhow::bail!("No devices file found. Please add a device first."); - } - - let nl_device = if config_file_exists { - let config = config::Config::parse_from_file(&config_file_path)?; - let name_to_search = if cli_options.device_name.is_some() { - &cli_options.device_name - } else { - &config.default_nl_device_name - }; - nanoleaf::NlDevice::find_in_file(&devices_file_path, name_to_search.as_deref())? - } else { - nanoleaf::NlDevice::find_in_file( - &devices_file_path, - cli_options.device_name.as_deref(), - )? - }; - - println!("Panel Layout Information for: {}", nl_device.name); - println!("Device IP: {}", nl_device.ip); - - nl_device.ensure_device_ready()?; - - let layout = nl_device.get_panel_layout()?; - let orientation = nl_device.get_global_orientation()?; - let global_orientation = orientation["value"].as_u64().unwrap_or(0) as u16; - - // Parse and visualize the layout - let panels = layout_visualizer::parse_layout(&layout)?; - layout_visualizer::visualize_layout(&panels, global_orientation); - - println!("\n=== Raw Panel Layout JSON ==="); - println!("{}", serde_json::to_string_pretty(&layout)?); - - println!("\n=== Raw Global Orientation JSON ==="); - println!("{}", serde_json::to_string_pretty(&orientation)?); - - nl_device.set_state(Some(false), Some(0))?; - Ok(()) - } - config::DumpType::Palettes => { - println!("Available Color Palettes:\n"); - let mut palette_names = palettes::get_palette_names(); - palette_names.sort(); - for name in palette_names { - let colors = palettes::get_palette(&name).unwrap(); - let color_strs: Vec = colors - .iter() - .map(|[r, g, b]| format!("[{}, {}, {}]", r, g, b)) - .collect(); - println!(" {} = [{}]", name, color_strs.join(", ")); - } - Ok(()) - } - config::DumpType::LayoutGraphical => { - // Need to connect to device for layout - let ((config_file_path, config_file_exists), (devices_file_path, devices_file_exists)) = - config::resolve_paths( - cli_options.config_file_path.clone(), - cli_options.devices_file_path.clone(), - )?; - - if !devices_file_exists { - anyhow::bail!("No devices file found. Please add a device first."); - } - - let nl_device = if config_file_exists { - let config = config::Config::parse_from_file(&config_file_path)?; - let name_to_search = if cli_options.device_name.is_some() { - &cli_options.device_name - } else { - &config.default_nl_device_name - }; - nanoleaf::NlDevice::find_in_file(&devices_file_path, name_to_search.as_deref())? - } else { - nanoleaf::NlDevice::find_in_file( - &devices_file_path, - cli_options.device_name.as_deref(), - )? - }; - - nl_device.ensure_device_ready()?; - // Request UDP control so panel flash commands work - nl_device.request_udp_control()?; - - let layout = nl_device.get_panel_layout()?; - let orientation = nl_device.get_global_orientation()?; - let global_orientation = orientation["value"].as_u64().unwrap_or(0) as u16; - - let panels = layout_visualizer::parse_layout(&layout)?; - - // Call the graphical visualizer - it has its own macroquad::main wrapper - graphical_layout::visualize_graphical(panels, global_orientation, nl_device.clone()); - - nl_device.set_state(Some(false), Some(0))?; - Ok(()) - } - config::DumpType::Info => { - // Need to connect to device for info - let ((config_file_path, config_file_exists), (devices_file_path, devices_file_exists)) = - config::resolve_paths( - cli_options.config_file_path.clone(), - cli_options.devices_file_path.clone(), - )?; - - if !devices_file_exists { - anyhow::bail!("No devices file found. Please add a device first."); - } - - let nl_device = if config_file_exists { - let config = config::Config::parse_from_file(&config_file_path)?; - let name_to_search = if cli_options.device_name.is_some() { - &cli_options.device_name - } else { - &config.default_nl_device_name - }; - nanoleaf::NlDevice::find_in_file(&devices_file_path, name_to_search.as_deref())? - } else { - nanoleaf::NlDevice::find_in_file( - &devices_file_path, - cli_options.device_name.as_deref(), - )? - }; - - println!("Device Information for: {}", nl_device.name); - println!("Device IP: {}", nl_device.ip); - - nl_device.ensure_device_ready()?; - - println!("\n=== Device Info (from /api/v1/) ==="); - let info = nl_device.get_device_info()?; - println!("{}", serde_json::to_string_pretty(&info)?); - - nl_device.set_state(Some(false), Some(0))?; - Ok(()) - } - } -} diff --git a/src/nanoleaf.rs b/src/nanoleaf.rs index f05010d..9f3c612 100644 --- a/src/nanoleaf.rs +++ b/src/nanoleaf.rs @@ -20,6 +20,41 @@ pub struct NlDevice { pub token: String, } +/// A named palette pulled from a Nanoleaf device's saved effects. +/// Replaces the static palette catalog that used to live in `src/palettes.rs`. +#[derive(Clone, Debug, Serialize)] +pub struct NamedPalette { + pub name: String, + pub colors: Vec<[u8; 3]>, +} + +/// Nanoleaf palette entries are HSB triples (hue 0-360, saturation 0-100, +/// brightness 0-100). Convert to sRGB byte triples. +fn hsb_to_rgb(h: f32, s: f32, b: f32) -> [u8; 3] { + let hue = h.rem_euclid(360.0) / 360.0; + let sat = (s / 100.0).clamp(0.0, 1.0); + let val = (b / 100.0).clamp(0.0, 1.0); + // Standard HSV → RGB; Nanoleaf "brightness" semantically matches HSV value. + let i = (hue * 6.0).floor() as i32; + let f = hue * 6.0 - i as f32; + let p = val * (1.0 - sat); + let q = val * (1.0 - f * sat); + let t = val * (1.0 - (1.0 - f) * sat); + let (r, g, b) = match i.rem_euclid(6) { + 0 => (val, t, p), + 1 => (q, val, p), + 2 => (p, val, t), + 3 => (p, q, val), + 4 => (t, p, val), + _ => (val, p, q), + }; + [ + (r * 255.0).round().clamp(0.0, 255.0) as u8, + (g * 255.0).round().clamp(0.0, 255.0) as u8, + (b * 255.0).round().clamp(0.0, 255.0) as u8, + ] +} + /// wrapper struct for TOML serialization #[derive(Debug, Serialize, Deserialize)] struct NlDevices { @@ -61,12 +96,15 @@ impl NlDevice { } fn get_token(ip: &Ipv4Addr) -> Result { - let Ok(res) = utils::request_post( + // Propagate the underlying error (reqwest::Error carrying the HTTP + // status, or a transport error) so callers like the /api/devices/pair + // handler can distinguish HTTP 403 ("device not in pairing mode") from + // a real connection failure. The CLI flow surfaces both as a generic + // connection error via its own context message. + let res = utils::request_post( &format!("http://{}:{}/api/v1/new", ip, constants::NL_API_PORT), None, - ) else { - bail!(utils::generate_connection_error_msg(ip)); - }; + )?; let res_json: serde_json::Value = serde_json::from_str(&res)?; Ok(res_json["auth_token"].as_str().unwrap().trim().to_string()) @@ -181,21 +219,14 @@ impl NlDevice { let info = self.get_device_info()?; let is_on = info["state"]["on"]["value"].as_bool().unwrap_or(true); - let brightness = info["state"]["brightness"]["value"].as_u64().unwrap_or(100) as u8; - let needs_power = !is_on; - let needs_brightness = brightness != 100; - - if needs_power { + // Only flip the device on if needed. Brightness is left alone here — + // it's now driven by the AirPlay client's volume slider via the + // `pvol` metadata handler, and forcing it to 100 on every session + // start would override the user's most recent volume setting. + if !is_on { eprintln!("Device is off. Turning on..."); - } - if needs_brightness { - eprintln!("Device brightness is {}. Setting to 100...", brightness); - } - - if needs_power || needs_brightness { - self.set_state(if needs_power { Some(true) } else { None }, Some(100))?; - // Give the device a moment to respond to the state change + self.set_state(Some(true), None)?; std::thread::sleep(std::time::Duration::from_millis(500)); } @@ -234,6 +265,66 @@ impl NlDevice { Ok(socket) } + /// Fetch every saved effect from the device with its full palette, + /// converted to sRGB triples. One round-trip via PUT /effects + /// `{"write":{"command":"requestAll"}}`. Skips effects with empty palettes. + pub fn list_effect_palettes(&self) -> Result> { + let body = json!({ "write": { "command": "requestAll" } }); + let Ok(res) = utils::request_put( + &format!( + "http://{}:{}/api/v1/{}/effects", + self.ip, + constants::NL_API_PORT, + self.token + ), + Some(&body), + ) else { + bail!(utils::generate_connection_error_msg(&self.ip)); + }; + let json: serde_json::Value = serde_json::from_str(&res)?; + + // Newer firmwares wrap the array in {"animations": [...]}; older or + // simulator builds return the raw JSON-array as a string. Try both. + let animations: Vec = match json.get("animations") { + Some(serde_json::Value::Array(arr)) => arr.clone(), + _ => match json.as_array() { + Some(arr) => arr.clone(), + None => bail!("Unexpected /effects response shape: missing animations array"), + }, + }; + + let mut out = Vec::with_capacity(animations.len()); + for anim in animations { + let Some(name) = anim.get("animName").and_then(|v| v.as_str()) else { + continue; + }; + let Some(palette) = anim.get("palette").and_then(|v| v.as_array()) else { + continue; + }; + let mut colors = Vec::with_capacity(palette.len()); + for entry in palette { + let h = entry.get("hue").and_then(|v| v.as_f64()).unwrap_or(0.0); + let s = entry + .get("saturation") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let b = entry + .get("brightness") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + colors.push(hsb_to_rgb(h as f32, s as f32, b as f32)); + } + if colors.is_empty() { + continue; + } + out.push(NamedPalette { + name: name.to_string(), + colors, + }); + } + Ok(out) + } + pub fn request_udp_control(&self) -> Result<()> { let data = json!({ "write": { @@ -278,6 +369,15 @@ impl NlDevice { } } + #[allow(dead_code)] + pub fn all_from_file(path: &Path) -> Result> { + let mut devices_file = File::open(path)?; + let mut contents = String::new(); + devices_file.read_to_string(&mut contents)?; + let devices: NlDevices = toml::from_str(&contents)?; + Ok(devices.nl_devices) + } + pub fn append_to_file(&self, path: &Path) -> Result<()> { // Create parent directory if it doesn't exist if let Some(parent) = path.parent() { diff --git a/src/now_playing.rs b/src/now_playing.rs index 0f05086..e9ffb53 100644 --- a/src/now_playing.rs +++ b/src/now_playing.rs @@ -16,6 +16,36 @@ macro_rules! debug_log { }; } +pub fn extract_prominent_colors_from_bytes(image_bytes: &[u8]) -> Option> { + use auto_palette::{ImageData, Palette, Theme}; + + 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()?; + + let swatches = palette + .find_swatches_with_theme(6, Theme::Vivid) + .or_else(|_| palette.find_swatches(6)) + .ok()?; + + let colors: Vec<[u8; 3]> = swatches + .iter() + .filter(|s| s.color().to_oklch().l > 0.2) + .take(4) + .map(|s| { + let rgb = s.color().to_rgb(); + [rgb.r, rgb.g, rgb.b] + }) + .collect(); + + if colors.is_empty() { + None + } else { + Some(colors) + } +} + /// Returns the title of the currently playing track. pub fn get_track_title() -> Option { #[cfg(target_os = "macos")] @@ -26,10 +56,6 @@ pub fn get_track_title() -> Option { { linux::get_track_title() } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - None - } } /// Fetches artwork bytes once and returns both the raw image and the extracted palette. @@ -47,269 +73,32 @@ pub fn fetch_artwork_and_palette() -> Option<(Vec, Vec<[u8; 3]>)> { let colors = linux::extract_colors_from_bytes(&bytes)?; Some((bytes, colors)) } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - None - } } -// ── macOS — ScriptingBridge ────────────────────────────────────────────────── +// ── macOS — MediaRemote.framework via media-remote crate ───────────────────── #[cfg(target_os = "macos")] mod macos { - use objc2::msg_send; - use objc2::rc::Retained; - use objc2::runtime::{AnyClass, AnyObject}; - use objc2_foundation::NSString; - - #[link(name = "ScriptingBridge", kind = "framework")] - unsafe extern "C" {} - pub fn get_track_title() -> Option { - sb_spotify_title().or_else(sb_apple_music_title) + use media_remote::NowPlayingPerl; + let np = NowPlayingPerl::new(); + let guard = np.get_info(); + guard.as_ref()?.title.clone() } 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 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")?; - let bid = NSString::from_str(bundle_id); - let app: Option> = - msg_send![cls, applicationWithBundleIdentifier: &*bid]; - let app = app?; - let running: bool = msg_send![&*app, isRunning]; - if !running { - 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]; - 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?; - let url_str = url.to_string(); - debug_log!("DEBUG now_playing: Spotify artwork URL = {}", url_str); - reqwest::blocking::get(&url_str) - .ok()? - .bytes() - .ok() - .map(|b| b.to_vec()) - } - } - - // ── Apple Music via ScriptingBridge ─────────────────────────────────────── - - 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]; - 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?; - - // 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. - - // 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); - } - } - } - } - - // 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); - } - } - } - } - } - } - - // 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) - } - } - - /// 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_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 { - let mut out = String::with_capacity(s.len() * 2); - for b in s.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => { - out.push(b as char); - } - b' ' => out.push('+'), - _ => { - out.push('%'); - out.push_str(&format!("{:02X}", b)); - } - } - } - out + use media_remote::NowPlayingPerl; + let np = NowPlayingPerl::new(); + let guard = np.get_info(); + let info = guard.as_ref()?; + let cover = info.album_cover.as_ref()?; + let mut buf = std::io::Cursor::new(Vec::new()); + cover.write_to(&mut buf, image::ImageFormat::Jpeg).ok()?; + Some(buf.into_inner()) } 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()?; - let mut swatches = palette.swatches().to_vec(); - swatches.sort_by_key(|s| std::cmp::Reverse(s.population())); - let colors: Vec<[u8; 3]> = swatches - .iter() - .filter(|s| s.color().to_oklch().l > 0.15) - .take(4) - .map(|s| { - let rgb = s.color().to_rgb(); - [rgb.r, rgb.g, rgb.b] - }) - .collect(); - if colors.is_empty() { - None - } else { - Some(colors) - } + super::extract_prominent_colors_from_bytes(image_bytes) } } @@ -355,27 +144,6 @@ mod linux { } pub fn extract_colors_from_bytes(bytes: &[u8]) -> Option> { - use auto_palette::{ImageData, Palette}; - - 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()?; - let mut swatches = palette.swatches().to_vec(); - swatches.sort_by_key(|s| std::cmp::Reverse(s.population())); - let colors: Vec<[u8; 3]> = swatches - .iter() - .filter(|s| s.color().to_oklch().l > 0.15) - .take(4) - .map(|s| { - let rgb = s.color().to_rgb(); - [rgb.r, rgb.g, rgb.b] - }) - .collect(); - if colors.is_empty() { - None - } else { - Some(colors) - } + super::extract_prominent_colors_from_bytes(bytes) } } diff --git a/src/palettes.rs b/src/palettes.rs index a42436d..91aa67c 100644 --- a/src/palettes.rs +++ b/src/palettes.rs @@ -1,182 +1,9 @@ -/// Predefined color palettes -/// -/// This module contains named color palettes that users can reference -/// in their config.toml instead of manually specifying RGB color arrays. -/// Each color is an [R, G, B] triplet. At runtime these are converted -/// to Oklch so the visualizer can animate perceptually-uniform lightness. -use std::collections::HashMap; - -/// Get a predefined palette by name -pub fn get_palette(name: &str) -> Option> { - let palettes = get_all_palettes(); - palettes.get(name).cloned() -} - -/// Get all available palette names -pub fn get_palette_names() -> Vec { - get_all_palettes().keys().cloned().collect() -} - -/// Get all predefined palettes -fn get_all_palettes() -> HashMap> { - let mut palettes = HashMap::new(); - - // Deep sky blues, indigos, magentas, and cyans - palettes.insert( - "ocean-nightclub".to_string(), - vec![ - [0, 191, 255], // deep sky blue - [0, 128, 255], // azure - [0, 0, 255], // blue - [128, 0, 255], // violet - [191, 0, 255], // purple - [255, 0, 255], // magenta - [0, 255, 255], // cyan - ], - ); - - // Warm reds, oranges, deep pinks, and purples - palettes.insert( - "sunset".to_string(), - vec![ - [255, 64, 0], // red-orange - [255, 106, 0], // orange - [255, 0, 85], // crimson - [255, 0, 43], // rose - [255, 0, 0], // red - [255, 43, 0], // scarlet - [255, 0, 128], // hot pink - [170, 0, 255], // violet - ], - ); - - // Magentas, purples, indigos, and cyans - palettes.insert( - "house-music-party".to_string(), - vec![ - [255, 0, 255], // magenta - [191, 0, 255], // purple - [128, 0, 255], // violet - [64, 0, 255], // indigo - [0, 0, 255], // blue - [0, 191, 255], // deep sky blue - [0, 255, 255], // cyan - ], - ); - - // Cyans, teals, greens, and limes - palettes.insert( - "tropical-beach".to_string(), - vec![ - [0, 255, 255], // cyan - [0, 255, 234], // turquoise - [0, 255, 213], // aquamarine - [0, 255, 170], // spring green - [128, 255, 0], // lime - [191, 255, 0], // chartreuse - [255, 255, 0], // yellow - ], - ); - - // Reds through oranges to yellow - palettes.insert( - "fire".to_string(), - vec![ - [255, 0, 0], // red - [255, 43, 0], // scarlet - [255, 85, 0], // vermillion - [255, 128, 0], // orange - [255, 170, 0], // amber - [255, 213, 0], // golden - [255, 255, 0], // yellow - ], - ); - - // Chartreuse through greens to teal - palettes.insert( - "forest".to_string(), - vec![ - [128, 255, 0], // lime - [85, 255, 0], // green-yellow - [43, 255, 0], // lawn green - [0, 255, 0], // green - [0, 255, 43], // emerald - [0, 255, 85], // jade - [0, 255, 128], // mint - ], - ); - - // Full spectrum rainbow - palettes.insert( - "neon-rainbow".to_string(), - vec![ - [255, 0, 0], // red - [255, 255, 0], // yellow - [0, 255, 0], // green - [0, 255, 255], // cyan - [0, 0, 255], // blue - [255, 0, 255], // magenta - ], - ); - - // Pinks from deep to light rose - palettes.insert( - "pink-dreams".to_string(), - vec![ - [255, 0, 170], // deep pink - [255, 0, 149], // hot pink - [255, 0, 128], // pink - [255, 0, 106], // rose - [255, 0, 85], // fuchsia-rose - [255, 0, 64], // crimson-rose - [255, 0, 43], // warm rose - ], - ); - - // Cool blue spectrum - palettes.insert( - "cool-blues".to_string(), - vec![ - [0, 170, 255], // sky blue - [0, 128, 255], // azure - [0, 85, 255], // royal blue - [0, 43, 255], // cobalt - [0, 0, 255], // blue - [43, 0, 255], // indigo - [64, 0, 255], // deep indigo - ], - ); - - // Teenage Mutant Ninja Turtles: character bandana colors + turtle green - palettes.insert( - "tmnt".to_string(), - vec![ - [0, 200, 0], // turtle green - [0, 80, 255], // Leonardo blue - [128, 0, 255], // Donatello purple - [255, 128, 0], // Michelangelo orange - [255, 0, 0], // Raphael red - [0, 255, 64], // sewer green - [0, 128, 255], // Leonardo azure - [170, 0, 255], // Donatello violet - [255, 170, 0], // Michelangelo amber - [255, 43, 0], // Raphael scarlet - ], - ); - - // Red, white, and green - palettes.insert( - "christmas".to_string(), - vec![ - [255, 0, 0], // red - [255, 0, 0], // red - [0, 255, 0], // green - [255, 21, 0], // warm red - [0, 255, 43], // festive green - [255, 21, 0], // warm red - [255, 255, 255], // white - ], - ); - - palettes -} +//! This module previously held a static catalog of named palettes that users +//! referenced from `config.toml` via `colors = "ocean-nightclub"`. As of the +//! Nanoleaf-sourced palette refactor, palettes are pulled live from the +//! connected device's saved effects (see `NlDevice::list_effect_palettes`). +//! +//! The static catalog is gone. Re-export the palette type so callers don't +//! need to know that the source moved to `nanoleaf.rs`. + +pub use crate::nanoleaf::NamedPalette; diff --git a/src/panic.rs b/src/panic.rs index 2ac6f66..8c5e4ab 100644 --- a/src/panic.rs +++ b/src/panic.rs @@ -1,18 +1,40 @@ use crate::constants; use std::{backtrace, fs, io::Write}; -/// Registers a custom panic hook to handle application crashes gracefully. +/// Install a panic hook that prints the panic + backtrace to stderr (so it +/// reaches journald / `podman logs`) and also tries to drop a copy at +/// `${XDG_CACHE_HOME}/nanoviz_backtrace.log` for post-mortem reading. /// -/// Captures and saves backtrace to cache/audioleaf_backtrace.log if possible. +/// Critical for the container deploy path: `Cargo.toml` sets +/// `panic = "abort"`, so a panic becomes SIGABRT → container exit 134. The +/// runtime aborts immediately after the hook returns, skipping destructors +/// and any buffered output. We explicitly `flush()` stderr inside the hook +/// so the message survives. pub fn register_backtrace_panic_handler() { std::panic::set_hook(Box::new(|panic_info| { - eprintln!("Audioleaf crashed unexpectedly!"); + let backtrace = backtrace::Backtrace::force_capture(); + + // stderr first — this is what journald and `podman logs` see. Use + // a single write so the lines don't get interleaved with other + // threads' output. Flush before returning because the abort below + // skips destructors. + let mut stderr = std::io::stderr().lock(); + let _ = writeln!(stderr, "\n=== NanoViz panicked ==="); + let _ = writeln!(stderr, "{panic_info}"); + let _ = writeln!(stderr, "{backtrace}"); + let _ = writeln!(stderr, "=== end panic ===\n"); + let _ = stderr.flush(); + + // Best-effort copy to a file so users can grab it after the fact + // (only useful on non-containerized installs — the container's + // cache dir is ephemeral). 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(); - eprintln!("The backtrace has been saved to {}", path.to_string_lossy()); + let _ = writeln!(file, "{panic_info}"); + let _ = writeln!(file, "{backtrace}"); + let _ = writeln!(stderr, "Backtrace also saved to {}", path.display()); + let _ = stderr.flush(); } } })); diff --git a/src/processing.rs b/src/processing.rs index 0da0e79..7944de8 100644 --- a/src/processing.rs +++ b/src/processing.rs @@ -140,18 +140,13 @@ pub fn update_brightness( } } -/// Updates per-panel brightness using an energy wave / cascade animation. +/// Updates per-panel brightness using per-band tracking with spatial bleed. /// -/// Instead of each panel independently tracking a frequency band (like `update_brightness`), -/// this effect creates a traveling wave of light across the panels: -/// -/// 1. Compute overall audio energy from the full spectrum (max equalized amplitude). -/// 2. Cascade: shift each panel's brightness to the next panel (with per-step decay). -/// 3. Feed new energy into the first panel with smooth attack/decay. -/// -/// The result is a flowing ripple of light that propagates across panels, -/// with intensity driven by audio amplitude. Visually very different from the -/// per-band spectrum effect. +/// Like `update_brightness` (Spectrum), each panel tracks its own logarithmic +/// frequency band. The difference is a neighbor-bleed pass after the per-band +/// update: each panel's brightness is mixed with its left and right neighbors. +/// This creates a flowing, wave-like appearance while preserving individual +/// panel reactivity so all palette colors reach full brightness. /// /// # Arguments /// @@ -159,8 +154,8 @@ pub fn update_brightness( /// * `hz_per_bin` - Frequency resolution (Hz per bin in spectrum). /// * `min_freq`/`max_freq` - Frequency range to consider. /// * `brightness` - Mutable slice of brightness multipliers [0,1] per panel (mutated). -/// * `prev_max` - Previous overall energy for delta computation (only index 0 used). -/// * `speed` - Velocity accumulator for the lead panel's brightness (only index 0 used). +/// * `prev_max` - Previous max amplitudes per interval (mutated). +/// * `speed` - Velocity accumulators per interval (mutated). pub fn update_brightness_wave( spectrum: Vec, hz_per_bin: u32, @@ -170,100 +165,67 @@ pub fn update_brightness_wave( prev_max: &mut [f32], speed: &mut [f32], ) { - let n_panels = brightness.len(); - if n_panels == 0 { - return; - } - let (min_freq, max_freq) = (min_freq as u32, max_freq as u32); - - // 1. Compute overall audio energy: max equalized amplitude in the frequency range - let mut overall_energy = 0.0_f32; - for (i, &l) in spectrum.iter().enumerate() { - let cur_freq = (i as u32) * hz_per_bin + hz_per_bin / 2; - if cur_freq < min_freq { - continue; - } - if cur_freq > max_freq { - break; - } - overall_energy = overall_energy.max(utils::equalize(ampl, cur_freq).min(1.0)); - } - - // 2. Cascade: shift brightness values from left to right with per-step decay - // This creates the traveling wave — each panel inherits its left neighbor's value - let cascade_decay = 0.92; - for i in (1..n_panels).rev() { - brightness[i] = brightness[i - 1] * cascade_decay; - } - - // 3. Feed new energy into the lead panel (index 0) with smooth attack/decay - let rate_func_inc = |x: f32| -> f32 { 1.0 - (1.0 - x).powi(3) }; - let rate_func_dec = |x: f32| -> f32 { 0.9 * (1.0 - (1.0 - x).powi(4)) }; + // First: identical per-band tracking as Spectrum + update_brightness( + spectrum, hz_per_bin, min_freq, max_freq, brightness, prev_max, speed, + ); - let energy_delta = overall_energy - prev_max[0]; - if energy_delta > 0.0 { - speed[0] = rate_func_inc(energy_delta); - } else if overall_energy > 0.01 { - // Audio getting quieter but still present → normal decay - speed[0] = -(rate_func_dec(-energy_delta).max(0.01)); - } else { - // Audio is essentially silent → strong decay so panels actually go dark - speed[0] = -(0.15_f32.max(brightness[0] * 0.3)); + // Second: spatial bleed — mix each panel with its neighbors for a flowing wave look. + // Two passes (left→right, right→left) to propagate energy in both directions. + let bleed = 0.25_f32; // how much a neighbor contributes + let n = brightness.len(); + if n < 2 { + return; } - brightness[0] = (brightness[0] + speed[0]).clamp(0.0, 1.0); - prev_max[0] = overall_energy; - - // Floor very small values to true zero so panels go fully dark - for b in brightness.iter_mut() { - if *b < 0.005 { - *b = 0.0; + // Snapshot before bleed so we read original values + let snap: Vec = brightness.to_vec(); + for i in 0..n { + let left = if i > 0 { snap[i - 1] } else { 0.0 }; + let right = if i + 1 < n { snap[i + 1] } else { 0.0 }; + let neighbor_max = left.max(right); + // Only bleed IN (raise brightness), never drag it down + if neighbor_max > brightness[i] { + brightness[i] += bleed * (neighbor_max - brightness[i]); } } } -/// Updates all panels with a unified beat-reactive pulse animation. +/// Onset-triggered ripples that propagate outward from panel 0. /// -/// All panels flash together on audio transients (beats, hits, kicks) and -/// fade quickly between them. The music's own rhythm drives the animation — -/// no fixed-rate oscillation. -/// -/// Uses asymmetric attack/decay with signal boost: -/// - **Boost**: `sqrt(energy)` expands the dynamic range so moderate audio -/// levels (0.25 → 0.5, 0.5 → 0.71) still produce visible flashes. -/// - **Attack**: When boosted energy exceeds current brightness, snap to it -/// almost instantly (90% of the gap per frame). Every kick/snare/transient -/// produces an immediate flash. -/// - **Decay**: When energy drops, brightness decays exponentially (×0.72 -/// per frame ≈ 250ms to half-brightness at 5.3 fps). The fast falloff -/// creates strong contrast between beats — panels go noticeably dark -/// before the next hit lands. +/// Each audio transient (kick, snare, beat) spawns a bright wavefront at the +/// first panel. The wavefront travels toward the last panel, stretching and +/// fading as it goes — like a ripple on water or a starship jumping to warp. +/// Multiple ripples overlap additively so rapid beats produce interference +/// patterns. /// /// State layout: -/// - `prev_max[0]`: current display brightness level (smoothed) +/// - `speed[0..n]` — the ripple wave field (amplitude at each panel position). +/// - `prev_max[0]` — smoothed energy envelope for onset detection. /// /// # Arguments /// /// * `spectrum` - FFT-derived amplitudes for frequency bins. /// * `hz_per_bin` - Frequency resolution (Hz per bin in spectrum). /// * `min_freq`/`max_freq` - Frequency range to consider. -/// * `brightness` - Mutable slice of brightness multipliers [0,1] per panel (all set to same value). -/// * `prev_max` - Index 0 stores the current pulse brightness across frames. -/// * `speed` - Unused by this effect (reserved for compatibility). -pub fn update_brightness_pulse( +/// * `brightness` - Mutable slice of brightness multipliers [0,1] per panel (mutated). +/// * `prev_max` - Index 0: smoothed energy tracker (mutated). +/// * `speed` - Ripple wave field (mutated). +pub fn update_brightness_ripple( spectrum: Vec, hz_per_bin: u32, min_freq: u16, max_freq: u16, brightness: &mut [f32], prev_max: &mut [f32], - _speed: &mut [f32], + speed: &mut [f32], ) { - if brightness.is_empty() { + let n_panels = brightness.len(); + if n_panels == 0 { return; } let (min_freq, max_freq) = (min_freq as u32, max_freq as u32); - // 1. Compute overall audio energy: max equalized amplitude in the frequency range + // 1. Compute overall audio energy let mut overall_energy = 0.0_f32; for (i, &l) in spectrum.iter().enumerate() { let cur_freq = (i as u32) * hz_per_bin + hz_per_bin / 2; @@ -275,28 +237,43 @@ pub fn update_brightness_pulse( } overall_energy = overall_energy.max(utils::equalize(ampl, cur_freq).min(1.0)); } - - // 2. Boost signal: sqrt expands the dynamic range so moderate levels produce visible pulses - // Without this, typical music energy (0.2-0.5) barely lights the panels let boosted = overall_energy.sqrt(); - // 3. Asymmetric attack/decay for punchy beat-reactive pulse + // 2. Onset detection — a new ripple spawns when energy jumps sharply + let onset_threshold = 0.08; + let is_onset = boosted > prev_max[0] + onset_threshold && boosted > 0.10; + // Envelope: fast attack, fast release so it drops between beats + // At 0.55 per frame (~5.3 fps), halves in ~250ms — a 120bpm kick + // (500ms apart) easily clears the threshold again. if boosted > prev_max[0] { - // Near-instant attack: snap 90% of the gap per frame - // Every transient produces an immediate flash - prev_max[0] += 0.9 * (boosted - prev_max[0]); + prev_max[0] = prev_max[0] * 0.4 + boosted * 0.6; } else { - // Fast exponential decay between beats - // 0.72 per frame at ~5.3 fps ≈ 250ms to half-brightness - // Creates strong contrast so the next beat has real impact - prev_max[0] *= 0.72; + prev_max[0] *= 0.55; + } + + // 3. Propagate the ripple field outward — wavefront travels from panel 0 → N + // Per-step decay controls how far ripples reach and how sharp the edges are. + let step_decay = 0.88; + for i in (1..n_panels).rev() { + speed[i] = speed[i - 1] * step_decay; } - // Floor very small values to true zero so panels go fully dark - if prev_max[0] < 0.005 { - prev_max[0] = 0.0; + // 4. Inject at source + if is_onset { + // New ripple: bright flash. Additive so overlapping ripples reinforce. + speed[0] = (speed[0] + boosted).min(1.0); + } else { + // Between onsets: fast fade at source so distinct rings separate + speed[0] *= 0.35; } - // 4. All panels pulse together at the same brightness - brightness.fill(prev_max[0]); + // 5. Output — leading-edge emphasis for that warp-stretch look + for i in 0..n_panels { + let ahead = if i + 1 < n_panels { speed[i + 1] } else { 0.0 }; + let edge_boost = if speed[i] > ahead * 1.2 { 1.2 } else { 1.0 }; + brightness[i] = (speed[i] * edge_boost).clamp(0.0, 1.0); + if brightness[i] < 0.005 { + brightness[i] = 0.0; + } + } } diff --git a/src/visualizer.rs b/src/visualizer.rs index d9142fc..9f52195 100644 --- a/src/visualizer.rs +++ b/src/visualizer.rs @@ -6,18 +6,112 @@ use crate::{ panic, processing, utils, }; use anyhow::Result; -use cpal::{InputCallbackInfo, SampleFormat, SizedSample, traits::*}; +use cpal::{InputCallbackInfo, SampleFormat, SizedSample, StreamError, traits::*}; use dasp_sample::conv::ToSample; +use flume::{self, RecvTimeoutError, TryRecvError}; +use hashbrown::HashMap; use palette::{FromColor, Oklch, Srgb}; +use parking_lot::Mutex; use std::{ - collections::HashMap, - sync::{ - Arc, Mutex, - mpsc::{self, TryRecvError}, - }, + sync::Arc, thread, + time::{Duration, Instant}, }; +const STREAM_ERROR_REPORT_INTERVAL: Duration = Duration::from_secs(10); +const AUDIO_RECV_TIMEOUT: Duration = Duration::from_millis(250); +/// Bound on the cpal-callback → visualizer audio queue. Capped so a producer +/// burst (e.g. AirPlay catching up after a stall) cannot grow memory +/// unboundedly; the callback drops oldest batches when the consumer falls +/// behind. +const AUDIO_TX_CHANNEL_DEPTH: usize = 64; +/// Initial wait before retrying audio-stream construction after a fault. +const AUDIO_RESTART_INITIAL_BACKOFF: Duration = Duration::from_millis(250); +/// Cap on the audio-stream rebuild backoff. +const AUDIO_RESTART_MAX_BACKOFF: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, Copy)] +enum StreamFault { + DeviceUnavailable, +} + +/// Why the inner audio pump returned to the outer loop. +enum AudioLoopExit { + /// Visualizer was asked to stop (End message). Exit the thread. + Done, + /// The audio stream went away or the device is unavailable. Drop the + /// current cpal stream, sleep with backoff, and try to rebuild — without + /// disturbing the Nanoleaf attachment. + RebuildAudio, +} + +#[derive(Debug, Clone, Copy)] +enum StreamErrorKind { + PollErr, + XRun, + DeviceUnavailable, + Other, +} + +#[derive(Debug, Default, Clone, Copy)] +struct StreamErrorCounts { + pollerr: u64, + xrun: u64, + other: u64, + device_unavailable: u64, +} + +impl StreamErrorCounts { + fn total(self) -> u64 { + self.pollerr + self.xrun + self.other + self.device_unavailable + } +} + +#[derive(Debug)] +struct StreamErrorTelemetry { + interval: StreamErrorCounts, + lifetime: StreamErrorCounts, + last_report: Instant, +} + +impl StreamErrorTelemetry { + fn new() -> Self { + Self { + interval: StreamErrorCounts::default(), + lifetime: StreamErrorCounts::default(), + last_report: Instant::now(), + } + } + + fn record(&mut self, kind: StreamErrorKind) { + let (interval_counter, lifetime_counter) = match kind { + StreamErrorKind::PollErr => (&mut self.interval.pollerr, &mut self.lifetime.pollerr), + StreamErrorKind::XRun => (&mut self.interval.xrun, &mut self.lifetime.xrun), + StreamErrorKind::Other => (&mut self.interval.other, &mut self.lifetime.other), + StreamErrorKind::DeviceUnavailable => ( + &mut self.interval.device_unavailable, + &mut self.lifetime.device_unavailable, + ), + }; + *interval_counter = interval_counter.saturating_add(1); + *lifetime_counter = lifetime_counter.saturating_add(1); + } + + fn take_report_if_due( + &mut self, + now: Instant, + ) -> Option<(StreamErrorCounts, StreamErrorCounts)> { + if now.duration_since(self.last_report) < STREAM_ERROR_REPORT_INTERVAL { + return None; + } + let interval = self.interval; + let lifetime = self.lifetime; + self.interval = StreamErrorCounts::default(); + self.last_report = now; + Some((interval, lifetime)) + } +} + #[derive(Debug, Default)] enum VisualizerState { #[default] @@ -25,19 +119,36 @@ enum VisualizerState { Done, } -#[derive(Debug)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StreamHealth { + #[default] + Starting, + Healthy, + Degraded, + Restarting, + Stopped, +} + +#[derive(Debug, Clone)] pub enum VisualizerMsg { End, + Ping, SetGain(f32), SetPalette(Vec<[u8; 3]>), SetEffect(Effect), ResetPanels, + SetTimeWindow(f32), + SetTransitionTime(u16), + SetFreqRange(u16, u16), SetSorting { primary_axis: crate::config::Axis, sort_primary: crate::config::Sort, sort_secondary: crate::config::Sort, global_orientation: u16, }, + /// Hold panels black; suppress audio-driven panel writes until a + /// SetPalette message clears the state. Used on AirPlay disconnect. + Blackout, } pub struct Visualizer { @@ -52,7 +163,11 @@ pub struct Visualizer { max_freq: u16, hues: Vec<[u8; 3]>, effect: Effect, - shared_colors: Arc>>, + color_subscribers: Vec>>, + stream_health: Option>>, + /// While true, the audio pump skips panel writes — panels stay on the + /// last static frame (typically black). Cleared by `SetPalette`. + suppress_audio_output: bool, } impl Visualizer { @@ -78,7 +193,8 @@ impl Visualizer { config: VisualizerConfig, audio_stream: AudioStream, nl_device: &NlDevice, - shared_colors: Arc>>, + color_subscribers: Vec>>, + initial_hues: Vec<[u8; 3]>, ) -> Result { let state = VisualizerState::default(); let mut nl_udp = nanoleaf::NlUdp::new(nl_device)?; @@ -102,9 +218,13 @@ impl Visualizer { .transition_time .unwrap_or(constants::DEFAULT_TRANSITION_TIME); let (min_freq, max_freq) = config.freq_range.unwrap_or(constants::DEFAULT_FREQ_RANGE); - let hues = config - .colors - .unwrap_or(Vec::from(constants::DEFAULT_COLORS)); + // Caller resolves colors (from Nanoleaf palette, artwork, or fallback) + // before constructing — this is just a safety net for empty input. + let hues = if initial_hues.is_empty() { + Vec::from(constants::DEFAULT_COLORS) + } else { + initial_hues + }; let effect = config.effect.unwrap_or_default(); Ok(Visualizer { state, @@ -118,10 +238,17 @@ impl Visualizer { max_freq, hues, effect, - shared_colors, + color_subscribers, + stream_health: None, + suppress_audio_output: false, }) } + pub fn with_stream_health(mut self, stream_health: Arc>) -> Self { + self.stream_health = Some(stream_health); + self + } + /// Sends a UDP frame setting all panels to black with instant transition. /// /// Used when starting the visualizer or changing effects/palettes to clear @@ -155,6 +282,7 @@ impl Visualizer { ) { match event { VisualizerMsg::End => self.state = VisualizerState::Done, + VisualizerMsg::Ping => {} VisualizerMsg::SetGain(gain) => self.gain = gain, VisualizerMsg::SetEffect(effect) => { self.effect = effect; @@ -166,12 +294,25 @@ impl Visualizer { self.send_black_frame(base_colors.len()); } VisualizerMsg::SetPalette(new_colors) => { + eprintln!( + "INFO: visualizer received SetPalette n={}", + new_colors.len() + ); self.hues = new_colors; *base_colors = utils::colors_from_rgb(&self.hues, base_colors.len()); brightness.fill(0.0); + // Coming back from a Blackout (e.g. AirPlay reconnect) — let + // the audio pump drive panels again. + self.suppress_audio_output = false; // Immediately send a black frame so the old palette's colors don't linger self.send_black_frame(base_colors.len()); } + VisualizerMsg::SetTimeWindow(tw) => self.time_window = tw, + VisualizerMsg::SetTransitionTime(tt) => self.trans_time = tt, + VisualizerMsg::SetFreqRange(min, max) => { + self.min_freq = min; + self.max_freq = max; + } VisualizerMsg::ResetPanels => { brightness.fill(0.0); prev_max.fill(0.0); @@ -196,6 +337,13 @@ impl Visualizer { // Immediately send a black frame so the old sort order's colors don't linger self.send_black_frame(base_colors.len()); } + VisualizerMsg::Blackout => { + brightness.fill(0.0); + prev_max.fill(0.0); + speed.fill(0.0); + self.suppress_audio_output = true; + self.send_black_frame(base_colors.len()); + } } } @@ -207,7 +355,7 @@ impl Visualizer { /// /// Generic over sample type T supporting sized conversion to f32. /// Used in `create_data_callback` closure for CPAL stream. - fn send_samples(data: &[T], n_channels: usize, tx: &mpsc::Sender>) + fn send_samples(data: &[T], n_channels: usize, tx: &flume::Sender>) where T: SizedSample + ToSample, { @@ -220,7 +368,12 @@ impl Visualizer { .fold(f32::NEG_INFINITY, f32::max), ); } - tx.send(samples).expect("sending samples failed"); + // Bounded channel: if the consumer is falling behind, drop this + // batch on the floor. cpal callbacks must never block, so send() + // is unsafe; and the channel only exposes try_send from the + // producer side. Dropping recent samples is acceptable — the + // visualizer treats missing audio as silence. + let _ = tx.try_send(samples); } /// Creates a closure suitable for CPAL `build_input_stream` callback. @@ -232,7 +385,7 @@ impl Visualizer { /// Generic T for sample type matching AudioStream format. fn create_data_callback( n_channels: usize, - tx: mpsc::Sender>, + tx: flume::Sender>, ) -> impl FnMut(&[T], &InputCallbackInfo) + Send + 'static where T: SizedSample + ToSample, @@ -240,6 +393,96 @@ impl Visualizer { move |data: &[T], _: &InputCallbackInfo| Self::send_samples(data, n_channels, &tx) } + fn classify_stream_error(err: &StreamError, err_text: &str) -> StreamErrorKind { + match err { + StreamError::DeviceNotAvailable | StreamError::StreamInvalidated => { + StreamErrorKind::DeviceUnavailable + } + StreamError::BufferUnderrun => StreamErrorKind::XRun, + StreamError::BackendSpecific { .. } => { + if err_text.contains("Buffer underrun/overrun occurred") { + StreamErrorKind::XRun + } else if err_text.contains("POLLERR") { + StreamErrorKind::PollErr + } else if err_text.contains("device is no longer available") + || err_text.contains("not available") + { + StreamErrorKind::DeviceUnavailable + } else { + StreamErrorKind::Other + } + } + } + } + + fn process_stream_faults(rx_stream_fault: &flume::Receiver) -> bool { + match rx_stream_fault.try_recv() { + Ok(StreamFault::DeviceUnavailable) => true, + Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => false, + } + } + + fn set_stream_health(stream_health: &Option>>, next: StreamHealth) { + if let Some(shared) = stream_health { + let mut guard = shared.lock(); + if *guard != next { + *guard = next; + } + } + } + + fn report_stream_errors_if_due( + stream_errors: &Arc>, + stream_health: &Option>>, + ) { + let report = { + let mut guard = stream_errors.lock(); + guard.take_report_if_due(Instant::now()) + }; + + let Some((interval, lifetime)) = report else { + return; + }; + if interval.total() == 0 { + Self::set_stream_health(stream_health, StreamHealth::Healthy); + return; + } + Self::set_stream_health(stream_health, StreamHealth::Degraded); + + eprintln!( + "INFO: audio stream errors (last {}s) xrun={} pollerr={} other={} device_unavailable={} | lifetime xrun={} pollerr={} other={} device_unavailable={}", + STREAM_ERROR_REPORT_INTERVAL.as_secs(), + interval.xrun, + interval.pollerr, + interval.other, + interval.device_unavailable, + lifetime.xrun, + lifetime.pollerr, + lifetime.other, + lifetime.device_unavailable + ); + } + + fn process_pending_events( + &mut self, + rx_events: &flume::Receiver, + base_colors: &mut Vec, + brightness: &mut Vec, + prev_max: &mut [f32], + speed: &mut [f32], + ) -> bool { + loop { + match rx_events.try_recv() { + Ok(event) => self.update_state(event, base_colors, brightness, prev_max, speed), + Err(TryRecvError::Empty) => return true, + Err(TryRecvError::Disconnected) => { + eprintln!("WARNING: visualizer events channel disconnected; stopping thread."); + return false; + } + } + } + } + /// Completes visualizer setup by starting audio capture stream and spawning processing thread. /// /// Builds and plays CPAL input stream matched to sample format, sending mono max samples via channel. @@ -255,141 +498,326 @@ impl Visualizer { /// Returns sender for sending `VisualizerMsg` to control runtime behavior. /// /// Consumes self (moved into thread closure). - pub fn init(mut self) -> mpsc::Sender { - let (tx_events, rx_events) = mpsc::channel(); + pub fn init(mut self) -> flume::Sender { + let (tx_events, rx_events) = flume::unbounded(); thread::spawn(move || { panic::register_backtrace_panic_handler(); - let (tx_audio, rx_audio) = mpsc::channel(); - macro_rules! build_input_stream { - ($type:ty) => { - self.audio_stream - .device - .build_input_stream( - &self.audio_stream.stream_config, - Self::create_data_callback::<$type>( - self.audio_stream.stream_config.channels as usize, - tx_audio, - ), - move |_| panic!("building the audio stream failed"), - None, - ) - .expect("stream initialization failed") - }; - } - let stream = match self.audio_stream.sample_format { - SampleFormat::I8 => build_input_stream!(i8), - SampleFormat::I16 => build_input_stream!(i16), - SampleFormat::I32 => build_input_stream!(i32), - SampleFormat::I64 => build_input_stream!(i64), - SampleFormat::U8 => build_input_stream!(u8), - SampleFormat::U16 => build_input_stream!(u16), - SampleFormat::U32 => build_input_stream!(u32), - SampleFormat::U64 => build_input_stream!(u64), - SampleFormat::F32 => build_input_stream!(f32), - SampleFormat::F64 => build_input_stream!(f64), - _ => panic!("unsupported sample format"), - }; - stream.play().expect("running the audio stream failed"); + Self::set_stream_health(&self.stream_health, StreamHealth::Starting); let n = self.nl_udp.panels.len(); let sample_rate = self.audio_stream.stream_config.sample_rate; - // Base colors hold the target Oklch values (with original lightness from the user's RGB) let mut base_colors = utils::colors_from_rgb(&self.hues, n); - // Brightness multiplier [0,1] per panel — animated by audio amplitude - // At 0 the panel is black; at 1 it shows the exact target color let mut brightness = vec![0.0_f32; n]; let mut prev_max = vec![0.0; n]; let mut speed = vec![0.0; n]; // Clear any colors left over from a previous Nanoleaf scene or effect self.send_black_frame(n); - loop { + + let stream_errors = Arc::new(Mutex::new(StreamErrorTelemetry::new())); + let mut audio_backoff = AUDIO_RESTART_INITIAL_BACKOFF; + + // Outer loop: lifetime of the visualizer thread. The Nanoleaf UDP + // attachment lives here. Audio cpal streams are created and dropped + // beneath us so the panels stay attached even if the audio device + // disappears (e.g. snd-aloop, AirPlay restart). + 'outer: loop { + if !self.process_pending_events( + &rx_events, + &mut base_colors, + &mut brightness, + &mut prev_max, + &mut speed, + ) { + break 'outer; + } if matches!(self.state, VisualizerState::Done) { - break; + break 'outer; } - 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 session = + Self::build_audio_session(&self.audio_stream, Arc::clone(&stream_errors)); + let (stream, rx_audio, rx_stream_fault) = match session { + Some(triple) => triple, + None => { + Self::set_stream_health(&self.stream_health, StreamHealth::Restarting); + if !self.idle_for_audio( + audio_backoff, + &rx_events, + &mut base_colors, + &mut brightness, + &mut prev_max, + &mut speed, + ) { + break 'outer; } + audio_backoff = (audio_backoff * 2).min(AUDIO_RESTART_MAX_BACKOFF); + continue 'outer; + } + }; + audio_backoff = AUDIO_RESTART_INITIAL_BACKOFF; + Self::set_stream_health(&self.stream_health, StreamHealth::Healthy); + + let outcome = self.run_audio_pump( + &rx_audio, + &rx_stream_fault, + &rx_events, + &mut base_colors, + &mut brightness, + &mut prev_max, + &mut speed, + &stream_errors, + sample_rate, + ); + drop(stream); + match outcome { + AudioLoopExit::Done => break 'outer, + AudioLoopExit::RebuildAudio => { + Self::set_stream_health(&self.stream_health, StreamHealth::Restarting); + eprintln!( + "WARNING: audio stream interrupted; rebuilding (Nanoleaf attachment preserved)." + ); } } - let to_collect = ((sample_rate as f32) * self.time_window).round() as usize; - let mut samples = Vec::with_capacity(2 * to_collect); - while samples.len() < to_collect { - let mut new_samples = rx_audio.recv().expect("receiving samples failed"); - samples.append(&mut new_samples); + } + Self::set_stream_health(&self.stream_health, StreamHealth::Stopped); + }); + + tx_events + } + + /// Construct a fresh cpal capture stream + sample/fault channels. + /// Returns None on any failure — callers should sleep with backoff and retry. + fn build_audio_session( + audio_stream: &AudioStream, + stream_errors: Arc>, + ) -> Option<( + cpal::Stream, + flume::Receiver>, + flume::Receiver, + )> { + let (tx_audio, rx_audio) = flume::bounded(AUDIO_TX_CHANNEL_DEPTH); + let (tx_stream_fault, rx_stream_fault) = flume::bounded(8); + let n_channels = audio_stream.stream_config.channels as usize; + + macro_rules! build_input_stream { + ($type:ty) => { + audio_stream.device.build_input_stream( + &audio_stream.stream_config, + Self::create_data_callback::<$type>(n_channels, tx_audio.clone()), + { + let tx_stream_fault = tx_stream_fault.clone(); + let stream_errors = Arc::clone(&stream_errors); + move |err| { + let err_text = err.to_string(); + let kind = Self::classify_stream_error(&err, &err_text); + let mut telemetry = stream_errors.lock(); + telemetry.record(kind); + if matches!(kind, StreamErrorKind::DeviceUnavailable) { + let _ = tx_stream_fault.try_send(StreamFault::DeviceUnavailable); + } + } + }, + None, + ) + }; + } + + let stream_result = match audio_stream.sample_format { + SampleFormat::I8 => build_input_stream!(i8), + SampleFormat::I16 => build_input_stream!(i16), + SampleFormat::I32 => build_input_stream!(i32), + SampleFormat::I64 => build_input_stream!(i64), + SampleFormat::U8 => build_input_stream!(u8), + SampleFormat::U16 => build_input_stream!(u16), + SampleFormat::U32 => build_input_stream!(u32), + SampleFormat::U64 => build_input_stream!(u64), + SampleFormat::F32 => build_input_stream!(f32), + SampleFormat::F64 => build_input_stream!(f64), + _ => { + eprintln!( + "WARNING: Unsupported sample format for live visualizer: {:?}", + audio_stream.sample_format + ); + return None; + } + }; + + let stream = match stream_result { + Ok(stream) => stream, + Err(err) => { + eprintln!("WARNING: stream initialization failed: {}", err); + return None; + } + }; + if let Err(err) = stream.play() { + eprintln!("WARNING: running the audio stream failed: {}", err); + return None; + } + Some((stream, rx_audio, rx_stream_fault)) + } + + /// Sleep for `duration` while continuing to process control events. + /// Returns false if the visualizer was asked to stop (End received or the + /// events channel was dropped) — caller should exit the outer loop. + #[allow(clippy::too_many_arguments)] + fn idle_for_audio( + &mut self, + duration: Duration, + rx_events: &flume::Receiver, + base_colors: &mut Vec, + brightness: &mut Vec, + prev_max: &mut [f32], + speed: &mut [f32], + ) -> bool { + let deadline = Instant::now() + duration; + loop { + let now = Instant::now(); + if now >= deadline { + return true; + } + let chunk = (deadline - now).min(Duration::from_millis(100)); + match rx_events.recv_timeout(chunk) { + Ok(event) => { + self.update_state(event, base_colors, brightness, prev_max, speed); + if matches!(self.state, VisualizerState::Done) { + return false; + } } - let spectrum = processing::process(samples, self.gain); - let hz_per_bin = (sample_rate / 2) / (spectrum.len() as u32); - match self.effect { - Effect::Spectrum => processing::update_brightness( - spectrum, - hz_per_bin, - self.min_freq, - self.max_freq, - &mut brightness, - &mut prev_max, - &mut speed, - ), - Effect::EnergyWave => processing::update_brightness_wave( - spectrum, - hz_per_bin, - self.min_freq, - self.max_freq, - &mut brightness, - &mut prev_max, - &mut speed, - ), - Effect::Pulse => processing::update_brightness_pulse( - spectrum, - hz_per_bin, - self.min_freq, - self.max_freq, - &mut brightness, - &mut prev_max, - &mut speed, - ), + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => return false, + } + } + } + + /// Inner audio pump. Drives panels from FFT output until either End is + /// received (Done) or the audio stream goes away (RebuildAudio). + #[allow(clippy::too_many_arguments)] + fn run_audio_pump( + &mut self, + rx_audio: &flume::Receiver>, + rx_stream_fault: &flume::Receiver, + rx_events: &flume::Receiver, + base_colors: &mut Vec, + brightness: &mut Vec, + prev_max: &mut [f32], + speed: &mut [f32], + stream_errors: &Arc>, + sample_rate: u32, + ) -> AudioLoopExit { + loop { + Self::report_stream_errors_if_due(stream_errors, &self.stream_health); + if matches!(self.state, VisualizerState::Done) { + return AudioLoopExit::Done; + } + if Self::process_stream_faults(rx_stream_fault) { + return AudioLoopExit::RebuildAudio; + } + if !self.process_pending_events(rx_events, base_colors, brightness, prev_max, speed) { + return AudioLoopExit::Done; + } + + let to_collect = ((sample_rate as f32) * self.time_window).round() as usize; + let max_buffered = to_collect.saturating_mul(4); + let mut samples = Vec::with_capacity(2 * to_collect); + while samples.len() < to_collect { + Self::report_stream_errors_if_due(stream_errors, &self.stream_health); + if Self::process_stream_faults(rx_stream_fault) { + return AudioLoopExit::RebuildAudio; } - // Compute display colors: scale base lightness by brightness multiplier - // This ensures at brightness=1.0, the output exactly matches the user's original RGB - let display_colors: Vec = base_colors - .iter() - .zip(brightness.iter()) - .map(|(base, &b)| Oklch::new(base.l * b, base.chroma, base.hue)) - .collect(); - if self + let mut new_samples = match rx_audio.recv_timeout(AUDIO_RECV_TIMEOUT) { + Ok(samples) => samples, + Err(RecvTimeoutError::Timeout) => { + if Self::process_stream_faults(rx_stream_fault) { + return AudioLoopExit::RebuildAudio; + } + if !self.process_pending_events( + rx_events, + base_colors, + brightness, + prev_max, + speed, + ) { + return AudioLoopExit::Done; + } + if matches!(self.state, VisualizerState::Done) { + return AudioLoopExit::Done; + } + continue; + } + Err(RecvTimeoutError::Disconnected) => { + // The cpal callback's tx_audio was dropped — the stream + // is gone. Rebuild rather than tearing down the thread. + return AudioLoopExit::RebuildAudio; + } + }; + samples.append(&mut new_samples); + // If we've buffered far more than this frame needs (consumer + // is behind a producer burst), drop the oldest excess so we + // catch up to real time instead of accumulating latency. + if samples.len() > max_buffered { + let drop_count = samples.len() - to_collect.saturating_mul(2); + samples.drain(..drop_count); + } + } + + let spectrum = processing::process(samples, self.gain); + let hz_per_bin = (sample_rate / 2) / (spectrum.len() as u32); + match self.effect { + Effect::Spectrum => processing::update_brightness( + spectrum, + hz_per_bin, + self.min_freq, + self.max_freq, + brightness, + prev_max, + speed, + ), + Effect::EnergyWave => processing::update_brightness_wave( + spectrum, + hz_per_bin, + self.min_freq, + self.max_freq, + brightness, + prev_max, + speed, + ), + Effect::Ripple => processing::update_brightness_ripple( + spectrum, + hz_per_bin, + self.min_freq, + self.max_freq, + brightness, + prev_max, + speed, + ), + } + let display_colors: Vec = base_colors + .iter() + .zip(brightness.iter()) + .map(|(base, &b)| Oklch::new(base.l * b, base.chroma, base.hue)) + .collect(); + if !self.suppress_audio_output + && self .nl_udp .update_panels(&display_colors, self.trans_time) .is_err() - { - // UDP send failed (e.g. extControl timed out) — re-request and retry once - if self.nl_device.request_udp_control().is_ok() { - let _ = self.nl_udp.update_panels(&display_colors, self.trans_time); - } + && self.nl_device.request_udp_control().is_ok() + { + let _ = self.nl_udp.update_panels(&display_colors, self.trans_time); + } + if !self.suppress_audio_output && !self.color_subscribers.is_empty() { + let mut frame = HashMap::with_capacity(display_colors.len()); + 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; + frame.insert(self.nl_udp.panels[i].id, [r, g, b]); } - // 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]); - } + for tx in &self.color_subscribers { + let _ = tx.try_send(frame.clone()); } } - }); - - tx_events + } } } diff --git a/web/.node-version b/web/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/web/.node-version @@ -0,0 +1 @@ +24 diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..a4d44d5 --- /dev/null +++ b/web/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..a8b637a --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + NanoViz Control Panel + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..392fabd --- /dev/null +++ b/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "nanoviz-web", + "private": true, + "version": "0.1.0", + "packageManager": "pnpm@11.0.8", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build", + "preview": "vite preview", + "check": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.0", + "@types/node": "^24.12.4", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "tailwindcss": "^4.3.0", + "typescript": "^6.0.3", + "vite": "^8.0.14" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..6f5daf6 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1038 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-separator': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.15)(react@19.2.6) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + react: + specifier: ^19.2.6 + version: 19.2.6 + react-dom: + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.3.0 + version: 4.3.0 + '@types/node': + specifier: ^24.12.4 + version: 24.12.4 + '@types/react': + specifier: ^19.2.15 + version: 19.2.15 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.15) + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.0.2(vite@8.0.14(@types/node@24.12.4)(jiti@2.7.0)) + autoprefixer: + specifier: ^10.5.0 + version: 10.5.0(postcss@8.5.15) + postcss: + specifier: ^8.5.15 + version: 8.5.15 + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@24.12.4)(jiti@2.7.0) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.0': + resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.15': + resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} + + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + baseline-browser-mapping@2.10.31: + resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.361: + resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==} + + enhanced-resolve@5.22.0: + resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==} + engines: {node: '>=10.13.0'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.132.0': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.15)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.15 + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.15)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.15 + + '@rolldown/binding-android-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-x64@1.0.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.2': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.22.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/postcss@4.3.0': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + postcss: 8.5.15 + tailwindcss: 4.3.0 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.15)': + dependencies: + '@types/react': 19.2.15 + + '@types/react@19.2.15': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.0.2(vite@8.0.14(@types/node@24.12.4)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.14(@types/node@24.12.4)(jiti@2.7.0) + + autoprefixer@10.5.0(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001793 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + baseline-browser-mapping@2.10.31: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.31 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.361 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + caniuse-lite@1.0.30001793: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.361: {} + + enhanced-resolve@5.22.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + jiti@2.7.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.12: {} + + node-releases@2.0.46: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react@19.2.6: {} + + rolldown@1.0.2: + dependencies: + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 + + scheduler@0.27.0: {} + + source-map-js@1.2.1: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@8.0.14(@types/node@24.12.4)(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.2 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + fsevents: 2.3.3 + jiti: 2.7.0 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..00f6fc4 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: set this to true or false diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs new file mode 100644 index 0000000..de8ec71 --- /dev/null +++ b/web/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +}; diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..340ff1b --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,2110 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + api, + apiAssetUrl, + apiWsUrl, + type AudioBackendsResponse, + type ConfigResponse, + type DeviceInfoResponse, + type DeviceLayoutPanel, + type DeviceLayoutResponse, + type DevicesResponse, + type DeviceStateUpdateRequest, + type DeviceSummary, + PairNotInPairingModeError, + type NowPlayingResponse, + type VisualizerSettingsUpdateRequest, + type VisualizerStatusResponse, + type VisualizerSortUpdateRequest, + type HealthResponse, + type PaletteEntry, + type PalettesResponse, +} from "@/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; + +type LoadState = "idle" | "loading" | "ready" | "error"; +const DEFAULT_BRIGHTNESS_DRAFT = "50"; +const EFFECT_OPTIONS = ["Spectrum", "EnergyWave", "Ripple"] as const; + +const LS_SHOW_LIVE_PREVIEW = "nanoviz.show_live_preview"; + +function readLocalBool(key: string, fallback: boolean): boolean { + if (typeof window === "undefined") return fallback; + try { + const raw = window.localStorage.getItem(key); + if (raw == null) return fallback; + return raw === "true"; + } catch { + return fallback; + } +} + +function writeLocalBool(key: string, value: boolean): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(key, String(value)); + } catch { + // ignore — storage may be disabled or full + } +} +type EffectOption = (typeof EFFECT_OPTIONS)[number]; + +function isValidBrightnessInput(value: string): boolean { + return /^\d{0,3}$/.test(value); +} + +function parseBrightness(value: string): number | null { + if (value.trim() === "") { + return null; + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + return null; + } + return parsed; +} + +function parseInteger(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const parsed = Number(trimmed); + if (!Number.isInteger(parsed)) { + return null; + } + return parsed; +} + +function parseNonNegativeFloat(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return parsed; +} + +function parsePositiveFloat(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +function formatTenths(value: number): string { + return (Math.round(value * 10) / 10).toFixed(1); +} + +function formatSeconds(totalSecs: number): string { + const m = Math.floor(totalSecs / 60); + const s = Math.floor(totalSecs % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function extractBrightnessFromInfo(info: Record): number | null { + const state = info.state; + if (!state || typeof state !== "object") { + return null; + } + const brightness = (state as Record).brightness; + if (!brightness || typeof brightness !== "object") { + return null; + } + const value = (brightness as Record).value; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + return null; + } + return parsed; +} + +function normalizeEffect(value: string | null | undefined): EffectOption { + if (value === "EnergyWave") { + return "EnergyWave"; + } + if (value === "Ripple") { + return "Ripple"; + } + return "Spectrum"; +} + +function paletteEntryFor( + name: string | null | undefined, + palettes: PaletteEntry[], +): PaletteEntry | null { + if (!name) { + return null; + } + return palettes.find((p) => p.name === name) ?? null; +} + +function resolveInitialLayoutDeviceName( + nextConfig: ConfigResponse, + nextDevices: DevicesResponse, +): string | null { + const configured = nextConfig.config?.default_nl_device_name; + if (configured && nextDevices.devices.some((device) => device.name === configured)) { + return configured; + } + return nextDevices.devices[0]?.name ?? null; +} + +function App() { + const [loadState, setLoadState] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(null); + const [actionMessage, setActionMessage] = useState(null); + const [updatingDeviceName, setUpdatingDeviceName] = useState(null); + const [loadingDeviceName, setLoadingDeviceName] = useState(null); + const [savingConfigSection, setSavingConfigSection] = useState< + "effect" | "palette" | "sort" | "settings" | "persist" | null + >(null); + const [brightnessDraftByDevice, setBrightnessDraftByDevice] = useState< + Record + >({}); + const [effectDraft, setEffectDraft] = useState("Spectrum"); + const [paletteDraft, setPaletteDraft] = useState(""); + const [sortDraft, setSortDraft] = useState({ + primary_axis: "Y", + sort_primary: "Asc", + sort_secondary: "Asc", + }); + const [settingsDraft, setSettingsDraft] = useState({ + audio_backend: "default", + freq_min: "20", + freq_max: "4500", + default_gain: "1", + transition_time: "0.2", + time_window: "0.2", + }); + const [audioBackends, setAudioBackends] = useState(["default"]); + const [showLivePreview, setShowLivePreview] = useState(() => + readLocalBool(LS_SHOW_LIVE_PREVIEW, false), + ); + const [livePreviewDeviceName, setLivePreviewDeviceName] = useState(null); + const [livePreviewColorsByPanel, setLivePreviewColorsByPanel] = useState< + Record + >({}); + const [visualizerStatus, setVisualizerStatus] = useState( + null, + ); + const [nowPlaying, setNowPlaying] = useState(null); + const brightnessCommitTimersRef = useRef>({}); + const lastAppliedBrightnessRef = useRef>({}); + + const [health, setHealth] = useState(null); + const [config, setConfig] = useState(null); + const [devices, setDevices] = useState(null); + const [showPairPanel, setShowPairPanel] = useState(false); + const [pairManualMode, setPairManualMode] = useState(false); + const [pairManualIp, setPairManualIp] = useState(""); + const [discoveredDevices, setDiscoveredDevices] = useState(null); + const [discoverState, setDiscoverState] = useState<"idle" | "scanning" | "error">("idle"); + const [pairSelectedIp, setPairSelectedIp] = useState(null); + const [pairState, setPairState] = useState<"idle" | "pairing">("idle"); + const [pairMessage, setPairMessage] = useState(null); + const [palettes, setPalettes] = useState(null); + const [selectedDeviceInfo, setSelectedDeviceInfo] = + useState(null); + const [selectedDeviceLayout, setSelectedDeviceLayout] = + useState(null); + + const visualizerConfig = config?.config?.visualizer_config; + const availableBackendOptions = Array.from( + new Set([settingsDraft.audio_backend, ...audioBackends].filter((name) => name.trim().length)), + ); + + useEffect(() => { + let isMounted = true; + + async function loadData() { + try { + setLoadState("loading"); + setErrorMessage(null); + + const [healthData, configData, devicesData, palettesData] = await Promise.all([ + api.health(), + api.config(), + api.devices(), + api.palettes(), + ]); + let nowPlayingData: NowPlayingResponse | null = null; + try { + nowPlayingData = await api.nowPlaying(); + } catch { + // Keep the dashboard usable if now-playing metadata is unavailable. + } + const brightnessEntries = await Promise.all( + devicesData.devices.map(async (device) => { + try { + const info = await api.deviceInfo(device.name); + const currentBrightness = extractBrightnessFromInfo(info.info); + return [ + device.name, + String(currentBrightness ?? Number(DEFAULT_BRIGHTNESS_DRAFT)), + ] as const; + } catch { + return [device.name, DEFAULT_BRIGHTNESS_DRAFT] as const; + } + }), + ); + const brightnessDraftMap = Object.fromEntries(brightnessEntries); + const brightnessAppliedMap = Object.fromEntries( + brightnessEntries.map(([name, value]) => [ + name, + parseBrightness(value) ?? Number(DEFAULT_BRIGHTNESS_DRAFT), + ]), + ); + const initialLayoutDeviceName = resolveInitialLayoutDeviceName(configData, devicesData); + let initialDeviceInfo: DeviceInfoResponse | null = null; + let initialDeviceLayout: DeviceLayoutResponse | null = null; + if (initialLayoutDeviceName) { + try { + [initialDeviceInfo, initialDeviceLayout] = await Promise.all([ + api.deviceInfo(initialLayoutDeviceName), + api.deviceLayout(initialLayoutDeviceName), + ]); + } catch { + // Keep dashboard usable even if initial layout preload fails. + } + } + let audioBackendsData: AudioBackendsResponse = { + current_audio_backend: configData.config?.visualizer_config.audio_backend ?? null, + available_audio_backends: ["default"], + }; + try { + audioBackendsData = await api.audioBackends(); + } catch { + // Keep UI usable even if backend enumeration is unavailable. + } + let visualizerStatusData: VisualizerStatusResponse | null = null; + try { + visualizerStatusData = await api.visualizerStatus(); + } catch { + // Keep UI usable if stream status endpoint is temporarily unavailable. + } + + if (!isMounted) { + return; + } + + setHealth(healthData); + setConfig(configData); + setDevices(devicesData); + setBrightnessDraftByDevice(brightnessDraftMap); + lastAppliedBrightnessRef.current = brightnessAppliedMap; + setSelectedDeviceInfo(initialDeviceInfo); + setSelectedDeviceLayout(initialDeviceLayout); + const availableBackends = + audioBackendsData.available_audio_backends.length > 0 + ? audioBackendsData.available_audio_backends + : ["default"]; + setAudioBackends(availableBackends); + setVisualizerStatus(visualizerStatusData); + setPalettes(palettesData); + setNowPlaying(nowPlayingData); + hydrateVisualizerDrafts( + configData, + palettesData.palettes, + availableBackends, + ); + setLoadState("ready"); + } catch (error) { + if (!isMounted) { + return; + } + setLoadState("error"); + setErrorMessage( + error instanceof Error ? error.message : "Unknown error contacting API", + ); + } + } + + void loadData(); + + return () => { + isMounted = false; + }; + }, []); + + useEffect(() => { + return () => { + for (const timerId of Object.values(brightnessCommitTimersRef.current)) { + window.clearTimeout(timerId); + } + brightnessCommitTimersRef.current = {}; + }; + }, []); + + useEffect(() => { + writeLocalBool(LS_SHOW_LIVE_PREVIEW, showLivePreview); + }, [showLivePreview]); + + // Best-effort cleanup of the legacy drive-palette localStorage key (now + // superseded by the persisted color_source field on visualizer_config). + useEffect(() => { + if (typeof window === "undefined") return; + try { + // Also clean up the legacy "audioleaf.*" key from pre-rename installs. + window.localStorage.removeItem("nanoviz.drive_visualizer_palette"); + window.localStorage.removeItem("audioleaf.drive_visualizer_palette"); + } catch { + // ignore — older browsers / private mode + } + }, []); + + useEffect(() => { + if (!showLivePreview) { + setLivePreviewColorsByPanel({}); + setLivePreviewDeviceName(null); + return; + } + + // Fetch device name once, then stream colors via WebSocket + let cancelled = false; + api.visualizerPreview().then((preview) => { + if (!cancelled) { + setLivePreviewDeviceName(preview.device?.name ?? null); + } + }).catch(() => {}); + + let ws: WebSocket | null = null; + let reconnectTimer: number | undefined; + + function connect() { + ws = new WebSocket(apiWsUrl("/api/visualizer/ws")); + ws.onmessage = (event) => { + try { + const colors: Array<{ panel_id: number; rgb: [number, number, number] }> = + JSON.parse(event.data); + setLivePreviewColorsByPanel( + Object.fromEntries(colors.map((c) => [c.panel_id, c.rgb])), + ); + } catch { /* ignore parse errors */ } + }; + ws.onclose = () => { + if (!cancelled) { + reconnectTimer = window.setTimeout(connect, 2000); + } + }; + ws.onerror = () => { + ws?.close(); + }; + } + + connect(); + + return () => { + cancelled = true; + if (reconnectTimer !== undefined) { + window.clearTimeout(reconnectTimer); + } + ws?.close(); + }; + }, [showLivePreview]); + + useEffect(() => { + let cancelled = false; + let timerId: number | undefined; + const pollNowPlaying = async () => { + try { + const snapshot = await api.nowPlaying(); + if (!cancelled) { + setNowPlaying(snapshot); + } + } catch { + // Keep previous now-playing snapshot visible if polling fails. + } finally { + if (!cancelled) { + timerId = window.setTimeout(() => void pollNowPlaying(), 1200); + } + } + }; + + void pollNowPlaying(); + + return () => { + cancelled = true; + if (timerId !== undefined) { + window.clearTimeout(timerId); + } + }; + }, []); + + useEffect(() => { + let cancelled = false; + let timerId: number | undefined; + const pollVisualizerStatus = async () => { + try { + const snapshot = await api.visualizerStatus(); + if (!cancelled) { + setVisualizerStatus(snapshot); + } + } catch { + // Keep previous status visible if polling fails. + } finally { + if (!cancelled) { + timerId = window.setTimeout(() => void pollVisualizerStatus(), 1200); + } + } + }; + + void pollVisualizerStatus(); + + return () => { + cancelled = true; + if (timerId !== undefined) { + window.clearTimeout(timerId); + } + }; + }, []); + + async function handleLoadDeviceDetails(name: string) { + try { + setErrorMessage(null); + setLoadingDeviceName(name); + const [info, layout] = await Promise.all([ + api.deviceInfo(name), + api.deviceLayout(name), + ]); + setSelectedDeviceInfo(info); + setSelectedDeviceLayout(layout); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Failed to load device info and layout", + ); + } finally { + setLoadingDeviceName(null); + } + } + + async function handleSetState( + name: string, + payload: DeviceStateUpdateRequest, + actionLabel: string, + ) { + try { + setErrorMessage(null); + setActionMessage(null); + setUpdatingDeviceName(name); + await api.setDeviceState(name, payload); + setActionMessage(`${actionLabel} applied on ${name}`); + + if (selectedDeviceInfo?.device.name === name) { + const refreshedInfo = await api.deviceInfo(name); + setSelectedDeviceInfo(refreshedInfo); + } + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to update device state", + ); + } finally { + setUpdatingDeviceName(null); + } + } + + function resetPairPanel() { + setShowPairPanel(false); + setPairManualMode(false); + setPairManualIp(""); + setDiscoveredDevices(null); + setDiscoverState("idle"); + setPairSelectedIp(null); + setPairState("idle"); + setPairMessage(null); + } + + async function handleDiscoverDevices() { + try { + setPairMessage(null); + setDiscoverState("scanning"); + const response = await api.discoverDevices(); + setDiscoveredDevices(response.devices); + setDiscoverState("idle"); + if (response.devices.length === 0) { + setPairMessage("No devices found on the local network."); + } + } catch (error) { + setDiscoverState("error"); + setPairMessage( + error instanceof Error ? error.message : "Failed to scan for devices", + ); + } + } + + async function handlePairDevice() { + const ip = pairManualMode ? pairManualIp.trim() : pairSelectedIp; + if (!ip) { + setPairMessage("Pick a discovered device or enter an IP."); + return; + } + try { + setPairMessage(null); + setPairState("pairing"); + const result = await api.pairDevice(ip); + // Refresh the devices list so the new device appears in the card. + const refreshed = await api.devices(); + setDevices(refreshed); + setActionMessage(`Paired ${result.device.name} (${result.device.ip}).`); + resetPairPanel(); + } catch (error) { + setPairState("idle"); + if (error instanceof PairNotInPairingModeError) { + setPairMessage( + "Device wasn't in pairing mode. Hold its power button until the lights flash, then try again.", + ); + } else { + setPairMessage( + error instanceof Error ? error.message : "Pairing failed.", + ); + } + } + } + + function hydrateVisualizerDrafts( + nextConfig: ConfigResponse, + nextPalettes: PaletteEntry[], + nextAudioBackends: string[], + ) { + const visualizer = nextConfig.config?.visualizer_config; + setEffectDraft(normalizeEffect(visualizer?.effect)); + // Persisted palette_name is the source of truth; fall back to "" so the + // dropdown shows the placeholder until the user picks one. + void nextPalettes; + setPaletteDraft(visualizer?.palette_name ?? ""); + setSortDraft({ + primary_axis: visualizer?.primary_axis === "X" ? "X" : "Y", + sort_primary: visualizer?.sort_primary === "Desc" ? "Desc" : "Asc", + sort_secondary: visualizer?.sort_secondary === "Desc" ? "Desc" : "Asc", + }); + + const configuredBackend = visualizer?.audio_backend ?? "default"; + const resolvedBackend = configuredBackend.trim().length > 0 ? configuredBackend : "default"; + const hasDefaultBackend = nextAudioBackends.includes("default"); + const backendIsAvailable = nextAudioBackends.includes(resolvedBackend); + const draftBackend = + backendIsAvailable || !hasDefaultBackend ? resolvedBackend : "default"; + setSettingsDraft({ + audio_backend: draftBackend, + freq_min: String(visualizer?.freq_range?.[0] ?? 20), + freq_max: String(visualizer?.freq_range?.[1] ?? 4500), + default_gain: String(visualizer?.default_gain ?? 1), + transition_time: formatTenths((visualizer?.transition_time ?? 2) / 10), + time_window: formatTenths(visualizer?.time_window ?? 0.1875), + }); + + if ( + resolvedBackend !== "default" && + !backendIsAvailable && + hasDefaultBackend + ) { + setActionMessage((prev) => + prev ?? + `Configured audio backend "${resolvedBackend}" is not currently available. Using "default".`, + ); + } + } + + async function applyEffect(nextEffect: EffectOption) { + try { + setErrorMessage(null); + setActionMessage(null); + setSavingConfigSection("effect"); + const updated = await api.setVisualizerEffect(nextEffect); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage(`Effect set to ${nextEffect}`); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to update effect"); + } finally { + setSavingConfigSection(null); + } + } + + async function applyPalette(nextPalette: string) { + try { + setErrorMessage(null); + setActionMessage(null); + setSavingConfigSection("palette"); + const updated = await api.setVisualizerPalette(nextPalette); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage(`Palette set to ${nextPalette}`); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to update palette"); + } finally { + setSavingConfigSection(null); + } + } + + async function applySort(nextSort: VisualizerSortUpdateRequest) { + try { + setErrorMessage(null); + setActionMessage(null); + setSavingConfigSection("sort"); + const updated = await api.setVisualizerSort(nextSort); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage( + `Sort updated (${nextSort.primary_axis}, ${nextSort.sort_primary}/${nextSort.sort_secondary})`, + ); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to update sort"); + } finally { + setSavingConfigSection(null); + } + } + + async function applySettingsPatch( + payload: VisualizerSettingsUpdateRequest, + successMessage: string, + ) { + try { + setErrorMessage(null); + setActionMessage(null); + setSavingConfigSection("settings"); + const updated = await api.setVisualizerSettings(payload); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage(successMessage); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to update visualizer settings", + ); + } finally { + setSavingConfigSection(null); + } + } + + function handleEffectChange(nextEffect: EffectOption) { + setEffectDraft(nextEffect); + if (nextEffect === effectDraft) { + return; + } + void applyEffect(nextEffect); + } + + function handlePaletteChange(nextPalette: string) { + setPaletteDraft(nextPalette); + if (!nextPalette) { + setErrorMessage("Current colors are custom. Select a named palette to apply."); + return; + } + if (nextPalette === paletteDraft) { + return; + } + void applyPalette(nextPalette); + } + + function handleSortAxisChange(primary_axis: "X" | "Y") { + setSortDraft((prev) => { + if (prev.primary_axis === primary_axis) { + return prev; + } + const next = { ...prev, primary_axis }; + void applySort(next); + return next; + }); + } + + function handleSortPrimaryChange(sort_primary: "Asc" | "Desc") { + setSortDraft((prev) => { + if (prev.sort_primary === sort_primary) { + return prev; + } + const next = { ...prev, sort_primary }; + void applySort(next); + return next; + }); + } + + function handleSortSecondaryChange(sort_secondary: "Asc" | "Desc") { + setSortDraft((prev) => { + if (prev.sort_secondary === sort_secondary) { + return prev; + } + const next = { ...prev, sort_secondary }; + void applySort(next); + return next; + }); + } + + function handleAudioBackendChange(nextBackend: string) { + const normalizedBackend = nextBackend.trim() || "default"; + setSettingsDraft((prev) => ({ + ...prev, + audio_backend: normalizedBackend, + })); + if ((visualizerConfig?.audio_backend ?? "default") === normalizedBackend) { + return; + } + void applySettingsPatch( + { audio_backend: normalizedBackend }, + `Audio backend set to ${normalizedBackend}.`, + ); + } + + function handleFreqRangeBlur() { + const freqMin = parseInteger(settingsDraft.freq_min); + const freqMax = parseInteger(settingsDraft.freq_max); + if (freqMin === null || freqMax === null) { + setErrorMessage("Frequency range must use integer values."); + return; + } + if (freqMin < 0 || freqMax < 0 || freqMin > 65535 || freqMax > 65535 || freqMin >= freqMax) { + setErrorMessage("Frequency range must be 0-65535 with min < max."); + return; + } + const currentMin = visualizerConfig?.freq_range?.[0] ?? 20; + const currentMax = visualizerConfig?.freq_range?.[1] ?? 4500; + if (currentMin === freqMin && currentMax === freqMax) { + return; + } + void applySettingsPatch( + { freq_range: [freqMin, freqMax] }, + `Frequency range set to ${freqMin}-${freqMax} Hz.`, + ); + } + + function handleDefaultGainBlur() { + const defaultGain = parseNonNegativeFloat(settingsDraft.default_gain); + if (defaultGain === null) { + setErrorMessage("Default gain must be a finite number >= 0."); + return; + } + const currentGain = visualizerConfig?.default_gain ?? 1; + if (Math.abs(currentGain - defaultGain) < 1e-6) { + return; + } + void applySettingsPatch({ default_gain: defaultGain }, `Default gain set to ${defaultGain}.`); + } + + function handleTransitionBlur() { + const transitionSeconds = parsePositiveFloat(settingsDraft.transition_time); + if (transitionSeconds === null || transitionSeconds < 0.1 || transitionSeconds > 1.0) { + setErrorMessage("Transition time must be between 0.1s and 1.0s."); + return; + } + const transitionTenths = transitionSeconds * 10; + if (Math.abs(transitionTenths - Math.round(transitionTenths)) > 1e-6) { + setErrorMessage("Transition time must use 0.1 second steps."); + return; + } + const transitionTime = Math.round(transitionTenths); + const currentTransition = visualizerConfig?.transition_time ?? 2; + if (currentTransition === transitionTime) { + return; + } + void applySettingsPatch( + { transition_time: transitionTime }, + `Transition time set to ${formatTenths(transitionSeconds)}s.`, + ); + } + + function handleTimeWindowBlur() { + const timeWindow = parsePositiveFloat(settingsDraft.time_window); + if (timeWindow === null || timeWindow < 0.1 || timeWindow > 1.0) { + setErrorMessage("Time window must be between 0.1s and 1.0s."); + return; + } + const timeWindowTenths = timeWindow * 10; + if (Math.abs(timeWindowTenths - Math.round(timeWindowTenths)) > 1e-6) { + setErrorMessage("Time window must use 0.1 second steps."); + return; + } + const normalizedTimeWindow = Math.round(timeWindowTenths) / 10; + const currentTimeWindow = visualizerConfig?.time_window ?? 0.1875; + if (Math.abs(currentTimeWindow - normalizedTimeWindow) < 1e-6) { + return; + } + void applySettingsPatch( + { time_window: normalizedTimeWindow }, + `Time window set to ${formatTenths(normalizedTimeWindow)}s.`, + ); + } + + async function handleSaveRuntimeConfig() { + try { + setErrorMessage(null); + setActionMessage(null); + setSavingConfigSection("persist"); + const updated = await api.saveConfig(); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage("Runtime config saved to config.toml."); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to save runtime config", + ); + } finally { + setSavingConfigSection(null); + } + } + + async function handleColorSourceChange(kind: "palette" | "artwork") { + try { + setErrorMessage(null); + setActionMessage(null); + const updated = await api.setVisualizerColorSource({ kind }); + setConfig(updated); + hydrateVisualizerDrafts(updated, palettes?.palettes ?? [], audioBackends); + setActionMessage( + kind === "artwork" + ? "Color source: album artwork." + : "Color source: named palette.", + ); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to update color source", + ); + } + } + + function getBrightnessDraft(name: string): string { + return brightnessDraftByDevice[name] ?? DEFAULT_BRIGHTNESS_DRAFT; + } + + function handleBrightnessSliderChange(name: string, value: string) { + setBrightnessDraftByDevice((prev) => ({ ...prev, [name]: value })); + const parsed = parseBrightness(value); + if (parsed !== null) { + scheduleBrightnessUpdate(name, parsed); + } + } + + function handleBrightnessInputChange(name: string, value: string) { + if (!isValidBrightnessInput(value)) { + return; + } + setBrightnessDraftByDevice((prev) => ({ ...prev, [name]: value })); + } + + function handleBrightnessInputBlur(name: string) { + const parsed = parseBrightness(getBrightnessDraft(name)); + if (parsed === null) { + setErrorMessage("Brightness must be an integer between 0 and 100."); + return; + } + scheduleBrightnessUpdate(name, parsed); + } + + function scheduleBrightnessUpdate(name: string, brightness: number) { + const timers = brightnessCommitTimersRef.current; + const existingTimer = timers[name]; + if (existingTimer !== undefined) { + window.clearTimeout(existingTimer); + } + timers[name] = window.setTimeout(() => { + delete timers[name]; + void commitBrightnessUpdate(name, brightness); + }, 120); + } + + async function commitBrightnessUpdate(name: string, brightness: number) { + const previouslyApplied = lastAppliedBrightnessRef.current[name]; + if (previouslyApplied === brightness) { + return; + } + + try { + setErrorMessage(null); + await api.setDeviceState(name, { brightness }); + lastAppliedBrightnessRef.current[name] = brightness; + setActionMessage(`Brightness ${brightness}% applied on ${name}`); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : "Failed to update device brightness", + ); + } + } + + // Persisted color source from config — drives both the radio control and + // the various "is artwork mode active" badges in the now-playing card. + const colorSource = config?.config?.visualizer_config.color_source ?? "palette"; + const isArtworkSource = colorSource === "artwork"; + + return ( +
+
+
+

+ Nanoleaf Audio Web Controller +

+

+ Nanoleaf Dashboard +

+
+
+ + {loadState === "ready" ? "API Connected" : "Connecting"} + + {health ? v{health.version} : null} +
+
+ + {errorMessage ? ( + + + {errorMessage} + + + ) : null} + + {actionMessage ? ( + + {actionMessage} + + ) : null} + +
+ + +
+
+

+ Now Playing +

+ + {nowPlaying?.reader_running ? "Reader Running" : "Reader Waiting"} + + {health?.airplay_name ? ( + + AirPlay: {health.airplay_name} + + ) : null} + {isArtworkSource && + nowPlaying?.palette_colors.length ? ( + + Visualizer palette: artwork + + ) : null} +
+
+ Color source + + +
+
+ + {nowPlaying?.last_error ? ( +

+ {nowPlaying.last_error} +

+ ) : null} + +
+
+ {nowPlaying?.artwork_available ? ( + Album artwork + ) : ( +
+ No artwork available yet +
+ )} +
+
+

+ {nowPlaying?.track?.title ?? "No active track"} +

+ {nowPlaying?.track?.artist ? ( +

+ {nowPlaying.track.artist} +

+ ) : null} + {nowPlaying?.track?.album ? ( +

+ {nowPlaying.track.album} +

+ ) : null} + {nowPlaying?.track?.genre || nowPlaying?.track?.composer ? ( +

+ {[nowPlaying.track.genre, nowPlaying.track.composer] + .filter(Boolean) + .join(" · ")} +

+ ) : null} + {nowPlaying?.track?.source_name || nowPlaying?.track?.source_ip ? ( +

+ via {nowPlaying.track.source_name ?? nowPlaying.track.source_ip} +

+ ) : null} +
+ + {nowPlaying?.playback_state && nowPlaying.playback_state !== "stopped" ? ( +
+
+ + + {nowPlaying.playback_state} + + {nowPlaying.volume_db != null ? ( + + {nowPlaying.volume_db <= -144 + ? "Muted" + : `${Math.round(nowPlaying.volume_db)} dB`} + + ) : null} +
+ {nowPlaying.progress_total_secs != null && + nowPlaying.progress_total_secs > 0 ? ( +
+ {formatSeconds(nowPlaying.progress_elapsed_secs ?? 0)} +
+
+
+ {formatSeconds(nowPlaying.progress_total_secs)} +
+ ) : null} +
+ ) : null} + + {nowPlaying?.palette_colors.length ? ( +
+

+ {isArtworkSource + ? "Active artwork palette" + : "Artwork palette (inactive)"} +

+
+ {nowPlaying.palette_colors.map(([r, g, b], idx) => ( + + ))} +
+
+ ) : null} +
+ + +
+ +
+ + + Runtime + + Backend health, file paths, and explicit runtime config persistence + + + + + {health?.status ?? (loadState === "loading" ? "Loading..." : "-")} + + + + {visualizerStatus?.status ?? (loadState === "loading" ? "Loading..." : "Unknown")} + + + + {config?.paths.config_file_path ?? "-"} + + + {config?.paths.devices_file_path ?? "-"} + + + {config?.config?.default_nl_device_name ?? "Not configured"} + +
+

+ Runtime Config +

+

+ Live changes are in-memory until you save them. +

+ +
+
+
+ + + + Visualizer Settings + + Loaded from live runtime state. Update effect, palette, sort, and core visualizer + settings without immediate disk writes. + + + + {visualizerConfig ? ( +
+
+
+

+ Configured Palette +

+ {isArtworkSource && (nowPlaying?.palette_colors.length ?? 0) > 0 ? ( + + Overridden by artwork palette + + ) : null} +
+ {(() => { + const configured = paletteEntryFor( + visualizerConfig.palette_name, + palettes?.palettes ?? [], + ); + if (!configured?.colors.length) { + return ( +

+ {visualizerConfig.palette_name + ? `Palette "${visualizerConfig.palette_name}" not found on the device.` + : "Using device's currently-selected effect."} +

+ ); + } + const dimmed = + isArtworkSource && (nowPlaying?.palette_colors.length ?? 0) > 0; + return ( +
+

{configured.name}

+
+ {configured.colors.map(([r, g, b], idx) => ( + + ))} +
+
+ ); + })()} +
+ +
+

+ Effect +

+
+ +

Applies immediately on change.

+
+
+ +
+

+ Palette +

+
+ + {(() => { + const selected = paletteEntryFor( + paletteDraft, + palettes?.palettes ?? [], + ); + if (!selected?.colors.length) { + return null; + } + return ( +
+ {selected.colors.map(([r, g, b], idx) => ( + + ))} +
+ ); + })()} +

Applies immediately on change.

+
+
+ +
+

+ Sort +

+
+ + + +
+

+ Applies immediately when a value changes. +

+
+ +
+

+ Core Visualizer Settings +

+
+ + + + + + + + + + + +
+

+ Applies on focus loss for numeric fields and immediately for backend selection. +

+
+
+ ) : ( +

+ Visualizer config not found in your config file. +

+ )} +
+
+ + + + Devices + + Known Nanoleaf devices loaded from your devices TOML + + + +
+

+ Pair new devices via SSDP discovery or by IP. +

+ +
+ + {showPairPanel && ( +
+
+ + +
+ + {pairManualMode ? ( + setPairManualIp(event.currentTarget.value)} + placeholder="192.168.1.42" + disabled={pairState === "pairing"} + className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm" + aria-label="Device IP address" + /> + ) : discoveredDevices && discoveredDevices.length > 0 ? ( +
    + {discoveredDevices.map((d) => ( +
  • + +
  • + ))} +
+ ) : null} + +

+ Hold the Nanoleaf power button until the control lights start + flashing, then press Pair. +

+ +
+ + {pairMessage && ( +

{pairMessage}

+ )} +
+
+ )} + + {devices?.devices.length ? ( + devices.devices.map((device) => { + const isUpdating = updatingDeviceName === device.name; + const isLoadingDetails = loadingDeviceName === device.name; + const isBusy = isUpdating || isLoadingDetails; + const brightnessDraft = getBrightnessDraft(device.name); + return ( +
+
+
+

{device.name}

+

{device.ip}

+
+ +
+ +
+ + +
+ +
+

+ Brightness +

+
+ + handleBrightnessSliderChange(device.name, event.currentTarget.value) + } + disabled={isBusy} + className="w-full accent-[hsl(var(--primary))]" + /> + + handleBrightnessInputChange(device.name, event.currentTarget.value) + } + onBlur={() => handleBrightnessInputBlur(device.name)} + disabled={isBusy} + className="h-10 w-20 rounded-md border border-input bg-background px-3 text-sm" + aria-label={`Brightness value for ${device.name}`} + /> +
+

+ Slider applies while dragging. Typed values apply on focus loss. +

+
+
+ ); + }) + ) : ( +

+ {loadState === "loading" + ? "Loading devices..." + : "No known devices yet. Use \"Pair new device\" above."} +

+ )} +
+
+ + + + All Palettes + + {palettes?.palettes.length ?? 0} palettes from /api/palettes + + + + {palettes?.palettes.length ? ( + palettes.palettes.map((palette) => ( + + )) + ) : ( +

+ {loadState === "loading" ? "Loading palettes..." : "No palettes found"} +

+ )} +
+
+
+ +
+ + +
+
+ Panel Layout + + {selectedDeviceLayout + ? `${selectedDeviceLayout.device.name} (${selectedDeviceLayout.device.ip}) • Global orientation ${selectedDeviceLayout.global_orientation}° • ${selectedDeviceLayout.panels.length} panels` + : "Default device layout is loading or unavailable."} + +
+ +
+
+ + {selectedDeviceLayout ? ( + + ) : ( +

+ No layout loaded yet. Use Load Details on a device to load manually. +

+ )} +
+
+
+ + {selectedDeviceInfo ? ( +
+ + + Selected Device Info + + {selectedDeviceInfo.device.name} ({selectedDeviceInfo.device.ip}) + + + +
+                {JSON.stringify(selectedDeviceInfo.info, null, 2)}
+              
+
+
+
+ ) : null} +
+ ); +} + +function DataRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+
+ {label} + {children} +
+ +
+ ); +} + +function SettingCell({ label, value }: { label: string; value: ReactNode }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function PaletteCard({ palette }: { palette: PaletteEntry }) { + return ( +
+

{palette.name}

+
+ {palette.colors.map(([r, g, b], idx) => ( + + ))} +
+
+ ); +} + +function DeviceLayoutViewer({ + layout, + livePreviewEnabled, + livePreviewColorsByPanel, +}: { + layout: DeviceLayoutResponse; + livePreviewEnabled: boolean; + livePreviewColorsByPanel: Record; +}) { + const width = 1400; + const height = 780; + const padding = 56; + + if (!layout.panels.length) { + return

No panel layout data found.

; + } + + const minX = Math.min(...layout.panels.map((panel) => panel.x)); + const maxX = Math.max(...layout.panels.map((panel) => panel.x)); + const minY = Math.min(...layout.panels.map((panel) => panel.y)); + const maxY = Math.max(...layout.panels.map((panel) => panel.y)); + + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const angle = (-layout.global_orientation * Math.PI) / 180; + + const rotated = layout.panels.map((panel) => { + const relX = panel.x - centerX; + const relY = panel.y - centerY; + const rx = relX * Math.cos(angle) - relY * Math.sin(angle); + const ry = relX * Math.sin(angle) + relY * Math.cos(angle); + return { + panel, + rx, + ry, + radius: panelBaseRadius(panel), + }; + }); + + const minRx = Math.min(...rotated.map((item) => item.rx - item.radius)); + const maxRx = Math.max(...rotated.map((item) => item.rx + item.radius)); + const minRy = Math.min(...rotated.map((item) => item.ry - item.radius)); + const maxRy = Math.max(...rotated.map((item) => item.ry + item.radius)); + + const spanX = Math.max(maxRx - minRx, 1); + const spanY = Math.max(maxRy - minRy, 1); + const scale = Math.min((width - 2 * padding) / spanX, (height - 2 * padding) / spanY); + + const offsetX = (width - spanX * scale) / 2 - minRx * scale; + const offsetY = (height - spanY * scale) / 2 - minRy * scale; + + const renderPanels = rotated.map(({ panel, rx, ry, radius }) => { + const sx = rx * scale + offsetX; + const sy = height - (ry * scale + offsetY); + return { + ...panel, + // Match the cluster rotation so polygons align with their (rotated) + // centers — otherwise hexagons share centers but not edges, producing + // a visible per-panel tilt of (globalOrientation mod 60)°. + // + // Sign note: the center rotation uses `angle = -globalOrientation` and + // its result is then Y-flipped at sy = height - (ry*scale + offsetY), + // which converts the math-CCW rotation into a screen-CW one. The + // polygon renderer (sx + r*cos, sy + r*sin) ALSO walks CW in screen + // coords because of the Y-flip. So to rotate the polygon the same + // visual direction as the centers, we ADD globalOrientation (not + // subtract it). + orientation: panel.orientation + layout.global_orientation, + sx, + sy, + scaledRadius: radius * scale, + }; + }); + const lightPanels = renderPanels.filter((panel) => panel.side_length >= 1); + const controllerPanels = renderPanels.filter((panel) => panel.side_length < 1); + + return ( +
+ + + + + + + {lightPanels.map((panel) => ( + + + + Panel {panel.panel_id} • {panel.shape_type_name} + + + ))} + {controllerPanels.map((panel) => ( + + + Controller + + ))} + +

+ Rendering {layout.panels.length} panels from Nanoleaf layout data. Controller + panels are shown as trapezoids. Live animation preview is{" "} + {livePreviewEnabled ? "enabled" : "disabled"}. +

+
+ ); +} + +function panelBaseRadius(panel: DeviceLayoutPanel): number { + if (panel.side_length < 1) { + return 14; + } + if (panel.num_sides === 3) { + return panel.side_length / Math.sqrt(3); + } + if (panel.num_sides === 4) { + return panel.side_length / Math.sqrt(2); + } + if (panel.num_sides === 6) { + return panel.side_length; + } + return Math.max(panel.side_length * 0.65, 20); +} + +const PANEL_OFF_FILL = "hsl(0 0% 96% / 0.92)"; +const PANEL_LIVE_DARK_THRESHOLD = 18; + +function panelFillColor( + panel: DeviceLayoutPanel, + liveRgb?: [number, number, number], +): string { + if (panel.side_length < 1) { + return "hsl(var(--accent) / 0.95)"; + } + if (liveRgb) { + const [r, g, b] = liveRgb; + if (Math.max(r, g, b) < PANEL_LIVE_DARK_THRESHOLD) { + return PANEL_OFF_FILL; + } + return `rgb(${r}, ${g}, ${b})`; + } + return PANEL_OFF_FILL; +} + +function buildPanelPolygonPoints(panel: { + sx: number; + sy: number; + scaledRadius: number; + orientation: number; + num_sides: number; +}) { + const sides = Math.max(3, Math.round(panel.num_sides || 4)); + const orientationRadians = (panel.orientation * Math.PI) / 180; + const points: string[] = []; + + for (let index = 0; index < sides; index += 1) { + const theta = orientationRadians + (2 * Math.PI * index) / sides; + points.push( + `${panel.sx + panel.scaledRadius * Math.cos(theta)},${panel.sy + panel.scaledRadius * Math.sin(theta)}`, + ); + } + + return points.join(" "); +} + +function buildControllerTrapezoidPoints( + controller: { + sx: number; + sy: number; + scaledRadius: number; + }, + lightPanels: Array<{ + sx: number; + sy: number; + scaledRadius: number; + orientation: number; + num_sides: number; + }>, +) { + if (!lightPanels.length) { + return buildPanelPolygonPoints({ + ...controller, + orientation: 0, + num_sides: 4, + }); + } + + const nearestPanel = lightPanels.reduce((best, panel) => { + const bestDistance = Math.hypot(best.sx - controller.sx, best.sy - controller.sy); + const panelDistance = Math.hypot(panel.sx - controller.sx, panel.sy - controller.sy); + return panelDistance < bestDistance ? panel : best; + }, lightPanels[0]); + + const numSides = Math.max(3, Math.round(nearestPanel.num_sides || 4)); + const parentRadius = nearestPanel.scaledRadius; + const parentOrientation = (nearestPanel.orientation * Math.PI) / 180; + const angleToController = Math.atan2( + controller.sy - nearestPanel.sy, + controller.sx - nearestPanel.sx, + ); + const anglePerSide = (2 * Math.PI) / numSides; + + let closestEdge = 0; + let minAngleDiff = Number.POSITIVE_INFINITY; + for (let index = 0; index < numSides; index += 1) { + const vertexAngle = parentOrientation + index * anglePerSide; + const rawDiff = Math.abs(angleToController - vertexAngle) % (2 * Math.PI); + const angleDiff = Math.min(rawDiff, 2 * Math.PI - rawDiff); + if (angleDiff < minAngleDiff) { + minAngleDiff = angleDiff; + closestEdge = index; + } + } + + const v1Angle = parentOrientation + closestEdge * anglePerSide; + const v2Angle = parentOrientation + (closestEdge + 1) * anglePerSide; + + const v1x = nearestPanel.sx + parentRadius * Math.cos(v1Angle); + const v1y = nearestPanel.sy + parentRadius * Math.sin(v1Angle); + const v2x = nearestPanel.sx + parentRadius * Math.cos(v2Angle); + const v2y = nearestPanel.sy + parentRadius * Math.sin(v2Angle); + + const edgeMidX = (v1x + v2x) / 2; + const edgeMidY = (v1y + v2y) / 2; + const perpDx = edgeMidX - nearestPanel.sx; + const perpDy = edgeMidY - nearestPanel.sy; + const perpLen = Math.hypot(perpDx, perpDy); + const perpNormX = perpLen < 1 ? 0 : perpDx / perpLen; + const perpNormY = perpLen < 1 ? -1 : perpDy / perpLen; + + const trapezoidHeight = Math.max(16, Math.min(28, parentRadius * 0.32)); + const narrowRatio = 0.6; + + const p1 = `${v1x},${v1y}`; + const p2 = `${v2x},${v2y}`; + const p3 = `${v2x + perpNormX * trapezoidHeight - (v2x - edgeMidX) * (1 - narrowRatio)},${v2y + perpNormY * trapezoidHeight - (v2y - edgeMidY) * (1 - narrowRatio)}`; + const p4 = `${v1x + perpNormX * trapezoidHeight - (v1x - edgeMidX) * (1 - narrowRatio)},${v1y + perpNormY * trapezoidHeight - (v1y - edgeMidY) * (1 - narrowRatio)}`; + + return `${p1} ${p2} ${p3} ${p4}`; +} + +export default App; diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..656c3a0 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,332 @@ +export type HealthResponse = { + status: string; + version: string; + /** AirPlay receiver name (from NANOVIZ_AIRPLAY_NAME env var). Null when unset. */ + airplay_name: string | null; +}; + +export type PathsResponse = { + config_file_path: string; + config_file_exists: boolean; + devices_file_path: string; + devices_file_exists: boolean; +}; + +export type ColorSourceKind = "palette" | "artwork"; + +export type VisualizerConfig = { + audio_backend: string | null; + freq_range: [number, number] | null; + /** "palette" = use a named Nanoleaf-side palette; "artwork" = drive from album cover */ + color_source: ColorSourceKind | null; + /** Name of the Nanoleaf effect whose palette to use. Only meaningful when color_source = "palette". */ + palette_name: string | null; + default_gain: number | null; + transition_time: number | null; + time_window: number | null; + primary_axis: string | null; + sort_primary: string | null; + sort_secondary: string | null; + effect: string | null; +}; + +export type VisualizerColorSourceUpdateRequest = { + kind: ColorSourceKind; + /** Optional. Only honored when kind = "palette". */ + palette_name?: string; +}; + +export type ConfigPayload = { + default_nl_device_name: string | null; + visualizer_config: VisualizerConfig; +}; + +export type ConfigResponse = { + paths: PathsResponse; + config: ConfigPayload | null; +}; + +export type VisualizerSortUpdateRequest = { + primary_axis: "X" | "Y"; + sort_primary: "Asc" | "Desc"; + sort_secondary: "Asc" | "Desc"; +}; + +export type VisualizerSettingsUpdateRequest = { + audio_backend?: string; + freq_range?: [number, number]; + default_gain?: number; + transition_time?: number; + time_window?: number; +}; + +export type NowPlayingTrack = { + title: string | null; + artist: string | null; + album: string | null; + genre: string | null; + composer: string | null; + stream_url: string | null; + source_name: string | null; + source_ip: string | null; + user_agent: string | null; + duration_ms: number | null; + song_data_kind: number | null; +}; + +export type NowPlayingResponse = { + reader_running: boolean; + metadata_pipe_path: string; + last_error: string | null; + track: NowPlayingTrack | null; + palette_colors: Array<[number, number, number]>; + artwork_available: boolean; + artwork_generation: number; + updated_at_ms: number | null; + playback_state: "stopped" | "playing" | "paused"; + progress_elapsed_secs: number | null; + progress_total_secs: number | null; + volume_db: number | null; +}; + +export type DeviceSummary = { + name: string; + ip: string; +}; + +export type DevicesResponse = { + devices: DeviceSummary[]; + devices_file_path: string; + devices_file_exists: boolean; +}; + +export type DiscoverResponse = { + devices: DeviceSummary[]; +}; + +export type PairResponse = { + device: DeviceSummary; +}; + +export class PairNotInPairingModeError extends Error { + constructor() { + super("device_not_in_pairing_mode"); + this.name = "PairNotInPairingModeError"; + } +} + +export type DeviceInfoResponse = { + device: DeviceSummary; + info: Record; +}; + +export type DeviceLayoutPanel = { + panel_id: number; + x: number; + y: number; + orientation: number; + shape_type_id: number; + shape_type_name: string; + num_sides: number; + side_length: number; +}; + +export type DeviceLayoutResponse = { + device: DeviceSummary; + global_orientation: number; + panels: DeviceLayoutPanel[]; +}; + +export type DeviceStateUpdateRequest = { + power_on?: boolean; + brightness?: number; +}; + +export type DeviceStateUpdateResponse = { + device: DeviceSummary; + power_on: boolean | null; + brightness: number | null; +}; + +export type PaletteEntry = { + name: string; + colors: Array<[number, number, number]>; +}; + +export type PalettesResponse = { + palettes: PaletteEntry[]; +}; + +export type AudioBackendsResponse = { + current_audio_backend: string | null; + available_audio_backends: string[]; +}; + +export type VisualizerPreviewPanelColor = { + panel_id: number; + rgb: [number, number, number]; +}; + +export type VisualizerPreviewResponse = { + enabled: boolean; + device: DeviceSummary | null; + panel_colors: VisualizerPreviewPanelColor[]; +}; + +export type VisualizerStatusResponse = { + status: string; + stream_health: string; + live_visualizer_attached: boolean; + restart_cooldown_active: boolean; + consecutive_restart_failures: number; + healthy_ping_streak: number; + auto_fallback_to_default_active: boolean; + current_audio_backend: string | null; + device: DeviceSummary | null; +}; + +function inferApiBaseUrl(): string { + if (typeof window === "undefined") { + return ""; + } + const { protocol, hostname, port } = window.location; + // In local Vite dev/preview, default API to the Rust backend on 8787. + if (port === "5173" || port === "4173") { + return `${protocol}//${hostname}:8787`; + } + return ""; +} + +const API_BASE_URL = + (import.meta.env.VITE_API_BASE_URL as string | undefined) + ?.trim() + .replace(/\/+$/, "") || inferApiBaseUrl(); + +function apiPath(path: string): string { + if (!API_BASE_URL) { + return path; + } + return `${API_BASE_URL}${path}`; +} + +async function apiGet(path: string): Promise { + const response = await fetch(apiPath(path)); + return parseResponse(response); +} + +async function apiSend(path: string, init: RequestInit): Promise { + const response = await fetch(apiPath(path), init); + return parseResponse(response); +} + +async function parseResponse(response: Response): Promise { + if (!response.ok) { + let errorMessage = `${response.status} ${response.statusText}`; + try { + const parsed = (await response.json()) as { error?: string }; + if (parsed.error) { + errorMessage = parsed.error; + } + } catch { + // Keep fallback status text if body is not JSON. + } + throw new Error(errorMessage); + } + + return (await response.json()) as T; +} + +export const api = { + health: () => apiGet("/api/health"), + config: () => apiGet("/api/config"), + saveConfig: () => + apiSend("/api/config/save", { + method: "POST", + }), + setVisualizerEffect: (effect: string) => + apiSend("/api/config/visualizer/effect", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ effect }), + }), + setVisualizerPalette: (palette_name: string) => + apiSend("/api/config/visualizer/palette", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ palette_name }), + }), + setVisualizerColorSource: (payload: VisualizerColorSourceUpdateRequest) => + apiSend("/api/config/visualizer/color-source", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }), + setVisualizerSort: (payload: VisualizerSortUpdateRequest) => + apiSend("/api/config/visualizer/sort", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }), + setVisualizerSettings: (payload: VisualizerSettingsUpdateRequest) => + apiSend("/api/config/visualizer/settings", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }), + nowPlaying: () => apiGet("/api/now-playing"), + visualizerPreview: () => + apiGet("/api/visualizer/preview"), + visualizerStatus: () => + apiGet("/api/visualizer/status"), + audioBackends: () => apiGet("/api/audio/backends"), + devices: () => apiGet("/api/devices"), + discoverDevices: () => + apiSend("/api/devices/discover", { + method: "POST", + }), + pairDevice: async (ip: string): Promise => { + const response = await fetch(apiPath("/api/devices/pair"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ip }), + }); + if (response.status === 409) { + throw new PairNotInPairingModeError(); + } + return parseResponse(response); + }, + deviceInfo: (name: string) => + apiGet(`/api/devices/${encodeURIComponent(name)}/info`), + deviceLayout: (name: string) => + apiGet(`/api/devices/${encodeURIComponent(name)}/layout`), + setDeviceState: (name: string, payload: DeviceStateUpdateRequest) => + apiSend( + `/api/devices/${encodeURIComponent(name)}/state`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }, + ), + palettes: () => apiGet("/api/palettes"), +}; + +export const apiAssetUrl = apiPath; + +export function apiWsUrl(path: string): string { + const base = API_BASE_URL || window.location.origin; + const wsBase = base.replace(/^http/, "ws"); + return `${wsBase}${path}`; +} diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..8cc890b --- /dev/null +++ b/web/src/components/ui/badge.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium tracking-wide", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground", + secondary: "border-transparent bg-secondary text-secondary-foreground", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +export function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx new file mode 100644 index 0000000..47b2d32 --- /dev/null +++ b/web/src/components/ui/button.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/85", + ghost: "hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx new file mode 100644 index 0000000..805df6c --- /dev/null +++ b/web/src/components/ui/card.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +export { Card, CardHeader, CardTitle, CardDescription, CardContent }; diff --git a/web/src/components/ui/separator.tsx b/web/src/components/ui/separator.tsx new file mode 100644 index 0000000..5be6558 --- /dev/null +++ b/web/src/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx new file mode 100644 index 0000000..91c9fa4 --- /dev/null +++ b/web/src/components/ui/switch.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +type SwitchProps = Omit< + React.InputHTMLAttributes, + "type" +>; + +const Switch = React.forwardRef( + ({ className, disabled, ...props }, ref) => ( + + +