New separate project (the Electron Mac app stays untouched). Reuses the visual engine (WebGL shaders, effect catalog, Web Audio) verbatim and replaces Electron IPC with a Node HTTP+WebSocket server: - server.js: serves the kiosk output page + mobile control PWA, relays the same ctl/rpt JSON protocol over WebSocket, plus REST for media library/upload, JSON state (pads), waveform peaks and MP4 recording (ffmpeg). - public/js/net.js: window.djv shim over WebSocket+HTTP so output.js runs intact. - public/index.html + control.js: touch-first mobile remote (effects, media/queue, ticker, pads, audio/EQ, fullscreen + recording). - PWA (manifest + service worker + generated icons). - scripts/: Chromium kiosk launcher + install-pi.sh (systemd units for Pi 5). Verified: REST endpoints, WebSocket relay both directions, control PWA loads the 2958-effect catalog and renders. (Headless WebGL output not testable without GPU.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
59 lines
2.6 KiB
JavaScript
59 lines
2.6 KiB
JavaScript
// Network shim that recreates the Electron `window.djv` API over WebSocket + HTTP,
|
|
// so the visual engine (output.js) and the control UI run unchanged in a browser.
|
|
// The page sets window.DJV_ROLE = 'output' | 'control' before loading this file.
|
|
(function () {
|
|
const role = window.DJV_ROLE || 'control';
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
let ws = null, ready = false;
|
|
const queue = [];
|
|
const ctlCbs = [], rptCbs = [], statusCbs = [];
|
|
const setStatus = (ok) => statusCbs.forEach(cb => cb(ok));
|
|
|
|
function connect() {
|
|
ws = new WebSocket(proto + '://' + location.host + '/?role=' + role);
|
|
ws.onopen = () => { ready = true; setStatus(true); while (queue.length) ws.send(queue.shift()); };
|
|
ws.onclose = () => { ready = false; setStatus(false); setTimeout(connect, 1000); }; // survive server restarts
|
|
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
|
ws.onmessage = (ev) => {
|
|
let m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
|
if (m.ch === 'ctl') ctlCbs.forEach(cb => cb(m.msg));
|
|
else if (m.ch === 'rpt') rptCbs.forEach(cb => cb(m.msg));
|
|
};
|
|
}
|
|
connect();
|
|
|
|
function rawSend(obj) {
|
|
const s = JSON.stringify(obj);
|
|
if (ready && ws.readyState === 1) ws.send(s); else queue.push(s);
|
|
}
|
|
|
|
async function post(url, body, raw) {
|
|
const opts = { method: 'POST' };
|
|
if (raw) { opts.headers = { 'Content-Type': 'application/octet-stream' }; opts.body = body; }
|
|
else if (body !== undefined) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); }
|
|
const r = await fetch(url, opts);
|
|
try { return await r.json(); } catch (e) { return null; }
|
|
}
|
|
|
|
window.djv = {
|
|
role,
|
|
// control -> output
|
|
send: (msg) => rawSend({ ch: 'ctl', msg }),
|
|
onControl: (cb) => ctlCbs.push(cb),
|
|
// output -> control
|
|
report: (msg) => rawSend({ ch: 'rpt', msg }),
|
|
onReport: (cb) => rptCbs.push(cb),
|
|
onStatus: (cb) => { statusCbs.push(cb); cb(ready); },
|
|
// recording (output side streams chunks to the server)
|
|
recStart: () => post('/api/rec/start'),
|
|
recChunk: (bytes) => post('/api/rec/chunk', bytes, true),
|
|
recStop: (opts) => post('/api/rec/stop', opts || {}),
|
|
// helpers used by the control UI
|
|
listMedia: () => fetch('/api/media').then(r => r.json()),
|
|
listSvgs: () => fetch('/api/svgs').then(r => r.json()),
|
|
loadState: (key) => fetch('/api/state/' + key).then(r => r.json()),
|
|
saveState: (key, data) => post('/api/state/' + key, data),
|
|
peaks: (file, buckets) => fetch('/api/peaks?file=' + encodeURIComponent(file) + '&buckets=' + (buckets || 400)).then(r => r.json())
|
|
};
|
|
})();
|