diff --git a/CHANGELOG.md b/CHANGELOG.md index 960d4de..95fbc14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.15.0] - 2026-04-23 + +### Added +- MilkDrop Warp visualizer – deep-tunnel feedback variant with vortex spiral warp, radial frequency bars, waveform spirals, concentric depth rings, and cross-shaped energy flare (Three.js/WebGL). +- MilkDrop visualizer – audio-reactive feedback-warp visualizer with ping-pong framebuffers, per-pixel motion vectors, kaleidoscopic symmetry, and psychedelic color cycling (Three.js/WebGL). +- Dependency bumps + ## [0.14.0] - 2026-04-22 ### Added diff --git a/package-lock.json b/package-lock.json index 50de7b8..f2acaa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,32 +1,32 @@ { "name": "voltviz", - "version": "0.14.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "voltviz", - "version": "0.14.0", + "version": "0.15.0", "dependencies": { - "@sendspin/sendspin-js": "^3.0.3", + "@sendspin/sendspin-js": "^3.1.0", "d3-geo": "^3.1.1", - "lucide-react": "^1.8.0", + "lucide-react": "^1.9.0", "react": "^19.2.5", "react-dom": "^19.2.5", "three": "^0.184.0" }, "devDependencies": { "@playwright/test": "^1.59.1", - "@tailwindcss/vite": "^4.2.2", + "@tailwindcss/vite": "^4.2.4", "@types/d3-geo": "^3.1.0", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/three": "^0.184.0", "@vitejs/plugin-react": "^6.0.1", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.2.4", "typescript": "~6.0.3", - "vite": "^8.0.9" + "vite": "^8.0.10" } }, "node_modules/@dimforge/rapier3d-compat": { @@ -37,9 +37,9 @@ "license": "Apache-2.0" }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, @@ -49,9 +49,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -140,9 +140,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.126.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", - "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "dev": true, "license": "MIT", "funding": { @@ -166,9 +166,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", + "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==", "cpu": [ "arm64" ], @@ -183,9 +183,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", + "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==", "cpu": [ "arm64" ], @@ -200,9 +200,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", - "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", + "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==", "cpu": [ "x64" ], @@ -217,9 +217,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", - "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", + "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==", "cpu": [ "x64" ], @@ -234,9 +234,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", - "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", + "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==", "cpu": [ "arm" ], @@ -251,9 +251,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", + "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==", "cpu": [ "arm64" ], @@ -271,9 +271,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", - "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", + "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==", "cpu": [ "arm64" ], @@ -291,9 +291,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", + "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==", "cpu": [ "ppc64" ], @@ -311,9 +311,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", + "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==", "cpu": [ "s390x" ], @@ -331,9 +331,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", - "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", + "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==", "cpu": [ "x64" ], @@ -351,9 +351,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", - "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", + "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==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", + "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==", "cpu": [ "arm64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", - "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", + "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==", "cpu": [ "wasm32" ], @@ -398,8 +398,8 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { @@ -407,9 +407,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", - "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", + "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==", "cpu": [ "arm64" ], @@ -424,9 +424,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", - "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", + "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==", "cpu": [ "x64" ], @@ -448,9 +448,9 @@ "license": "MIT" }, "node_modules/@sendspin/sendspin-js": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@sendspin/sendspin-js/-/sendspin-js-3.0.3.tgz", - "integrity": "sha512-fGSlfxm5iuNLxaTOFFBarP2ZY7jA8B/sOQxYP4Z9jgImF2L8TA3aYO3xbg1uTTbrkpnSa5vZsB+lmOZK0nM+Cw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sendspin/sendspin-js/-/sendspin-js-3.1.0.tgz", + "integrity": "sha512-tljpoE7JwGIvAMk9M+UBGKkur4ME1RyZRxIV/FFJ0trp6fJOC5bWjewaO8bgm9ybEKO64su5PMbn/u63YzlaMQ==", "license": "Apache-2.0", "dependencies": { "opus-encdec": "^0.1.1" @@ -460,9 +460,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", "dev": true, "license": "MIT", "dependencies": { @@ -472,37 +472,37 @@ "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "tailwindcss": "4.2.4" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + "@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" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", "cpu": [ "arm64" ], @@ -517,9 +517,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "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==", "cpu": [ "arm64" ], @@ -534,9 +534,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "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==", "cpu": [ "x64" ], @@ -551,9 +551,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "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==", "cpu": [ "x64" ], @@ -568,9 +568,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "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==", "cpu": [ "arm" ], @@ -585,9 +585,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "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==", "cpu": [ "arm64" ], @@ -605,9 +605,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "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==", "cpu": [ "arm64" ], @@ -625,9 +625,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "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==", "cpu": [ "x64" ], @@ -645,9 +645,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "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==", "cpu": [ "x64" ], @@ -665,9 +665,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -695,9 +695,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "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==", "cpu": [ "arm64" ], @@ -712,9 +712,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "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==", "cpu": [ "x64" ], @@ -729,15 +729,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", - "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "tailwindcss": "4.2.2" + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -905,14 +905,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -1258,9 +1258,9 @@ } }, "node_modules/lucide-react": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", - "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.9.0.tgz", + "integrity": "sha512-6qVAmbgCjcJz7sAGSPSSJ++RAwjlK2XCbRrZKv63Ciko1KT8jX0//CXxgI3jg2HlJu8tADqdYlNDebmYjeoruA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1411,14 +1411,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", - "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.126.0", - "@rolldown/pluginutils": "1.0.0-rc.16" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1427,27 +1427,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.16", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", - "@rolldown/binding-darwin-x64": "1.0.0-rc.16", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" + "@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" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", - "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", + "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==", "dev": true, "license": "MIT" }, @@ -1468,16 +1468,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -1541,16 +1541,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.0.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", - "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.16", + "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "bin": { diff --git a/package.json b/package.json index 4040bab..79cdb65 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "voltviz", "private": true, - "version": "0.14.0", + "version": "0.15.0", "type": "module", "scripts": { "dev": "vite --port=3000 --host=0.0.0.0", @@ -11,24 +11,24 @@ "lint": "tsc --noEmit" }, "dependencies": { - "@sendspin/sendspin-js": "^3.0.3", + "@sendspin/sendspin-js": "^3.1.0", "d3-geo": "^3.1.1", - "lucide-react": "^1.8.0", + "lucide-react": "^1.9.0", "react": "^19.2.5", "react-dom": "^19.2.5", "three": "^0.184.0" }, "devDependencies": { "@playwright/test": "^1.59.1", - "@tailwindcss/vite": "^4.2.2", + "@tailwindcss/vite": "^4.2.4", "@types/d3-geo": "^3.1.0", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/three": "^0.184.0", "@vitejs/plugin-react": "^6.0.1", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.2.4", "typescript": "~6.0.3", - "vite": "^8.0.9" + "vite": "^8.0.10" } } diff --git a/src/App.tsx b/src/App.tsx index 18209cc..418b6b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,7 +40,10 @@ type VisualizerType = | 'hexglobe' | 'vinylsendspin' | 'glitchbackgroundsendspin' - | 'backgroundimagesendspin'; + | 'backgroundimagesendspin' + | 'icons' + | 'milkdrop' + | 'milkdropwarp'; type VisualizerProps = { stream: MediaStream; @@ -102,6 +105,9 @@ const visualizerComponents: Record import('./components/visualizers/VinylSendspin')), glitchbackgroundsendspin: lazy(() => import('./components/visualizers/GlitchBackgroundSendspin')), backgroundimagesendspin: lazy(() => import('./components/visualizers/BackgroundImageSendspin')), + icons: lazy(() => import('./components/visualizers/Icons')), + milkdrop: lazy(() => import('./components/visualizers/MilkDrop')), + milkdropwarp: lazy(() => import('./components/visualizers/MilkDropWarp')), }; export default function App() { @@ -365,6 +371,7 @@ export default function App() { + @@ -392,6 +399,8 @@ export default function App() { + + diff --git a/src/components/visualizers/Icons.tsx b/src/components/visualizers/Icons.tsx new file mode 100644 index 0000000..69f6dfe --- /dev/null +++ b/src/components/visualizers/Icons.tsx @@ -0,0 +1,474 @@ +import { useEffect, useRef, useState, createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { VisualizerSettings } from '../../types'; +import type { LucideIcon } from 'lucide-react'; +import { + Shuffle, Eye, EyeOff, + Heart, Star, Music, Zap, Flame, Crown, Diamond, Skull, Ghost, + Rocket, Atom, Brain, Bug, Camera, Cloud, Coffee, Compass, Crosshair, + Flower2, Gamepad2, Globe, Headphones, Hexagon, Key, Leaf, Moon, + Shield, Snowflake, Sun, Target, TreePine, Umbrella, Wind, Aperture, + Fingerprint, Anchor, Bell, Bookmark, Fish +} from 'lucide-react'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +const ICON_POOL: [string, LucideIcon][] = [ + ['Heart', Heart], ['Star', Star], ['Music', Music], ['Zap', Zap], + ['Flame', Flame], ['Crown', Crown], ['Diamond', Diamond], ['Skull', Skull], + ['Ghost', Ghost], ['Rocket', Rocket], ['Atom', Atom], ['Brain', Brain], + ['Bug', Bug], ['Camera', Camera], ['Cloud', Cloud], ['Coffee', Coffee], + ['Compass', Compass], ['Crosshair', Crosshair], ['Flower', Flower2], + ['Gamepad', Gamepad2], ['Globe', Globe], ['Headphones', Headphones], + ['Hexagon', Hexagon], ['Key', Key], ['Leaf', Leaf], ['Moon', Moon], + ['Shield', Shield], ['Snowflake', Snowflake], ['Sun', Sun], ['Target', Target], + ['Tree', TreePine], ['Umbrella', Umbrella], ['Wind', Wind], ['Aperture', Aperture], + ['Fingerprint', Fingerprint], ['Anchor', Anchor], ['Bell', Bell], + ['Bookmark', Bookmark], ['Eye', Eye], ['Fish', Fish], +]; + +type ParsedPath = { path: number[][]; color: null }; + +function parseSVGString(svgString: string): ParsedPath[] { + const parser = new DOMParser(); + const doc = parser.parseFromString(svgString, 'image/svg+xml'); + const elements = Array.from(doc.querySelectorAll('path, circle, rect, polygon, polyline, line, ellipse')); + + const svgContainer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svgContainer.style.position = 'absolute'; + svgContainer.style.visibility = 'hidden'; + svgContainer.style.width = '0'; + svgContainer.style.height = '0'; + document.body.appendChild(svgContainer); + + const parsedPaths: ParsedPath[] = []; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + + elements.forEach(el => { + const clone = el.cloneNode() as SVGGeometryElement; + svgContainer.appendChild(clone); + try { + if (typeof clone.getTotalLength === 'function') { + const length = clone.getTotalLength(); + if (length === 0) return; + + const numPoints = Math.min(3000, Math.max(300, Math.floor(length))); + const arcLength = length / numPoints; + let currentSubPath: number[][] = []; + let prevPt: DOMPoint | null = null; + + for (let i = 0; i <= numPoints; i++) { + const pt = clone.getPointAtLength((i / numPoints) * length); + + if (prevPt) { + const dist = Math.hypot(pt.x - prevPt.x, pt.y - prevPt.y); + if (dist > arcLength * 1.5 + 0.1) { + if (currentSubPath.length > 0) { + parsedPaths.push({ path: currentSubPath, color: null }); + currentSubPath = []; + } + } + } + + currentSubPath.push([pt.x, pt.y, 0]); + prevPt = pt; + + minX = Math.min(minX, pt.x); + maxX = Math.max(maxX, pt.x); + minY = Math.min(minY, pt.y); + maxY = Math.max(maxY, pt.y); + } + if (currentSubPath.length > 0) { + parsedPaths.push({ path: currentSubPath, color: null }); + } + } + } catch { + // Ignore elements that fail + } + }); + + document.body.removeChild(svgContainer); + + if (parsedPaths.length > 0) { + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const scale = Math.max(maxX - minX, maxY - minY) / 2 || 1; + + return parsedPaths.map(item => ({ + path: item.path.map(p => [ + (p[0] - cx) / scale, + (p[1] - cy) / scale, + 0, + ]), + color: null, + })); + } + + return []; +} + +export default function Icons({ stream, settings }: Props) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const animationRef = useRef(null); + const audioCtxRef = useRef(null); + const analyserRef = useRef(null); + const sourceRef = useRef(null); + const settingsRef = useRef(settings); + const activeCopiesRef = useRef>(new Map()); + + const allIconDataRef = useRef>(new Map()); + const currentPathsRef = useRef([]); + const lastSwitchTimeRef = useRef(0); + + const [currentIconName, setCurrentIconName] = useState(null); + const [ready, setReady] = useState(false); + const [showUI, setShowUI] = useState(true); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + // Parse all icons on mount + useEffect(() => { + const parsed = new Map(); + ICON_POOL.forEach(([name, component]) => { + const svgString = renderToStaticMarkup(createElement(component)); + const paths = parseSVGString(svgString); + if (paths.length > 0) parsed.set(name, paths); + }); + allIconDataRef.current = parsed; + + const names = Array.from(parsed.keys()); + if (names.length > 0) { + const initial = names[Math.floor(Math.random() * names.length)]; + currentPathsRef.current = parsed.get(initial) || []; + setCurrentIconName(initial); + } + setReady(true); + }, []); + + const shuffleIcon = () => { + const names = Array.from(allIconDataRef.current.keys()); + if (names.length === 0) return; + let next = names[Math.floor(Math.random() * names.length)]; + if (names.length > 1) { + while (next === currentIconName) { + next = names[Math.floor(Math.random() * names.length)]; + } + } + currentPathsRef.current = allIconDataRef.current.get(next) || []; + activeCopiesRef.current.clear(); + setCurrentIconName(next); + lastSwitchTimeRef.current = performance.now(); + }; + + useEffect(() => { + if (!canvasRef.current || !containerRef.current || !ready || currentPathsRef.current.length === 0) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + audioCtxRef.current = audioCtx; + + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.8; + analyserRef.current = analyser; + + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + sourceRef.current = source; + + const dataArray = new Uint8Array(analyser.frequencyBinCount); + + const resize = () => { + if (containerRef.current && canvasRef.current) { + canvasRef.current.width = containerRef.current.clientWidth; + canvasRef.current.height = containerRef.current.clientHeight; + } + }; + window.addEventListener('resize', resize); + resize(); + + const particles: { x: number; y: number; z: number; vx: number; vy: number; vz: number; life: number; maxLife: number; color: string }[] = []; + + let time = 0; + let lastBass = 0; + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + const w = canvas.width; + const h = canvas.height; + const currentSettings = settingsRef.current; + let svgPaths = currentPathsRef.current; + time += 0.01 * currentSettings.speed; + + analyser.getByteFrequencyData(dataArray); + + const bass = dataArray.slice(0, 10).reduce((a, b) => a + b, 0) / 10; + const treble = dataArray.slice(50, 150).reduce((a, b) => a + b, 0) / 100; + + const isBeat = bass > 180 && bass > lastBass + 10; + + // Switch icon on strong beat (min 4s interval) + if (isBeat && performance.now() - lastSwitchTimeRef.current > 4000) { + const names = Array.from(allIconDataRef.current.keys()); + if (names.length > 1) { + const next = names[Math.floor(Math.random() * names.length)]; + currentPathsRef.current = allIconDataRef.current.get(next) || currentPathsRef.current; + svgPaths = currentPathsRef.current; + activeCopiesRef.current.clear(); + lastSwitchTimeRef.current = performance.now(); + setCurrentIconName(next); + } + } + + if (isBeat || activeCopiesRef.current.size === 0) { + const closedIndices: number[] = []; + svgPaths.forEach((item, idx) => { + const path = item.path; + if (path.length > 2) { + const first = path[0]; + const last = path[path.length - 1]; + if (Math.hypot(first[0] - last[0], first[1] - last[1]) < 0.05) { + closedIndices.push(idx); + } + } + }); + + closedIndices.sort(() => Math.random() - 0.5); + const numToPick = Math.max(1, Math.floor(closedIndices.length * 0.75)); + + const newActive = new Map(); + closedIndices.slice(0, numToPick).forEach(idx => { + newActive.set(idx, { + dir: Math.random() > 0.5 ? 1 : -1, + freqBin: Math.floor(Math.random() * 100), + }); + }); + activeCopiesRef.current = newActive; + } + + lastBass = bass; + + ctx.fillStyle = '#050508'; + ctx.fillRect(0, 0, w, h); + + const cx = w / 2; + const cy = h / 2; + const baseRadius = Math.min(w, h) * 0.3 * currentSettings.scale; + const radius = baseRadius * (1 + (bass / 255) * 0.3 * currentSettings.sensitivity); + + const rotX = time * 0.5; + const rotY = time * 0.7; + + const rotate = (x: number, y: number, z: number): [number, number, number] => { + const y1 = y * Math.cos(rotX) - z * Math.sin(rotX); + const z1 = y * Math.sin(rotX) + z * Math.cos(rotX); + const x2 = x * Math.cos(rotY) + z1 * Math.sin(rotY); + const z2 = -x * Math.sin(rotY) + z1 * Math.cos(rotY); + return [x2, y1, z2]; + }; + + // Spawn particles from active subpaths + if (treble * currentSettings.sensitivity > 80 && Math.random() > 0.3) { + const activeIndices = Array.from(activeCopiesRef.current.keys()); + if (activeIndices.length > 0) { + for (let i = 0; i < 8; i++) { + const randomPathIdx = activeIndices[Math.floor(Math.random() * activeIndices.length)]; + const randomPathItem = svgPaths[randomPathIdx]; + if (!randomPathItem) continue; + + const randomPath = randomPathItem.path; + const activeData = activeCopiesRef.current.get(randomPathIdx); + + if (randomPath && randomPath.length > 0 && activeData) { + const v = randomPath[Math.floor(Math.random() * randomPath.length)]; + const speed = 2 + Math.random() * 8 * currentSettings.speed * (treble / 255); + + const freqVal = dataArray[activeData.freqBin] / 255; + const individualZOffset = freqVal * 200 * currentSettings.sensitivity * activeData.dir; + + const vx = (Math.random() - 0.5) * speed * 2; + const vy = (Math.random() - 0.5) * speed * 2; + const vz = (Math.random() - 0.5) * speed * 2; + + const particleColor = Math.random() > 0.5 + ? `hsla(${180 + currentSettings.hueShift}, 100%, 60%, 1)` + : `hsla(${300 + currentSettings.hueShift}, 100%, 60%, 1)`; + + particles.push({ + x: v[0] * radius, + y: v[1] * radius, + z: v[2] * radius + individualZOffset, + vx, vy, vz, + life: 1, + maxLife: 0.5 + Math.random() * 1, + color: particleColor, + }); + } + } + } + } + + // Draw particles + for (let i = particles.length - 1; i >= 0; i--) { + const p = particles[i]; + p.x += p.vx; + p.y += p.vy; + p.z += p.vz; + p.life -= 0.02 * currentSettings.speed; + + if (p.life <= 0) { + particles.splice(i, 1); + continue; + } + + const [rx, ry, rz] = rotate(p.x, p.y, p.z); + const scale = 500 / (500 + rz); + const px = cx + rx * scale; + const py = cy + ry * scale; + + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(px - p.vx * scale * 2, py - p.vy * scale * 2); + ctx.strokeStyle = p.color.replace('1)', `${p.life / p.maxLife})`); + ctx.lineWidth = 2 * scale * currentSettings.scale; + ctx.stroke(); + } + + // Prepare SVG paths for drawing + const drawItems: { type: 'original' | 'copy'; pathIdx: number; path: number[][]; z: number; isClosed: boolean; zOffset?: number }[] = []; + + svgPaths.forEach((item, pathIdx) => { + const path = item.path; + if (path.length === 0) return; + + const first = path[0]; + const last = path[path.length - 1]; + const isClosed = path.length > 2 && Math.hypot(first[0] - last[0], first[1] - last[1]) < 0.05; + + let sumZOrig = 0; + for (let i = 0; i < path.length; i++) { + const [, , rz] = rotate(path[i][0] * radius, path[i][1] * radius, path[i][2] * radius); + sumZOrig += rz; + } + drawItems.push({ type: 'original', pathIdx, path, z: sumZOrig / path.length, isClosed }); + + const activeData = activeCopiesRef.current.get(pathIdx); + if (isClosed && activeData) { + const freqVal = dataArray[activeData.freqBin] / 255; + const individualZOffset = freqVal * 200 * currentSettings.sensitivity * activeData.dir; + + let sumZCopy = 0; + for (let i = 0; i < path.length; i++) { + const [, , rz] = rotate(path[i][0] * radius, path[i][1] * radius, path[i][2] * radius + individualZOffset); + sumZCopy += rz; + } + drawItems.push({ type: 'copy', pathIdx, path, z: sumZCopy / path.length, isClosed, zOffset: individualZOffset }); + } + }); + + // Sort items back-to-front + drawItems.sort((a, b) => b.z - a.z); + + // Draw items + drawItems.forEach(item => { + ctx.beginPath(); + + for (let i = 0; i < item.path.length; i++) { + const v = item.path[i]; + const [rx, ry, rz] = rotate(v[0] * radius, v[1] * radius, v[2] * radius + (item.zOffset || 0)); + const scale = 500 / (500 + rz); + const screenX = cx + rx * scale; + const screenY = cy + ry * scale; + + if (i === 0) { + ctx.moveTo(screenX, screenY); + } else { + ctx.lineTo(screenX, screenY); + } + } + + if (item.isClosed) { + ctx.closePath(); + } + + const hue = item.pathIdx % 2 === 0 ? 180 : 300; + const finalHue = (hue + currentSettings.hueShift) % 360; + + if (item.type === 'copy') { + ctx.fillStyle = `hsla(${finalHue}, 100%, 60%, 0.15)`; + ctx.fill(); + ctx.strokeStyle = `hsla(${finalHue}, 100%, 60%, 0.4)`; + ctx.lineWidth = 1 * currentSettings.scale; + ctx.shadowBlur = 0; + ctx.stroke(); + } else { + const pulse = bass / 255; + ctx.strokeStyle = `hsla(${finalHue}, 100%, 60%, 0.8)`; + ctx.lineWidth = (2 + pulse * 4) * currentSettings.scale; + ctx.shadowBlur = (10 + pulse * 60 * currentSettings.sensitivity) * currentSettings.scale; + ctx.shadowColor = `hsla(${finalHue}, 100%, ${50 + pulse * 30}%, ${0.6 + pulse * 0.4})`; + ctx.stroke(); + ctx.shadowBlur = 0; + } + }); + + ctx.shadowBlur = 0; + }; + + 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, ready]); + + return ( +
+ + + {ready && ( +
+ {showUI && ( + <> + {currentIconName && ( + + {currentIconName} + + )} + + + + )} + + +
+ )} +
+ ); +} diff --git a/src/components/visualizers/MilkDrop.tsx b/src/components/visualizers/MilkDrop.tsx new file mode 100644 index 0000000..e77c008 --- /dev/null +++ b/src/components/visualizers/MilkDrop.tsx @@ -0,0 +1,331 @@ +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +export default function MilkDrop({ 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.82; + + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + sourceRef.current = source; + + const freqBins = analyser.frequencyBinCount; + const freqData = new Uint8Array(freqBins); + const waveData = new Uint8Array(analyser.fftSize); + + // --- Three.js --- + const renderer = new THREE.WebGLRenderer({ alpha: false, antialias: false, powerPreference: 'high-performance' }); + renderer.setSize(w, h); + renderer.setPixelRatio(1); + renderer.autoClear = false; + + while (container.firstChild) container.removeChild(container.firstChild); + container.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + // Ping-pong render targets for feedback loop + const rtOpts: THREE.RenderTargetOptions = { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + type: THREE.UnsignedByteType, + }; + let rtA = new THREE.WebGLRenderTarget(w, h, rtOpts); + let rtB = new THREE.WebGLRenderTarget(w, h, rtOpts); + + // Audio data textures + const freqTex = new THREE.DataTexture(freqData, freqBins, 1, THREE.RedFormat); + freqTex.needsUpdate = true; + const waveTex = new THREE.DataTexture(waveData, analyser.fftSize, 1, THREE.RedFormat); + waveTex.needsUpdate = true; + + const geo = new THREE.PlaneGeometry(2, 2); + + // Feedback warp + composite material + const feedbackMat = new THREE.ShaderMaterial({ + uniforms: { + uPrev: { value: rtA.texture }, + uFreq: { value: freqTex }, + uWave: { value: waveTex }, + uTime: { value: 0 }, + uSens: { value: 1.0 }, + uSpeed: { value: 1.0 }, + uHue: { value: 0 }, + uScale: { value: 1.0 }, + uRes: { value: new THREE.Vector2(w, h) }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); + } + `, + fragmentShader: ` + precision highp float; + varying vec2 vUv; + + uniform sampler2D uPrev; + uniform sampler2D uFreq; + uniform sampler2D uWave; + uniform float uTime; + uniform float uSens; + uniform float uSpeed; + uniform float uHue; + uniform float uScale; + uniform vec2 uRes; + + #define PI 3.14159265 + #define TAU 6.28318530 + + vec3 hsl2rgb(float h, float s, float l) { + vec3 rgb = clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); + return l + s * (rgb - 0.5) * (1.0 - abs(2.0 * l - 1.0)); + } + + void main() { + vec2 uv = vUv; + float ar = uRes.x / uRes.y; + vec2 p = (uv - 0.5) * vec2(ar, 1.0); + float t = uTime; + + // --- Audio bands --- + float bass = texture2D(uFreq, vec2(0.04, 0.5)).r; + float mid = texture2D(uFreq, vec2(0.22, 0.5)).r; + float treble = texture2D(uFreq, vec2(0.55, 0.5)).r; + float energy = (bass * 2.0 + mid + treble * 0.5) / 3.5; + + float sB = bass * uSens; + float sM = mid * uSens; + float sT = treble * uSens; + float sE = energy * uSens; + + // === MOTION VECTORS (MilkDrop-style per-pixel warp) === + float dist = length(p); + + // Zoom: slight pull toward center, bass-reactive + float zoom = 1.0 - (0.006 + sB * 0.010) * uScale; + p *= zoom; + + // Rotation: varies with distance for spiral feel + float rot = (0.003 + sM * 0.007) * uSpeed * (1.0 - dist * 0.4); + float cs = cos(rot), sn = sin(rot); + p = mat2(cs, -sn, sn, cs) * p; + + // Per-pixel sinusoidal warp (multiple frequencies for organic motion) + float wa = (0.003 + sT * 0.007) * uScale; + float sp = uSpeed; + p.x += sin(p.y * 7.0 + t * 1.3 * sp) * wa; + p.y += cos(p.x * 7.0 + t * 1.1 * sp) * wa; + p.x += sin(p.y * 3.5 - t * 0.7 * sp + sB * 4.0) * wa * 0.4; + p.y += cos(p.x * 4.5 + t * 0.5 * sp + sM * 3.0) * wa * 0.4; + + // Sample previous frame with warped coordinates + vec2 wUv = p / vec2(ar, 1.0) + 0.5; + vec4 prev = texture2D(uPrev, wUv); + + // Decay (slight darkening to prevent saturation) + prev.rgb *= 0.990 - sE * 0.008; + + // Edge vignette in feedback (creates tunnel/zoom effect) + float vig = smoothstep(0.7, 0.3, dist); + prev.rgb *= 0.97 + 0.03 * vig; + + // Hue rotation on existing feedback content + if (uHue != 0.0) { + float hr = uHue / 360.0 * 0.04 * TAU; + float cH = cos(hr), sH = sin(hr); + vec3 o = prev.rgb; + prev.r = dot(o, vec3(0.299 + 0.701*cH + 0.168*sH, 0.587 - 0.587*cH + 0.330*sH, 0.114 - 0.114*cH - 0.497*sH)); + prev.g = dot(o, vec3(0.299 - 0.299*cH - 0.328*sH, 0.587 + 0.413*cH + 0.035*sH, 0.114 - 0.114*cH + 0.292*sH)); + prev.b = dot(o, vec3(0.299 - 0.300*cH + 1.250*sH, 0.587 - 0.588*cH - 1.050*sH, 0.114 + 0.886*cH - 0.203*sH)); + } + + // === NEW CONTENT === + vec3 nc = vec3(0.0); + vec2 cp = (uv - 0.5) * vec2(ar, 1.0); + float cDist = length(cp); + float cAng = atan(cp.y, cp.x); + + // 1. Waveform circle (inner ring) + float wn = (cAng + PI) / TAU; + float wv = texture2D(uWave, vec2(wn, 0.5)).r; + float wd = (wv - 0.5) * 0.12 * uSens * uScale; + float rInner = 0.18 * uScale + wd; + float innerLine = smoothstep(0.005, 0.0, abs(cDist - rInner)); + float h1 = mod(t * 0.12 * uSpeed + uHue / 360.0 + wn * 0.7, 1.0); + nc += hsl2rgb(h1, 0.95, 0.6) * innerLine * (0.4 + sE * 0.6); + + // 2. Frequency spectrum ring (outer) + float fv = texture2D(uFreq, vec2(wn * 0.5, 0.5)).r; + float rOuter = 0.32 * uScale + fv * 0.07 * uSens * uScale; + float outerLine = smoothstep(0.004, 0.0, abs(cDist - rOuter)); + float h2 = mod(t * 0.09 * uSpeed + uHue / 360.0 + 0.5 + wn * 0.5, 1.0); + nc += hsl2rgb(h2, 0.85, 0.55) * outerLine * (0.3 + sE * 0.7); + + // 3. Kaleidoscope lines (6-fold symmetry) + float nSym = 6.0; + float sa = mod(cAng + t * 0.25 * uSpeed, TAU / nSym) - PI / nSym; + vec2 sp2 = vec2(cos(sa), sin(sa)) * cDist; + float symLine = smoothstep(0.003, 0.0, abs(sp2.y)); + symLine *= smoothstep(0.38 * uScale, 0.06, cDist); + symLine *= sT; + float h3 = mod(t * 0.14 * uSpeed + uHue / 360.0 + cDist * 2.5, 1.0); + nc += hsl2rgb(h3, 0.8, 0.5) * symLine * 0.6; + + // 4. Center glow (bass pulse) + float glow = exp(-cDist * (5.5 / max(uScale, 0.01))) * sE * 0.35; + float hg = mod(t * 0.07 * uSpeed + uHue / 360.0 + 0.33, 1.0); + nc += hsl2rgb(hg, 0.75, 0.6) * glow; + + // Combine with soft clamp to prevent harsh clipping + vec3 col = prev.rgb + nc; + col = col / (1.0 + col * 0.15); + + gl_FragColor = vec4(col, 1.0); + } + `, + depthTest: false, + depthWrite: false, + }); + + // Display material (copies feedback buffer to screen) + const displayMat = new THREE.ShaderMaterial({ + uniforms: { + uTex: { value: rtB.texture }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); + } + `, + fragmentShader: ` + precision highp float; + varying vec2 vUv; + uniform sampler2D uTex; + void main() { + vec3 col = texture2D(uTex, vUv).rgb; + col = pow(col, vec3(0.95)); + gl_FragColor = vec4(col, 1.0); + } + `, + depthTest: false, + depthWrite: false, + }); + + const quad = new THREE.Mesh(geo, feedbackMat); + scene.add(quad); + + let time = 0; + let lastTime = performance.now(); + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + + const now = performance.now(); + const dt = (now - lastTime) / 1000; + lastTime = now; + + const cur = settingsRef.current; + time += dt * cur.speed; + + // Update audio data + analyser.getByteFrequencyData(freqData); + analyser.getByteTimeDomainData(waveData); + freqTex.needsUpdate = true; + waveTex.needsUpdate = true; + + // Feedback pass: read rtA → render to rtB + feedbackMat.uniforms.uPrev.value = rtA.texture; + feedbackMat.uniforms.uTime.value = time; + feedbackMat.uniforms.uSens.value = cur.sensitivity; + feedbackMat.uniforms.uSpeed.value = cur.speed; + feedbackMat.uniforms.uHue.value = cur.hueShift; + feedbackMat.uniforms.uScale.value = cur.scale; + + quad.material = feedbackMat; + renderer.setRenderTarget(rtB); + renderer.clear(); + renderer.render(scene, camera); + + // Display pass: copy rtB to screen + displayMat.uniforms.uTex.value = rtB.texture; + quad.material = displayMat; + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(scene, camera); + + // Swap ping-pong targets + const tmp = rtA; + rtA = rtB; + rtB = tmp; + }; + + const onResize = () => { + if (!containerRef.current) return; + const nw = containerRef.current.clientWidth; + const nh = containerRef.current.clientHeight; + renderer.setSize(nw, nh); + feedbackMat.uniforms.uRes.value.set(nw, nh); + rtA.setSize(nw, nh); + rtB.setSize(nw, nh); + }; + window.addEventListener('resize', onResize); + + draw(); + + return () => { + window.removeEventListener('resize', onResize); + if (animationRef.current) cancelAnimationFrame(animationRef.current); + if (sourceRef.current) sourceRef.current.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + geo.dispose(); + feedbackMat.dispose(); + displayMat.dispose(); + rtA.dispose(); + rtB.dispose(); + freqTex.dispose(); + waveTex.dispose(); + renderer.dispose(); + if (containerRef.current && renderer.domElement.parentNode === containerRef.current) { + containerRef.current.removeChild(renderer.domElement); + } + }; + }, [stream]); + + return
; +} diff --git a/src/components/visualizers/MilkDropWarp.tsx b/src/components/visualizers/MilkDropWarp.tsx new file mode 100644 index 0000000..933b8f2 --- /dev/null +++ b/src/components/visualizers/MilkDropWarp.tsx @@ -0,0 +1,355 @@ +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; +import { VisualizerSettings } from '../../types'; + +interface Props { + stream: MediaStream; + settings: VisualizerSettings; +} + +export default function MilkDropWarp({ 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 = 1024; + analyser.smoothingTimeConstant = 0.78; + + const source = audioCtx.createMediaStreamSource(stream); + source.connect(analyser); + sourceRef.current = source; + + const freqBins = analyser.frequencyBinCount; + const freqData = new Uint8Array(freqBins); + const waveData = new Uint8Array(analyser.fftSize); + + // --- Three.js --- + const renderer = new THREE.WebGLRenderer({ alpha: false, antialias: false, powerPreference: 'high-performance' }); + renderer.setSize(w, h); + renderer.setPixelRatio(1); + renderer.autoClear = false; + + while (container.firstChild) container.removeChild(container.firstChild); + container.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + // Ping-pong render targets + const rtOpts: THREE.RenderTargetOptions = { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + type: THREE.UnsignedByteType, + }; + let rtA = new THREE.WebGLRenderTarget(w, h, rtOpts); + let rtB = new THREE.WebGLRenderTarget(w, h, rtOpts); + + // Audio data textures + const freqTex = new THREE.DataTexture(freqData, freqBins, 1, THREE.RedFormat); + freqTex.needsUpdate = true; + const waveTex = new THREE.DataTexture(waveData, analyser.fftSize, 1, THREE.RedFormat); + waveTex.needsUpdate = true; + + const geo = new THREE.PlaneGeometry(2, 2); + + // Feedback warp material – deep tunnel / fractal aesthetic + const feedbackMat = new THREE.ShaderMaterial({ + uniforms: { + uPrev: { value: rtA.texture }, + uFreq: { value: freqTex }, + uWave: { value: waveTex }, + uTime: { value: 0 }, + uSens: { value: 1.0 }, + uSpeed: { value: 1.0 }, + uHue: { value: 0 }, + uScale: { value: 1.0 }, + uRes: { value: new THREE.Vector2(w, h) }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); + } + `, + fragmentShader: ` + precision highp float; + varying vec2 vUv; + + uniform sampler2D uPrev; + uniform sampler2D uFreq; + uniform sampler2D uWave; + uniform float uTime; + uniform float uSens; + uniform float uSpeed; + uniform float uHue; + uniform float uScale; + uniform vec2 uRes; + + #define PI 3.14159265 + #define TAU 6.28318530 + + vec3 hsl2rgb(float h, float s, float l) { + vec3 rgb = clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); + return l + s * (rgb - 0.5) * (1.0 - abs(2.0 * l - 1.0)); + } + + void main() { + float ar = uRes.x / uRes.y; + vec2 p = (vUv - 0.5) * vec2(ar, 1.0); + float t = uTime; + + // --- Audio bands --- + float bass = texture2D(uFreq, vec2(0.03, 0.5)).r; + float mid = texture2D(uFreq, vec2(0.18, 0.5)).r; + float treble = texture2D(uFreq, vec2(0.50, 0.5)).r; + float energy = (bass * 2.0 + mid + treble) / 4.0; + + float sB = bass * uSens; + float sM = mid * uSens; + float sT = treble * uSens; + float sE = energy * uSens; + + // === DEEP TUNNEL WARP === + float dist = length(p); + float ang = atan(p.y, p.x); + + // Strong inward zoom – bass kicks pull you deeper + float zoom = 1.0 - (0.012 + sB * 0.018) * uScale; + p *= zoom; + + // Spiral rotation – accelerates toward center for vortex feel + float spiral = (0.008 + sM * 0.014) * uSpeed / (0.3 + dist); + float cs = cos(spiral), sn = sin(spiral); + p = mat2(cs, -sn, sn, cs) * p; + + // Radial breathing (expand/contract with mid) + float breath = 1.0 + sin(t * 2.2 * uSpeed) * sM * 0.008 * uScale; + p *= breath; + + // Hyperbolic warp – creates star/diamond distortion patterns + float hStrength = (0.004 + sT * 0.008) * uScale; + float hAng = ang * 4.0 + t * 0.6 * uSpeed; + p += vec2(cos(hAng), sin(hAng)) * hStrength * (0.5 - dist); + + // Domain-warped displacement for organic complexity + float wStr = (0.002 + sE * 0.005) * uScale; + float wx = sin(p.y * 11.0 + t * 1.7 * uSpeed + sB * 5.0); + float wy = cos(p.x * 13.0 - t * 1.3 * uSpeed + sM * 4.0); + p.x += wx * wStr; + p.y += wy * wStr; + + // Sample feedback + vec2 wUv = p / vec2(ar, 1.0) + 0.5; + vec4 prev = texture2D(uPrev, wUv); + + // Decay with color channel separation for chromatic drift + float decayBase = 0.375 - sE * 0.010; + prev.r *= decayBase + 0.002; + prev.g *= decayBase; + prev.b *= decayBase + 0.004; + + // Tunnel vignette: darken edges, preserve center depth + float vig = smoothstep(0.75, 0.15, dist); + prev.rgb *= 0.95 + 0.05 * vig; + + // Gradual hue rotation on feedback + if (uHue != 0.0) { + float hr = uHue / 360.0 * 0.06 * TAU; + float cH = cos(hr), sH = sin(hr); + vec3 o = prev.rgb; + prev.r = dot(o, vec3(0.299 + 0.701*cH + 0.168*sH, 0.587 - 0.587*cH + 0.330*sH, 0.114 - 0.114*cH - 0.497*sH)); + prev.g = dot(o, vec3(0.299 - 0.299*cH - 0.328*sH, 0.587 + 0.413*cH + 0.035*sH, 0.114 - 0.114*cH + 0.292*sH)); + prev.b = dot(o, vec3(0.299 - 0.300*cH + 1.250*sH, 0.587 - 0.588*cH - 1.050*sH, 0.114 + 0.886*cH - 0.203*sH)); + } + + // === NEW CONTENT === + vec3 nc = vec3(0.0); + vec2 cp = (vUv - 0.5) * vec2(ar, 1.0); + float cDist = length(cp); + float cAng = atan(cp.y, cp.x); + + // 1. Radial frequency bars – spectrum mapped around the center + float nBars = 48.0; + float barAng = mod(cAng + PI, TAU) / TAU; + float barIdx = floor(barAng * nBars) / nBars; + float barFrac = fract(barAng * nBars); + float barGap = smoothstep(0.0, 0.08, barFrac) * smoothstep(1.0, 0.92, barFrac); + float freqVal = texture2D(uFreq, vec2(barIdx * 0.6, 0.5)).r * uSens; + float barLen = 0.08 + freqVal * 0.28 * uScale; + float barStart = 0.06 * uScale; + float barMask = step(barStart, cDist) * step(cDist, barStart + barLen) * barGap; + float bH = mod(barIdx + t * 0.08 * uSpeed + uHue / 360.0, 1.0); + nc += hsl2rgb(bH, 0.9, 0.45) * barMask * (0.3 + freqVal * 0.4); + + // 2. Waveform spiral – time-domain data drawn as a rotating spiral + float spiralArms = 3.0; + float spiralAng = mod(cAng + t * 0.4 * uSpeed, TAU); + float armPhase = mod(spiralAng * spiralArms / TAU, 1.0); + float wIdx = mod(armPhase + cDist * 2.0, 1.0); + float wVal = texture2D(uWave, vec2(wIdx, 0.5)).r; + float wDev = (wVal - 0.5) * 0.06 * uSens * uScale; + float spiralR = 0.12 * uScale + cDist * 0.15 + wDev; + float spiralLine = smoothstep(0.006, 0.0, abs(fract(spiralAng * spiralArms / TAU) - 0.5) - 0.46); + spiralLine *= smoothstep(0.45 * uScale, 0.08, cDist) * smoothstep(0.04, 0.08, cDist); + float sH = mod(cDist * 3.0 + t * 0.1 * uSpeed + uHue / 360.0 + 0.5, 1.0); + nc += hsl2rgb(sH, 0.85, 0.4) * spiralLine * (0.2 + sE * 0.45); + + // 3. Pulsing concentric rings – tunnel depth markers + float ringSpace = 0.09 * uScale; + float ringPhase = mod(cDist - t * 0.15 * uSpeed * uScale, ringSpace); + float ringLine = smoothstep(0.003, 0.0, abs(ringPhase - ringSpace * 0.5) - ringSpace * 0.45); + float ringBrightness = (0.08 + sB * 0.14) * smoothstep(0.55 * uScale, 0.05, cDist); + float rH = mod(cDist * 2.0 + t * 0.06 * uSpeed + uHue / 360.0 + 0.25, 1.0); + nc += hsl2rgb(rH, 0.7, 0.45) * ringLine * ringBrightness; + + // 4. Center flare – energy burst + float flare = exp(-cDist * (8.0 / max(uScale, 0.01))) * sE * 0.28; + // Cross-shaped flare spikes + float spike = max( + exp(-abs(cp.x) * 40.0) * exp(-abs(cp.y) * 8.0), + exp(-abs(cp.y) * 40.0) * exp(-abs(cp.x) * 8.0) + ) * sE * 0.15; + float fH = mod(t * 0.05 * uSpeed + uHue / 360.0, 1.0); + nc += hsl2rgb(fH, 0.8, 0.55) * (flare + spike); + + // Combine + vec3 col = prev.rgb + nc; + col = col / (1.0 + col * 0.22); + + gl_FragColor = vec4(col, 1.0); + } + `, + depthTest: false, + depthWrite: false, + }); + + // Display pass material + const displayMat = new THREE.ShaderMaterial({ + uniforms: { + uTex: { value: rtB.texture }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); + } + `, + fragmentShader: ` + precision highp float; + varying vec2 vUv; + uniform sampler2D uTex; + void main() { + vec3 col = texture2D(uTex, vUv).rgb; + col = pow(col, vec3(1.0)); + gl_FragColor = vec4(col, 1.0); + } + `, + depthTest: false, + depthWrite: false, + }); + + const quad = new THREE.Mesh(geo, feedbackMat); + scene.add(quad); + + let time = 0; + let lastTime = performance.now(); + + const draw = () => { + animationRef.current = requestAnimationFrame(draw); + + const now = performance.now(); + const dt = (now - lastTime) / 1000; + lastTime = now; + + const cur = settingsRef.current; + time += dt * cur.speed; + + // Update audio data + analyser.getByteFrequencyData(freqData); + analyser.getByteTimeDomainData(waveData); + freqTex.needsUpdate = true; + waveTex.needsUpdate = true; + + // Feedback pass: read rtA → render to rtB + feedbackMat.uniforms.uPrev.value = rtA.texture; + feedbackMat.uniforms.uTime.value = time; + feedbackMat.uniforms.uSens.value = cur.sensitivity; + feedbackMat.uniforms.uSpeed.value = cur.speed; + feedbackMat.uniforms.uHue.value = cur.hueShift; + feedbackMat.uniforms.uScale.value = cur.scale; + + quad.material = feedbackMat; + renderer.setRenderTarget(rtB); + renderer.clear(); + renderer.render(scene, camera); + + // Display pass: copy rtB to screen + displayMat.uniforms.uTex.value = rtB.texture; + quad.material = displayMat; + renderer.setRenderTarget(null); + renderer.clear(); + renderer.render(scene, camera); + + // Swap + const tmp = rtA; + rtA = rtB; + rtB = tmp; + }; + + const onResize = () => { + if (!containerRef.current) return; + const nw = containerRef.current.clientWidth; + const nh = containerRef.current.clientHeight; + renderer.setSize(nw, nh); + feedbackMat.uniforms.uRes.value.set(nw, nh); + rtA.setSize(nw, nh); + rtB.setSize(nw, nh); + }; + window.addEventListener('resize', onResize); + + draw(); + + return () => { + window.removeEventListener('resize', onResize); + if (animationRef.current) cancelAnimationFrame(animationRef.current); + if (sourceRef.current) sourceRef.current.disconnect(); + if (audioCtxRef.current && audioCtxRef.current.state !== 'closed') { + audioCtxRef.current.close(); + } + geo.dispose(); + feedbackMat.dispose(); + displayMat.dispose(); + rtA.dispose(); + rtB.dispose(); + freqTex.dispose(); + waveTex.dispose(); + renderer.dispose(); + if (containerRef.current && renderer.domElement.parentNode === containerRef.current) { + containerRef.current.removeChild(renderer.domElement); + } + }; + }, [stream]); + + return
; +}