diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2bbf9d1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.terminal.autoApprove": { + "npm install": true + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d4a24..0593b3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.17.0] - 2026-05-13 + +### Added +- Music Assistant player visibility: Sendspin player is now automatically unhidden in Music Assistant after connecting, so it is visible in the UI. +- Moss Ball visualizer. +- Razor 1911 visualizer. + +### Changed +- Updated Sendspin correction mode from `sync` to `quality-local`. +- Dependency bumps + ## [0.16.0] - 2026-05-05 ### Added diff --git a/README.md b/README.md index d4f905f..3c1f699 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## 🎨 Features -![VoltViz](https://img.shields.io/badge/React-19.2-blue?style=flat-square) ![VoltViz](https://img.shields.io/badge/Three.js-0.184-green?style=flat-square) ![VoltViz](https://img.shields.io/badge/Vite-8.0-purple?style=flat-square) ![VoltViz](https://img.shields.io/badge/License-MIT-orange?style=flat-square) +![VoltViz](https://img.shields.io/badge/React-19.2.6-blue?style=flat-square) ![VoltViz](https://img.shields.io/badge/Three.js-0.184-green?style=flat-square) ![VoltViz](https://img.shields.io/badge/Vite-8.0.12-purple?style=flat-square) ![VoltViz](https://img.shields.io/badge/License-MIT-orange?style=flat-square) [![Voltviz](images/voltviz.png)](https://voltviz.com) --- @@ -86,14 +86,14 @@ http://localhost:8080 ## 🛠 Technology Stack **Frontend:** -- **React** 19.2 - UI framework +- **React** 19.2.6 - UI framework - **TypeScript** 6.0 - Type-safe development -- **Vite** 8.0 - Next-gen build tool -- **Three.js** 0.183 - 3D graphics -- **D3.js** 3.1 - Data visualization -- **Tailwind CSS** 4.2 - Utility-first styling -- **Lucide React** 1.8 - Icon library -- **[@sendspin/sendspin-js](https://www.sendspin-audio.com)** 3.0 - Synchronized audio streaming client +- **Vite** 8.0.12 - Next-gen build tool +- **Three.js** 0.184 - 3D graphics +- **D3.js** 3.1.1 - Data visualization +- **Tailwind CSS** 4.3 - Utility-first styling +- **Lucide React** 1.9 - Icon library +- **[@sendspin/sendspin-js](https://www.sendspin-audio.com)** 3.1 - Synchronized audio streaming client **Infrastructure:** - **Docker** - Containerization @@ -107,7 +107,7 @@ http://localhost:8080 ``` src/ ├── components/ -│ └── visualizers/ # 30+ visualization components +│ └── visualizers/ # 40+ visualization components ├── data/ # Static data (geographic, etc.) ├── images/ # Asset images ├── App.tsx # Main app component diff --git a/package-lock.json b/package-lock.json index a665abd..4bf3b7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,32 +1,32 @@ { "name": "voltviz", - "version": "0.16.0", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "voltviz", - "version": "0.16.0", + "version": "0.17.0", "dependencies": { "@sendspin/sendspin-js": "^3.1.0", "d3-geo": "^3.1.1", "lucide-react": "^1.9.0", - "react": "^19.2.5", - "react-dom": "^19.2.5", + "react": "^19.2.6", + "react-dom": "^19.2.6", "three": "^0.184.0" }, "devDependencies": { - "@playwright/test": "^1.59.1", - "@tailwindcss/vite": "^4.2.4", + "@playwright/test": "^1.60.0", + "@tailwindcss/vite": "^4.3.0", "@types/d3-geo": "^3.1.0", - "@types/node": "^25.6.0", + "@types/node": "^25.7.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/three": "^0.184.0", + "@types/three": "^0.184.1", "@vitejs/plugin-react": "^6.0.1", - "tailwindcss": "^4.2.4", + "tailwindcss": "^4.3.0", "typescript": "~6.0.3", - "vite": "^8.0.10" + "vite": "^8.0.12" } }, "node_modules/@dimforge/rapier3d-compat": { @@ -140,9 +140,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", + "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", "dev": true, "license": "MIT", "funding": { @@ -150,13 +150,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.1" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -166,9 +166,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", + "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", "cpu": [ "arm64" ], @@ -183,9 +183,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", "cpu": [ "arm64" ], @@ -200,9 +200,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", + "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", "cpu": [ "x64" ], @@ -217,9 +217,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", + "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", "cpu": [ "x64" ], @@ -234,9 +234,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", + "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", "cpu": [ "arm" ], @@ -251,9 +251,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", + "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", "cpu": [ "arm64" ], @@ -271,9 +271,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", + "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", "cpu": [ "arm64" ], @@ -291,9 +291,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", + "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", "cpu": [ "ppc64" ], @@ -311,9 +311,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", + "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", "cpu": [ "s390x" ], @@ -331,9 +331,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", "cpu": [ "x64" ], @@ -351,9 +351,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", + "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", + "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", "cpu": [ "arm64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", + "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", "cpu": [ "wasm32" ], @@ -407,9 +407,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", + "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", "cpu": [ "arm64" ], @@ -424,9 +424,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", "cpu": [ "x64" ], @@ -460,49 +460,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + "@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" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -517,9 +517,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -534,9 +534,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -551,9 +551,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -568,9 +568,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -585,9 +585,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], @@ -605,9 +605,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], @@ -625,9 +625,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], @@ -645,9 +645,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], @@ -665,9 +665,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -683,10 +683,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -695,9 +695,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -712,9 +712,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -729,15 +729,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", - "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -751,9 +751,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -779,13 +779,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.21.0" } }, "node_modules/@types/react": { @@ -816,9 +816,9 @@ "license": "MIT" }, "node_modules/@types/three": { - "version": "0.184.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz", - "integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==", + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", "dev": true, "license": "MIT", "dependencies": { @@ -905,9 +905,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1127,9 +1127,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1151,9 +1148,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1175,9 +1169,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1199,9 +1190,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1284,9 +1272,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -1329,13 +1317,13 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -1348,9 +1336,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1361,9 +1349,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -1390,35 +1378,35 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.6" } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", + "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.129.0", + "@rolldown/pluginutils": "1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1427,27 +1415,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.0.0", + "@rolldown/binding-darwin-arm64": "1.0.0", + "@rolldown/binding-darwin-x64": "1.0.0", + "@rolldown/binding-freebsd-x64": "1.0.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", + "@rolldown/binding-linux-arm64-gnu": "1.0.0", + "@rolldown/binding-linux-arm64-musl": "1.0.0", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0", + "@rolldown/binding-linux-s390x-gnu": "1.0.0", + "@rolldown/binding-linux-x64-gnu": "1.0.0", + "@rolldown/binding-linux-x64-musl": "1.0.0", + "@rolldown/binding-openharmony-arm64": "1.0.0", + "@rolldown/binding-wasm32-wasi": "1.0.0", + "@rolldown/binding-win32-arm64-msvc": "1.0.0", + "@rolldown/binding-win32-x64-msvc": "1.0.0" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", + "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", "dev": true, "license": "MIT" }, @@ -1468,9 +1456,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "dev": true, "license": "MIT" }, @@ -1534,23 +1522,23 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", "dev": true, "license": "MIT" }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", + "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", + "postcss": "^8.5.14", + "rolldown": "1.0.0", "tinyglobby": "^0.2.16" }, "bin": { @@ -1567,7 +1555,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index ca44b68..de9331a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "voltviz", "private": true, - "version": "0.16.0", + "version": "0.17.0", "type": "module", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", @@ -14,21 +14,21 @@ "@sendspin/sendspin-js": "^3.1.0", "d3-geo": "^3.1.1", "lucide-react": "^1.9.0", - "react": "^19.2.5", - "react-dom": "^19.2.5", + "react": "^19.2.6", + "react-dom": "^19.2.6", "three": "^0.184.0" }, "devDependencies": { - "@playwright/test": "^1.59.1", - "@tailwindcss/vite": "^4.2.4", + "@playwright/test": "^1.60.0", + "@tailwindcss/vite": "^4.3.0", "@types/d3-geo": "^3.1.0", - "@types/node": "^25.6.0", + "@types/node": "^25.7.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/three": "^0.184.0", + "@types/three": "^0.184.1", "@vitejs/plugin-react": "^6.0.1", - "tailwindcss": "^4.2.4", + "tailwindcss": "^4.3.0", "typescript": "~6.0.3", - "vite": "^8.0.10" + "vite": "^8.0.12" } } diff --git a/src/App.tsx b/src/App.tsx index 692cd97..b4a1c1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,7 +46,9 @@ type VisualizerType = | 'milkdropwarp' | 'aurorawaves' | 'msdefrag' - | 'fractalorb'; + | 'fractalorb' + | 'mossball' + | 'razor1911'; type VisualizerProps = { stream: MediaStream; @@ -114,6 +116,8 @@ const visualizerComponents: Record import('./components/visualizers/AuroraWaves')), msdefrag: lazy(() => import('./components/visualizers/MsDefrag')), fractalorb: lazy(() => import('./components/visualizers/FractalOrb')), + mossball: lazy(() => import('./components/visualizers/MossBall')), + razor1911: lazy(() => import('./components/visualizers/Razor1911')), }; export default function App() { @@ -251,6 +255,35 @@ export default function App() { } }; + const unhidePlayerInMA = async (playerId: string) => { + try { + const configResp = await fetch(new URL('ma-config.json', window.location.href).href); + if (!configResp.ok) return; + const { ingress_entry } = await configResp.json(); + if (!ingress_entry) return; + + const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ingressPath = ingress_entry.endsWith('/') ? ingress_entry : ingress_entry + '/'; + const ws = new WebSocket(`${wsProto}//${window.location.host}${ingressPath}ws`); + let commandSent = false; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (!commandSent && msg.server_version) { + commandSent = true; + const msgId = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); + ws.send(JSON.stringify({ + message_id: msgId, + command: 'config/players/save', + args: { player_id: playerId, values: { hide_in_ui: false } } + })); + setTimeout(() => ws.close(), 2000); + } + }; + ws.onerror = () => ws.close(); + } catch { /* not running in HA add-on context */ } + }; + const startSendspin = async (url?: string) => { const serverUrl = url || sendspinUrl; try { @@ -270,7 +303,7 @@ export default function App() { baseUrl: serverUrl, audioElement: audioEl, clientName: 'VoltViz', - correctionMode: 'sync', + correctionMode: 'quality-local', onStateChange: (state) => { const patch: Partial = { playing: state.isPlaying }; if (state.serverState?.metadata) { @@ -301,6 +334,8 @@ export default function App() { // Kick-start playback on mobile where autoplay may be blocked audioEl.play().catch(() => {}); + unhidePlayerInMA(sendspinClientIdRef.current); + setError(null); updateSendspin({ active: true }); setShowSendspinDialog(false); @@ -410,6 +445,8 @@ export default function App() { + + diff --git a/src/components/visualizers/MossBall.tsx b/src/components/visualizers/MossBall.tsx new file mode 100644 index 0000000..b8544cd --- /dev/null +++ b/src/components/visualizers/MossBall.tsx @@ -0,0 +1,462 @@ +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +// Adapted from https://github.com/ledhieu/imoss (MIT) — shaders kept as-is, +// host code rewritten to plain Three.js with audio-reactive uniforms. + +const vertexShader = ` + uniform float uTime; + uniform float uWindSpeed; + uniform float uWindAmplitude; + uniform float uLengthMultiplier; + uniform float uClumpStrength; + uniform float uMotionClumpBoost; + + attribute vec3 aBasePos; + attribute vec3 aNormal; + attribute vec3 aTangent; + attribute vec3 aBitangent; + attribute vec2 aBendState; + attribute float aHeightScale; + attribute float aLengthScale; + + varying float vHeight; + varying vec3 vViewPos; + varying vec3 vNormal; + varying vec3 vWorldPos; + varying float vLengthScale; + + void main() { + vec3 pos = position; + float t = uv.y; + vHeight = t; + + float taper = 1.0 - t * 0.82; + pos.x *= taper; + pos.z *= taper; + + pos.y *= aHeightScale * aLengthScale * uLengthMultiplier; + + float strandThin = 0.5 - smoothstep(1.5, 3.5, aLengthScale * uLengthMultiplier) * 0.7; + pos.x *= strandThin; + pos.z *= strandThin; + + float w1 = sin(aBasePos.x * 2.0 + aBasePos.y * 1.5 + uTime * uWindSpeed); + float w2 = cos(aBasePos.z * 1.8 + uTime * uWindSpeed * 0.7); + float windX = (w1 + w2) * uWindAmplitude * 0.5; + float windZ = (w1 - w2) * uWindAmplitude * 0.3; + + float bendFactor = pow(t, 1.8); + + float totalClump = uClumpStrength + uMotionClumpBoost; + float clumpX = sin(aBasePos.x * 4.0 + aBasePos.z * 2.0) * totalClump; + float clumpZ = cos(aBasePos.z * 4.0 + aBasePos.x * 2.0) * totalClump; + + float dx = (aBendState.x + windX + clumpX) * bendFactor; + float dz = (aBendState.y + windZ + clumpZ) * bendFactor; + + float origLenSq = pos.x * pos.x + pos.y * pos.y + pos.z * pos.z; + float newX = pos.x + dx; + float newZ = pos.z + dz; + float newLenSq = newX * newX + pos.y * pos.y + newZ * newZ; + float lenScale = sqrt(origLenSq / max(newLenSq, 0.0001)); + pos.x = newX * lenScale; + pos.y = pos.y * lenScale; + pos.z = newZ * lenScale; + + vec3 orientedPos = aTangent * pos.x + aNormal * pos.y + aBitangent * pos.z; + vec3 worldPos = aBasePos + orientedPos; + vWorldPos = worldPos; + vNormal = normalize((modelMatrix * vec4(aNormal, 0.0)).xyz); + vLengthScale = aLengthScale; + + vec4 viewPos = modelViewMatrix * vec4(worldPos, 1.0); + vViewPos = viewPos.xyz; + gl_Position = projectionMatrix * viewPos; + } +`; + +const fragmentShader = ` + uniform vec3 uBaseColor; + uniform vec3 uTipColor; + uniform vec3 uFogColor; + uniform float uFogStart; + uniform float uFogEnd; + + varying float vHeight; + varying vec3 vViewPos; + varying vec3 vNormal; + varying vec3 vWorldPos; + varying float vLengthScale; + + void main() { + float t = vHeight; + float shade = smoothstep(0.0, 0.85, t); + vec3 color = mix(uBaseColor, uTipColor, shade); + + float longFactor = smoothstep(1.0, 3.5, vLengthScale); + color *= 1.0 + longFactor * 0.35; + + vec3 normal = normalize(vNormal); + vec3 viewDir = normalize(cameraPosition - vWorldPos); + + vec3 topLightDir = normalize(vec3(0.0, 1.0, -0.35)); + float topDiff = dot(normal, topLightDir) * 0.5 + 0.5; + vec3 topLight = vec3(1.0, 0.92, 0.72) * topDiff * 1.15; + + float backDiff = (-normal.z) * 0.5 + 0.5; + vec3 backLight = vec3(0.88, 0.80, 0.60) * backDiff * 1.20; + + float rim = 1.0 - abs(dot(normal, viewDir)); + vec3 rimLight = vec3(0.75, 0.68, 0.50) * pow(rim, 3.0) * 1.20; + + float rawLight = topDiff * 1.15 + backDiff * 0.90 + pow(rim, 3.0) * 0.70; + float lightLevel = rawLight / 2.75; + vec3 gray = vec3(dot(color, vec3(0.299, 0.587, 0.114))); + color = mix(color, gray * 0.18, smoothstep(0.5, 0.0, lightLevel) * 0.75); + color = mix(color, vec3(1.0, 0.88, 0.22), smoothstep(0.20, 0.75, lightLevel) * 0.30); + float backLit = smoothstep(0.55, 0.95, backDiff) * smoothstep(0.2, 0.7, rim); + color = mix(color, vec3(1.0, 0.78, 0.12), backLit * 0.45); + + float frontFacing = normal.z * 0.5 + 0.5; + float noBackLight = 1.0 - smoothstep(0.0, 0.5, backDiff); + color *= mix(1.0, 0.35, frontFacing * noBackLight * 0.15); + + float isLong = step(1.0, vLengthScale); + float radialDist = length(vWorldPos); + float centerMask = (1.0 - smoothstep(2.5, 6.0, radialDist)) * (1.0 - isLong); + color *= mix(1.0, 0.82, centerMask); + vec3 lum = vec3(dot(color, vec3(0.299, 0.587, 0.114))); + color = mix(lum, color, 1.0 + centerMask * 0.2); + + vec3 ambient = vec3(0.08); + vec3 lighting = ambient + topLight + backLight + rimLight; + + vec3 litColor = color * lighting; + + float glow = smoothstep(0.55, 0.95, lightLevel); + float multiplier = 1.5; + vec3 subsurface = vec3(1.0, 0.78, 0.3) * glow * multiplier; + litColor += subsurface; + + litColor *= 1.0 + longFactor * 0.6; + + float dist = length(vViewPos); + float fogFactor = smoothstep(uFogStart, uFogEnd, dist); + litColor = mix(litColor, uFogColor, fogFactor); + + float alpha = step(0.03, t); + gl_FragColor = vec4(litColor, alpha); + } +`; + +const BLADE_COUNT = 50000; +const SPHERE_RADIUS = 3; +const BG_COLOR = 0x050810; + +function createBladeGeometry() { + const segs = 6; + const W = 0.1; + const H = 0.4; + const verts: number[] = []; + const norms: number[] = []; + const uvArr: number[] = []; + const idx: number[] = []; + for (let i = 0; i <= segs; i++) { + const t = i / segs; + const y = t * H; + const hw = W * 0.5 * (1.0 - t * 0.82); + verts.push(-hw, y, 0, hw, y, 0); + norms.push(0, 0, 1, 0, 0, 1); + uvArr.push(0, t, 1, t); + } + for (let i = 0; i < segs; i++) { + const b = i * 2; + idx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geo.setAttribute('normal', new THREE.Float32BufferAttribute(norms, 3)); + geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvArr, 2)); + geo.setIndex(idx); + return geo; +} + +function noise3D(x: number, y: number, z: number) { + return ( + Math.sin(x * 1.7 + y * 0.3) * Math.cos(y * 1.3 + z * 0.7) * 0.5 + + Math.sin(z * 0.9 + x * 1.1) * 0.3 + ) + 0.5; +} + +export default function MossBall({ stream, settings }: Props) { + const containerRef = useRef(null); + const animationRef = useRef(null); + const audioCtxRef = useRef(null); + const sourceRef = useRef(null); + const settingsRef = useRef(settings); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + useEffect(() => { + if (!containerRef.current) return; + + const container = containerRef.current; + const w = container.clientWidth; + const h = container.clientHeight; + + // Audio + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioCtxRef.current = audioCtx; + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.8; + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + sourceRef.current = source; + const dataArray = new Uint8Array(analyser.frequencyBinCount); + + // Renderer + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); + renderer.setSize(w, h); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.toneMapping = THREE.AgXToneMapping; + renderer.toneMappingExposure = 1.0; + while (container.firstChild) container.removeChild(container.firstChild); + container.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(BG_COLOR); + + const camera = new THREE.PerspectiveCamera(35, w / h, 0.1, 100); + camera.position.set(0, 0, 20); + camera.lookAt(0, 0, 0); + + // Base/tip colors → HSL so we can hue-shift live + const baseColor = new THREE.Color('#1a5a0e'); + const tipColor = new THREE.Color('#8fcc4f'); + const baseHSL = { h: 0, s: 0, l: 0 }; + const tipHSL = { h: 0, s: 0, l: 0 }; + baseColor.getHSL(baseHSL); + tipColor.getHSL(tipHSL); + + const uniforms = { + uTime: { value: 0 }, + uWindSpeed: { value: 1.2 }, + uWindAmplitude: { value: 0.25 }, + uLengthMultiplier: { value: 1.2 }, + uClumpStrength: { value: 0.4 }, + uMotionClumpBoost: { value: 0.0 }, + uBaseColor: { value: baseColor.clone() }, + uTipColor: { value: tipColor.clone() }, + uFogColor: { value: new THREE.Color(BG_COLOR) }, + uFogStart: { value: 15.0 }, + uFogEnd: { value: 40.0 }, + }; + + const material = new THREE.ShaderMaterial({ + vertexShader, + fragmentShader, + side: THREE.DoubleSide, + alphaTest: 0.05, + uniforms, + }); + + const bladeGeo = createBladeGeometry(); + const mesh = new THREE.InstancedMesh(bladeGeo, material, BLADE_COUNT); + mesh.frustumCulled = false; + + // Per-blade attributes + const basePosArr = new Float32Array(BLADE_COUNT * 3); + const normalArr = new Float32Array(BLADE_COUNT * 3); + const tangentArr = new Float32Array(BLADE_COUNT * 3); + const bitangentArr = new Float32Array(BLADE_COUNT * 3); + const bendStateArr = new Float32Array(BLADE_COUNT * 2); + const heightScaleArr = new Float32Array(BLADE_COUNT); + const lengthScaleArr = new Float32Array(BLADE_COUNT); + + for (let i = 0; i < BLADE_COUNT; i++) { + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const sinPhi = Math.sin(phi); + const cosPhi = Math.cos(phi); + + let x = SPHERE_RADIUS * sinPhi * Math.cos(theta); + let y = SPHERE_RADIUS * sinPhi * Math.sin(theta); + let z = SPHERE_RADIUS * cosPhi; + + const flowAngle = noise3D(x * 0.22 + 100, y * 0.22 + 200, z * 0.22 + 300) * Math.PI * 2; + const lockStrengthNoise = noise3D(x * 0.55 + 400, y * 0.45 + 500, z * 0.65 + 600); + const lockStrength = Math.max(0, (lockStrengthNoise - 0.22) * 2.2); + const detailAngle = noise3D(x * 0.9 + 700, y * 0.9 + 800, z * 0.9 + 900) * Math.PI * 2; + const detailStrength = Math.max(0, (noise3D(x * 0.9 + 1000, y * 0.9 + 1100, z * 0.9 + 1200) - 0.4) * 1.3); + const finalFlowAngle = flowAngle + detailAngle * detailStrength * 0.4; + const randomRot = Math.random() * Math.PI * 2; + const blend = lockStrength * 0.78; + let rotAngle = randomRot * (1 - blend) + finalFlowAngle * blend; + rotAngle += (Math.random() - 0.5) * 0.30 * (1 - lockStrength); + + const jitter = 0.08 * lockStrength; + if (jitter > 0.002) { + x += (Math.random() - 0.5) * jitter; + y += (Math.random() - 0.5) * jitter; + z += (Math.random() - 0.5) * jitter; + const len = Math.sqrt(x * x + y * y + z * z); + x = (x / len) * SPHERE_RADIUS; + y = (y / len) * SPHERE_RADIUS; + z = (z / len) * SPHERE_RADIUS; + } + + const idx3 = i * 3; + basePosArr[idx3] = x; + basePosArr[idx3 + 1] = y; + basePosArr[idx3 + 2] = z; + + const normal = new THREE.Vector3(x, y, z).normalize(); + normalArr[idx3] = normal.x; + normalArr[idx3 + 1] = normal.y; + normalArr[idx3 + 2] = normal.z; + + const arbitrary = Math.abs(normal.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0); + const tangentBase = new THREE.Vector3().crossVectors(arbitrary, normal).normalize(); + const bitangentBase = new THREE.Vector3().crossVectors(normal, tangentBase); + const cosR = Math.cos(rotAngle); + const sinR = Math.sin(rotAngle); + const tangent = new THREE.Vector3() + .addScaledVector(tangentBase, cosR) + .addScaledVector(bitangentBase, sinR) + .normalize(); + const bitangent = new THREE.Vector3().crossVectors(normal, tangent).normalize(); + + tangentArr[idx3] = tangent.x; + tangentArr[idx3 + 1] = tangent.y; + tangentArr[idx3 + 2] = tangent.z; + bitangentArr[idx3] = bitangent.x; + bitangentArr[idx3 + 1] = bitangent.y; + bitangentArr[idx3 + 2] = bitangent.z; + + const equatorFactor = sinPhi; + const heightVar = 1.2 + Math.random() * 1.3; + heightScaleArr[i] = heightVar * (0.4 + 0.6 * equatorFactor); + + const isLongStrand = Math.random() < 0.0008; + lengthScaleArr[i] = isLongStrand ? 1 + Math.random() * 1.2 : 0.5 + Math.random() * 0.5; + } + + bladeGeo.setAttribute('aBasePos', new THREE.InstancedBufferAttribute(basePosArr, 3)); + bladeGeo.setAttribute('aNormal', new THREE.InstancedBufferAttribute(normalArr, 3)); + bladeGeo.setAttribute('aTangent', new THREE.InstancedBufferAttribute(tangentArr, 3)); + bladeGeo.setAttribute('aBitangent', new THREE.InstancedBufferAttribute(bitangentArr, 3)); + bladeGeo.setAttribute('aBendState', new THREE.InstancedBufferAttribute(bendStateArr, 2)); + bladeGeo.setAttribute('aHeightScale', new THREE.InstancedBufferAttribute(heightScaleArr, 1)); + bladeGeo.setAttribute('aLengthScale', new THREE.InstancedBufferAttribute(lengthScaleArr, 1)); + + const dummy = new THREE.Object3D(); + for (let i = 0; i < BLADE_COUNT; i++) mesh.setMatrixAt(i, dummy.matrix); + mesh.instanceMatrix.needsUpdate = true; + + // Inner dark sphere fills any gaps between blades + const darkGeo = new THREE.SphereGeometry(SPHERE_RADIUS - 0.05, 64, 64); + const darkMat = new THREE.MeshBasicMaterial({ color: 0x0a1a06 }); + const darkSphere = new THREE.Mesh(darkGeo, darkMat); + + const ballGroup = new THREE.Group(); + ballGroup.add(mesh); + ballGroup.add(darkSphere); + scene.add(ballGroup); + + const tmpBase = new THREE.Color(); + const tmpTip = new THREE.Color(); + const clock = new THREE.Clock(); + let smoothedAmp = 0; + let bassPrev = 0; + let windGust = 0; + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + const s = settingsRef.current; + const delta = clock.getDelta(); + + // Audio bands + analyser.getByteFrequencyData(dataArray); + const binCount = dataArray.length; + const bassEnd = Math.floor(binCount * 0.15); + const midsEnd = Math.floor(binCount * 0.55); + let bassSum = 0, midsSum = 0, highsSum = 0; + for (let i = 0; i < bassEnd; i++) bassSum += dataArray[i]; + for (let i = bassEnd; i < midsEnd; i++) midsSum += dataArray[i]; + for (let i = midsEnd; i < binCount; i++) highsSum += dataArray[i]; + const bass = (bassSum / (bassEnd * 255)) * s.sensitivity; + const mids = (midsSum / ((midsEnd - bassEnd) * 255)) * s.sensitivity; + const highs = (highsSum / ((binCount - midsEnd) * 255)) * s.sensitivity; + const amp = (bass + mids + highs) / 3; + smoothedAmp += (amp - smoothedAmp) * 0.15; + const bassKick = Math.max(0, bass - bassPrev); + bassPrev = bass; + + // Wind gust on bass kicks, decays over ~0.4s + windGust += bassKick * 1.6; + windGust *= Math.exp(-3.0 * delta); + + uniforms.uTime.value += delta * (1.0 + smoothedAmp * 0.4) * s.speed; + uniforms.uWindSpeed.value = 1.2 + mids * 1.4; + uniforms.uWindAmplitude.value = 0.25 + bass * 0.55 + windGust; + uniforms.uLengthMultiplier.value = 1.2 + smoothedAmp * 0.35; + uniforms.uMotionClumpBoost.value = highs * 0.8; + + // Hue-shift base/tip colors + const hueDelta = s.hueShift / 360; + tmpBase.setHSL((baseHSL.h + hueDelta + 1) % 1, baseHSL.s, baseHSL.l); + tmpTip.setHSL((tipHSL.h + hueDelta + 1) % 1, tipHSL.s, tipHSL.l); + uniforms.uBaseColor.value.copy(tmpBase); + uniforms.uTipColor.value.copy(tmpTip); + + // Auto-rotate, audio-boosted + const rotSpeed = (0.15 + smoothedAmp * 0.6) * s.speed; + ballGroup.rotation.y += delta * rotSpeed; + ballGroup.rotation.x += delta * rotSpeed * 0.25; + + // Scale → camera distance (closer = larger ball) + const targetZ = 20 / Math.max(0.5, Math.min(3.0, s.scale)); + camera.position.z += (targetZ - camera.position.z) * 0.08; + + renderer.render(scene, camera); + }; + + draw(); + + const handleResize = () => { + const width = container.clientWidth; + const height = container.clientHeight; + renderer.setSize(width, height); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + }; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + if (animationRef.current) cancelAnimationFrame(animationRef.current); + if (sourceRef.current) sourceRef.current.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + bladeGeo.dispose(); + material.dispose(); + darkGeo.dispose(); + darkMat.dispose(); + renderer.dispose(); + }; + }, [stream]); + + return
; +} diff --git a/src/components/visualizers/Razor1911.tsx b/src/components/visualizers/Razor1911.tsx new file mode 100644 index 0000000..ac69726 --- /dev/null +++ b/src/components/visualizers/Razor1911.tsx @@ -0,0 +1,323 @@ +import { useEffect, useRef } from 'react'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +const SCROLL_TEXT = + ' ' + + 'RAZOR 1911 • SINCE 1985 • THE OLDEST STILL ACTIVE SCENE GROUP • ' + + 'GREETINGS FLY OUT TO: FAIRLIGHT • PARADOX • SKIDROW • DEVIANCE • CLASS • RELOADED • HOODLUM • ' + + 'THE DEMOSCENE WILL NEVER DIE • KEEP CRACKING • RAZOR 1911' + + ' '; + +export default function Razor1911({ stream, settings }: Props) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const animationRef = useRef(null); + const audioCtxRef = useRef(null); + const sourceRef = useRef(null); + const settingsRef = useRef(settings); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + useEffect(() => { + if (!canvasRef.current || !containerRef.current) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d', { alpha: false }); + if (!ctx) return; + + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioCtxRef.current = audioCtx; + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 1024; + analyser.smoothingTimeConstant = 0; + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + sourceRef.current = source; + + const freqBins = analyser.frequencyBinCount; + const freqData = new Uint8Array(freqBins); + + let bassSlow = 0; + let bassKickDecay = 0; + + interface Star { + x: number; + y: number; + z: number; + } + + const STAR_COUNT = 250; + const stars: Star[] = []; + for (let i = 0; i < STAR_COUNT; i++) { + stars.push({ x: Math.random() * 2 - 1, y: Math.random() * 2 - 1, z: Math.random() }); + } + + let scrollX = 0; + let elapsed = 0; + let w = 0; + let h = 0; + let flashIntensity = 0; + + const resize = () => { + if (!containerRef.current || !canvasRef.current) return; + const dpr = Math.min(window.devicePixelRatio, 2); + w = containerRef.current.clientWidth; + h = containerRef.current.clientHeight; + canvasRef.current.width = Math.floor(w * dpr); + canvasRef.current.height = Math.floor(h * dpr); + canvasRef.current.style.width = `${w}px`; + canvasRef.current.style.height = `${h}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + window.addEventListener('resize', resize); + resize(); + + let lastTime = performance.now(); + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + + const now = performance.now(); + const dt = Math.min(0.05, (now - lastTime) / 1000); + lastTime = now; + const cur = settingsRef.current; + const speed = cur.speed; + const sens = cur.sensitivity; + const hue = cur.hueShift; + const scale = cur.scale; + + analyser.getByteFrequencyData(freqData); + const bassEnd = Math.max(2, Math.floor(freqBins * 0.06)); + const midEnd = Math.floor(freqBins * 0.25); + const trebleEnd = Math.floor(freqBins * 0.6); + let bass = 0, mid = 0, treble = 0; + for (let i = 2; i < bassEnd; i++) bass += freqData[i]; + bass = bass / Math.max(1, bassEnd - 2) / 255; + for (let i = bassEnd; i < midEnd; i++) mid += freqData[i]; + mid = mid / Math.max(1, midEnd - bassEnd) / 255; + for (let i = midEnd; i < trebleEnd; i++) treble += freqData[i]; + treble = treble / Math.max(1, trebleEnd - midEnd) / 255; + const energy = bass * 0.5 + mid * 0.3 + treble * 0.2; + + // Bass kick detection: raw bass vs slow envelope + bassSlow = bassSlow * 0.95 + bass * 0.05; + const kickStrength = Math.max(0, bass - bassSlow - 0.05 * (1 / sens)); + if (kickStrength > 0) { + bassKickDecay = Math.min(1, bassKickDecay + kickStrength * 6 * sens); + flashIntensity = Math.min(1, kickStrength * 8 * sens); + } + bassKickDecay *= 0.88; + flashIntensity *= 0.85; + + const audioActive = energy * sens; + elapsed += dt * speed * audioActive; + + // Background — flash white on hard bass kicks + if (flashIntensity > 0.05) { + const fl = Math.round(flashIntensity * 30); + ctx.fillStyle = `rgb(${fl},${fl},${fl})`; + } else { + ctx.fillStyle = '#000'; + } + ctx.fillRect(0, 0, w, h); + + // --- Starfield (speed reacts to bass) --- + const starSpeed = (bass * 3.5 + bassKickDecay * 4) * sens * speed; + for (let i = 0; i < STAR_COUNT; i++) { + const star = stars[i]; + star.z -= starSpeed * dt; + if (star.z <= 0.01) { + star.x = Math.random() * 2 - 1; + star.y = Math.random() * 2 - 1; + star.z = 1; + } + const sx = (star.x / star.z) * (w * 0.5) + w * 0.5; + const sy = (star.y / star.z) * (h * 0.5) + h * 0.5; + if (sx < 0 || sx > w || sy < 0 || sy > h) { + star.x = Math.random() * 2 - 1; + star.y = Math.random() * 2 - 1; + star.z = 1; + continue; + } + const depth = 1 - star.z; + const brightness = depth * (0.12 + bass * 1.5 * sens); + const size = depth * (2 + bassKickDecay * 3) * scale; + const starHue = (hue + depth * 60) % 360; + ctx.fillStyle = `hsl(${starHue}, 30%, ${Math.round(Math.min(1, brightness) * 100)}%)`; + ctx.fillRect(sx - size / 2, sy - size / 2, size, size); + } + + // --- Copper raster bars (height and intensity driven by bass) --- + if (bass * sens > 0.02) { + const barCount = 6; + const barHeight = (8 + bass * 60 * sens + bassKickDecay * 30) * scale; + const barAlpha = Math.min(1, bass * 4 * sens); + for (let i = 0; i < barCount; i++) { + const phase = elapsed * 2.0 + i * (Math.PI * 2 / barCount); + const yPos = h * 0.5 + Math.sin(phase) * h * 0.3; + const barHue = (hue + i * 60 + elapsed * 50) % 360; + + for (let line = -barHeight / 2; line < barHeight / 2; line++) { + const y = Math.round(yPos + line); + if (y < 0 || y >= h) continue; + const dist = Math.abs(line) / (barHeight / 2); + const lightness = 0.55 * (1 - dist * dist) * barAlpha; + ctx.fillStyle = `hsl(${barHue}, 90%, ${Math.round(lightness * 100)}%)`; + ctx.fillRect(0, y, w, 1); + } + } + } + + // --- "RAZOR 1911" main title --- + const baseTitleSize = Math.max(36, Math.min(120, w / 8)); + const titlePulse = 1 + bassKickDecay * 0.2 * sens; + const titleSize = baseTitleSize * scale * titlePulse; + ctx.font = `900 ${titleSize}px "Impact", "Arial Black", sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Shake on bass kick + const shakeX = bassKickDecay * (Math.random() - 0.5) * 8 * sens; + const shakeY = bassKickDecay * (Math.random() - 0.5) * 5 * sens; + const titleY = h * 0.28 + shakeY; + const titleX = w / 2 + shakeX; + + const titleHue = (hue + elapsed * 20) % 360; + const titleGlow = 0.3 + bass * 0.7 * sens; + + // Outer glow — scales with bass + if (bass * sens > 0.02) { + ctx.shadowColor = `hsl(${titleHue}, 100%, 55%)`; + ctx.shadowBlur = (10 + bass * 50 * sens + bassKickDecay * 40) * scale; + ctx.fillStyle = `hsl(${titleHue}, 100%, ${Math.round(titleGlow * 55)}%)`; + ctx.fillText('RAZOR 1911', titleX, titleY); + + // Second glow pass for extra punch on kicks + if (bassKickDecay > 0.15) { + ctx.shadowBlur = (bassKickDecay * 80) * scale; + ctx.fillText('RAZOR 1911', titleX, titleY); + } + ctx.shadowBlur = 0; + } + + // Chrome gradient text + const gradient = ctx.createLinearGradient( + titleX - titleSize * 2.5, titleY - titleSize / 2, + titleX + titleSize * 2.5, titleY + titleSize / 2 + ); + const h1 = (hue + elapsed * 15) % 360; + const h2 = (h1 + 40) % 360; + const h3 = (h1 + 80) % 360; + const baseBright = 35 + bass * 45 * sens; + gradient.addColorStop(0, `hsl(${h1}, 80%, ${Math.round(baseBright)}%)`); + gradient.addColorStop(0.5, `hsl(${h2}, 95%, ${Math.round(baseBright + 15)}%)`); + gradient.addColorStop(1, `hsl(${h3}, 80%, ${Math.round(baseBright)}%)`); + ctx.fillStyle = audioActive > 0.02 ? gradient : `hsl(${hue}, 40%, 30%)`; + ctx.fillText('RAZOR 1911', titleX, titleY); + + // Outline + ctx.strokeStyle = `hsl(${titleHue}, 60%, ${Math.round(20 + titleGlow * 40)}%)`; + ctx.lineWidth = 1.5 * scale; + ctx.strokeText('RAZOR 1911', titleX, titleY); + + // --- Subtitle --- + const subSize = Math.max(12, Math.min(24, w / 40)) * scale; + ctx.font = `bold ${subSize}px ui-monospace, "Courier New", monospace`; + ctx.fillStyle = `hsl(${(hue + 30) % 360}, 50%, ${Math.round(25 + bass * 35 * sens)}%)`; + ctx.fillText('EST. 1985 • THE LEGACY LIVES ON', titleX, titleY + baseTitleSize * scale * 0.7); + + // --- Sine-wave text scroller --- + const fontSize = Math.max(16, Math.min(36, w / 24)) * scale; + ctx.font = `bold ${fontSize}px ui-monospace, "Courier New", monospace`; + const charWidth = ctx.measureText('M').width; + + // Scroll speed driven by mid but boosted on bass kicks + const scrollSpeed = (mid * 180 + bassKickDecay * 300) * sens * speed; + scrollX += scrollSpeed * dt; + const totalWidth = SCROLL_TEXT.length * charWidth; + if (scrollX > totalWidth) scrollX -= totalWidth; + + const scrollY = h * 0.78; + // Amplitude is purely bass-driven + const amplitude = (bass * 60 + bassKickDecay * 30) * sens * scale; + const waveFreq = 0.06 / Math.max(0.5, scale); + + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + + for (let i = 0; i < SCROLL_TEXT.length; i++) { + let xPos = i * charWidth - scrollX; + if (xPos < -charWidth * 2) xPos += totalWidth; + if (xPos > w + charWidth || xPos < -charWidth) continue; + + const sineOffset = amplitude > 1 ? Math.sin(xPos * waveFreq + elapsed * 4) * amplitude : 0; + const charHue = (hue + xPos * 0.4 + elapsed * 40) % 360; + const charBright = 30 + bass * 30 * sens + treble * 20 * sens; + + if (bassKickDecay > 0.1) { + ctx.shadowColor = `hsl(${charHue}, 100%, 60%)`; + ctx.shadowBlur = bassKickDecay * 12 * sens; + } + ctx.fillStyle = `hsl(${charHue}, 85%, ${Math.round(charBright)}%)`; + ctx.fillText(SCROLL_TEXT[i], xPos, scrollY + sineOffset); + } + ctx.shadowBlur = 0; + + // --- Horizontal separator lines (pulse on bass) --- + const lineAlpha = 0.15 + bass * 0.7 * sens; + const lineHue = (hue + 180) % 360; + ctx.strokeStyle = `hsla(${lineHue}, 70%, ${Math.round(30 + bassKickDecay * 40)}%, ${lineAlpha})`; + ctx.lineWidth = 1 + bassKickDecay * 2; + const sep1 = h * 0.48; + const sep2 = h * 0.62; + ctx.beginPath(); + ctx.moveTo(w * 0.1, sep1); + ctx.lineTo(w * 0.9, sep1); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(w * 0.1, sep2); + ctx.lineTo(w * 0.9, sep2); + ctx.stroke(); + + // --- Middle text --- + const midSize = Math.max(14, Math.min(28, w / 30)) * scale; + ctx.font = `600 ${midSize}px ui-monospace, "Courier New", monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const midY = (sep1 + sep2) / 2; + const midHue = (hue + elapsed * 30 + 90) % 360; + ctx.fillStyle = `hsl(${midHue}, 60%, ${Math.round(25 + bass * 40 * sens)}%)`; + ctx.fillText('★ DEMOSCENE CRACKTRO TRIBUTE ★', w / 2, midY); + + // --- Scanlines --- + ctx.fillStyle = 'rgba(0,0,0,0.06)'; + for (let y = 0; y < h; y += 3) { + ctx.fillRect(0, y, w, 1); + } + }; + + draw(); + + return () => { + window.removeEventListener('resize', resize); + if (animationRef.current) cancelAnimationFrame(animationRef.current); + if (sourceRef.current) sourceRef.current.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + }; + }, [stream]); + + return ( +
+ +
+ ); +}