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:
1 parent
f246b8bdb8
commit
ab1ab89127
16 files changed
+973
-21
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
@@ -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
@@ -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(/</g, '<');
|
||||
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();
|
||||
})();
|
||||
@@ -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; }
|
||||
@@ -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); }
|
||||
Reference in new issue
Block a user