Phone remote: LAN web page + WebSocket, QR pairing, blackout/freeze

remote-server.js: http + ws server (off by default, started from the panel,
port 8787 or next free), every request gated by a secret key embedded in a
QR code; the key persists in userData/remote.json so a phone's home-screen
shortcut survives restarts, 'Nuovo codice' revokes it. Phones send commands
that the panel executes through its own functions (transport, track pick,
effect next/prev/random/family/sequence, Auto VJ, pads, mapping on + visible
zones, blackout, freeze) and the panel pushes a compact live state at ~6 Hz
while a phone is connected (track + progress, effect, sequence, pads,
zones, meters, BPM). New panic controls in output and aux windows: blackout
(fade to black above every layer) and freeze (hold the last frame); also
available as panel buttons. src/remote.{html,css,js} is the touch UI in the
Neon Grid style, installable to the home screen, auto-reconnecting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc
This commit is contained in:
lucianoandClaude Opus 5.5 committed 2026-10-02 13:39:28 +02:00
1 parent f246b8bdb8
commit ab1ab89127
16 files changed
+973 -21

No files matched your search

+36
View File
@@ -3,6 +3,8 @@ const path = require('path');
const fs = require('fs');
const { execFile } = require('child_process');
const { pathToFileURL } = require('url');
const crypto = require('crypto');
const { RemoteServer } = require('./remote-server');
// djvres:// serves the MediaPipe WASM runtime and the bundled pose model to
// the renderer: fetch() does not work on file:// URLs, and the pose tracker
@@ -156,6 +158,7 @@ function createWindows() {
controlWin = null;
for (const win of auxWins.values()) { if (!win.isDestroyed()) win.destroy(); }
auxWins.clear();
remote.stop();
if (outputWin) outputWin.close();
app.quit();
});
@@ -412,6 +415,39 @@ ipcMain.handle('rec:openFolder', () => {
return dir;
});
// --- Phone remote (LAN web page + WebSocket) ---
// Commands from phones go to the panel, which runs them through its own code
// paths; the panel pushes a compact state back for the phones to display.
const remoteCfgFile = () => path.join(app.getPath('userData'), 'remote.json');
function remoteCfg() {
try { return JSON.parse(fs.readFileSync(remoteCfgFile(), 'utf8')); } catch (e) { return {}; }
}
function saveRemoteCfg(c) { try { fs.writeFileSync(remoteCfgFile(), JSON.stringify(c)); } catch (e) {} }
const remote = new RemoteServer({
srcDir: path.join(__dirname, 'src'),
onCommand: (m) => { if (controlWin && !controlWin.isDestroyed()) controlWin.webContents.send('remote', m); }
});
ipcMain.handle('remote:start', async () => {
const c = remoteCfg();
// the key survives restarts so a phone's home-screen shortcut keeps working
if (!c.key) c.key = crypto.randomBytes(6).toString('hex');
c.on = true; saveRemoteCfg(c);
try { return await remote.start(8787, c.key); } catch (e) { return { on: false, error: e.message }; }
});
ipcMain.handle('remote:stop', () => {
remote.stop();
const c = remoteCfg(); c.on = false; saveRemoteCfg(c);
return { on: false };
});
ipcMain.handle('remote:newKey', async () => {
// revoke: every phone that had the old code is disconnected and locked out
remote.stop();
const c = remoteCfg(); c.key = crypto.randomBytes(6).toString('hex'); c.on = true; saveRemoteCfg(c);
try { return await remote.start(8787, c.key); } catch (e) { return { on: false, error: e.message }; }
});
ipcMain.handle('remote:info', async () => (remote.running ? remote.info() : { on: false, wanted: !!remoteCfg().on }));
ipcMain.on('remote:state', (_e, state) => remote.broadcast(state));
// --- Pad bank persistence ---
const padsFile = () => path.join(app.getPath('userData'), 'pads.json');
ipcMain.handle('pads:load', () => {
+230 -13
View File
@@ -1,15 +1,17 @@
{
"name": "dj-visualizer",
"version": "1.5.1",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dj-visualizer",
"version": "1.5.1",
"version": "1.11.0",
"license": "MIT",
"dependencies": {
"@mediapipe/tasks-vision": "^1.0.0"
"@mediapipe/tasks-vision": "^1.0.0",
"qrcode": "^1.5.4",
"ws": "^8.22.0"
},
"devDependencies": {
"electron": "^33.4.11",
@@ -710,7 +712,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -720,7 +721,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -1227,6 +1227,15 @@
"node": ">= 0.4"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1327,7 +1336,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -1340,7 +1348,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -1539,6 +1546,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -1634,6 +1650,12 @@
"license": "MIT",
"optional": true
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-compare": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz",
@@ -2004,7 +2026,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
@@ -2181,6 +2202,19 @@
"minimatch": "^5.0.1"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -2285,7 +2319,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -2706,7 +2739,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2909,6 +2941,18 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -3192,6 +3236,42 @@
"node": ">=8"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -3199,6 +3279,15 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -3272,6 +3361,15 @@
"node": ">=10.4.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -3325,6 +3423,89 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@@ -3387,12 +3568,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
@@ -3526,6 +3712,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3670,7 +3862,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -3701,7 +3892,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -3968,6 +4158,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -4012,6 +4208,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"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
}
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
+4 -1
View File
@@ -41,6 +41,7 @@
"files": [
"main.js",
"preload.js",
"remote-server.js",
"src/**/*",
"svg/**/*",
"models/**/*",
@@ -56,6 +57,8 @@
"electron-builder": "^24.13.3"
},
"dependencies": {
"@mediapipe/tasks-vision": "^1.0.0"
"@mediapipe/tasks-vision": "^1.0.0",
"qrcode": "^1.5.4",
"ws": "^8.22.0"
}
}
+8 -1
View File
@@ -50,5 +50,12 @@ contextBridge.exposeInMainWorld('djv', {
// Convert Mixamo FBX files via Blender: animation clips / character models.
convertAnim: (p) => ipcRenderer.invoke('anim:convert', p),
convertModel: (p) => ipcRenderer.invoke('model:convert', p),
readFile: (path) => ipcRenderer.invoke('file:read', path)
readFile: (path) => ipcRenderer.invoke('file:read', path),
// Phone remote: LAN server control, incoming commands, outgoing state.
remoteStart: () => ipcRenderer.invoke('remote:start'),
remoteStop: () => ipcRenderer.invoke('remote:stop'),
remoteNewKey: () => ipcRenderer.invoke('remote:newKey'),
remoteInfo: () => ipcRenderer.invoke('remote:info'),
remoteState: (state) => ipcRenderer.send('remote:state', state),
onRemote: (cb) => ipcRenderer.on('remote', (_e, m) => cb(m))
});
+137
View File
@@ -0,0 +1,137 @@
// Phone remote: a tiny HTTP + WebSocket server on the local network.
// Off by default; the panel switches it on. Every request must carry the
// secret key from the QR code (?k=...), so only whoever scans it can drive
// the app. Phones send commands, the panel executes them (same code paths
// as its own buttons) and pushes back a compact live state.
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { WebSocketServer } = require('ws');
const QRCode = require('qrcode');
const STATIC = {
'/': ['remote.html', 'text/html; charset=utf-8'],
'/remote.js': ['remote.js', 'text/javascript; charset=utf-8'],
'/remote.css': ['remote.css', 'text/css; charset=utf-8'],
'/fonts/orbitron-latin-800-normal.woff2': ['fonts/orbitron-latin-800-normal.woff2', 'font/woff2'],
'/fonts/rajdhani-latin-600-normal.woff2': ['fonts/rajdhani-latin-600-normal.woff2', 'font/woff2'],
'/fonts/rajdhani-latin-700-normal.woff2': ['fonts/rajdhani-latin-700-normal.woff2', 'font/woff2'],
'/manifest.webmanifest': [null, 'application/manifest+json']
};
// LAN IPv4 addresses, Wi-Fi/Ethernet first (skips loopback and link-local).
function lanAddresses() {
const out = [];
for (const [name, list] of Object.entries(os.networkInterfaces())) {
for (const a of list || []) {
if (a.family !== 'IPv4' || a.internal || a.address.startsWith('169.254.')) continue;
out.push({ name, address: a.address });
}
}
out.sort((a, b) => (/^en/.test(b.name) ? 1 : 0) - (/^en/.test(a.name) ? 1 : 0));
return out.map(a => a.address);
}
class RemoteServer {
constructor({ srcDir, onCommand }) {
this.srcDir = srcDir;
this.onCommand = onCommand; // (msg) => void
this.server = null;
this.wss = null;
this.key = null;
this.port = 0;
this.lastState = null;
}
get running() { return !!this.server; }
async start(preferredPort = 8787, key) {
if (this.server) return this.info();
this.key = key || crypto.randomBytes(6).toString('hex');
const server = http.createServer((req, res) => this._http(req, res));
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, sock, head) => {
const u = new URL(req.url, 'http://x');
if (u.pathname !== '/ws' || u.searchParams.get('k') !== this.key) { sock.destroy(); return; }
wss.handleUpgrade(req, sock, head, (ws) => wss.emit('connection', ws));
});
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (data) => {
let m; try { m = JSON.parse(String(data)); } catch (e) { return; }
if (m && typeof m.cmd === 'string') this.onCommand(m);
});
if (this.lastState) ws.send(JSON.stringify({ type: 'state', state: this.lastState }));
this.onCommand({ cmd: '_clients', n: wss.clients.size });
ws.on('close', () => this.onCommand({ cmd: '_clients', n: wss.clients.size }));
});
// drop phones that went to sleep without closing the socket
this._ping = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; }
ws.isAlive = false; try { ws.ping(); } catch (e) {}
}
}, 10000);
// first free port from the preferred one
for (let p = preferredPort; p < preferredPort + 20; p++) {
const ok = await new Promise(resolve => {
server.once('error', () => resolve(false));
server.listen(p, '0.0.0.0', () => resolve(true));
});
if (ok) { this.port = p; break; }
}
if (!this.port) { clearInterval(this._ping); throw new Error('Nessuna porta libera per il telecomando'); }
this.server = server; this.wss = wss;
return this.info();
}
stop() {
if (!this.server) return;
clearInterval(this._ping);
for (const ws of this.wss.clients) { try { ws.close(); } catch (e) {} }
this.wss.close(); this.server.close();
this.server = null; this.wss = null; this.port = 0;
}
async info() {
if (!this.server) return { on: false };
const ips = lanAddresses();
const urls = ips.map(ip => `http://${ip}:${this.port}/?k=${this.key}`);
const qr = urls[0] ? await QRCode.toDataURL(urls[0], { margin: 1, width: 360, color: { dark: '#04050bff', light: '#ffffffff' } }) : null;
return { on: true, port: this.port, key: this.key, urls, qr, clients: this.wss ? this.wss.clients.size : 0 };
}
broadcast(state) {
this.lastState = state;
if (!this.wss) return;
const msg = JSON.stringify({ type: 'state', state });
for (const ws of this.wss.clients) if (ws.readyState === 1) ws.send(msg);
}
_http(req, res) {
const u = new URL(req.url, 'http://x');
const entry = STATIC[u.pathname];
// the page itself needs the key; its assets are public (no secrets in them)
if (!entry || (u.pathname === '/' && u.searchParams.get('k') !== this.key)) {
res.writeHead(u.pathname === '/' ? 403 : 404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(u.pathname === '/' ? 'Codice non valido: inquadra di nuovo il QR dal pannello.' : 'not found');
return;
}
if (u.pathname === '/manifest.webmanifest') {
res.writeHead(200, { 'Content-Type': entry[1] });
res.end(JSON.stringify({ name: 'DJ Visualizer Remote', short_name: 'DJV Remote',
start_url: '/?k=' + this.key, display: 'standalone', background_color: '#04050b', theme_color: '#04050b' }));
return;
}
fs.readFile(path.join(this.srcDir, entry[0]), (err, buf) => {
if (err) { res.writeHead(404); res.end(); return; }
res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-cache' });
res.end(buf);
});
}
}
module.exports = { RemoteServer };
+2
View File
@@ -21,6 +21,8 @@
<div class="side-col right"><div class="side-track"><span class="side-copy"></span><span class="side-copy"></span></div></div>
</div>
<div id="blackout"></div>
<script src="shaders.js"></script>
<script src="effects.js"></script>
<script src="fluid.js"></script>
+4 -1
View File
@@ -12,6 +12,7 @@ const viz = new Visualizer(cv);
const MY_ID = window.djv.displayId;
let mode = 'follow';
let frozen = false; // 'freeze' from the remote/panel: hold the last frame
let ownEffect = null; // effect object for mode 'effect'
let followEffect = null; // last effect broadcast by the control panel
@@ -117,6 +118,8 @@ window.djv.onControl((m) => {
applyText(m.text);
break;
}
case 'blackout': document.getElementById('blackout').classList.toggle('show', !!m.on); break;
case 'freeze': frozen = !!m.on; break;
case 'auxFx': {
// Scene cue targeted at aux outputs. displayId -1 = every aux window,
// but follow-mode windows ignore the broadcast form: they receive the
@@ -134,7 +137,7 @@ function frame() {
const now = performance.now();
clockT += (now - clockAt) / 1000;
clockAt = now;
if (mode === 'follow' || mode === 'effect') {
if ((mode === 'follow' || mode === 'effect') && !frozen) {
viz.render(clockT, audioData || SILENT);
}
// Ticker letters glow with the music, same formula as the main output.
+20
View File
@@ -491,6 +491,26 @@
<button id="btn-fullscreen">⛶ Fullscreen</button>
</div>
</section>
<section class="span2">
<label>📱 Telecomando dal telefono <span id="remote-clients"></span></label>
<div class="remote-box">
<div class="remote-qr-wrap"><img id="remote-qr" alt="" hidden /><div id="remote-qr-off">OFF</div></div>
<div class="remote-side">
<div class="row">
<button id="remote-toggle">▶ Attiva telecomando</button>
<button id="remote-newkey" title="Genera un nuovo codice: i telefoni collegati col vecchio QR vengono scollegati">🔑 Nuovo codice</button>
</div>
<div id="remote-url" class="remote-url">—</div>
<div class="hint-mini">Inquadra il QR col telefono (stessa rete Wi-Fi del Mac, anche l'hotspot del telefono).
Si apre il telecomando nel browser: con "Aggiungi a Home" diventa un'icona. Il codice nel QR
protegge l'accesso; se macOS chiede di consentire connessioni in entrata, rispondi Consenti.</div>
<div class="row">
<button id="btn-blackout" title="Tutto nero, su ogni uscita">⬛ Blackout</button>
<button id="btn-freeze" title="Ferma il visual sull'ultimo fotogramma">❄️ Freeze</button>
</div>
</div>
</div>
</section>
<section class="span2">
<label>Uscite multiple <small>— una finestra indipendente per ogni schermo in più</small></label>
<div id="aux-list" class="aux-list"></div>
+130 -1
View File
@@ -2,7 +2,15 @@
// It also receives reports (meters, fps, device list, play state).
const $ = (s) => document.querySelector(s);
const send = (msg) => djv.send(msg);
// Some output state is only ever SENT (never reported back): remember it so
// the panel and the phone remote can show it.
const outState = { blackout: false, freeze: false, mapShow: null };
const send = (msg) => {
if (msg.type === 'blackout') outState.blackout = !!msg.on;
else if (msg.type === 'freeze') outState.freeze = !!msg.on;
else if (msg.type === 'mapShow') outState.mapShow = Array.isArray(msg.ids) ? msg.ids : null;
djv.send(msg);
};
const filePath = (f) => djv.pathForFile(f);
// ---------------------------------------------------------------- effects
@@ -2677,6 +2685,7 @@ djv.onReport((m) => {
break;
}
case 'meters':
lastMeters = m;
$('#m-bass').style.width = (m.bass * 100).toFixed(0) + '%';
$('#m-mid').style.width = (m.mid * 100).toFixed(0) + '%';
$('#m-treble').style.width = (m.treble * 100).toFixed(0) + '%';
@@ -2764,6 +2773,126 @@ djv.onReport((m) => {
}
});
// ---------------------------------------------------------------- phone remote
// The phone sends commands; they run through the SAME functions as the
// panel's own buttons, so panel and phone can never disagree. A compact
// state goes back at ~6 Hz while at least one phone is connected.
let lastMeters = { bass: 0, mid: 0, treble: 0, bpm: 0 };
let remoteOn = false, remoteClients = 0, remoteTimer = null;
function setBlackout(on) {
send({ type: 'blackout', on });
$('#btn-blackout').classList.toggle('active', on);
}
function setFreeze(on) {
send({ type: 'freeze', on });
$('#btn-freeze').classList.toggle('active', on);
}
$('#btn-blackout').addEventListener('click', () => setBlackout(!outState.blackout));
$('#btn-freeze').addEventListener('click', () => setFreeze(!outState.freeze));
// Effects a remote may step through: everything but camera/3D families.
const remoteFx = () => EFFECTS.list.filter(e => !e.isInteractive && !e.isModel3d);
function remoteStepEffect(dir) {
if (sequence.length) {
const n = seqIndex < 0 ? (dir > 0 ? 0 : sequence.length - 1) : (seqIndex + dir + sequence.length) % sequence.length;
return applySeqIndex(n);
}
// no personal sequence: walk the presets of the current family
const fam = EFFECTS.list.filter(e => e.family === currentEffect.family);
const k = fam.indexOf(currentEffect);
applyEffect(fam[(k + dir + fam.length) % fam.length] || currentEffect);
}
function remoteCommand(m) {
switch (m.cmd) {
case '_clients':
remoteClients = m.n || 0;
$('#remote-clients').textContent = remoteClients ? '● ' + remoteClients + (remoteClients === 1 ? ' telefono' : ' telefoni') : '';
pushRemoteState();
break;
case 'play': togglePlayPause(); break;
case 'next': nextTrack(); break;
case 'prev': if (playlist.length) playIndex(Math.max(0, currentIndex - 1)); break;
case 'track': if (m.i >= 0 && m.i < playlist.length) playIndex(m.i); break;
case 'fxNext': remoteStepEffect(1); break;
case 'fxPrev': remoteStepEffect(-1); break;
case 'fxRandom': { const L = remoteFx(); applyEffect(L[Math.floor(Math.random() * L.length)]); break; }
case 'fxSeq': if (m.i >= 0 && m.i < sequence.length) applySeqIndex(m.i); break;
case 'fxFamily': {
const e = EFFECTS.list.find(x => x.family === m.fam && !x.isInteractive && !x.isModel3d);
if (e) applyEffect(e);
break;
}
case 'autoVj': avSend(!autoVjOn); break;
case 'pad': if (pads[m.i]) playPad(m.i); break; // empty pads would open a file dialog on the Mac
case 'padStop': $('#btn-pad-stop').click(); break;
case 'mapOn':
mapCfg.on = !!m.on; mapSave(); $('#map-on').checked = mapCfg.on;
send({ type: 'mapShow', ids: null });
send({ type: 'mapOn', on: mapCfg.on });
break;
case 'mapShow':
send({ type: 'mapShow', ids: Array.isArray(m.ids) ? m.ids.map(Number) : null });
break;
case 'blackout': setBlackout(!!m.on); break;
case 'freeze': setFreeze(!!m.on); break;
}
pushRemoteState();
}
djv.onRemote(remoteCommand);
let remoteFamilies = null;
function pushRemoteState() {
if (!remoteOn || !remoteClients) return;
const tr = playlist[currentIndex];
if (!remoteFamilies) remoteFamilies = EFFECTS.families
.map((name, i) => ({ i, name }))
.filter(f => EFFECTS.list.some(e => e.family === f.i && !e.isInteractive && !e.isModel3d));
djv.remoteState({
track: tr ? { i: currentIndex, name: tr.name, cur: playCur, dur: playDur, playing: isPlaying } : null,
tracks: playlist.slice(0, 60).map(t => (t.isInterlude ? '✨ ' : '') + t.name),
effect: currentEffect ? currentEffect.name : '',
family: currentEffect ? currentEffect.family : -1,
families: remoteFamilies,
autoVj: autoVjOn,
seq: sequence.map(x => x.effect.name),
seqIndex,
pads: pads.map(p => p ? p.name : null),
activePad: padPlaying ? activePad : -1,
map: { on: !!mapCfg.on, zones: mapCfg.zones.map(z => ({ id: z.id, name: z.name || 'Zona' })), show: outState.mapShow },
blackout: outState.blackout, freeze: outState.freeze,
meters: { bass: lastMeters.bass || 0, mid: lastMeters.mid || 0, treble: lastMeters.treble || 0, bpm: lastMeters.bpm || 0 }
});
}
async function remoteShowInfo(info) {
remoteOn = !!(info && info.on);
$('#remote-toggle').textContent = remoteOn ? '⏹ Disattiva telecomando' : '▶ Attiva telecomando';
$('#remote-toggle').classList.toggle('active', remoteOn);
$('#remote-newkey').disabled = !remoteOn;
$('#remote-qr').hidden = !(remoteOn && info.qr);
$('#remote-qr-off').hidden = remoteOn && !!info.qr;
if (remoteOn && info.qr) $('#remote-qr').src = info.qr;
$('#remote-url').textContent = remoteOn
? (info.urls && info.urls.length ? info.urls[0] : 'Nessuna rete: collega il Mac al Wi-Fi o all\'hotspot')
: (info && info.error ? '⚠ ' + info.error : '—');
if (!remoteOn) { remoteClients = 0; $('#remote-clients').textContent = ''; }
clearInterval(remoteTimer);
if (remoteOn) remoteTimer = setInterval(pushRemoteState, 160);
}
$('#remote-toggle').addEventListener('click', async () => {
remoteShowInfo(remoteOn ? await djv.remoteStop() : await djv.remoteStart());
});
$('#remote-newkey').addEventListener('click', async () => remoteShowInfo(await djv.remoteNewKey()));
(async () => {
// switched on last time: bring it back with the same code
const info = await djv.remoteInfo();
if (info.on) remoteShowInfo(info);
else if (info.wanted) remoteShowInfo(await djv.remoteStart());
else remoteShowInfo(info);
})();
// ---------------------------------------------------------------- init
renderLibrary();
renderSequence();
+3
View File
@@ -51,6 +51,9 @@
DOM order = paints above everything without needing a z-index. -->
<div id="rec-guide"></div>
<!-- Blackout: above everything (last in DOM), toggled by the panel/remote -->
<div id="blackout"></div>
<script src="shaders.js"></script>
<script src="effects.js"></script>
<script src="fluid.js"></script>
+9 -4
View File
@@ -401,6 +401,10 @@ djv.onControl(async (m) => {
break;
case 'mapSel': if (mapping) mapping.selected = m.index; break;
case 'auxActive': auxActive = !!m.on; break;
// Panic controls (phone remote / panel): blackout fades everything to
// black above every layer; freeze holds the last visual frame.
case 'blackout': $('#blackout').classList.toggle('show', !!m.on); break;
case 'freeze': frozen = !!m.on; break;
case 'glbAnims': viz.setClipFilter(m.names || []); break;
case 'modelBpm': viz.setManualBpm(m.bpm || 0); break;
case 'modelSpread': viz.setArmSpread(m.deg || 0); break;
@@ -599,7 +603,8 @@ djv.onControl(async (m) => {
// ---------------------------------------------------------------- render loop
const startTime = performance.now();
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0;
let auxActive = false, lastAfr = 0; // aux outputs: relay audio only when some exist
let auxActive = false, lastAfr = 0;
let frozen = false; // 'freeze': stop rendering, the canvases keep the last frame // aux outputs: relay audio only when some exist
// ---- Auto VJ: a director that adapts the visuals to the playing track ------
// Uses the engine's musical features (mood/bpm/drop/beatCount): switches preset
@@ -654,8 +659,8 @@ function frame() {
audio.update();
const a = audio.values;
avTick(performance.now());
viz.render(t * speed, a);
if (mapOn && mapping) {
if (!frozen) viz.render(t * speed, a);
if (mapOn && mapping && !frozen) {
for (const id in zoneOv) if (zoneOv[id].text) zoneOv[id].text.render(a);
mapping.main = mapVisualSource();
mapping.render(t * speed, a);
@@ -702,7 +707,7 @@ function frame() {
if (now - fpsT > 500) { fps = frames * 1000 / (now - fpsT); frames = 0; fpsT = now; }
if (now - lastReport > 66) {
lastReport = now;
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps });
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps, bpm: audio.bpm || 0 });
}
// Stop at the trim end point if one is set.
audio.checkTrim();
+149
View File
@@ -0,0 +1,149 @@
/* DJV Remote — phone page, same Neon Grid look as the control panel */
@font-face { font-family: 'Orbitron'; font-weight: 800; font-display: swap; src: url('/fonts/orbitron-latin-800-normal.woff2') format('woff2'); }
@font-face { font-family: 'Rajdhani'; font-weight: 600; font-display: swap; src: url('/fonts/rajdhani-latin-600-normal.woff2') format('woff2'); }
@font-face { font-family: 'Rajdhani'; font-weight: 700; font-display: swap; src: url('/fonts/rajdhani-latin-700-normal.woff2') format('woff2'); }
:root {
--cyan: #2ef2ff; --blue: #4d7cff; --mag: #ff2bd6; --lime: #7dff8a; --red: #ff3d6e;
--text: #dbe9ff; --dim: rgba(190, 215, 255, 0.55);
--grad: linear-gradient(90deg, var(--cyan), var(--blue) 50%, var(--mag));
--ui: 'Rajdhani', system-ui, sans-serif;
--display: 'Orbitron', 'Rajdhani', system-ui, sans-serif;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; }
body {
min-height: 100vh;
color: var(--text);
font-family: var(--ui); font-weight: 600; font-size: 16px;
background:
radial-gradient(500px 300px at 0% 0%, rgba(46, 242, 255, 0.14), transparent 60%),
radial-gradient(500px 400px at 100% 30%, rgba(255, 43, 214, 0.12), transparent 60%),
linear-gradient(rgba(120, 200, 255, 0.05) 1px, transparent 1px) 0 0 / 30px 30px,
linear-gradient(90deg, rgba(120, 200, 255, 0.05) 1px, transparent 1px) 0 0 / 30px 30px,
#04050b;
background-attachment: fixed;
padding-bottom: calc(86px + env(safe-area-inset-bottom));
touch-action: manipulation;
user-select: none; -webkit-user-select: none;
}
#top {
position: sticky; top: 0; z-index: 5;
display: flex; justify-content: space-between; align-items: center;
padding: calc(12px + env(safe-area-inset-top)) 16px 12px;
background: linear-gradient(180deg, rgba(4, 6, 16, 0.96), rgba(4, 6, 16, 0.75));
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(46, 242, 255, 0.35);
box-shadow: 0 1px 12px rgba(46, 242, 255, 0.25);
}
.brand { font-family: var(--display); font-weight: 800; letter-spacing: 3px; font-size: 16px; }
.brand b { background: linear-gradient(90deg, var(--cyan), var(--mag)); -webkit-background-clip: text; background-clip: text; color: transparent; }
.top-right { display: flex; align-items: center; gap: 12px; }
#bpm { font-family: var(--display); font-weight: 800; font-size: 12px; color: var(--cyan); letter-spacing: 1px; }
.conn { width: 11px; height: 11px; border-radius: 50%; background: var(--red); box-shadow: 0 0 8px var(--red); }
.conn.on { background: var(--lime); box-shadow: 0 0 10px var(--lime); }
main { padding: 12px; display: flex; flex-direction: column; gap: 12px; }
.card {
position: relative;
padding: 14px;
border-radius: 18px;
border: 1px solid transparent;
background:
linear-gradient(rgba(12, 16, 32, 0.78), rgba(12, 16, 32, 0.78)) padding-box,
linear-gradient(135deg, rgba(46, 242, 255, 0.5), rgba(77, 124, 255, 0.12) 45%, rgba(255, 43, 214, 0.4)) border-box;
box-shadow: 0 8px 26px rgba(0, 0, 0, 0.5);
}
.cap {
display: flex; justify-content: space-between; align-items: center;
font-weight: 700; font-size: 12px; letter-spacing: 2px; color: #9fdcff; margin-bottom: 10px;
}
.cap.sub { margin: 14px 0 8px; }
button, select {
font: inherit; font-weight: 700; letter-spacing: 0.8px;
color: var(--text);
min-height: 52px; padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(120, 200, 255, 0.28);
background: linear-gradient(180deg, rgba(30, 40, 72, 0.85), rgba(14, 18, 36, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
transition: transform .08s ease, box-shadow .15s ease, border-color .15s ease;
touch-action: manipulation;
}
button:active { transform: scale(0.96); border-color: var(--cyan); box-shadow: 0 0 18px rgba(46, 242, 255, 0.6); }
button.on {
color: #fff; border: 1px solid transparent;
background:
linear-gradient(180deg, rgba(36, 18, 64, 0.95), rgba(18, 10, 40, 0.95)) padding-box,
linear-gradient(90deg, var(--cyan), var(--mag)) border-box;
box-shadow: 0 0 18px rgba(255, 43, 214, 0.55);
text-shadow: 0 0 8px rgba(255, 43, 214, 0.8);
}
button.mini { min-height: 32px; padding: 4px 12px; font-size: 12px; border-radius: 10px; }
select { appearance: none; -webkit-appearance: none; width: 100%; background-color: #0e1426; }
.np-name { font-size: 20px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.np-bar { height: 5px; border-radius: 5px; background: rgba(120, 200, 255, 0.12); margin: 10px 0 6px; overflow: hidden; }
.np-bar i { display: block; height: 100%; width: 0; background: var(--grad); box-shadow: 0 0 10px var(--cyan); }
.np-times { display: flex; justify-content: space-between; font-family: var(--display); font-weight: 800; font-size: 11px; color: var(--cyan); }
.transport { display: grid; grid-template-columns: 1fr 1.5fr 1fr; gap: 10px; margin: 14px 0 12px; }
.transport button { font-size: 24px; min-height: 64px; }
.transport button.big { font-size: 30px; }
.meters { display: flex; flex-direction: column; gap: 5px; }
.mtr { display: flex; align-items: center; gap: 8px; }
.mtr span { width: 12px; font-family: var(--display); font-size: 10px; color: #9fdcff; }
.mtr .bar { flex: 1; height: 10px; border-radius: 3px; background: repeating-linear-gradient(90deg, rgba(120, 200, 255, 0.1) 0 6px, transparent 6px 8px); overflow: hidden; }
.mtr .bar i {
display: block; height: 100%; width: 0;
background: linear-gradient(90deg, var(--lime), var(--cyan) 45%, var(--blue) 70%, var(--mag));
-webkit-mask: repeating-linear-gradient(90deg, #000 0 6px, transparent 6px 8px);
mask: repeating-linear-gradient(90deg, #000 0 6px, transparent 6px 8px);
transition: width .12s linear;
}
.fx-name { font-size: 18px; font-weight: 700; color: #fff; margin-bottom: 12px; min-height: 22px; }
.row3 { display: grid; grid-template-columns: 1fr 0.7fr 1fr; gap: 10px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.grid button { font-size: 13px; min-height: 56px; white-space: normal; line-height: 1.15; text-align: left; }
.pads { grid-template-columns: repeat(4, 1fr); }
.pads button { font-size: 11.5px; min-height: 64px; text-align: center; padding: 6px; overflow: hidden; }
.pads button.empty { opacity: 0.3; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.chips button { min-height: 44px; font-size: 14px; }
.tracks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.tracks li {
padding: 13px 12px; border-radius: 12px; font-size: 15px;
border: 1px solid rgba(120, 200, 255, 0.16); background: rgba(6, 10, 24, 0.6);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tracks li.cur { border-color: var(--cyan); box-shadow: inset 3px 0 0 var(--cyan), 0 0 12px rgba(46, 242, 255, 0.25); color: #fff; }
.tracks li:active { background: rgba(46, 242, 255, 0.12); }
#panic {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 6;
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: linear-gradient(0deg, rgba(4, 6, 16, 0.97), rgba(4, 6, 16, 0.8));
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-top: 1px solid rgba(255, 43, 214, 0.35);
}
#panic button { min-height: 58px; font-family: var(--display); font-weight: 800; font-size: 13px; letter-spacing: 2px; }
#btn-blackout.on { background: #000; border: 2px solid var(--red); box-shadow: 0 0 22px rgba(255, 61, 110, 0.8); color: var(--red); text-shadow: none; }
#btn-freeze.on { border: 2px solid var(--cyan); box-shadow: 0 0 22px rgba(46, 242, 255, 0.8); text-shadow: none; }
#offline {
position: fixed; inset: 0; z-index: 9;
display: grid; place-content: center; text-align: center; gap: 6px;
background: rgba(2, 3, 8, 0.86); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
font-family: var(--display); font-weight: 800; letter-spacing: 1px; color: var(--red);
}
#offline small { font-family: var(--ui); color: var(--dim); letter-spacing: 0; }
[hidden] { display: none !important; }
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#04050b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="DJV Remote" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>DJV Remote</title>
<link rel="stylesheet" href="/remote.css" />
</head>
<body>
<header id="top">
<div class="brand">DJV <b>REMOTE</b></div>
<div class="top-right">
<span id="bpm">— BPM</span>
<span id="conn" class="conn" title="Connessione"></span>
</div>
</header>
<main>
<!-- now playing -->
<section class="card np">
<div class="cap">IN RIPRODUZIONE</div>
<div id="np-name" class="np-name">—</div>
<div class="np-bar"><i id="np-prog"></i></div>
<div class="np-times"><span id="np-cur">00:00</span><span id="np-rem">-00:00</span></div>
<div class="transport">
<button data-cmd="prev" aria-label="Brano precedente">⏮</button>
<button data-cmd="play" id="btn-play" class="big" aria-label="Play/Pausa">▶</button>
<button data-cmd="next" aria-label="Brano successivo">⏭</button>
</div>
<div class="meters">
<div class="mtr"><span>B</span><div class="bar"><i id="m-b"></i></div></div>
<div class="mtr"><span>M</span><div class="bar"><i id="m-m"></i></div></div>
<div class="mtr"><span>H</span><div class="bar"><i id="m-h"></i></div></div>
</div>
</section>
<!-- effects -->
<section class="card">
<div class="cap">EFFETTO</div>
<div id="fx-name" class="fx-name">—</div>
<div class="row3">
<button data-cmd="fxPrev">◀ FX</button>
<button data-cmd="fxRandom">🎲</button>
<button data-cmd="fxNext">FX ▶</button>
</div>
<div class="row2">
<button data-cmd="autoVj" id="btn-autovj">🤖 AUTO VJ</button>
<select id="fam"><option value="">Famiglia…</option></select>
</div>
<div class="cap sub" id="seq-cap" hidden>LA TUA SEQUENZA</div>
<div id="seq" class="grid"></div>
</section>
<!-- pads -->
<section class="card" id="pads-card" hidden>
<div class="cap">PAD <button class="mini" data-cmd="padStop">■ STOP</button></div>
<div id="pads" class="grid pads"></div>
</section>
<!-- mapping -->
<section class="card">
<div class="cap">MAPPATURA</div>
<div class="row2">
<button id="btn-map">🗺 MAPPATURA OFF</button>
<button id="btn-allzones">TUTTE LE ZONE</button>
</div>
<div id="zones" class="chips"></div>
</section>
<!-- playlist -->
<section class="card">
<div class="cap">PLAYLIST</div>
<ol id="tracks" class="tracks"></ol>
</section>
</main>
<!-- panic bar -->
<footer id="panic">
<button id="btn-blackout">⬛ BLACKOUT</button>
<button id="btn-freeze">❄️ FREEZE</button>
</footer>
<div id="offline" hidden>Connessione al Mac persa…<br /><small>riprovo da solo</small></div>
<script src="/remote.js"></script>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
// DJV Remote — runs in the phone's browser. Connects back to the Mac with the
// key from the QR code, sends commands and redraws from the state pushed by
// the panel. Reconnects on its own when the Wi-Fi drops for a moment.
(function () {
'use strict';
const $ = (s) => document.querySelector(s);
const key = new URLSearchParams(location.search).get('k') || '';
let ws = null, retry = 600, st = null;
const fmt = (s) => { s = Math.max(0, Math.floor(s || 0)); return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); };
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
const buzz = () => { try { navigator.vibrate && navigator.vibrate(12); } catch (e) {} };
function cmd(c, extra) {
buzz();
if (ws && ws.readyState === 1) ws.send(JSON.stringify(Object.assign({ cmd: c }, extra || {})));
}
function connect() {
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws?k=' + encodeURIComponent(key));
ws.onopen = () => { retry = 600; $('#conn').classList.add('on'); $('#offline').hidden = true; };
ws.onmessage = (ev) => {
let m; try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.type === 'state') render(m.state);
};
ws.onclose = () => {
$('#conn').classList.remove('on');
$('#offline').hidden = false;
setTimeout(connect, retry);
retry = Math.min(5000, retry * 1.6);
};
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
// static buttons
document.querySelectorAll('[data-cmd]').forEach(b => b.addEventListener('click', () => cmd(b.dataset.cmd)));
$('#btn-blackout').addEventListener('click', () => cmd('blackout', { on: !(st && st.blackout) }));
$('#btn-freeze').addEventListener('click', () => cmd('freeze', { on: !(st && st.freeze) }));
$('#btn-map').addEventListener('click', () => cmd('mapOn', { on: !(st && st.map.on) }));
$('#btn-allzones').addEventListener('click', () => cmd('mapShow', { ids: null }));
$('#fam').addEventListener('change', (e) => { if (e.target.value !== '') cmd('fxFamily', { fam: parseInt(e.target.value, 10) }); });
// lists are rebuilt only when their content changes (keeps taps reliable)
const sig = {};
function changed(k, v) { const j = JSON.stringify(v); if (sig[k] === j) return false; sig[k] = j; return true; }
function render(s) {
st = s;
// now playing
const t = s.track;
$('#np-name').textContent = t ? t.name : 'Nessun brano';
$('#np-prog').style.width = t && t.dur > 0 ? Math.min(100, t.cur / t.dur * 100) + '%' : '0%';
$('#np-cur').textContent = fmt(t ? t.cur : 0);
$('#np-rem').textContent = '-' + fmt(t && t.dur ? t.dur - t.cur : 0);
$('#btn-play').textContent = t && t.playing ? '⏸' : '▶';
$('#bpm').textContent = s.meters.bpm ? s.meters.bpm + ' BPM' : '— BPM';
$('#m-b').style.width = Math.min(100, s.meters.bass * 100) + '%';
$('#m-m').style.width = Math.min(100, s.meters.mid * 100) + '%';
$('#m-h').style.width = Math.min(100, s.meters.treble * 100) + '%';
// effects
$('#fx-name').textContent = s.effect || '—';
$('#btn-autovj').classList.toggle('on', !!s.autoVj);
if (changed('fams', s.families)) {
$('#fam').innerHTML = '<option value="">Famiglia…</option>' +
s.families.map(f => '<option value="' + f.i + '">' + esc(f.name) + '</option>').join('');
}
$('#fam').value = s.families.some(f => f.i === s.family) ? String(s.family) : '';
if (changed('seq', s.seq)) {
$('#seq-cap').hidden = !s.seq.length;
$('#seq').innerHTML = s.seq.map((n, i) => '<button data-i="' + i + '">' + esc(n) + '</button>').join('');
$('#seq').querySelectorAll('button').forEach(b => b.addEventListener('click', () => cmd('fxSeq', { i: +b.dataset.i })));
}
$('#seq').querySelectorAll('button').forEach((b, i) => b.classList.toggle('on', i === s.seqIndex));
// pads
const anyPad = s.pads.some(Boolean);
$('#pads-card').hidden = !anyPad;
if (changed('pads', s.pads)) {
$('#pads').innerHTML = s.pads.map((n, i) => '<button data-i="' + i + '" class="' + (n ? '' : 'empty') + '">' +
(n ? esc(n.replace(/\.[^.]+$/, '')) : i + 1) + '</button>').join('');
$('#pads').querySelectorAll('button').forEach(b => b.addEventListener('click', () => { if (!b.classList.contains('empty')) cmd('pad', { i: +b.dataset.i }); }));
}
$('#pads').querySelectorAll('button').forEach((b, i) => b.classList.toggle('on', i === s.activePad));
// mapping
$('#btn-map').classList.toggle('on', s.map.on);
$('#btn-map').textContent = s.map.on ? '🗺 MAPPATURA ON' : '🗺 MAPPATURA OFF';
$('#btn-allzones').classList.toggle('on', s.map.on && !s.map.show);
if (changed('zones', s.map.zones)) {
$('#zones').innerHTML = s.map.zones.map(z => '<button data-id="' + z.id + '">' + esc(z.name) + '</button>').join('') ||
'<span style="color:var(--dim);font-size:14px">Nessuna zona: creale nel tab Mappatura del Mac</span>';
$('#zones').querySelectorAll('button').forEach(b => b.addEventListener('click', () => {
// tap = show/hide that zone; starting from "all", a tap means "only this one"
const id = +b.dataset.id, all = st.map.zones.map(z => z.id);
const cur = st.map.show || all;
let next = cur.includes(id) ? cur.filter(x => x !== id) : cur.concat(id);
if (!st.map.show) next = [id];
if (next.length === all.length) next = null;
if (!st.map.on) cmd('mapOn', { on: true });
cmd('mapShow', { ids: next });
}));
}
$('#zones').querySelectorAll('button').forEach(b => {
const id = +b.dataset.id;
b.classList.toggle('on', s.map.on && (!s.map.show || s.map.show.includes(id)));
});
// playlist
if (changed('tracks', s.tracks)) {
$('#tracks').innerHTML = s.tracks.map((n, i) => '<li data-i="' + i + '">' + (i + 1) + '. ' + esc(n) + '</li>').join('') ||
'<li style="opacity:.5">Playlist vuota</li>';
$('#tracks').querySelectorAll('li[data-i]').forEach(li => li.addEventListener('click', () => cmd('track', { i: +li.dataset.i })));
}
$('#tracks').querySelectorAll('li[data-i]').forEach(li => li.classList.toggle('cur', t && +li.dataset.i === t.i));
// panic
$('#btn-blackout').classList.toggle('on', !!s.blackout);
$('#btn-freeze').classList.toggle('on', !!s.freeze);
}
connect();
})();
+8
View File
@@ -1011,3 +1011,11 @@ body.control #map-grid { width: auto; flex: 0 0 auto; padding: 3px 8px; font-siz
/* With mapping on the map canvas sits above the video layers: an opaque
ground keeps semi-transparent zone pixels from revealing the raw video. */
#map-canvas.show { background: #000; }
/* Blackout (output + aux windows): fades everything to black, above all layers */
#blackout {
position: fixed; inset: 0; background: #000;
opacity: 0; visibility: hidden; pointer-events: none;
transition: opacity 0.35s ease, visibility 0s linear 0.35s;
}
#blackout.show { opacity: 1; visibility: visible; transition: opacity 0.35s ease, visibility 0s; }
+18
View File
@@ -383,3 +383,21 @@ body.control ::-webkit-scrollbar-thumb:hover { background: linear-gradient(180de
@media (prefers-reduced-motion: reduce) {
body.control *, body.control *::before, body.control *::after { animation: none !important; transition: none !important; }
}
/* Phone remote section (tab Schermo) */
body.control .remote-box { display: flex; gap: 14px; align-items: flex-start; }
body.control .remote-qr-wrap {
flex: 0 0 150px; height: 150px; border-radius: 12px; overflow: hidden;
display: grid; place-items: center;
background: rgba(4, 8, 20, 0.7);
border: 1px solid rgba(46, 242, 255, 0.35);
box-shadow: 0 0 18px rgba(46, 242, 255, 0.18);
}
body.control #remote-qr { width: 100%; height: 100%; image-rendering: pixelated; background: #fff; padding: 6px; box-sizing: border-box; }
body.control #remote-qr-off { font-family: var(--ng-display); font-weight: 800; letter-spacing: 3px; color: rgba(160, 200, 255, 0.35); }
body.control .remote-side { flex: 1; min-width: 0; }
body.control .remote-url {
font-family: var(--ng-display); font-weight: 600; font-size: 11px; letter-spacing: 0.5px;
color: var(--ng-cyan); word-break: break-all; margin: 4px 0 6px; user-select: text;
}
body.control #remote-clients { color: var(--ng-lime); }