diff --git a/main.js b/main.js
index 3167af9..e172c58 100644
--- a/main.js
+++ b/main.js
@@ -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', () => {
diff --git a/package-lock.json b/package-lock.json
index 9f6fece..4f9e243 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 3de4068..0aef6bd 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/preload.js b/preload.js
index 19f9480..5571aba 100644
--- a/preload.js
+++ b/preload.js
@@ -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))
});
diff --git a/remote-server.js b/remote-server.js
new file mode 100644
index 0000000..2703f60
--- /dev/null
+++ b/remote-server.js
@@ -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 };
diff --git a/src/aux.html b/src/aux.html
index 0eeda3d..e93fa05 100644
--- a/src/aux.html
+++ b/src/aux.html
@@ -21,6 +21,8 @@
+
+
diff --git a/src/aux.js b/src/aux.js
index 845e1f0..546b264 100644
--- a/src/aux.js
+++ b/src/aux.js
@@ -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.
diff --git a/src/control.html b/src/control.html
index d77ed99..64ecda9 100644
--- a/src/control.html
+++ b/src/control.html
@@ -491,6 +491,26 @@
⛶ Fullscreen
+
+ 📱 Telecomando dal telefono
+
+
OFF
+
+
+ ▶ Attiva telecomando
+ 🔑 Nuovo codice
+
+
—
+
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.
+
+ ⬛ Blackout
+ ❄️ Freeze
+
+
+
+
Uscite multiple — una finestra indipendente per ogni schermo in più
diff --git a/src/control.js b/src/control.js
index 47bba51..c5cf724 100644
--- a/src/control.js
+++ b/src/control.js
@@ -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();
diff --git a/src/output.html b/src/output.html
index 53ea158..9382a11 100644
--- a/src/output.html
+++ b/src/output.html
@@ -51,6 +51,9 @@
DOM order = paints above everything without needing a z-index. -->
+
+
+
diff --git a/src/output.js b/src/output.js
index 0bc0944..bbe59aa 100644
--- a/src/output.js
+++ b/src/output.js
@@ -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();
diff --git a/src/remote.css b/src/remote.css
new file mode 100644
index 0000000..572f3ed
--- /dev/null
+++ b/src/remote.css
@@ -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; }
diff --git a/src/remote.html b/src/remote.html
new file mode 100644
index 0000000..25296e4
--- /dev/null
+++ b/src/remote.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+ DJV Remote
+
+
+
+
+ DJV REMOTE
+
+ — BPM
+
+
+
+
+
+
+
+ IN RIPRODUZIONE
+ —
+
+ 00:00 -00:00
+
+ ⏮
+ ▶
+ ⏭
+
+
+
+
+
+
+ EFFETTO
+ —
+
+ ◀ FX
+ 🎲
+ FX ▶
+
+
+ 🤖 AUTO VJ
+ Famiglia…
+
+ LA TUA SEQUENZA
+
+
+
+
+
+
+
+
+ MAPPATURA
+
+ 🗺 MAPPATURA OFF
+ TUTTE LE ZONE
+
+
+
+
+
+
+
+
+
+
+ ⬛ BLACKOUT
+ ❄️ FREEZE
+
+
+ Connessione al Mac persa…riprovo da solo
+
+
+
diff --git a/src/remote.js b/src/remote.js
new file mode 100644
index 0000000..ef3ae2f
--- /dev/null
+++ b/src/remote.js
@@ -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, '&').replace(/ { 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 = 'Famiglia… ' +
+ s.families.map(f => '' + esc(f.name) + ' ').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) => '' + esc(n) + ' ').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) => '' +
+ (n ? esc(n.replace(/\.[^.]+$/, '')) : i + 1) + ' ').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 => '' + esc(z.name) + ' ').join('') ||
+ 'Nessuna zona: creale nel tab Mappatura del Mac ';
+ $('#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) => '' + (i + 1) + '. ' + esc(n) + ' ').join('') ||
+ 'Playlist vuota ';
+ $('#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();
+})();
diff --git a/src/style.css b/src/style.css
index 031ca53..461e326 100644
--- a/src/style.css
+++ b/src/style.css
@@ -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; }
diff --git a/src/theme.css b/src/theme.css
index 8c23d77..41c3dd4 100644
--- a/src/theme.css
+++ b/src/theme.css
@@ -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); }