From b94c787231d4d6697e5a3c31f8e555e156c43cc4 Mon Sep 17 00:00:00 2001 From: luciano Date: Mon, 29 Jun 2026 13:06:15 +0200 Subject: [PATCH] DJ Visualizer Pi: standalone Node+WebSocket server with mobile PWA remote New separate project (the Electron Mac app stays untouched). Reuses the visual engine (WebGL shaders, effect catalog, Web Audio) verbatim and replaces Electron IPC with a Node HTTP+WebSocket server: - server.js: serves the kiosk output page + mobile control PWA, relays the same ctl/rpt JSON protocol over WebSocket, plus REST for media library/upload, JSON state (pads), waveform peaks and MP4 recording (ffmpeg). - public/js/net.js: window.djv shim over WebSocket+HTTP so output.js runs intact. - public/index.html + control.js: touch-first mobile remote (effects, media/queue, ticker, pads, audio/EQ, fullscreen + recording). - PWA (manifest + service worker + generated icons). - scripts/: Chromium kiosk launcher + install-pi.sh (systemd units for Pi 5). Verified: REST endpoints, WebSocket relay both directions, control PWA loads the 2958-effect catalog and renders. (Headless WebGL output not testable without GPU.) Co-Authored-By: Claude Opus 4.8 --- .gitignore | 8 + README.md | 78 + media/.gitkeep | 0 package-lock.json | 952 +++++ package.json | 16 + public/css/mobile.css | 91 + public/css/style.css | 798 ++++ public/index.html | 137 + public/js/control.js | 204 + public/js/engine/audio.js | 326 ++ public/js/engine/effects.js | 110 + public/js/engine/shaders.js | 414 +++ public/js/engine/visualizer.js | 139 + public/js/net.js | 58 + public/js/output.js | 501 +++ public/manifest.webmanifest | 15 + public/output.html | 52 + public/sw.js | 19 + scripts/gen-icons.js | 54 + scripts/install-pi.sh | 65 + scripts/kiosk.sh | 12 + server.js | 173 + svg/Colorful-Dancing-Women-Silhouette.svg | 560 +++ svg/Gerald_G_Fifties_Dancers_4.svg | 6 + svg/Lithe-Dancing-Woman-Silhouette.svg | 328 ++ svg/Vintage-Ballerina-Silhouette.svg | 115 + svg/fireworks-svgrepo-com (1).svg | 73 + svg/fireworks-svgrepo-com (2).svg | 89 + svg/fireworks-svgrepo-com.svg | 2 + svg/freesvg-audio-equalizer-perspective.svg | 186 + svg/freesvg-audio-player.svg | 62 + svg/freesvg-crowd-hands.svg | 497 +++ svg/freesvg-crowd-of-people.svg | 124 + svg/freesvg-dance-moves.svg | 191 + svg/freesvg-dancing-couple-silhouette.svg | 129 + svg/freesvg-dancing-man-2.svg | 59 + ...vg-dancing-music-notes-vector-graphics.svg | 196 + svg/freesvg-dancing-silhouette.svg | 1144 ++++++ svg/freesvg-discos-lp.svg | 79 + svg/freesvg-djs-turntable.svg | 3289 +++++++++++++++++ svg/freesvg-drum-set-silhouette.svg | 145 + svg/freesvg-guitar-silhouettes.svg | 193 + svg/freesvg-hand-torch.svg | 59 + svg/freesvg-headphone-plug-stereo.svg | 230 ++ svg/freesvg-headphones-silhouette.svg | 52 + svg/freesvg-jumping-dancer.svg | 137 + svg/freesvg-lightning-bolt.svg | 98 + svg/freesvg-lightning-bolt1.svg | 201 + svg/freesvg-lightning-stylized.svg | 21 + svg/freesvg-music-silhouette.svg | 9 + ...eesvg-music-visual-effect-vector-image.svg | 2132 +++++++++++ ...freesvg-musical-notes-silhouette-image.svg | 29 + svg/freesvg-musical-notes-silhouette.svg | 99 + svg/freesvg-musical-notes-with-headphones.svg | 1842 +++++++++ svg/freesvg-person-silhouette.svg | 179 + svg/freesvg-public-speaker-silhouette.svg | 79 + svg/freesvg-saxophone-player-2.svg | 134 + svg/freesvg-saxophone-player.svg | 967 +++++ svg/freesvg-saxophone-silhouette.svg | 445 +++ svg/freesvg-shooting-star.svg | 4 + svg/freesvg-sound-wave-radial.svg | 872 +++++ svg/freesvg-sound-wave.svg | 1081 ++++++ svg/freesvg-speaker-symbol.svg | 96 + svg/freesvg-spectrum-sound-wave.svg | 2974 +++++++++++++++ ...man-dancing-silhouette-svgrepo-com (1).svg | 18 + ...man-dancing-silhouette-svgrepo-com (2).svg | 18 + svg/man-dancing-silhouette-svgrepo-com.svg | 18 + svg/music-instruments-drum-svgrepo-com.svg | 26 + svg/music-note-2-svgrepo-com.svg | 6 + svg/music-note-3-svgrepo-com.svg | 17 + svg/music-note-double-svgrepo-com.svg | 4 + svg/music-note-svgrepo-com (1).svg | 17 + svg/music-note-svgrepo-com.svg | 9 + svg/music-player-cinema-svgrepo-com.svg | 29 + svg/music-player-circle-svgrepo-com.svg | 36 + svg/music-player-song-svgrepo-com.svg | 17 + svg/stars-shine-svgrepo-com.svg | 68 + svg/stars-star-svgrepo-com (1).svg | 49 + svg/stars-star-svgrepo-com.svg | 53 + svg/stars-svgrepo-com.svg | 22 + ...an-dancing-light-skin-tone-svgrepo-com.svg | 158 + 81 files changed, 23994 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 media/.gitkeep create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/css/mobile.css create mode 100644 public/css/style.css create mode 100644 public/index.html create mode 100644 public/js/control.js create mode 100644 public/js/engine/audio.js create mode 100644 public/js/engine/effects.js create mode 100644 public/js/engine/shaders.js create mode 100644 public/js/engine/visualizer.js create mode 100644 public/js/net.js create mode 100644 public/js/output.js create mode 100644 public/manifest.webmanifest create mode 100644 public/output.html create mode 100644 public/sw.js create mode 100644 scripts/gen-icons.js create mode 100644 scripts/install-pi.sh create mode 100644 scripts/kiosk.sh create mode 100644 server.js create mode 100644 svg/Colorful-Dancing-Women-Silhouette.svg create mode 100644 svg/Gerald_G_Fifties_Dancers_4.svg create mode 100644 svg/Lithe-Dancing-Woman-Silhouette.svg create mode 100644 svg/Vintage-Ballerina-Silhouette.svg create mode 100644 svg/fireworks-svgrepo-com (1).svg create mode 100644 svg/fireworks-svgrepo-com (2).svg create mode 100644 svg/fireworks-svgrepo-com.svg create mode 100644 svg/freesvg-audio-equalizer-perspective.svg create mode 100644 svg/freesvg-audio-player.svg create mode 100644 svg/freesvg-crowd-hands.svg create mode 100644 svg/freesvg-crowd-of-people.svg create mode 100644 svg/freesvg-dance-moves.svg create mode 100644 svg/freesvg-dancing-couple-silhouette.svg create mode 100644 svg/freesvg-dancing-man-2.svg create mode 100644 svg/freesvg-dancing-music-notes-vector-graphics.svg create mode 100644 svg/freesvg-dancing-silhouette.svg create mode 100644 svg/freesvg-discos-lp.svg create mode 100644 svg/freesvg-djs-turntable.svg create mode 100644 svg/freesvg-drum-set-silhouette.svg create mode 100644 svg/freesvg-guitar-silhouettes.svg create mode 100644 svg/freesvg-hand-torch.svg create mode 100644 svg/freesvg-headphone-plug-stereo.svg create mode 100644 svg/freesvg-headphones-silhouette.svg create mode 100644 svg/freesvg-jumping-dancer.svg create mode 100644 svg/freesvg-lightning-bolt.svg create mode 100644 svg/freesvg-lightning-bolt1.svg create mode 100644 svg/freesvg-lightning-stylized.svg create mode 100644 svg/freesvg-music-silhouette.svg create mode 100644 svg/freesvg-music-visual-effect-vector-image.svg create mode 100644 svg/freesvg-musical-notes-silhouette-image.svg create mode 100644 svg/freesvg-musical-notes-silhouette.svg create mode 100644 svg/freesvg-musical-notes-with-headphones.svg create mode 100644 svg/freesvg-person-silhouette.svg create mode 100644 svg/freesvg-public-speaker-silhouette.svg create mode 100644 svg/freesvg-saxophone-player-2.svg create mode 100644 svg/freesvg-saxophone-player.svg create mode 100644 svg/freesvg-saxophone-silhouette.svg create mode 100644 svg/freesvg-shooting-star.svg create mode 100644 svg/freesvg-sound-wave-radial.svg create mode 100644 svg/freesvg-sound-wave.svg create mode 100644 svg/freesvg-speaker-symbol.svg create mode 100644 svg/freesvg-spectrum-sound-wave.svg create mode 100644 svg/man-dancing-silhouette-svgrepo-com (1).svg create mode 100644 svg/man-dancing-silhouette-svgrepo-com (2).svg create mode 100644 svg/man-dancing-silhouette-svgrepo-com.svg create mode 100644 svg/music-instruments-drum-svgrepo-com.svg create mode 100644 svg/music-note-2-svgrepo-com.svg create mode 100644 svg/music-note-3-svgrepo-com.svg create mode 100644 svg/music-note-double-svgrepo-com.svg create mode 100644 svg/music-note-svgrepo-com (1).svg create mode 100644 svg/music-note-svgrepo-com.svg create mode 100644 svg/music-player-cinema-svgrepo-com.svg create mode 100644 svg/music-player-circle-svgrepo-com.svg create mode 100644 svg/music-player-song-svgrepo-com.svg create mode 100644 svg/stars-shine-svgrepo-com.svg create mode 100644 svg/stars-star-svgrepo-com (1).svg create mode 100644 svg/stars-star-svgrepo-com.svg create mode 100644 svg/stars-svgrepo-com.svg create mode 100644 svg/woman-dancing-light-skin-tone-svgrepo-com.svg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0e7f75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +recordings/ +media/* +!media/.gitkeep +state/*.json +public/icons/*.png +.DS_Store +npm-debug.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b63256 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# DJ Visualizer Pi + +Versione **standalone** del DJ Visualizer pensata per girare su un **Raspberry Pi 5** +e farsi comandare da **telefono/tablet** (webapp PWA, iOS + Android, niente store). + +Riusa il motore grafico/audio dell'app Mac (shader WebGL2, catalogo effetti, Web Audio): +cambia solo il "tubo" di comunicazione — l'IPC di Electron è sostituito da un +**server Node con WebSocket**, e il pannello di controllo è una **webapp mobile**. + +## Architettura + +``` + Raspberry Pi 5 (device standalone) + ┌───────────────────────────────────────────────┐ + │ server.js (Node) │ + │ • serve la PWA di controllo ── HTTP ──> 📱 │ + │ • relay comandi ── WebSocket ──> 📱 <─> 🖥 │ + │ • API: media, stato, peaks, registrazione │ + │ │ + │ Chromium kiosk ──> output.html (WebGL+audio)│ + │ └── fullscreen su HDMI ──> proiettore │ + │ Audio ──> uscita Pi (HDMI/DAC USB) ──> casse │ + └───────────────────────────────────────────────┘ +``` + +- **Output** (`public/output.html`): gira sul Pi in Chromium kiosk, rende i visual a 60fps + e riproduce l'audio dei file sul Pi. Riceve comandi via WebSocket. +- **Controllo** (`public/index.html`): la webapp che apri dal telefono. Stesso protocollo + di messaggi JSON dell'app Mac (`effect`, `playTrack`, `ticker*`, `bandGain`, …). + +## Provarlo sul Mac (prima del Pi) + +```bash +cd DjVisualizerPi +npm install +node scripts/gen-icons.js # genera le icone PWA +npm start +``` + +- Output: (clicca una volta per fullscreen/audio) +- Controllo: (aprilo anche dal telefono sulla stessa rete) + +Metti qualche brano nella cartella `media/` (o caricalo dall'app, tab **Media**). + +## Installazione sul Raspberry Pi 5 + +Pi OS Bookworm (64-bit). Copia la cartella sul Pi e: + +```bash +bash scripts/install-pi.sh +``` + +Installa node/ffmpeg/chromium/cage, crea due servizi systemd +(`djvisualizer-server` + `djvisualizer-kiosk`) e li avvia. Al boot il Pi mostra +i visual a tutto schermo; dal telefono apri `http://:8080/`. + +I brani vanno in `media/` (chiavetta USB, condivisione di rete, o upload dall'app). + +### Rete +- **Hotspot del Pi** (consigliato da palco): configura il Pi come access point + (NetworkManager: `nmcli device wifi hotspot`), poi il telefono si collega alla + rete del Pi e apre l'indirizzo. +- **WiFi del locale**: il Pi e il telefono sulla stessa rete; usa l'IP del Pi. + +## Funzioni del telecomando +- **🌀 Effetti**: libreria completa (famiglie + ricerca) + sagome SVG. +- **🎵 Media**: libreria sul dispositivo, upload, coda con play/seek/prev/next. +- **🔤 Testo**: ticker con direzione, posizione, effetto, grandezza, velocità, colore. +- **🎹 Pad**: griglia 3×4 (tieni premuto per assegnare un brano). +- **🎚 Audio**: reattività, velocità, EQ visual (bassi/medi/alti) + meter live. +- **🖥 Out**: fullscreen output + registrazione MP4 (16:9, 9:16, 1:1, 4:3). + +## Da fare (port successivo dall'app Mac) +- Scene a tempo per brano (effetto/testo/immagine con timing indipendente). +- Trim inizio/fine + waveform per traccia nella coda. +- Intermezzi e pause tra i brani. + +> L'app Mac (Electron) resta un progetto separato e invariato. diff --git a/media/.gitkeep b/media/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f8da9ee --- /dev/null +++ b/package-lock.json @@ -0,0 +1,952 @@ +{ + "name": "dj-visualizer-pi", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dj-visualizer-pi", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "express": "^4.19.2", + "multer": "^2.0.1", + "ws": "^8.18.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3071b43 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "dj-visualizer-pi", + "version": "0.1.0", + "description": "Standalone audio-reactive visualizer for Raspberry Pi 5, controlled from a mobile web app (PWA).", + "author": "Dj LuZa", + "license": "MIT", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.19.2", + "multer": "^2.0.1", + "ws": "^8.18.0" + } +} diff --git a/public/css/mobile.css b/public/css/mobile.css new file mode 100644 index 0000000..64aa66c --- /dev/null +++ b/public/css/mobile.css @@ -0,0 +1,91 @@ +:root { --bg:#0b0b14; --panel:#15151f; --line:rgba(255,255,255,.10); --accent:#7aa2ff; --txt:#e8e8f0; } +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } +html, body { margin: 0; height: 100%; } +body { + background: var(--bg); color: var(--txt); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Helvetica, Arial, sans-serif; + display: flex; flex-direction: column; + padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); +} + +#bar { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; border-bottom: 1px solid var(--line); + font-size: 15px; position: sticky; top: 0; background: var(--bg); z-index: 5; +} +#bar #now { margin-left: auto; opacity: .7; font-size: 12px; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#conn { font-size: 10px; padding: 2px 7px; border-radius: 10px; } +#conn.on { background: rgba(110,231,160,.18); color: #8ef0b0; } +#conn.off { background: rgba(255,138,138,.18); color: #ff9a9a; } + +main { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 12px 12px 90px; } +.panel.hidden { display: none; } + +.lbl { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; opacity: .6; margin: 16px 0 7px; } +.hint, .empty { font-size: 12px; opacity: .55; margin: 8px 2px; line-height: 1.4; } +.row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } +.row > * { flex: 1; } +.chk { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; font-size: 13px; } + +button, select, input, textarea { + font: inherit; color: var(--txt); background: var(--panel); + border: 1px solid var(--line); border-radius: 11px; padding: 12px; +} +button { cursor: pointer; } +button:active { filter: brightness(1.3); } +button.wide { width: 100%; margin-top: 10px; font-size: 15px; } +input[type=range] { -webkit-appearance: none; appearance: none; background: transparent; padding: 0; height: 34px; border: none; } +input[type=range]::-webkit-slider-runnable-track { height: 6px; border-radius: 4px; background: rgba(255,255,255,.14); } +input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; border-radius: 50%; background: var(--accent); margin-top: -9px; box-shadow: 0 1px 4px rgba(0,0,0,.5); } +input[type=color] { padding: 2px; width: 46px; height: 38px; flex: 0 0 auto; } +textarea { width: 100%; min-height: 90px; resize: vertical; } + +.list { display: flex; flex-direction: column; gap: 6px; } +.item { + display: flex; align-items: center; gap: 8px; + padding: 13px 12px; background: var(--panel); + border: 1px solid var(--line); border-radius: 11px; font-size: 14px; +} +.item span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.item button { flex: 0 0 auto; padding: 7px 12px; border-radius: 9px; } +.item.active { border-color: var(--accent); background: rgba(110,150,255,.18); } +#fx-list { max-height: 52vh; overflow-y: auto; } +#lib { max-height: 40vh; overflow-y: auto; } + +.nowbar { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 12px; margin-bottom: 12px; } +.nowbar.hidden { display: none; } +.np-top { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; } +.np-top #np-name { max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nowbar .row { margin: 8px 0 0; } +.nowbar .row button { font-size: 18px; } + +.pad-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 10px; } +.pad { + aspect-ratio: 1 / 1; display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 4px; font-size: 22px; border-radius: 14px; text-align: center; overflow: hidden; +} +.pad small { font-size: 10px; opacity: .7; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 4px; } +.pad.set { background: rgba(110,150,255,.14); border-color: rgba(120,150,255,.4); } +.pad.active { background: rgba(110,231,160,.22); border-color: #6ee7a0; } +.pad.assign { outline: 2px dashed var(--accent); } + +.meters { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; } +.meter { display: flex; align-items: center; gap: 8px; font-size: 10px; } +.meter span { width: 38px; opacity: .6; } +.meter i { display: block; height: 10px; width: 0; border-radius: 5px; background: linear-gradient(90deg,#5ad1ff,#7aa2ff,#ff7ad1); transition: width .08s linear; flex: 1; } +.band.b { color: #8fd0ff; } .band.m { color: #b6ff9b; } .band.t { color: #ffd27a; } + +#rec.recording { background: rgba(255,80,80,.25); border-color: #ff6a6a; } + +#tabs { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; + display: flex; background: #0e0e18; border-top: 1px solid var(--line); + padding-bottom: env(safe-area-inset-bottom); +} +#tabs button { + flex: 1; background: none; border: none; border-radius: 0; + display: flex; flex-direction: column; align-items: center; gap: 2px; + font-size: 19px; padding: 9px 0 10px; opacity: .5; +} +#tabs button small { font-size: 9px; } +#tabs button.active { opacity: 1; color: var(--accent); } diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..7586ff1 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,798 @@ +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: #000; + color: #e8e8f0; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Helvetica, Arial, sans-serif; + cursor: default; +} + +body.hide-cursor { cursor: none; } + +#gl { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + display: block; +} + +/* ============ CONTROL WINDOW LAYOUT ============ */ +body.control { + display: flex; + flex-direction: column; + height: 100vh; + overflow-x: hidden; /* never a horizontal scrollbar */ + overflow-y: auto; + background: #0e0e16; + cursor: default; +} + +#topbar { + flex: 0 0 auto; + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid rgba(255,255,255,0.08); + background: rgba(20,20,30,0.6); +} +#topbar strong { font-size: 15px; letter-spacing: 0.3px; } +#topbar #status { font-size: 11px; opacity: 0.6; font-variant-numeric: tabular-nums; } + +/* Tab bar */ +#tabs { + flex: 0 0 auto; + display: flex; + gap: 4px; + padding: 8px 10px 0; + border-bottom: 1px solid rgba(255,255,255,0.08); + background: rgba(20,20,30,0.45); + overflow-x: auto; +} +#tabs button { + flex: 0 0 auto; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + border-radius: 8px 8px 0 0; + padding: 8px 12px; + opacity: 0.6; + font-size: 13px; +} +#tabs button:hover { background: rgba(255,255,255,0.06); opacity: 0.9; } +#tabs button.active { + opacity: 1; + background: rgba(110,150,255,0.14); + border-bottom-color: #7aa2ff; +} + +#content { flex: 1 1 auto; overflow-y: auto; min-width: 0; } + +.tab-panel { display: none; } +.tab-panel.active { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 14px; + align-content: start; +} +.tab-panel section { + min-width: 0; /* allow grid cells to shrink, no overflow */ + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 14px; + padding: 12px; +} +.tab-panel section.span2 { grid-column: 1 / -1; } + +@media (max-width: 460px) { + .tab-panel.active { grid-template-columns: 1fr; } +} + +body.control label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + opacity: 0.6; + margin: 8px 0 6px; +} +body.control label:first-child { margin-top: 0; } +body.control label span { float: right; opacity: 0.9; text-transform: none; letter-spacing: 0; } +body.control .chk { text-transform: none; letter-spacing: 0; opacity: 0.85; margin: 0; } + +#hints { + flex: 0 0 auto; + font-size: 10.5px; + opacity: 0.5; + line-height: 1.7; + padding: 10px 16px; + border-top: 1px solid rgba(255,255,255,0.08); +} + +body.control select { width: 100%; max-width: 100%; } + +/* Per-band equaliser labels + sliders */ +.band { display: block; } +#bass-gain { accent-color: #5ad1ff; } +#mid-gain { accent-color: #7aa2ff; } +#treble-gain { accent-color: #ff7ad1; } +.band-bass span, .band-mid span, .band-treble span { color: #fff; } + +/* ---- Effect library ---- */ +.effect-list { + margin-top: 8px; + max-height: 220px; + overflow-y: auto; + border: 1px solid rgba(255,255,255,0.07); + border-radius: 10px; + padding: 4px; +} +.fx { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border-radius: 7px; + font-size: 12px; + cursor: pointer; +} +.fx:hover { background: rgba(255,255,255,0.07); } +.fx.active { background: rgba(110,150,255,0.25); } +.fx .fx-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fx .fx-add { + flex: 0 0 auto; + padding: 2px 8px; + border-radius: 6px; + font-size: 12px; +} +#effect-family { flex: 0 0 45%; } +#effect-search { flex: 1; margin: 0; } + +/* ---- Pad bank (5×5 launchpad) ---- */ +.pad-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-top: 8px; +} +.pad { + position: relative; + aspect-ratio: 1 / 1; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 10px; + background: rgba(255,255,255,0.04); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + cursor: pointer; + overflow: hidden; + padding: 4px; + transition: background 0.12s ease, box-shadow 0.12s ease; +} +.pad:hover { background: rgba(255,255,255,0.09); } +.pad.filled { background: rgba(90,120,200,0.18); border-color: rgba(120,150,255,0.4); } +.pad.active { + background: rgba(60,200,120,0.35); + border-color: #5ad18a; + box-shadow: 0 0 12px rgba(90,230,150,0.55); +} +.pad-num { position: absolute; top: 3px; left: 5px; font-size: 9px; opacity: 0.5; } +.pad-name { + font-size: 9.5px; + line-height: 1.15; + word-break: break-word; + max-height: 100%; + overflow: hidden; + opacity: 0.92; +} +.pad-plus { font-size: 22px; opacity: 0.3; } +.pad-x { + position: absolute; + top: 1px; right: 2px; + background: transparent; + border: none; + color: #ff8a8a; + font-size: 11px; + padding: 0 3px; + opacity: 0; + flex: none; + width: auto; +} +.pad:hover .pad-x { opacity: 0.9; } +.pad-key { + position: absolute; + bottom: 2px; + right: 3px; + background: rgba(255,255,255,0.12); + border: none; + color: #cdd6ff; + font-size: 9px; + font-weight: 700; + line-height: 1; + padding: 2px 4px; + border-radius: 4px; + min-width: 14px; + width: auto; + flex: none; + opacity: 0.85; +} +.pad-key:hover { background: rgba(120,150,255,0.45); } +.pad-time { + position: absolute; + bottom: 3px; + left: 4px; + font-size: 8.5px; + opacity: 0.7; + font-variant-numeric: tabular-nums; +} +.pad-prog { + position: absolute; + left: 0; bottom: 0; + height: 3px; + background: linear-gradient(90deg, #5ad18a, #aef0c0); + width: 0; +} + +/* Larger ticker text area */ +textarea.textarea-lg { + min-height: 78px; + resize: vertical; + font-size: 14px; + line-height: 1.4; +} + +/* ---- Recording status ---- */ +.rec-status { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-size: 13px; +} +#rec-dot { width: 12px; height: 12px; border-radius: 50%; flex: 0 0 auto; } +#rec-dot.off { background: #555; } +#rec-dot.on { background: #ff3b30; box-shadow: 0 0 10px #ff3b30; animation: recpulse 1s infinite; } +#rec-time { margin-left: auto; font-variant-numeric: tabular-nums; opacity: 0.85; } +@keyframes recpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } + +/* ---- SVG quick-pick chips ---- */ +.svg-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.svg-chip { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + padding: 4px 8px; +} +.svg-chip img { + width: 26px; height: 26px; + object-fit: contain; + background: #9aa3b2; + border-radius: 5px; + padding: 2px; +} +.svg-chip span { + font-size: 11px; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---- Playlist ---- */ +.playlist { list-style: none; margin: 8px 0 0; padding: 0; } +.hint-mini { font-size: 10px; opacity: 0.45; margin-top: 6px; } + +.track { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + margin-bottom: 6px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 9px; + font-size: 12px; + overflow: hidden; +} +.track .track-head { + position: relative; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; +} +.track .ttime { + flex: 0 0 auto; + font-size: 10px; + opacity: 0.65; + font-variant-numeric: tabular-nums; + min-width: 34px; + text-align: right; +} +.track .tprog { + position: absolute; + left: 0; bottom: 0; + height: 2px; + background: linear-gradient(90deg, #5ad1ff, #7aa2ff, #ff7ad1); + width: 0; +} +.track .track-body { padding: 0 8px 8px; } +.track .track-times { + display: flex; gap: 10px; align-items: center; + font-size: 10px; font-variant-numeric: tabular-nums; margin-bottom: 5px; +} +.track .track-times .tt-el { color: #9be6a0; } +.track .track-times .tt-tot { opacity: 0.7; } +.track .track-times .tt-rem { color: #ffb3b3; margin-left: auto; } +.track .wave { + display: block; width: 100%; height: 46px; + background: rgba(0,0,0,0.28); border-radius: 6px; cursor: ew-resize; + touch-action: none; +} +.track .track-trim { + display: flex; align-items: center; gap: 5px; margin-top: 6px; font-size: 10px; flex-wrap: wrap; +} +.track .track-trim .trim-lbl { opacity: 0.6; } +.track .track-trim .trim-lbl-s { color: #9be6a0; opacity: 0.9; } +.track .track-trim .trim-lbl-e { color: #ffb3b3; opacity: 0.9; } +.track .track-trim input { width: 52px; text-align: center; padding: 4px 3px; font-variant-numeric: tabular-nums; } +.track .track-trim button { padding: 3px 6px; } +.track .track-trim .trim-reset { margin-left: auto; } +.track.playing { + background: rgba(110,150,255,0.22); + border-color: rgba(120,170,255,0.6); +} +.track.dragging { opacity: 0.4; } +.track .grip { cursor: grab; opacity: 0.4; font-size: 14px; } +.track .tname { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} +.track .tname:hover { color: #cde0ff; } +.track button { + flex: 0 0 auto; + padding: 3px 8px; + border-radius: 7px; + font-size: 11px; +} +.track .key-btn { + min-width: 30px; + font-weight: 700; + background: rgba(255,255,255,0.1); +} +.track .play-btn { color: #8ef0b0; } +.track .del-btn { color: #ff8a8a; } +.track .scene-btn { filter: grayscale(1) opacity(0.5); } +.track .scene-btn.active { filter: none; } + +/* Per-track scene editor */ +.scene-editor { + list-style: none; + margin: -2px 0 9px; + padding: 12px; + background: rgba(110,150,255,0.10); + border: 1px solid rgba(120,150,255,0.38); + border-radius: 12px; +} +.scene-editor .se-title { font-size: 12px; font-weight: 600; opacity: 0.92; margin-bottom: 10px; } +.scene-editor .se-title small { font-weight: 400; opacity: 0.55; } + +.scene-editor .se-empty { font-size: 11px; opacity: 0.5; padding: 6px 2px 10px; } +.scene-editor .cue { + background: rgba(0,0,0,0.22); + border: 1px solid rgba(255,255,255,0.08); + border-left: 3px solid rgba(255,255,255,0.18); + border-radius: 10px; + padding: 9px; + margin-bottom: 9px; +} +.scene-editor .cue-effect { border-left-color: #8fb6ff; } +.scene-editor .cue-text { border-left-color: #ffd27a; } +.scene-editor .cue-image { border-left-color: #9be6a0; } +.scene-editor .cue-head { display: flex; align-items: center; gap: 6px; } +.scene-editor .cue-ico { flex: 0 0 auto; } +.scene-editor .cue-type { flex: 0 0 auto; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.85; } +.scene-editor .cue-at { opacity: 0.5; flex: 0 0 auto; margin-left: 4px; } +.scene-editor .cue-time { width: 58px; flex: 0 0 auto; text-align: center; padding: 6px 4px; font-variant-numeric: tabular-nums; } +.scene-editor .cue-durl { flex: 0 0 auto; opacity: 0.5; font-size: 11px; } +.scene-editor .cue-dur { width: 46px; flex: 0 0 auto; text-align: center; padding: 6px 4px; } +.scene-editor .cue-del { flex: 0 0 auto; color: #ff9a9a; margin-left: auto; } + +.scene-editor .cue-body { margin-top: 8px; } +.scene-editor .cue-eff-fam, .scene-editor .cue-eff-pre { flex: 1; min-width: 0; } +.scene-editor .cue-eff-cur { flex: 0 0 auto; } +.scene-editor .cue-eff-name { flex: 1; min-width: 0; font-size: 10px; opacity: 0.72; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.scene-editor .cue-text { width: 100%; margin: 0 0 7px; } +.scene-editor .cue-row { display: flex; align-items: center; gap: 6px; } +.scene-editor .cue-row + .cue-row { margin-top: 6px; } +.scene-editor .cue-tx-dir, .scene-editor .cue-tx-fx, +.scene-editor .cue-tx-font, .scene-editor .cue-tx-pos { flex: 1; min-width: 0; } +.scene-editor .cue-tx-size, .scene-editor .cue-tx-speed, .scene-editor .cue-img-size { flex: 1; } +.scene-editor .cue-tx-color { flex: 0 0 auto; width: 34px; padding: 0; } +.scene-editor .cue-tx-bold { width: auto; } +.scene-editor .cue-img, .scene-editor .cue-img-clear { flex: 0 0 auto; } +.scene-editor .cue-name { flex: 1; min-width: 0; font-size: 10px; opacity: 0.72; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.scene-editor .cue-img-pos { flex: 0 0 auto; width: 92px; } +.scene-editor .cue-dim { flex: 0 0 auto; font-size: 10px; opacity: 0.55; } +.scene-editor .se-foot { display: flex; gap: 6px; margin-top: 2px; flex-wrap: wrap; } +.scene-editor .se-foot .se-clear { color: #ff9a9a; margin-left: auto; } +.scene-editor .se-gap { display: flex; align-items: center; gap: 6px; margin-top: 9px; font-size: 12px; opacity: 0.9; } +.scene-editor .se-gap input { width: 56px; text-align: center; padding: 6px 4px; } +.scene-editor .se-gap small { opacity: 0.5; font-size: 10px; } +.playlist .gap-badge { margin-left: 6px; font-size: 9.5px; padding: 1px 5px; border-radius: 6px; background: rgba(255,200,120,0.18); color: #ffd27a; vertical-align: middle; } +.playlist .tname.is-interlude { color: #c9b6ff; font-style: italic; } + +/* ============ OUTPUT WINDOW ============ */ +body.output { cursor: none; background: #000; } + +/* ---- Image overlay layer ---- */ +#overlay-images { + position: fixed; + inset: 0; + z-index: 4; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + --beat-scale: 1; +} +#overlay-images img { + position: absolute; + max-width: calc(var(--img-size, 70) * 1vw); + max-height: calc(var(--img-size, 70) * 1vh); + object-fit: contain; + mix-blend-mode: screen; + opacity: 0; + transform: scale(var(--beat-scale)); + transition: opacity 0.8s ease; + filter: drop-shadow(0 0 30px rgba(0,0,0,0.6)); +} +#overlay-images img.show { opacity: 1; } + +/* ---- Video layer (VJ loop) ---- */ +.bg-video { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + object-fit: cover; + z-index: 1; /* above the WebGL canvas, below overlays */ + pointer-events: none; + display: none; +} +.bg-video.show { display: block; } + +.track-video { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + object-fit: contain; + background: #000; + z-index: 2; /* main visual when a video track plays */ + pointer-events: none; + display: none; +} +.track-video.show { display: block; } + +/* ---- Per-track scene image ---- */ +.scene-image { + position: fixed; + left: 50%; top: 50%; + transform: translate(-50%, -50%); + max-width: 62vw; + max-height: 62vh; + object-fit: contain; + z-index: 4; + pointer-events: none; + display: none; + filter: drop-shadow(0 0 24px rgba(0,0,0,0.5)); +} +.scene-image.show { display: block; } + +/* ---- Independent logos ---- */ +.logo { + position: fixed; + left: 50%; + top: 50%; + width: 20vw; + transform: translate(-50%, -50%); + z-index: 5; + pointer-events: none; + display: none; + filter: drop-shadow(0 0 18px rgba(0,0,0,0.45)); +} +.logo.show { display: block; } + +/* ---- Scrolling text ticker ---- */ +#ticker { + position: fixed; + left: 0; + right: 0; + z-index: 6; + pointer-events: none; + overflow: hidden; + display: none; + padding: 14px 0; + background: linear-gradient(transparent, rgba(0,0,0,0.35), transparent); +} +#ticker.show { display: block; } +#ticker.pos-bottom { bottom: 4vh; } +#ticker.pos-top { top: 4vh; } +#ticker.pos-middle { top: 50%; transform: translateY(-50%); } + +#ticker-track { + display: inline-flex; + white-space: nowrap; + will-change: transform; + font-size: 6vh; + font-weight: 800; + color: #fff; + text-transform: uppercase; +} +.tick-copy { display: inline-flex; padding: 0 2vw; } + +/* Horizontal scroll (default) */ +#ticker.dir-h #ticker-track { + animation: ticker-scroll var(--ticker-dur, 18s) linear infinite; +} + +/* Vertical scroll: full-height column, the two copies stacked and scrolling */ +#ticker.dir-vup, #ticker.dir-vdown { + top: 0 !important; + bottom: 0 !important; + height: 100vh; + padding: 0; + background: none; + transform: none !important; +} +#ticker.dir-vup.show, #ticker.dir-vdown.show { + display: flex; + align-items: flex-start; + justify-content: center; +} +#ticker.dir-vup #ticker-track, #ticker.dir-vdown #ticker-track { + flex-direction: column; + align-items: center; + white-space: nowrap; +} +#ticker.dir-vup .tick-copy, #ticker.dir-vdown .tick-copy { padding: 32vh 0; } +#ticker.dir-vup #ticker-track { animation: ticker-scroll-vup var(--ticker-dur, 18s) linear infinite; } +#ticker.dir-vdown #ticker-track { animation: ticker-scroll-vdown var(--ticker-dur, 18s) linear infinite; } +@keyframes ticker-scroll-vup { from { transform: translateY(0); } to { transform: translateY(-50%); } } +@keyframes ticker-scroll-vdown { from { transform: translateY(-50%); } to { transform: translateY(0); } } + +/* Side text: vertical stacked characters scrolling on both edges */ +#side-text { + position: fixed; + inset: 0; + z-index: 6; + pointer-events: none; + display: none; + font-size: 6vh; + font-weight: 800; + color: #fff; + text-transform: uppercase; +} +#side-text.show { display: block; } +.side-col { position: absolute; top: 0; bottom: 0; overflow: hidden; display: flex; } +.side-col.left { left: 1.5vw; } +.side-col.right { right: 1.5vw; } +.side-track { display: flex; flex-direction: column; align-items: center; will-change: transform; } +.side-col.left .side-track { animation: side-scroll-down var(--ticker-dur, 18s) linear infinite; } +.side-col.right .side-track { animation: side-scroll-up var(--ticker-dur, 18s) linear infinite; } +.side-copy { display: flex; flex-direction: column; align-items: center; padding: 18vh 0; } +.side-copy .tl { + display: block; + line-height: 1.0; + letter-spacing: 0; + text-shadow: 0 0 calc(8px + var(--ticker-glow, 0px)) rgba(140,180,255,0.9), + 0 2px 20px rgba(0,0,0,0.8); +} +@keyframes side-scroll-up { from { transform: translateY(0); } to { transform: translateY(-50%); } } +@keyframes side-scroll-down { from { transform: translateY(-50%); } to { transform: translateY(0); } } +.tl { + display: inline-block; + white-space: pre; + letter-spacing: 2px; + text-shadow: 0 0 calc(8px + var(--ticker-glow, 0px)) rgba(140,180,255,0.9), + 0 2px 30px rgba(0,0,0,0.8); +} +@keyframes ticker-scroll { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} + +/* Text movement effects (applied per letter) */ +.fx-updown .tl { animation: tl-updown 1.1s ease-in-out infinite; } +.fx-wave .tl { animation: tl-wave 1.2s ease-in-out infinite; animation-delay: calc(var(--i) * 0.05s); } +.fx-zoom .tl { animation: tl-zoom 0.9s ease-in-out infinite; animation-delay: calc(var(--i) * 0.04s); } +.fx-flash .tl { animation: tl-flash 0.6s steps(1, end) infinite; } +.fx-rotate .tl { animation: tl-rotate 2s ease-in-out infinite; animation-delay: calc(var(--i) * 0.03s); } +@keyframes tl-updown { 0%, 100% { transform: translateY(-0.18em); } 50% { transform: translateY(0.18em); } } +@keyframes tl-wave { 0%, 100% { transform: translateY(-0.24em); } 50% { transform: translateY(0.24em); } } +@keyframes tl-zoom { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.35); } } +@keyframes tl-flash { 0%, 100% { opacity: 1; } 50% { opacity: 0.12; } } +@keyframes tl-rotate { 0%, 100% { transform: rotate(-12deg); } 50% { transform: rotate(12deg); } } + +/* ---- Drop hint ---- */ +#drop-hint { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + pointer-events: none; + text-shadow: 0 2px 20px rgba(0,0,0,0.8); + transition: opacity 0.4s ease; +} +#drop-hint .big { font-size: 34px; font-weight: 600; } +#drop-hint .small { font-size: 15px; opacity: 0.7; } +#drop-hint.hidden { opacity: 0; } + +body.dragover #drop-hint { opacity: 1; } +body.dragover::after { + content: ""; + position: fixed; + inset: 18px; + border: 3px dashed rgba(120,170,255,0.8); + border-radius: 18px; + pointer-events: none; +} + +/* ---- Control panel ---- */ +#panel { + position: fixed; + top: 16px; + left: 16px; + width: 320px; + max-height: calc(100vh - 32px); + overflow-y: auto; + background: rgba(14, 14, 22, 0.82); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 16px; + padding: 14px 16px; + font-size: 13px; + z-index: 10; + transition: opacity 0.25s ease, transform 0.25s ease; + box-shadow: 0 12px 40px rgba(0,0,0,0.5); +} +#panel.hidden { opacity: 0; transform: translateX(-12px); pointer-events: none; } + +#panel header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 10px; +} +#panel header strong { font-size: 15px; letter-spacing: 0.3px; } +#fps { font-size: 11px; opacity: 0.55; font-variant-numeric: tabular-nums; } + +#panel section { margin-bottom: 14px; } +#panel label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + opacity: 0.6; + margin-bottom: 6px; +} +#panel label span { float: right; opacity: 0.9; text-transform: none; letter-spacing: 0; } + +.row { display: flex; gap: 6px; margin-bottom: 6px; } + +.scene-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} +.scene-grid button { font-size: 12px; padding: 7px 6px; text-align: left; } + +button, select { + font: inherit; + color: #e8e8f0; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 9px; + padding: 7px 10px; + cursor: pointer; + flex: 1; + transition: background 0.15s ease, border-color 0.15s ease; + white-space: nowrap; +} +button:hover, select:hover { background: rgba(255,255,255,0.12); } +button:disabled { opacity: 0.4; cursor: default; } +button.active { + background: rgba(110,150,255,0.28); + border-color: rgba(120,170,255,0.7); +} +button.wide { width: 100%; } + +select { appearance: none; } + +.chk { + display: flex !important; + align-items: center; + gap: 6px; + flex: 1; + text-transform: none !important; + letter-spacing: 0 !important; + opacity: 0.85 !important; + font-size: 12px; + margin: 0 !important; + cursor: pointer; +} +.chk input { accent-color: #7aa2ff; cursor: pointer; } + +.textfield { + width: 100%; + font: inherit; + color: #e8e8f0; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 9px; + padding: 8px 10px; + margin-bottom: 6px; +} +.textfield::placeholder { color: rgba(232,232,240,0.4); } + +input[type=range] { + width: 100%; + accent-color: #7aa2ff; +} + +/* ---- Meters ---- */ +.meters { display: flex; flex-direction: column; gap: 6px; } +.meter { display: flex; align-items: center; gap: 8px; } +.meter span { width: 38px; font-size: 10px; opacity: 0.6; } +.meter .bar { + flex: 1; + height: 8px; + background: rgba(255,255,255,0.08); + border-radius: 6px; + overflow: hidden; +} +.meter .bar i { + display: block; + height: 100%; + width: 0%; + border-radius: 6px; + background: linear-gradient(90deg, #5ad1ff, #7aa2ff, #ff7ad1); + transition: width 0.05s linear; +} + +#panel footer { + font-size: 10.5px; + opacity: 0.5; + line-height: 1.7; + border-top: 1px solid rgba(255,255,255,0.08); + padding-top: 10px; +} + +kbd { + font-family: ui-monospace, Menlo, monospace; + font-size: 10px; + background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.15); + border-radius: 4px; + padding: 1px 5px; + margin: 0 1px; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1d50dc9 --- /dev/null +++ b/public/index.html @@ -0,0 +1,137 @@ + + + + + + + + + + + + DJ Visualizer — Remote + + +
+ 🎛 DJ Visualizer + offline + — +
+ +
+ +
+
+ + +
+
+ + +
+ + + + + + + + + + + + + + + +
+ + + + + + + + + + + diff --git a/public/js/control.js b/public/js/control.js new file mode 100644 index 0000000..1f32c3b --- /dev/null +++ b/public/js/control.js @@ -0,0 +1,204 @@ +// Mobile control: speaks the same JSON message protocol as the Electron app, +// over WebSocket (window.djv from net.js). Engine catalog (window.EFFECTS) is +// loaded for the effect library; everything else is plain commands + REST. +const $ = (s) => document.querySelector(s); +const send = (m) => djv.send(m); +const fmt = (s) => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }; + +// ---- Tabs ------------------------------------------------------------------ +document.querySelectorAll('#tabs button').forEach((b) => b.addEventListener('click', () => { + document.querySelectorAll('#tabs button').forEach((x) => x.classList.toggle('active', x === b)); + document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('hidden', p.dataset.tab !== b.dataset.tab)); +})); + +// ---- Connection status ----------------------------------------------------- +djv.onStatus((ok) => { const e = $('#conn'); e.textContent = ok ? 'online' : 'offline'; e.className = ok ? 'on' : 'off'; }); + +// ---- Effetti --------------------------------------------------------------- +const EFFECTS = window.EFFECTS; +const famSel = $('#fx-fam'); +EFFECTS.families.forEach((n, i) => { const o = document.createElement('option'); o.value = i; o.textContent = n; famSel.appendChild(o); }); +let curEffect = EFFECTS.list[0]; +function applyEffect(e) { curEffect = e; send({ type: 'effect', effect: e }); $('#now').textContent = e.name; renderFx(); } +function renderFx() { + const fam = parseInt(famSel.value, 10); + const q = $('#fx-search').value.trim().toLowerCase(); + const list = $('#fx-list'); list.innerHTML = ''; + let count = 0; + for (const e of EFFECTS.list) { + if (fam >= 0 && e.family !== fam) continue; + if (q && e.name.toLowerCase().indexOf(q) < 0) continue; + const d = document.createElement('div'); + d.className = 'item' + (e === curEffect ? ' active' : ''); + d.textContent = e.name; + d.addEventListener('click', () => applyEffect(e)); + list.appendChild(d); + if (++count >= 250) break; + } +} +famSel.addEventListener('change', () => { + const fam = parseInt(famSel.value, 10); + if (fam >= 0) { const f = EFFECTS.list.find((e) => e.family === fam); if (f) applyEffect(f); } + else renderFx(); +}); +$('#fx-search').addEventListener('input', renderFx); +renderFx(); +send({ type: 'effect', effect: curEffect }); // push the starting effect + +// SVG sagome +djv.listSvgs().then((svgs) => { + const sel = $('#svg-sel'); + svgs.forEach((s) => { const o = document.createElement('option'); o.value = s.url; o.textContent = s.name; sel.appendChild(o); }); +}); +$('#svg-sel').addEventListener('change', (e) => { + if (!e.target.value) return; + const fam = EFFECTS.families.indexOf('SVG/Immagine'); + const eff = EFFECTS.list.find((x) => x.family === fam); + if (eff) applyEffect(eff); + send({ type: 'svg', dataUrl: e.target.value }); +}); + +// ---- Media / Playlist ------------------------------------------------------ +let library = [], queue = [], qIndex = -1, playing = false, curDur = 0, curCur = 0; +function loadLibrary() { djv.listMedia().then((items) => { library = items; renderLib(); }); } +function mediaIcon(k) { return k === 'video' ? '🎞 ' : (k === 'image' ? '🖼 ' : '🎵 '); } +function renderLib() { + const el = $('#lib'); el.innerHTML = ''; + if (!library.length) { el.innerHTML = '
Nessun file. Caricane con ⬆️ o copiali nella cartella media/ del dispositivo.
'; return; } + library.forEach((it) => { + const d = document.createElement('div'); d.className = 'item'; + d.innerHTML = '' + mediaIcon(it.kind) + it.name + ''; + const add = document.createElement('button'); add.textContent = '+'; add.title = 'In coda'; + add.addEventListener('click', (ev) => { ev.stopPropagation(); queue.push(it); renderQueue(); }); + d.appendChild(add); + d.addEventListener('click', () => { + if (assignPad >= 0) { assignToPad(assignPad, it); assignPad = -1; switchTab('pad'); return; } + if (it.kind === 'image') { send({ type: 'sceneImage', path: it.url, size: 60, x: 50, y: 50 }); return; } + queue.push(it); playAt(queue.length - 1); + }); + el.appendChild(d); + }); +} +function renderQueue() { + $('#queue-count').textContent = queue.length; + const el = $('#queue'); el.innerHTML = ''; + queue.forEach((it, i) => { + const d = document.createElement('div'); d.className = 'item' + (i === qIndex ? ' active' : ''); + d.innerHTML = '' + mediaIcon(it.kind) + it.name + ''; + const del = document.createElement('button'); del.textContent = '✕'; + del.addEventListener('click', (ev) => { ev.stopPropagation(); queue.splice(i, 1); if (i < qIndex) qIndex--; renderQueue(); }); + d.appendChild(del); + d.addEventListener('click', () => playAt(i)); + el.appendChild(d); + }); +} +function playAt(i) { + if (i < 0 || i >= queue.length) return; + qIndex = i; const it = queue[i]; playing = true; + send({ type: it.kind === 'video' ? 'playVideoTrack' : 'playTrack', path: it.url, start: 0, end: 0 }); + $('#np-name').textContent = it.name; $('#np-play').textContent = '⏸'; + $('#nowbar').classList.remove('hidden'); renderQueue(); +} +$('#np-next').addEventListener('click', () => { if (qIndex + 1 < queue.length) playAt(qIndex + 1); }); +$('#np-prev').addEventListener('click', () => { if (qIndex > 0) playAt(qIndex - 1); }); +$('#np-play').addEventListener('click', () => send({ type: 'togglePlay' })); +$('#seek').addEventListener('input', (e) => { if (curDur > 0) send({ type: 'seek', time: e.target.value / 1000 * curDur }); }); +$('#media-refresh').addEventListener('click', loadLibrary); +$('#media-upload-btn').addEventListener('click', () => $('#media-upload').click()); +$('#media-upload').addEventListener('change', (e) => { + const fd = new FormData(); [...e.target.files].forEach((f) => fd.append('files', f)); + $('#media-upload-btn').textContent = '⏳ Carico…'; + fetch('/api/upload', { method: 'POST', body: fd }).then(() => { e.target.value = ''; $('#media-upload-btn').textContent = '⬆️ Carica file'; loadLibrary(); }); +}); +loadLibrary(); + +// ---- Testo (ticker) -------------------------------------------------------- +const tk = (id) => $('#tk-' + id); +tk('text').addEventListener('input', (e) => send({ type: 'tickerText', text: e.target.value.replace(/\s*\n\s*/g, ' · ') })); +tk('on').addEventListener('change', (e) => send({ type: 'tickerOn', on: e.target.checked })); +tk('dir').addEventListener('change', (e) => send({ type: 'tickerDir', value: e.target.value })); +tk('pos').addEventListener('change', (e) => send({ type: 'tickerPos', pos: e.target.value })); +tk('fx').addEventListener('change', (e) => send({ type: 'tickerFx', value: e.target.value })); +tk('size').addEventListener('input', (e) => { $('#tk-size-v').textContent = e.target.value; send({ type: 'tickerSize', value: parseFloat(e.target.value) }); }); +tk('speed').addEventListener('input', (e) => { $('#tk-speed-v').textContent = (+e.target.value).toFixed(1) + '×'; send({ type: 'tickerSpeed', mult: parseFloat(e.target.value) }); }); +tk('bold').addEventListener('change', (e) => send({ type: 'tickerWeight', on: e.target.checked })); +tk('color').addEventListener('input', (e) => send({ type: 'tickerColor', value: e.target.value })); + +// ---- Pad ------------------------------------------------------------------- +const PAD_N = 12; +let pads = new Array(PAD_N).fill(null); +let assignPad = -1, activePad = -1; +function renderPads() { + const grid = $('#pads'); grid.innerHTML = ''; + for (let i = 0; i < PAD_N; i++) { + const p = pads[i]; + const b = document.createElement('button'); + b.className = 'pad' + (p ? ' set' : '') + (i === activePad ? ' active' : '') + (i === assignPad ? ' assign' : ''); + b.innerHTML = p ? mediaIcon(p.kind) + '' + p.name + '' : 'vuoto'; + let timer = null; + b.addEventListener('pointerdown', () => { timer = setTimeout(() => { timer = null; assignPad = i; switchTab('media'); }, 550); }); + b.addEventListener('pointerup', () => { + if (timer) { clearTimeout(timer); timer = null; padTap(i); } + }); + b.addEventListener('pointerleave', () => { if (timer) { clearTimeout(timer); timer = null; } }); + grid.appendChild(b); + } +} +function padTap(i) { + const p = pads[i]; if (!p) { assignPad = i; switchTab('media'); return; } + if (i === activePad && playing) { send({ type: 'togglePlay' }); return; } + activePad = i; playing = true; qIndex = -1; + send({ type: p.kind === 'video' ? 'playVideoTrack' : 'playTrack', path: p.url, start: 0, end: 0 }); + $('#now').textContent = p.name; renderPads(); +} +function assignToPad(i, it) { pads[i] = { name: it.name, url: it.url, kind: it.kind }; savePads(); renderPads(); } +function savePads() { djv.saveState('pads', pads); } +djv.loadState('pads').then((s) => { if (Array.isArray(s)) { pads = s.concat(new Array(PAD_N).fill(null)).slice(0, PAD_N); } renderPads(); }); + +// ---- Audio ----------------------------------------------------------------- +function slider(id, on) { const el = $('#' + id); el.addEventListener('input', (e) => on(parseFloat(e.target.value), e)); } +slider('gain', (v) => { $('#gain-v').textContent = v.toFixed(2) + '×'; send({ type: 'gain', value: v }); }); +slider('speed', (v) => { $('#speed-v').textContent = v.toFixed(2) + '×'; send({ type: 'speed', value: v }); }); +slider('bass', (v) => { $('#bass-v').textContent = v.toFixed(2) + '×'; send({ type: 'bandGain', band: 'bass', value: v }); }); +slider('mid', (v) => { $('#mid-v').textContent = v.toFixed(2) + '×'; send({ type: 'bandGain', band: 'mid', value: v }); }); +slider('treble', (v) => { $('#treble-v').textContent = v.toFixed(2) + '×'; send({ type: 'bandGain', band: 'treble', value: v }); }); + +// ---- Output / Rec ---------------------------------------------------------- +let recOn = false; +$('#fs').addEventListener('click', () => send({ type: 'outputFullscreen' })); +$('#rec').addEventListener('click', () => { + if (!recOn) { send({ type: 'recStart' }); } + else { const [w, h] = $('#rec-fmt').value.split('x').map(Number); send({ type: 'recStop', w, h }); } +}); +function updateRec() { + $('#rec').textContent = recOn ? '⏹ Ferma registrazione' : '🔴 Avvia registrazione'; + $('#rec').classList.toggle('recording', recOn); +} + +// ---- Reports --------------------------------------------------------------- +function setMeter(id, v) { const e = $('#' + id); if (e) e.style.width = Math.min(100, (v || 0) * 100) + '%'; } +djv.onReport((m) => { + switch (m.type) { + case 'progress': + curCur = m.currentTime; curDur = m.duration; + $('#np-time').textContent = fmt(curCur) + ' / ' + fmt(curDur); + if (curDur > 0) $('#seek').value = Math.round(curCur / curDur * 1000); + break; + case 'playState': + playing = m.playing; $('#np-play').textContent = playing ? '⏸' : '▶'; + break; + case 'trackEnded': + if (qIndex >= 0 && qIndex + 1 < queue.length) playAt(qIndex + 1); + else { playing = false; $('#np-play').textContent = '▶'; } + break; + case 'meters': + setMeter('m-bass', m.bass); setMeter('m-mid', m.mid); setMeter('m-treble', m.treble); + break; + case 'recState': recOn = m.recording; updateRec(); break; + case 'recSaved': $('#rec-status').textContent = '✅ Salvato: ' + m.path; break; + case 'recError': $('#rec-status').textContent = '⚠️ ' + m.message; break; + } +}); + +function switchTab(tab) { const b = document.querySelector('#tabs button[data-tab="' + tab + '"]'); if (b) b.click(); } +renderPads(); diff --git a/public/js/engine/audio.js b/public/js/engine/audio.js new file mode 100644 index 0000000..b18317e --- /dev/null +++ b/public/js/engine/audio.js @@ -0,0 +1,326 @@ +// Audio engine: builds a Web Audio graph from either a loaded file or a live +// input device, runs an FFT analyser, and exposes smoothed bass/mid/treble/ +// level values plus a simple beat detector. + +class AudioEngine { + constructor() { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + this.analyser = this.ctx.createAnalyser(); + this.analyser.fftSize = 2048; + this.analyser.smoothingTimeConstant = 0.78; + this.freq = new Uint8Array(this.analyser.frequencyBinCount); + + // Output gain so a loaded file is audible; live input stays muted to + // avoid feedback (it's already playing through the system). + this.outGain = this.ctx.createGain(); + this.outGain.gain.value = 1.0; + this.outGain.connect(this.ctx.destination); + + // Tap for recording: every source also feeds this destination so the + // recorder captures file playback and live input alike. + this.recordDest = this.ctx.createMediaStreamDestination(); + // Silent keep-alive so the audio track always produces frames (otherwise, + // with no source connected, the recorder stalls with empty data). + try { + const keep = this.ctx.createConstantSource(); + keep.offset.value = 0; + keep.connect(this.recordDest); + keep.start(); + } catch (e) { /* ConstantSource unsupported: live/file audio still flows */ } + + this.sourceNode = null; // current MediaElement / MediaStream source + this.sourceGain = null; // per-source gain (for crossfades) + this.mediaEl = null; //