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

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

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

No files matched your search

+2
View File
@@ -21,6 +21,8 @@
<div class="side-col right"><div class="side-track"><span class="side-copy"></span><span class="side-copy"></span></div></div>
</div>
<div id="blackout"></div>
<script src="shaders.js"></script>
<script src="effects.js"></script>
<script src="fluid.js"></script>
+4 -1
View File
@@ -12,6 +12,7 @@ const viz = new Visualizer(cv);
const MY_ID = window.djv.displayId;
let mode = 'follow';
let frozen = false; // 'freeze' from the remote/panel: hold the last frame
let ownEffect = null; // effect object for mode 'effect'
let followEffect = null; // last effect broadcast by the control panel
@@ -117,6 +118,8 @@ window.djv.onControl((m) => {
applyText(m.text);
break;
}
case 'blackout': document.getElementById('blackout').classList.toggle('show', !!m.on); break;
case 'freeze': frozen = !!m.on; break;
case 'auxFx': {
// Scene cue targeted at aux outputs. displayId -1 = every aux window,
// but follow-mode windows ignore the broadcast form: they receive the
@@ -134,7 +137,7 @@ function frame() {
const now = performance.now();
clockT += (now - clockAt) / 1000;
clockAt = now;
if (mode === 'follow' || mode === 'effect') {
if ((mode === 'follow' || mode === 'effect') && !frozen) {
viz.render(clockT, audioData || SILENT);
}
// Ticker letters glow with the music, same formula as the main output.
+20
View File
@@ -491,6 +491,26 @@
<button id="btn-fullscreen">⛶ Fullscreen</button>
</div>
</section>
<section class="span2">
<label>📱 Telecomando dal telefono <span id="remote-clients"></span></label>
<div class="remote-box">
<div class="remote-qr-wrap"><img id="remote-qr" alt="" hidden /><div id="remote-qr-off">OFF</div></div>
<div class="remote-side">
<div class="row">
<button id="remote-toggle">▶ Attiva telecomando</button>
<button id="remote-newkey" title="Genera un nuovo codice: i telefoni collegati col vecchio QR vengono scollegati">🔑 Nuovo codice</button>
</div>
<div id="remote-url" class="remote-url">—</div>
<div class="hint-mini">Inquadra il QR col telefono (stessa rete Wi-Fi del Mac, anche l'hotspot del telefono).
Si apre il telecomando nel browser: con "Aggiungi a Home" diventa un'icona. Il codice nel QR
protegge l'accesso; se macOS chiede di consentire connessioni in entrata, rispondi Consenti.</div>
<div class="row">
<button id="btn-blackout" title="Tutto nero, su ogni uscita">⬛ Blackout</button>
<button id="btn-freeze" title="Ferma il visual sull'ultimo fotogramma">❄️ Freeze</button>
</div>
</div>
</div>
</section>
<section class="span2">
<label>Uscite multiple <small>— una finestra indipendente per ogni schermo in più</small></label>
<div id="aux-list" class="aux-list"></div>
+130 -1
View File
@@ -2,7 +2,15 @@
// It also receives reports (meters, fps, device list, play state).
const $ = (s) => document.querySelector(s);
const send = (msg) => djv.send(msg);
// Some output state is only ever SENT (never reported back): remember it so
// the panel and the phone remote can show it.
const outState = { blackout: false, freeze: false, mapShow: null };
const send = (msg) => {
if (msg.type === 'blackout') outState.blackout = !!msg.on;
else if (msg.type === 'freeze') outState.freeze = !!msg.on;
else if (msg.type === 'mapShow') outState.mapShow = Array.isArray(msg.ids) ? msg.ids : null;
djv.send(msg);
};
const filePath = (f) => djv.pathForFile(f);
// ---------------------------------------------------------------- effects
@@ -2677,6 +2685,7 @@ djv.onReport((m) => {
break;
}
case 'meters':
lastMeters = m;
$('#m-bass').style.width = (m.bass * 100).toFixed(0) + '%';
$('#m-mid').style.width = (m.mid * 100).toFixed(0) + '%';
$('#m-treble').style.width = (m.treble * 100).toFixed(0) + '%';
@@ -2764,6 +2773,126 @@ djv.onReport((m) => {
}
});
// ---------------------------------------------------------------- phone remote
// The phone sends commands; they run through the SAME functions as the
// panel's own buttons, so panel and phone can never disagree. A compact
// state goes back at ~6 Hz while at least one phone is connected.
let lastMeters = { bass: 0, mid: 0, treble: 0, bpm: 0 };
let remoteOn = false, remoteClients = 0, remoteTimer = null;
function setBlackout(on) {
send({ type: 'blackout', on });
$('#btn-blackout').classList.toggle('active', on);
}
function setFreeze(on) {
send({ type: 'freeze', on });
$('#btn-freeze').classList.toggle('active', on);
}
$('#btn-blackout').addEventListener('click', () => setBlackout(!outState.blackout));
$('#btn-freeze').addEventListener('click', () => setFreeze(!outState.freeze));
// Effects a remote may step through: everything but camera/3D families.
const remoteFx = () => EFFECTS.list.filter(e => !e.isInteractive && !e.isModel3d);
function remoteStepEffect(dir) {
if (sequence.length) {
const n = seqIndex < 0 ? (dir > 0 ? 0 : sequence.length - 1) : (seqIndex + dir + sequence.length) % sequence.length;
return applySeqIndex(n);
}
// no personal sequence: walk the presets of the current family
const fam = EFFECTS.list.filter(e => e.family === currentEffect.family);
const k = fam.indexOf(currentEffect);
applyEffect(fam[(k + dir + fam.length) % fam.length] || currentEffect);
}
function remoteCommand(m) {
switch (m.cmd) {
case '_clients':
remoteClients = m.n || 0;
$('#remote-clients').textContent = remoteClients ? '● ' + remoteClients + (remoteClients === 1 ? ' telefono' : ' telefoni') : '';
pushRemoteState();
break;
case 'play': togglePlayPause(); break;
case 'next': nextTrack(); break;
case 'prev': if (playlist.length) playIndex(Math.max(0, currentIndex - 1)); break;
case 'track': if (m.i >= 0 && m.i < playlist.length) playIndex(m.i); break;
case 'fxNext': remoteStepEffect(1); break;
case 'fxPrev': remoteStepEffect(-1); break;
case 'fxRandom': { const L = remoteFx(); applyEffect(L[Math.floor(Math.random() * L.length)]); break; }
case 'fxSeq': if (m.i >= 0 && m.i < sequence.length) applySeqIndex(m.i); break;
case 'fxFamily': {
const e = EFFECTS.list.find(x => x.family === m.fam && !x.isInteractive && !x.isModel3d);
if (e) applyEffect(e);
break;
}
case 'autoVj': avSend(!autoVjOn); break;
case 'pad': if (pads[m.i]) playPad(m.i); break; // empty pads would open a file dialog on the Mac
case 'padStop': $('#btn-pad-stop').click(); break;
case 'mapOn':
mapCfg.on = !!m.on; mapSave(); $('#map-on').checked = mapCfg.on;
send({ type: 'mapShow', ids: null });
send({ type: 'mapOn', on: mapCfg.on });
break;
case 'mapShow':
send({ type: 'mapShow', ids: Array.isArray(m.ids) ? m.ids.map(Number) : null });
break;
case 'blackout': setBlackout(!!m.on); break;
case 'freeze': setFreeze(!!m.on); break;
}
pushRemoteState();
}
djv.onRemote(remoteCommand);
let remoteFamilies = null;
function pushRemoteState() {
if (!remoteOn || !remoteClients) return;
const tr = playlist[currentIndex];
if (!remoteFamilies) remoteFamilies = EFFECTS.families
.map((name, i) => ({ i, name }))
.filter(f => EFFECTS.list.some(e => e.family === f.i && !e.isInteractive && !e.isModel3d));
djv.remoteState({
track: tr ? { i: currentIndex, name: tr.name, cur: playCur, dur: playDur, playing: isPlaying } : null,
tracks: playlist.slice(0, 60).map(t => (t.isInterlude ? '✨ ' : '') + t.name),
effect: currentEffect ? currentEffect.name : '',
family: currentEffect ? currentEffect.family : -1,
families: remoteFamilies,
autoVj: autoVjOn,
seq: sequence.map(x => x.effect.name),
seqIndex,
pads: pads.map(p => p ? p.name : null),
activePad: padPlaying ? activePad : -1,
map: { on: !!mapCfg.on, zones: mapCfg.zones.map(z => ({ id: z.id, name: z.name || 'Zona' })), show: outState.mapShow },
blackout: outState.blackout, freeze: outState.freeze,
meters: { bass: lastMeters.bass || 0, mid: lastMeters.mid || 0, treble: lastMeters.treble || 0, bpm: lastMeters.bpm || 0 }
});
}
async function remoteShowInfo(info) {
remoteOn = !!(info && info.on);
$('#remote-toggle').textContent = remoteOn ? '⏹ Disattiva telecomando' : '▶ Attiva telecomando';
$('#remote-toggle').classList.toggle('active', remoteOn);
$('#remote-newkey').disabled = !remoteOn;
$('#remote-qr').hidden = !(remoteOn && info.qr);
$('#remote-qr-off').hidden = remoteOn && !!info.qr;
if (remoteOn && info.qr) $('#remote-qr').src = info.qr;
$('#remote-url').textContent = remoteOn
? (info.urls && info.urls.length ? info.urls[0] : 'Nessuna rete: collega il Mac al Wi-Fi o all\'hotspot')
: (info && info.error ? '⚠ ' + info.error : '—');
if (!remoteOn) { remoteClients = 0; $('#remote-clients').textContent = ''; }
clearInterval(remoteTimer);
if (remoteOn) remoteTimer = setInterval(pushRemoteState, 160);
}
$('#remote-toggle').addEventListener('click', async () => {
remoteShowInfo(remoteOn ? await djv.remoteStop() : await djv.remoteStart());
});
$('#remote-newkey').addEventListener('click', async () => remoteShowInfo(await djv.remoteNewKey()));
(async () => {
// switched on last time: bring it back with the same code
const info = await djv.remoteInfo();
if (info.on) remoteShowInfo(info);
else if (info.wanted) remoteShowInfo(await djv.remoteStart());
else remoteShowInfo(info);
})();
// ---------------------------------------------------------------- init
renderLibrary();
renderSequence();
+3
View File
@@ -51,6 +51,9 @@
DOM order = paints above everything without needing a z-index. -->
<div id="rec-guide"></div>
<!-- Blackout: above everything (last in DOM), toggled by the panel/remote -->
<div id="blackout"></div>
<script src="shaders.js"></script>
<script src="effects.js"></script>
<script src="fluid.js"></script>
+9 -4
View File
@@ -401,6 +401,10 @@ djv.onControl(async (m) => {
break;
case 'mapSel': if (mapping) mapping.selected = m.index; break;
case 'auxActive': auxActive = !!m.on; break;
// Panic controls (phone remote / panel): blackout fades everything to
// black above every layer; freeze holds the last visual frame.
case 'blackout': $('#blackout').classList.toggle('show', !!m.on); break;
case 'freeze': frozen = !!m.on; break;
case 'glbAnims': viz.setClipFilter(m.names || []); break;
case 'modelBpm': viz.setManualBpm(m.bpm || 0); break;
case 'modelSpread': viz.setArmSpread(m.deg || 0); break;
@@ -599,7 +603,8 @@ djv.onControl(async (m) => {
// ---------------------------------------------------------------- render loop
const startTime = performance.now();
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0;
let auxActive = false, lastAfr = 0; // aux outputs: relay audio only when some exist
let auxActive = false, lastAfr = 0;
let frozen = false; // 'freeze': stop rendering, the canvases keep the last frame // aux outputs: relay audio only when some exist
// ---- Auto VJ: a director that adapts the visuals to the playing track ------
// Uses the engine's musical features (mood/bpm/drop/beatCount): switches preset
@@ -654,8 +659,8 @@ function frame() {
audio.update();
const a = audio.values;
avTick(performance.now());
viz.render(t * speed, a);
if (mapOn && mapping) {
if (!frozen) viz.render(t * speed, a);
if (mapOn && mapping && !frozen) {
for (const id in zoneOv) if (zoneOv[id].text) zoneOv[id].text.render(a);
mapping.main = mapVisualSource();
mapping.render(t * speed, a);
@@ -702,7 +707,7 @@ function frame() {
if (now - fpsT > 500) { fps = frames * 1000 / (now - fpsT); frames = 0; fpsT = now; }
if (now - lastReport > 66) {
lastReport = now;
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps });
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps, bpm: audio.bpm || 0 });
}
// Stop at the trim end point if one is set.
audio.checkTrim();
+149
View File
@@ -0,0 +1,149 @@
/* DJV Remote — phone page, same Neon Grid look as the control panel */
@font-face { font-family: 'Orbitron'; font-weight: 800; font-display: swap; src: url('/fonts/orbitron-latin-800-normal.woff2') format('woff2'); }
@font-face { font-family: 'Rajdhani'; font-weight: 600; font-display: swap; src: url('/fonts/rajdhani-latin-600-normal.woff2') format('woff2'); }
@font-face { font-family: 'Rajdhani'; font-weight: 700; font-display: swap; src: url('/fonts/rajdhani-latin-700-normal.woff2') format('woff2'); }
:root {
--cyan: #2ef2ff; --blue: #4d7cff; --mag: #ff2bd6; --lime: #7dff8a; --red: #ff3d6e;
--text: #dbe9ff; --dim: rgba(190, 215, 255, 0.55);
--grad: linear-gradient(90deg, var(--cyan), var(--blue) 50%, var(--mag));
--ui: 'Rajdhani', system-ui, sans-serif;
--display: 'Orbitron', 'Rajdhani', system-ui, sans-serif;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; }
body {
min-height: 100vh;
color: var(--text);
font-family: var(--ui); font-weight: 600; font-size: 16px;
background:
radial-gradient(500px 300px at 0% 0%, rgba(46, 242, 255, 0.14), transparent 60%),
radial-gradient(500px 400px at 100% 30%, rgba(255, 43, 214, 0.12), transparent 60%),
linear-gradient(rgba(120, 200, 255, 0.05) 1px, transparent 1px) 0 0 / 30px 30px,
linear-gradient(90deg, rgba(120, 200, 255, 0.05) 1px, transparent 1px) 0 0 / 30px 30px,
#04050b;
background-attachment: fixed;
padding-bottom: calc(86px + env(safe-area-inset-bottom));
touch-action: manipulation;
user-select: none; -webkit-user-select: none;
}
#top {
position: sticky; top: 0; z-index: 5;
display: flex; justify-content: space-between; align-items: center;
padding: calc(12px + env(safe-area-inset-top)) 16px 12px;
background: linear-gradient(180deg, rgba(4, 6, 16, 0.96), rgba(4, 6, 16, 0.75));
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(46, 242, 255, 0.35);
box-shadow: 0 1px 12px rgba(46, 242, 255, 0.25);
}
.brand { font-family: var(--display); font-weight: 800; letter-spacing: 3px; font-size: 16px; }
.brand b { background: linear-gradient(90deg, var(--cyan), var(--mag)); -webkit-background-clip: text; background-clip: text; color: transparent; }
.top-right { display: flex; align-items: center; gap: 12px; }
#bpm { font-family: var(--display); font-weight: 800; font-size: 12px; color: var(--cyan); letter-spacing: 1px; }
.conn { width: 11px; height: 11px; border-radius: 50%; background: var(--red); box-shadow: 0 0 8px var(--red); }
.conn.on { background: var(--lime); box-shadow: 0 0 10px var(--lime); }
main { padding: 12px; display: flex; flex-direction: column; gap: 12px; }
.card {
position: relative;
padding: 14px;
border-radius: 18px;
border: 1px solid transparent;
background:
linear-gradient(rgba(12, 16, 32, 0.78), rgba(12, 16, 32, 0.78)) padding-box,
linear-gradient(135deg, rgba(46, 242, 255, 0.5), rgba(77, 124, 255, 0.12) 45%, rgba(255, 43, 214, 0.4)) border-box;
box-shadow: 0 8px 26px rgba(0, 0, 0, 0.5);
}
.cap {
display: flex; justify-content: space-between; align-items: center;
font-weight: 700; font-size: 12px; letter-spacing: 2px; color: #9fdcff; margin-bottom: 10px;
}
.cap.sub { margin: 14px 0 8px; }
button, select {
font: inherit; font-weight: 700; letter-spacing: 0.8px;
color: var(--text);
min-height: 52px; padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(120, 200, 255, 0.28);
background: linear-gradient(180deg, rgba(30, 40, 72, 0.85), rgba(14, 18, 36, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
transition: transform .08s ease, box-shadow .15s ease, border-color .15s ease;
touch-action: manipulation;
}
button:active { transform: scale(0.96); border-color: var(--cyan); box-shadow: 0 0 18px rgba(46, 242, 255, 0.6); }
button.on {
color: #fff; border: 1px solid transparent;
background:
linear-gradient(180deg, rgba(36, 18, 64, 0.95), rgba(18, 10, 40, 0.95)) padding-box,
linear-gradient(90deg, var(--cyan), var(--mag)) border-box;
box-shadow: 0 0 18px rgba(255, 43, 214, 0.55);
text-shadow: 0 0 8px rgba(255, 43, 214, 0.8);
}
button.mini { min-height: 32px; padding: 4px 12px; font-size: 12px; border-radius: 10px; }
select { appearance: none; -webkit-appearance: none; width: 100%; background-color: #0e1426; }
.np-name { font-size: 20px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.np-bar { height: 5px; border-radius: 5px; background: rgba(120, 200, 255, 0.12); margin: 10px 0 6px; overflow: hidden; }
.np-bar i { display: block; height: 100%; width: 0; background: var(--grad); box-shadow: 0 0 10px var(--cyan); }
.np-times { display: flex; justify-content: space-between; font-family: var(--display); font-weight: 800; font-size: 11px; color: var(--cyan); }
.transport { display: grid; grid-template-columns: 1fr 1.5fr 1fr; gap: 10px; margin: 14px 0 12px; }
.transport button { font-size: 24px; min-height: 64px; }
.transport button.big { font-size: 30px; }
.meters { display: flex; flex-direction: column; gap: 5px; }
.mtr { display: flex; align-items: center; gap: 8px; }
.mtr span { width: 12px; font-family: var(--display); font-size: 10px; color: #9fdcff; }
.mtr .bar { flex: 1; height: 10px; border-radius: 3px; background: repeating-linear-gradient(90deg, rgba(120, 200, 255, 0.1) 0 6px, transparent 6px 8px); overflow: hidden; }
.mtr .bar i {
display: block; height: 100%; width: 0;
background: linear-gradient(90deg, var(--lime), var(--cyan) 45%, var(--blue) 70%, var(--mag));
-webkit-mask: repeating-linear-gradient(90deg, #000 0 6px, transparent 6px 8px);
mask: repeating-linear-gradient(90deg, #000 0 6px, transparent 6px 8px);
transition: width .12s linear;
}
.fx-name { font-size: 18px; font-weight: 700; color: #fff; margin-bottom: 12px; min-height: 22px; }
.row3 { display: grid; grid-template-columns: 1fr 0.7fr 1fr; gap: 10px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.grid button { font-size: 13px; min-height: 56px; white-space: normal; line-height: 1.15; text-align: left; }
.pads { grid-template-columns: repeat(4, 1fr); }
.pads button { font-size: 11.5px; min-height: 64px; text-align: center; padding: 6px; overflow: hidden; }
.pads button.empty { opacity: 0.3; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.chips button { min-height: 44px; font-size: 14px; }
.tracks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.tracks li {
padding: 13px 12px; border-radius: 12px; font-size: 15px;
border: 1px solid rgba(120, 200, 255, 0.16); background: rgba(6, 10, 24, 0.6);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tracks li.cur { border-color: var(--cyan); box-shadow: inset 3px 0 0 var(--cyan), 0 0 12px rgba(46, 242, 255, 0.25); color: #fff; }
.tracks li:active { background: rgba(46, 242, 255, 0.12); }
#panic {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 6;
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: linear-gradient(0deg, rgba(4, 6, 16, 0.97), rgba(4, 6, 16, 0.8));
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-top: 1px solid rgba(255, 43, 214, 0.35);
}
#panic button { min-height: 58px; font-family: var(--display); font-weight: 800; font-size: 13px; letter-spacing: 2px; }
#btn-blackout.on { background: #000; border: 2px solid var(--red); box-shadow: 0 0 22px rgba(255, 61, 110, 0.8); color: var(--red); text-shadow: none; }
#btn-freeze.on { border: 2px solid var(--cyan); box-shadow: 0 0 22px rgba(46, 242, 255, 0.8); text-shadow: none; }
#offline {
position: fixed; inset: 0; z-index: 9;
display: grid; place-content: center; text-align: center; gap: 6px;
background: rgba(2, 3, 8, 0.86); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
font-family: var(--display); font-weight: 800; letter-spacing: 1px; color: var(--red);
}
#offline small { font-family: var(--ui); color: var(--dim); letter-spacing: 0; }
[hidden] { display: none !important; }
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#04050b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="DJV Remote" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>DJV Remote</title>
<link rel="stylesheet" href="/remote.css" />
</head>
<body>
<header id="top">
<div class="brand">DJV <b>REMOTE</b></div>
<div class="top-right">
<span id="bpm">— BPM</span>
<span id="conn" class="conn" title="Connessione"></span>
</div>
</header>
<main>
<!-- now playing -->
<section class="card np">
<div class="cap">IN RIPRODUZIONE</div>
<div id="np-name" class="np-name">—</div>
<div class="np-bar"><i id="np-prog"></i></div>
<div class="np-times"><span id="np-cur">00:00</span><span id="np-rem">-00:00</span></div>
<div class="transport">
<button data-cmd="prev" aria-label="Brano precedente">⏮</button>
<button data-cmd="play" id="btn-play" class="big" aria-label="Play/Pausa">▶</button>
<button data-cmd="next" aria-label="Brano successivo">⏭</button>
</div>
<div class="meters">
<div class="mtr"><span>B</span><div class="bar"><i id="m-b"></i></div></div>
<div class="mtr"><span>M</span><div class="bar"><i id="m-m"></i></div></div>
<div class="mtr"><span>H</span><div class="bar"><i id="m-h"></i></div></div>
</div>
</section>
<!-- effects -->
<section class="card">
<div class="cap">EFFETTO</div>
<div id="fx-name" class="fx-name">—</div>
<div class="row3">
<button data-cmd="fxPrev">◀ FX</button>
<button data-cmd="fxRandom">🎲</button>
<button data-cmd="fxNext">FX ▶</button>
</div>
<div class="row2">
<button data-cmd="autoVj" id="btn-autovj">🤖 AUTO VJ</button>
<select id="fam"><option value="">Famiglia…</option></select>
</div>
<div class="cap sub" id="seq-cap" hidden>LA TUA SEQUENZA</div>
<div id="seq" class="grid"></div>
</section>
<!-- pads -->
<section class="card" id="pads-card" hidden>
<div class="cap">PAD <button class="mini" data-cmd="padStop">■ STOP</button></div>
<div id="pads" class="grid pads"></div>
</section>
<!-- mapping -->
<section class="card">
<div class="cap">MAPPATURA</div>
<div class="row2">
<button id="btn-map">🗺 MAPPATURA OFF</button>
<button id="btn-allzones">TUTTE LE ZONE</button>
</div>
<div id="zones" class="chips"></div>
</section>
<!-- playlist -->
<section class="card">
<div class="cap">PLAYLIST</div>
<ol id="tracks" class="tracks"></ol>
</section>
</main>
<!-- panic bar -->
<footer id="panic">
<button id="btn-blackout">⬛ BLACKOUT</button>
<button id="btn-freeze">❄️ FREEZE</button>
</footer>
<div id="offline" hidden>Connessione al Mac persa…<br /><small>riprovo da solo</small></div>
<script src="/remote.js"></script>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
// DJV Remote — runs in the phone's browser. Connects back to the Mac with the
// key from the QR code, sends commands and redraws from the state pushed by
// the panel. Reconnects on its own when the Wi-Fi drops for a moment.
(function () {
'use strict';
const $ = (s) => document.querySelector(s);
const key = new URLSearchParams(location.search).get('k') || '';
let ws = null, retry = 600, st = null;
const fmt = (s) => { s = Math.max(0, Math.floor(s || 0)); return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); };
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
const buzz = () => { try { navigator.vibrate && navigator.vibrate(12); } catch (e) {} };
function cmd(c, extra) {
buzz();
if (ws && ws.readyState === 1) ws.send(JSON.stringify(Object.assign({ cmd: c }, extra || {})));
}
function connect() {
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws?k=' + encodeURIComponent(key));
ws.onopen = () => { retry = 600; $('#conn').classList.add('on'); $('#offline').hidden = true; };
ws.onmessage = (ev) => {
let m; try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.type === 'state') render(m.state);
};
ws.onclose = () => {
$('#conn').classList.remove('on');
$('#offline').hidden = false;
setTimeout(connect, retry);
retry = Math.min(5000, retry * 1.6);
};
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
// static buttons
document.querySelectorAll('[data-cmd]').forEach(b => b.addEventListener('click', () => cmd(b.dataset.cmd)));
$('#btn-blackout').addEventListener('click', () => cmd('blackout', { on: !(st && st.blackout) }));
$('#btn-freeze').addEventListener('click', () => cmd('freeze', { on: !(st && st.freeze) }));
$('#btn-map').addEventListener('click', () => cmd('mapOn', { on: !(st && st.map.on) }));
$('#btn-allzones').addEventListener('click', () => cmd('mapShow', { ids: null }));
$('#fam').addEventListener('change', (e) => { if (e.target.value !== '') cmd('fxFamily', { fam: parseInt(e.target.value, 10) }); });
// lists are rebuilt only when their content changes (keeps taps reliable)
const sig = {};
function changed(k, v) { const j = JSON.stringify(v); if (sig[k] === j) return false; sig[k] = j; return true; }
function render(s) {
st = s;
// now playing
const t = s.track;
$('#np-name').textContent = t ? t.name : 'Nessun brano';
$('#np-prog').style.width = t && t.dur > 0 ? Math.min(100, t.cur / t.dur * 100) + '%' : '0%';
$('#np-cur').textContent = fmt(t ? t.cur : 0);
$('#np-rem').textContent = '-' + fmt(t && t.dur ? t.dur - t.cur : 0);
$('#btn-play').textContent = t && t.playing ? '⏸' : '▶';
$('#bpm').textContent = s.meters.bpm ? s.meters.bpm + ' BPM' : '— BPM';
$('#m-b').style.width = Math.min(100, s.meters.bass * 100) + '%';
$('#m-m').style.width = Math.min(100, s.meters.mid * 100) + '%';
$('#m-h').style.width = Math.min(100, s.meters.treble * 100) + '%';
// effects
$('#fx-name').textContent = s.effect || '—';
$('#btn-autovj').classList.toggle('on', !!s.autoVj);
if (changed('fams', s.families)) {
$('#fam').innerHTML = '<option value="">Famiglia…</option>' +
s.families.map(f => '<option value="' + f.i + '">' + esc(f.name) + '</option>').join('');
}
$('#fam').value = s.families.some(f => f.i === s.family) ? String(s.family) : '';
if (changed('seq', s.seq)) {
$('#seq-cap').hidden = !s.seq.length;
$('#seq').innerHTML = s.seq.map((n, i) => '<button data-i="' + i + '">' + esc(n) + '</button>').join('');
$('#seq').querySelectorAll('button').forEach(b => b.addEventListener('click', () => cmd('fxSeq', { i: +b.dataset.i })));
}
$('#seq').querySelectorAll('button').forEach((b, i) => b.classList.toggle('on', i === s.seqIndex));
// pads
const anyPad = s.pads.some(Boolean);
$('#pads-card').hidden = !anyPad;
if (changed('pads', s.pads)) {
$('#pads').innerHTML = s.pads.map((n, i) => '<button data-i="' + i + '" class="' + (n ? '' : 'empty') + '">' +
(n ? esc(n.replace(/\.[^.]+$/, '')) : i + 1) + '</button>').join('');
$('#pads').querySelectorAll('button').forEach(b => b.addEventListener('click', () => { if (!b.classList.contains('empty')) cmd('pad', { i: +b.dataset.i }); }));
}
$('#pads').querySelectorAll('button').forEach((b, i) => b.classList.toggle('on', i === s.activePad));
// mapping
$('#btn-map').classList.toggle('on', s.map.on);
$('#btn-map').textContent = s.map.on ? '🗺 MAPPATURA ON' : '🗺 MAPPATURA OFF';
$('#btn-allzones').classList.toggle('on', s.map.on && !s.map.show);
if (changed('zones', s.map.zones)) {
$('#zones').innerHTML = s.map.zones.map(z => '<button data-id="' + z.id + '">' + esc(z.name) + '</button>').join('') ||
'<span style="color:var(--dim);font-size:14px">Nessuna zona: creale nel tab Mappatura del Mac</span>';
$('#zones').querySelectorAll('button').forEach(b => b.addEventListener('click', () => {
// tap = show/hide that zone; starting from "all", a tap means "only this one"
const id = +b.dataset.id, all = st.map.zones.map(z => z.id);
const cur = st.map.show || all;
let next = cur.includes(id) ? cur.filter(x => x !== id) : cur.concat(id);
if (!st.map.show) next = [id];
if (next.length === all.length) next = null;
if (!st.map.on) cmd('mapOn', { on: true });
cmd('mapShow', { ids: next });
}));
}
$('#zones').querySelectorAll('button').forEach(b => {
const id = +b.dataset.id;
b.classList.toggle('on', s.map.on && (!s.map.show || s.map.show.includes(id)));
});
// playlist
if (changed('tracks', s.tracks)) {
$('#tracks').innerHTML = s.tracks.map((n, i) => '<li data-i="' + i + '">' + (i + 1) + '. ' + esc(n) + '</li>').join('') ||
'<li style="opacity:.5">Playlist vuota</li>';
$('#tracks').querySelectorAll('li[data-i]').forEach(li => li.addEventListener('click', () => cmd('track', { i: +li.dataset.i })));
}
$('#tracks').querySelectorAll('li[data-i]').forEach(li => li.classList.toggle('cur', t && +li.dataset.i === t.i));
// panic
$('#btn-blackout').classList.toggle('on', !!s.blackout);
$('#btn-freeze').classList.toggle('on', !!s.freeze);
}
connect();
})();
+8
View File
@@ -1011,3 +1011,11 @@ body.control #map-grid { width: auto; flex: 0 0 auto; padding: 3px 8px; font-siz
/* With mapping on the map canvas sits above the video layers: an opaque
ground keeps semi-transparent zone pixels from revealing the raw video. */
#map-canvas.show { background: #000; }
/* Blackout (output + aux windows): fades everything to black, above all layers */
#blackout {
position: fixed; inset: 0; background: #000;
opacity: 0; visibility: hidden; pointer-events: none;
transition: opacity 0.35s ease, visibility 0s linear 0.35s;
}
#blackout.show { opacity: 1; visibility: visible; transition: opacity 0.35s ease, visibility 0s; }
+18
View File
@@ -383,3 +383,21 @@ body.control ::-webkit-scrollbar-thumb:hover { background: linear-gradient(180de
@media (prefers-reduced-motion: reduce) {
body.control *, body.control *::before, body.control *::after { animation: none !important; transition: none !important; }
}
/* Phone remote section (tab Schermo) */
body.control .remote-box { display: flex; gap: 14px; align-items: flex-start; }
body.control .remote-qr-wrap {
flex: 0 0 150px; height: 150px; border-radius: 12px; overflow: hidden;
display: grid; place-items: center;
background: rgba(4, 8, 20, 0.7);
border: 1px solid rgba(46, 242, 255, 0.35);
box-shadow: 0 0 18px rgba(46, 242, 255, 0.18);
}
body.control #remote-qr { width: 100%; height: 100%; image-rendering: pixelated; background: #fff; padding: 6px; box-sizing: border-box; }
body.control #remote-qr-off { font-family: var(--ng-display); font-weight: 800; letter-spacing: 3px; color: rgba(160, 200, 255, 0.35); }
body.control .remote-side { flex: 1; min-width: 0; }
body.control .remote-url {
font-family: var(--ng-display); font-weight: 600; font-size: 11px; letter-spacing: 0.5px;
color: var(--ng-cyan); word-break: break-all; margin: 4px 0 6px; user-select: text;
}
body.control #remote-clients { color: var(--ng-lime); }