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 @@ +
+ +
+
OFF
+
+
+ + +
+
—
+
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.
+
+ + +
+
+
+
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
+
+ + + +
+
+
B
+
M
+
H
+
+
+ + +
+
EFFETTO
+
—
+
+ + + +
+
+ + +
+ +
+
+ + + + + +
+
MAPPATURA
+
+ + +
+
+
+ + +
+
PLAYLIST
+
    +
    +
    + + + + + + + + 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 = '' + + s.families.map(f => '').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) => '').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) => '').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 => '').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) => '
  1. ' + (i + 1) + '. ' + esc(n) + '
  2. ').join('') || + '
  3. Playlist vuota
  4. '; + $('#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); }