// CONTROL window: builds the UI and sends commands to the output window.
// It also receives reports (meters, fps, device list, play state).
const $ = (s) => document.querySelector(s);
// 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
const EFFECTS = window.EFFECTS;
let currentEffect = EFFECTS.list[0];
let lastNon3d = EFFECTS.list[0]; // to step back out of the 3D families
function applyEffect(effect) {
if (!effect.isModel3d && !effect.isInteractive) lastNon3d = effect;
currentEffect = effect;
send({ type: 'effect', effect });
$('#scene-name').textContent = effect.name;
renderLibrary();
}
// Auto VJ: the output-side director picks presets in time with the music.
// avMode: 'smart' = curated mood pools, 'all' = every family, 'custom' = the
// user's checklist. Persisted in localStorage.
let autoVjOn = false;
let avCfg = { mode: 'smart', families: [] };
try { avCfg = Object.assign(avCfg, JSON.parse(localStorage.getItem('autovj') || '{}')); } catch (e) {}
function avSend(on) { send({ type: 'autoVj', on, mode: avCfg.mode, families: avCfg.families }); }
function avSave() {
localStorage.setItem('autovj', JSON.stringify(avCfg));
if (autoVjOn) avSend(true); // re-push the new config while running
}
function renderAvFams() {
const box = $('#av-fams');
box.classList.toggle('hidden', avCfg.mode !== 'custom');
if (avCfg.mode !== 'custom') return;
box.innerHTML = '';
EFFECTS.families.forEach((name) => {
const l = document.createElement('label');
l.className = 'av-fam' + (avCfg.families.includes(name) ? ' on' : '');
const c = document.createElement('input');
c.type = 'checkbox'; c.checked = avCfg.families.includes(name);
c.addEventListener('change', () => {
avCfg.families = c.checked
? avCfg.families.concat(name)
: avCfg.families.filter((f) => f !== name);
l.classList.toggle('on', c.checked);
avSave();
});
l.appendChild(c); l.appendChild(document.createTextNode(' ' + name));
box.appendChild(l);
});
}
$('#av-mode').value = avCfg.mode;
$('#av-mode').addEventListener('change', (e) => { avCfg.mode = e.target.value; renderAvFams(); avSave(); });
renderAvFams();
$('#auto-vj').addEventListener('click', () => avSend(!autoVjOn));
// --- Library browser (hundreds of presets, filterable) ---
// 3D-model families live in the Animazioni 3D tab, not in the effect library.
const FAM_3D = (name) => /^Modello 3D$|^Interattivo Modello$|^Interattivo Avatar$/.test(name);
const familySel = $('#effect-family');
EFFECTS.families.forEach((name, i) => {
if (FAM_3D(name)) return;
const o = document.createElement('option'); o.value = i; o.textContent = name;
familySel.appendChild(o);
});
const LIB_CAP = 300; // max items rendered at once
function filteredEffects() {
const fam = parseInt($('#effect-family').value, 10);
const q = $('#effect-search').value.trim().toLowerCase();
const out = [];
for (let i = 0; i < EFFECTS.list.length; i++) {
const e = EFFECTS.list[i];
if (FAM_3D(e.familyName)) continue;
if (fam >= 0 && e.family !== fam) continue;
if (q && e.name.toLowerCase().indexOf(q) < 0) continue;
out.push(e);
if (out.length >= LIB_CAP) break;
}
return out;
}
function renderLibrary() {
const list = $('#effect-list');
const items = filteredEffects();
list.innerHTML = '';
items.forEach((e) => {
const row = document.createElement('div');
row.className = 'fx' + (e === currentEffect ? ' active' : '');
row.innerHTML = '' + e.name + '' +
'';
row.querySelector('.fx-name').addEventListener('click', () => applyEffect(e));
row.querySelector('.fx-add').addEventListener('click', () => addToSequence(e));
list.appendChild(row);
});
$('#effects-count').textContent = EFFECTS.count + ' preset' +
(items.length >= LIB_CAP ? ' · mostro i primi ' + LIB_CAP : '');
}
$('#effect-family').addEventListener('change', () => {
renderLibrary();
// Switching to a specific family applies its first preset, so the
// visualization changes category immediately (e.g. away from an SVG).
const fam = parseInt($('#effect-family').value, 10);
if (fam >= 0) {
const first = filteredEffects()[0];
if (first) applyEffect(first);
}
});
$('#effect-search').addEventListener('input', renderLibrary);
// --- SVG / image as a custom effect source ---
function applySvgFamily() {
const fam = EFFECTS.families.indexOf('SVG/Immagine');
const eff = EFFECTS.list.find(e => e.family === fam);
if (eff) applyEffect(eff);
}
function useSvg(dataUrl) {
send({ type: 'svg', dataUrl });
applySvgFamily();
}
const svgSelect = $('#svg-select');
let builtinSvgs = [];
(async () => {
builtinSvgs = (djv.listBuiltinSvgs ? await djv.listBuiltinSvgs() : []) || [];
builtinSvgs
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((s) => {
const o = document.createElement('option');
o.value = builtinSvgs.indexOf(s);
o.textContent = s.name;
svgSelect.appendChild(o);
});
})();
svgSelect.addEventListener('change', (e) => {
const i = parseInt(e.target.value, 10);
if (i >= 0 && builtinSvgs[i]) useSvg(builtinSvgs[i].dataUrl);
});
$('#btn-svg-load').addEventListener('click', () => $('#svg-input').click());
$('#svg-input').addEventListener('change', (e) => {
const f = e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => useSvg(r.result); // data: URL
r.readAsDataURL(f);
e.target.value = '';
});
// --- 3D model (GLB) for the 'Modello 3D' family -----------------------------
// The path persists and is re-sent when the output window (re)starts.
let glbPath = localStorage.getItem('glb3d') || '';
let glbBg = localStorage.getItem('glbbg') || 'gradient';
let glbClipSel = [];
try { glbClipSel = JSON.parse(localStorage.getItem('glbclips') || '[]'); } catch (e) {}
let glbAnimFiles = [];
try { glbAnimFiles = JSON.parse(localStorage.getItem('glbanimfiles') || '[]'); } catch (e) {}
let glbBpm = parseInt(localStorage.getItem('glbbpm') || '0', 10) || 0;
function glbSend() {
if (glbPath) send({ type: 'glb', path: glbPath });
send({ type: 'modelBg', mode: glbBg });
glbAnimFiles.forEach(p => send({ type: 'animAdd', path: p }));
send({ type: 'glbAnims', names: glbClipSel });
send({ type: 'modelBpm', bpm: glbBpm });
spreadSend();
}
function renderGlbClips(names) {
const box = $('#glb-anims');
box.innerHTML = '';
if (!names || !names.length) {
box.innerHTML = 'Carica un modello riggato per vedere le mosse.';
return;
}
names.forEach((n) => {
const l = document.createElement('label');
l.className = 'av-fam' + (glbClipSel.includes(n) ? ' on' : '');
const c = document.createElement('input');
c.type = 'checkbox'; c.checked = glbClipSel.includes(n);
c.addEventListener('change', () => {
glbClipSel = c.checked ? glbClipSel.concat(n) : glbClipSel.filter(x => x !== n);
l.classList.toggle('on', c.checked);
localStorage.setItem('glbclips', JSON.stringify(glbClipSel));
send({ type: 'glbAnims', names: glbClipSel });
});
l.appendChild(c);
l.appendChild(document.createTextNode(n));
box.appendChild(l);
});
}
// Model library: every loaded model is remembered for one-click switching.
let glbModels = [];
try { glbModels = JSON.parse(localStorage.getItem('glbmodels') || '[]'); } catch (e) {}
function saveGlbModels() { localStorage.setItem('glbmodels', JSON.stringify(glbModels)); }
function addGlbModel(name, p) {
const i = glbModels.findIndex(m => m.path === p);
if (i < 0) glbModels.push({ name, path: p }); else glbModels[i].name = name;
saveGlbModels();
renderGlbModels();
}
function renderGlbModels() {
const box = $('#glb-models');
box.innerHTML = '';
if (!glbModels.length) {
box.innerHTML = 'I modelli caricati appariranno qui per il cambio rapido.';
return;
}
glbModels.forEach((mo) => {
const row = document.createElement('div');
row.className = 'glb-model' + (mo.path === glbPath ? ' on' : '');
const nm = document.createElement('span');
nm.className = 'gm-name';
nm.textContent = mo.name;
nm.title = mo.path;
nm.addEventListener('click', () => {
glbPath = mo.path;
localStorage.setItem('glb3d', glbPath);
send({ type: 'glb', path: glbPath });
glbLabel(mo.name);
spreadUi();
spreadSend();
renderGlbModels();
});
const del = document.createElement('button');
del.className = 'gm-del';
del.textContent = '✕';
del.title = 'Togli dalla lista (il file resta sul disco)';
del.addEventListener('click', () => {
glbModels = glbModels.filter(x => x.path !== mo.path);
saveGlbModels();
renderGlbModels();
});
row.appendChild(nm);
row.appendChild(del);
box.appendChild(row);
});
}
// Arm spread, saved per model path (chunky models need more room).
let glbSpreadMap = {};
try { glbSpreadMap = JSON.parse(localStorage.getItem('glbspread') || '{}'); } catch (e) {}
function spreadFor(p) { return glbSpreadMap[p] || 0; }
function spreadUi() {
const v = spreadFor(glbPath);
$('#glb-spread').value = v;
$('#glb-spread-val').textContent = v + '°';
}
function spreadSend() { send({ type: 'modelSpread', deg: spreadFor(glbPath) }); }
function glbLabel(name) {
$('#glb-name').textContent = name
? '🧊 ' + name + ' — attiva la famiglia “Modello 3D” per vederlo.'
: 'Il modello ruota a tempo di musica nella famiglia “Modello 3D”.';
}
if (glbPath) {
glbLabel(glbPath.split(/[\\/]/).pop());
if (!glbModels.some(m => m.path === glbPath)) addGlbModel(glbPath.split(/[\\/]/).pop(), glbPath);
}
renderGlbModels();
// every shader family can be a backdrop too (fluid/interactive/3D excluded:
// fluids are the explicit options above, the others can't render as layers)
EFFECTS.families.forEach((name, fi) => {
if (/^Interattivo|^Modello 3D|^Fluido/.test(name)) return;
const o = document.createElement('option');
o.value = 'fam:' + fi;
o.textContent = name;
$('#glb-bg').appendChild(o);
});
$('#glb-bg').value = glbBg;
if (!$('#glb-bg').value) { glbBg = 'gradient'; $('#glb-bg').value = 'gradient'; }
$('#glb-bg').addEventListener('change', (e) => {
glbBg = e.target.value;
localStorage.setItem('glbbg', glbBg);
send({ type: 'modelBg', mode: glbBg });
});
renderGlbClips([]);
spreadUi();
$('#glb-spread').addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10) || 0;
if (glbPath) { glbSpreadMap[glbPath] = v; localStorage.setItem('glbspread', JSON.stringify(glbSpreadMap)); }
$('#glb-spread-val').textContent = v + '°';
send({ type: 'modelSpread', deg: v });
});
$('#btn-anim-load').addEventListener('click', () => $('#anim-input').click());
$('#anim-input').addEventListener('change', async (e) => {
for (const f of [...e.target.files]) {
const p = djv.pathForFile(f);
if (!p) continue;
if (/\.glb$/i.test(p)) {
if (!glbAnimFiles.includes(p)) glbAnimFiles.push(p);
send({ type: 'animAdd', path: p });
} else {
glbLabel('⏳ converto ' + f.name + '…');
const res = await djv.convertAnim(p);
if (res && res.ok) {
if (!glbAnimFiles.includes(res.path)) glbAnimFiles.push(res.path);
send({ type: 'animAdd', path: res.path });
glbLabel(glbPath ? glbPath.split(/[\\/]/).pop() : '');
} else {
$('#glb-name').textContent = '⚠️ ' + ((res && res.error) || 'conversione fallita');
}
}
}
localStorage.setItem('glbanimfiles', JSON.stringify(glbAnimFiles));
e.target.value = '';
});
function glbBpmLabel() { $('#glb-bpm-val').textContent = glbBpm > 0 ? glbBpm + ' BPM' : 'auto'; }
$('#glb-bpm').value = glbBpm;
glbBpmLabel();
$('#glb-bpm').addEventListener('change', (e) => {
glbBpm = Math.max(0, Math.min(220, parseInt(e.target.value || '0', 10) || 0));
e.target.value = glbBpm;
localStorage.setItem('glbbpm', String(glbBpm));
glbBpmLabel();
send({ type: 'modelBpm', bpm: glbBpm });
});
let tapTimes = [];
$('#glb-tap').addEventListener('click', () => {
const now = performance.now();
if (tapTimes.length && now - tapTimes[tapTimes.length-1] > 2000) tapTimes = [];
tapTimes.push(now);
if (tapTimes.length >= 4) {
const iv = [];
for (let i = Math.max(1, tapTimes.length-8); i < tapTimes.length; i++)
iv.push(tapTimes[i] - tapTimes[i-1]);
const avg = iv.reduce((a, b) => a + b, 0) / iv.length;
glbBpm = Math.max(40, Math.min(220, Math.round(60000/avg)));
$('#glb-bpm').value = glbBpm;
localStorage.setItem('glbbpm', String(glbBpm));
glbBpmLabel();
send({ type: 'modelBpm', bpm: glbBpm });
}
});
// ---- 3D effect activation (these families are hidden from the library) ----
const fam3dSel = $('#glb-effect-fam');
const pre3dSel = $('#glb-effect-preset');
EFFECTS.families.forEach((name, i) => {
if (!FAM_3D(name)) return;
const o = document.createElement('option'); o.value = i; o.textContent = name;
fam3dSel.appendChild(o);
});
function fill3dPresets() {
const fam = parseInt(fam3dSel.value, 10);
pre3dSel.innerHTML = '';
EFFECTS.list.forEach((e, i) => {
if (e.family !== fam) return;
const o = document.createElement('option'); o.value = i;
o.textContent = e.name.replace(EFFECTS.families[fam] + ' · ', '');
pre3dSel.appendChild(o);
});
}
function apply3d() {
const e = EFFECTS.list[parseInt(pre3dSel.value, 10)];
if (e) applyEffect(e);
}
fill3dPresets();
fam3dSel.addEventListener('change', () => { fill3dPresets(); apply3d(); });
pre3dSel.addEventListener('change', apply3d);
$('#glb-effect-go').addEventListener('click', apply3d);
$('#glb-effect-off').addEventListener('click', () => applyEffect(lastNon3d));
$('#btn-glb-load').addEventListener('click', () => $('#glb-input').click());
$('#glb-input').addEventListener('change', async (e) => {
const f = e.target.files[0];
if (!f) return;
let p = djv.pathForFile(f);
e.target.value = '';
if (!p) return;
// FBX characters (e.g. a Mixamo T-pose) are converted to GLB by Blender
if (/\.fbx$/i.test(p)) {
glbLabel('⏳ converto ' + f.name + '… (qualche secondo)');
const res = await djv.convertModel(p);
if (!res || !res.ok) {
$('#glb-name').textContent = '⚠️ ' + ((res && res.error) || 'conversione fallita');
return;
}
p = res.path;
}
glbPath = p;
localStorage.setItem('glb3d', p);
glbSend();
glbLabel(f.name.replace(/\.fbx$/i, '.glb'));
addGlbModel(f.name.replace(/\.fbx$/i, '.glb'), p);
spreadUi();
});
// --- Effect sequence (playlist of effects with auto-cycle) ---
let sequence = []; // { effect, key }
let seqIndex = -1;
let seqCapturing = -1;
let seqDragFrom = -1;
let autoCycle = false, onBeat = false, shuffle = false, seqMs = 8000;
let autoTimer = null, lastBeatAdvance = 0;
function addToSequence(effect) { sequence.push({ effect, key: null }); renderSequence(); }
function applySeqIndex(i) {
if (i < 0 || i >= sequence.length) return;
seqIndex = i;
applyEffect(sequence[i].effect);
renderSequence();
}
function nextInSequence() {
if (!sequence.length) return;
const n = shuffle ? Math.floor(Math.random() * sequence.length) : (seqIndex + 1) % sequence.length;
applySeqIndex(n);
}
function removeSeq(i) {
sequence.splice(i, 1);
if (i === seqIndex) seqIndex = -1; else if (i < seqIndex) seqIndex--;
renderSequence();
}
function moveSeq(from, to) {
if (to < 0 || to >= sequence.length) return;
const [it] = sequence.splice(from, 1);
sequence.splice(to, 0, it);
if (seqIndex === from) seqIndex = to;
renderSequence();
}
function renderSequence() {
const ol = $('#effect-seq');
ol.innerHTML = '';
sequence.forEach((s, i) => {
const li = document.createElement('li');
li.className = 'track' + (i === seqIndex ? ' playing' : '');
li.draggable = true;
li.innerHTML =
'⠿' +
'' +
(i + 1) + '. ' + s.effect.name + '' +
'' +
'' +
'';
li.querySelector('.play-btn').addEventListener('click', () => applySeqIndex(i));
li.querySelector('.del-btn').addEventListener('click', () => removeSeq(i));
li.querySelector('.key-btn').addEventListener('click', () => {
seqCapturing = seqCapturing === i ? -1 : i; renderSequence();
});
li.addEventListener('dragstart', () => { seqDragFrom = i; li.classList.add('dragging'); });
li.addEventListener('dragend', () => { seqDragFrom = -1; li.classList.remove('dragging'); });
li.addEventListener('dragover', (e) => e.preventDefault());
li.addEventListener('drop', (e) => { e.preventDefault(); if (seqDragFrom >= 0 && seqDragFrom !== i) moveSeq(seqDragFrom, i); });
ol.appendChild(li);
});
$('#seq-count').textContent = sequence.length + ' in sequenza';
}
function restartAuto() {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
if (autoCycle && !onBeat) autoTimer = setInterval(nextInSequence, seqMs);
}
$('#btn-seq-add').addEventListener('click', () => addToSequence(currentEffect));
$('#btn-seq-clear').addEventListener('click', () => { sequence = []; seqIndex = -1; renderSequence(); });
$('#seq-auto').addEventListener('change', (e) => { autoCycle = e.target.checked; restartAuto(); });
$('#seq-beat').addEventListener('change', (e) => { onBeat = e.target.checked; restartAuto(); });
$('#seq-shuffle').addEventListener('change', (e) => { shuffle = e.target.checked; });
$('#seq-interval').addEventListener('input', (e) => {
seqMs = parseFloat(e.target.value) * 1000;
$('#seq-interval-val').textContent = parseFloat(e.target.value).toFixed(1) + 's';
restartAuto();
});
// ---------------------------------------------------------------- audio
function togglePlayPause() {
// If the track finished, restart it; otherwise pause/resume.
if (ended && currentIndex >= 0) { playIndex(currentIndex); return; }
if (segTimer) { // a visual segment (interlude/gap) is running — pause/resume it
if (segPaused) { resumeSeg(); isPlaying = true; $('#btn-play').textContent = '⏸ Pausa'; }
else { pauseSeg(); isPlaying = false; $('#btn-play').textContent = '▶ Play'; }
renderPlaylist();
return;
}
send({ type: 'togglePlay' });
}
$('#btn-play').addEventListener('click', togglePlayPause);
// ---------------------------------------------------------------- playlist
let playlist = []; // { path, name, key }
let currentIndex = -1;
let repeat = false;
let isPlaying = false; // whether the current track is playing
let ended = false; // true when the current track reached its end
let playbackOwner = 'playlist'; // 'playlist' or 'pad' — who started playback
const durations = {}; // path -> seconds (probed from the output window)
const peaksCache = {}; // path -> Array(N) of 0..1 peak amplitudes (or 'loading')
const WAVE_BUCKETS = 400;
let playCur = 0, playDur = 0; // live progress of the currently playing track
// A low-rate AudioContext used only to decode files into waveform peaks.
let _peakCtx = null;
function peakCtx() {
if (!_peakCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
try { _peakCtx = new AC({ sampleRate: 8000 }); } catch (e) { _peakCtx = new AC(); }
}
return _peakCtx;
}
function peaksFromAudioBuffer(buf, buckets) {
const ch = buf.getChannelData(0);
const n = ch.length, per = Math.max(1, Math.floor(n / buckets));
const peaks = new Array(buckets).fill(0);
let top = 0.0001;
for (let b = 0; b < buckets; b++) {
let m = 0; const s = b * per, e = Math.min(n, s + per);
for (let i = s; i < e; i++) { const v = ch[i] < 0 ? -ch[i] : ch[i]; if (v > m) m = v; }
peaks[b] = m; if (m > top) top = m;
}
for (let b = 0; b < buckets; b++) peaks[b] = Math.min(1, peaks[b] / top);
return peaks;
}
// Decode a file's waveform in the renderer via Web Audio (no external ffmpeg).
function decodePeaksWebAudio(path) {
return djv.readFile(path).then((bytes) => {
if (!bytes || !bytes.byteLength) throw new Error('no bytes');
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
return peakCtx().decodeAudioData(ab);
}).then((audioBuf) => peaksFromAudioBuffer(audioBuf, WAVE_BUCKETS));
}
// Lazily compute the waveform peaks for a file, then redraw its card. Tries
// Web Audio first (works everywhere); falls back to ffmpeg in main if present.
function ensurePeaks(path) {
if (!path || peaksCache[path]) return;
peaksCache[path] = 'loading';
decodePeaksWebAudio(path)
.then((p) => { peaksCache[path] = p; renderPlaylist(); })
.catch(() => djv.peaks(path, WAVE_BUCKETS)
.then((p) => { peaksCache[path] = (p && p.length) ? p : null; renderPlaylist(); })
.catch(() => { peaksCache[path] = null; }));
}
// Draw a waveform on a canvas, shading the trimmed-out parts and the playhead.
function drawWave(canvas, tr, isCur) {
const peaks = peaksCache[tr.path];
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height, mid = h / 2;
ctx.clearRect(0, 0, w, h);
if (!Array.isArray(peaks)) { // still loading / unavailable
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillRect(0, mid - 1, w, 2);
return;
}
const dur = durations[tr.path] || 0;
const s = tr.start || 0;
const e = tr.end > 0 ? tr.end : dur;
const sx = dur > 0 ? (s / dur) * w : 0;
const ex = dur > 0 ? (e / dur) * w : w;
const N = peaks.length;
for (let x = 0; x < w; x++) {
const pk = peaks[Math.min(N - 1, Math.floor(x / w * N))];
const bar = Math.max(1, pk * (h * 0.46));
const inTrim = x >= sx && x <= ex;
ctx.fillStyle = inTrim ? 'rgba(140,182,255,0.85)' : 'rgba(140,182,255,0.18)';
ctx.fillRect(x, mid - bar, 1, bar * 2);
}
// trim handles
if (dur > 0) {
ctx.fillStyle = '#6ee7a0'; ctx.fillRect(sx, 0, 2, h); // start (green)
ctx.fillStyle = '#ff9a9a'; ctx.fillRect(ex - 2, 0, 2, h); // end (red)
}
// playhead
if (isCur && playDur > 0) {
const px = Math.min(w, (playCur / playDur) * w);
ctx.fillStyle = '#ffffff'; ctx.fillRect(px, 0, 1.5, h);
}
}
// Clamp and persist a track's start/end; push it live to the output if playing.
function commitTrim(tr, i, rerender) {
const dur = durations[tr.path] || 0;
tr.start = Math.max(0, tr.start || 0);
if (dur > 0 && tr.start > dur) tr.start = dur;
if (tr.end > 0) {
if (dur > 0 && tr.end > dur) tr.end = dur;
if (tr.end <= tr.start + 0.2) tr.end = Math.min(dur || tr.start + 1, tr.start + 1);
}
savePlaylistState();
if (i === currentIndex) send({ type: 'setTrim', start: tr.start || 0, end: tr.end || 0 });
if (rerender) renderPlaylist();
}
// Wire the trim inputs, the "set to current" buttons and waveform drag handles.
function wireTrim(li, canvas, tr, i) {
const sIn = li.querySelector('.trim-start');
const eIn = li.querySelector('.trim-end');
sIn.addEventListener('change', () => { tr.start = parseTime(sIn.value); commitTrim(tr, i, true); });
eIn.addEventListener('change', () => { tr.end = eIn.value.trim() ? parseTime(eIn.value) : 0; commitTrim(tr, i, true); });
li.querySelector('.trim-here-s').addEventListener('click', () => { if (i === currentIndex) { tr.start = Math.max(0, playCur); commitTrim(tr, i, true); } });
li.querySelector('.trim-here-e').addEventListener('click', () => { if (i === currentIndex) { tr.end = Math.max(0.5, playCur); commitTrim(tr, i, true); } });
li.querySelector('.trim-reset').addEventListener('click', () => { tr.start = 0; tr.end = 0; commitTrim(tr, i, true); });
// Pointer on the waveform: grab a trim handle if near one, otherwise SEEK —
// scrub the playhead on the current track, or start a stopped track from there.
const HANDLE_PX = 10;
let mode = null;
const dur = () => durations[tr.path] || (i === currentIndex ? playDur : 0) || 0;
const xToTime = (e) => {
const rect = canvas.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * dur();
};
const scrub = (t) => {
if (i !== currentIndex) return; // preview seek only for the playing track
playCur = t;
send({ type: 'seek', time: t });
drawWave(canvas, tr, true);
};
canvas.addEventListener('pointerdown', (e) => {
const d = dur();
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const sx = d ? (tr.start || 0) / d * rect.width : 0;
const ex = d ? (tr.end > 0 ? tr.end : d) / d * rect.width : rect.width;
if (d && Math.abs(x - sx) <= HANDLE_PX) mode = 'start';
else if (d && Math.abs(x - ex) <= HANDLE_PX) mode = 'end';
else mode = 'seek';
canvas.setPointerCapture(e.pointerId); e.preventDefault();
if (mode === 'seek') scrub(xToTime(e));
});
canvas.addEventListener('pointermove', (e) => {
if (!mode) return;
const t = xToTime(e);
if (mode === 'start') { tr.start = Math.max(0, Math.min(t, (tr.end > 0 ? tr.end : dur()) - 0.5)); }
else if (mode === 'end') { tr.end = Math.min(dur(), Math.max(t, (tr.start || 0) + 0.5)); }
else { scrub(t); return; }
drawWave(canvas, tr, i === currentIndex);
sIn.value = fmtTime(Math.floor(tr.start || 0));
eIn.value = tr.end > 0 ? fmtTime(Math.floor(tr.end)) : '';
});
const endDrag = (e) => {
if (!mode) return;
if (mode === 'seek') {
const t = xToTime(e);
if (i === currentIndex) scrub(t);
else playIndex(i, t); // start a stopped track from the clicked point
} else {
commitTrim(tr, i, true);
}
mode = null;
};
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', () => { mode = null; });
}
let padCrossfadeMs = 0; // crossfade duration for pad triggers
let capturingFor = -1; // index of the track awaiting a hotkey, or -1
let sceneEditing = -1; // playlist index whose scene editor is open
let sceneImgTarget = -1, sceneCueTarget = -1; // track/cue awaiting an image
let activeCues = [], firedCue = -1; // cue schedule for the playing track
function serializePlaylist() {
return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0, start: t.start || 0, end: t.end || 0 }));
}
function normalizeTrack(t) {
return { path: t.path || '', name: t.name || baseName(t.path || '') || 'Intermezzo', key: t.key || null, cues: migrateCues(Array.isArray(t.cues) ? t.cues : []), isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0, start: t.start || 0, end: t.end || 0 };
}
function savePlaylistState() {
if (djv.savePlaylist) djv.savePlaylist(serializePlaylist());
}
function hasScene(tr) {
return !!(tr.cues && tr.cues.some(c =>
(c.type === 'effect' && c.effectIndex != null) ||
(c.type === 'text' && c.text && c.text.trim()) ||
(c.type === 'image' && c.image) ||
(c.type === 'video' && c.path) ||
c.type === 'map'));
}
function parseTime(str) {
str = String(str).trim();
if (str.indexOf(':') >= 0) { const p = str.split(':').map(Number); return (p[0] || 0) * 60 + (p[1] || 0); }
return parseFloat(str) || 0;
}
// ---- Scene model -----------------------------------------------------------
// A track scene is a list of timed ELEMENTS, each of type 'effect' | 'text' |
// 'image' with its OWN appearance time and optional visible duration. The three
// channels are independent: an effect, a text and an image each appear/disappear
// on their own schedule. dur 0 = stays until the next element of the same type.
const TXT_FONTS = [
['-apple-system, BlinkMacSystemFont, sans-serif', 'Sistema'],
["'Arial Black', Impact, sans-serif", 'Arial Black'],
['Impact, sans-serif', 'Impact'],
["Georgia, 'Times New Roman', serif", 'Georgia'],
["'Courier New', monospace", 'Monospace'],
["'Brush Script MT', 'Snell Roundhand', cursive", 'Corsivo'],
['Verdana, Geneva, sans-serif', 'Verdana'],
["'Trebuchet MS', sans-serif", 'Trebuchet'],
];
const TXT_DIRS = [['h', '➡️ Orizzontale'], ['vup', '⬆️ Vert. su'], ['vdown', '⬇️ Vert. giù'], ['sides', '↕️ Bordi laterali']];
const TXT_FX = [['none', 'Nessuno'], ['updown', 'Su e giù'], ['wave', 'Onda'], ['zoom', 'Zoom'], ['flash', 'Flash'], ['rotate', 'Rotazione']];
const TXT_POS = [['bottom', 'Basso'], ['top', 'Alto'], ['middle', 'Centro']];
const IMG_POS = { center: { x: 50, y: 50 }, top: { x: 50, y: 18 }, bottom: { x: 50, y: 82 }, left: { x: 20, y: 50 }, right: { x: 80, y: 50 } };
const IMG_POS_LABELS = { center: 'Centro', top: 'Alto', bottom: 'Basso', left: 'Sinistra', right: 'Destra' };
function optList(pairs, sel) {
return pairs.map(([v, l]) => '').join('');
}
function newEffectEl(time) { return { type: 'effect', time, effectIndex: EFFECTS.list.indexOf(currentEffect), effectName: currentEffect.name, dur: 0, target: 'main' }; }
function newTextEl(time) { return { type: 'text', time, text: '', dir: 'h', fx: 'none', font: TXT_FONTS[0][0], size: 6, weight: true, color: '#ffffff', pos: 'bottom', speed: 1, dur: 0 }; }
// zones: null = every zone, otherwise the ids of the zones that show content
function newVideoEl(time) {
return { type: 'video', time, path: null, dur: 0, target: mapCfg.zones.length ? 'zone:' + mapCfg.zones[0].id : 'main' };
}
// "Su" selector for text / image / video elements: main output or one zone.
function cueTargetRow(c, allowMain) {
const tgt = String(c.target || 'main');
let o = allowMain ? '' : '';
mapCfg.zones.forEach(z => {
const v = 'zone:' + z.id;
o += '';
});
if (tgt.startsWith('zone:') && !mapCfg.zones.some(z => 'zone:' + z.id === tgt)) o += '';
if (!o) o = '';
return '
Su
';
}
const zoneIdOf = (k) => parseInt(String(k).slice(5), 10);
function sceneActiveBy(type, t) {
const best = {};
for (const el of activeCues) {
if (el.type !== type || el.time > t) continue;
if (el.dur && el.time + el.dur <= t) continue;
const k = String(el.target || 'main');
if (!best[k] || el.time >= best[k].time) best[k] = el;
}
return best;
}
function advanceTargeted(type, t, apply) {
const cur = sceneActiveBy(type, t);
const force = lastScene[type] === undefined;
const prev = (lastScene[type] && typeof lastScene[type] === 'object' && !lastScene[type].type) ? lastScene[type] : {};
const keys = new Set([...Object.keys(cur), ...Object.keys(prev)]);
if (force && type !== 'video') keys.add('main'); // clear a stale full-screen text/image
keys.forEach(k => {
const el = cur[k] || null;
if (!force && prev[k] === el) return;
apply(k, el);
});
lastScene[type] = cur;
}
function newMapEl(time) { return { type: 'map', time, on: true, zones: null, dur: 0 }; }
function newImageEl(time) { return { type: 'image', time, image: null, imageSize: 60, imagePos: 'center', dur: 0 }; }
// Convert legacy combined cues into the new per-element list (idempotent).
function migrateCues(cues) {
if (!Array.isArray(cues) || cues.length === 0) return cues || [];
if (cues.every(c => c && c.type)) return cues;
const out = [];
cues.forEach(c => {
if (c && c.type) { out.push(c); return; }
const t = (c && c.time) || 0;
if (c.effectIndex != null) out.push({ type: 'effect', time: t, effectIndex: c.effectIndex, effectName: c.effectName || '', dur: 0 });
if (c.text && c.text.trim()) {
const s = c.textStyle || {};
out.push({ type: 'text', time: t, text: c.text, dir: s.dir || 'h', fx: s.fx || 'none', font: s.font || TXT_FONTS[0][0], size: s.size || 6, weight: s.weight != null ? s.weight : true, color: s.color || '#ffffff', pos: 'bottom', speed: 1, dur: 0 });
}
if (c.image) out.push({ type: 'image', time: t, image: c.image, imageSize: c.imageSize || 60, imagePos: c.imagePos || 'center', dur: 0 });
});
return out;
}
// Runtime: re-evaluate which element is active per channel and diff-apply it.
let lastScene = { effect: null, text: null, image: null, map: null, video: null };
let userTrackBlend = 'normal'; // playlist-video blend chosen by the user
let sentTrackBlend = null; // last blend pushed to the output (auto or manual)
function startCues(tr) {
tr.cues = migrateCues(tr.cues || []);
activeCues = tr.cues.slice().sort((a, b) => a.time - b.time);
sentTrackBlend = null; // re-evaluate auto-blend for the new track
// `undefined` (not null) forces advanceCues to APPLY every channel once, so a
// stale text/image from the previous track is cleared even when the new scene
// has nothing active at t=0.
lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined, video: undefined };
send({ type: 'mapZoneReset' }); // zones fall back to their Mappatura content
advanceCues(0);
}
function sceneActive(type, t) {
let best = null;
for (const el of activeCues) {
if (el.type !== type || el.time > t) continue;
if (el.dur && el.time + el.dur <= t) continue;
if (!best || el.time >= best.time) best = el;
}
return best;
}
// Effect cues carry a target (main output, one aux display, or all screens):
// each target is its own channel so cues aimed at different monitors never
// supersede one another.
function sceneActiveEffects(t) {
const best = {};
for (const el of activeCues) {
if (el.type !== 'effect' || el.time > t) continue;
if (el.dur && el.time + el.dur <= t) continue;
const k = String(el.target || 'main');
if (!best[k] || el.time >= best[k].time) best[k] = el;
}
return best;
}
function applyEffectTo(targetKey, el) {
if (targetKey.startsWith('zone:')) {
// Live override of ONE mapping zone (the saved mapping is untouched):
// the zone runs this preset; 'no effect' gives it back its own content.
const id = parseInt(targetKey.slice(5), 10);
const ok = el.effectIndex != null && EFFECTS.list[el.effectIndex];
send({ type: 'mapZoneSet', id, kind: 'effect', data: ok ? { effectIndex: el.effectIndex } : null });
return;
}
const e = EFFECTS.list[el.effectIndex];
if (!e) return;
if (targetKey === 'main') applyEffect(e);
else if (targetKey === 'all') {
applyEffect(e);
// -1 = every aux window (follow-mode ones get it via the effect broadcast)
send({ type: 'auxFx', displayId: -1, effectIndex: el.effectIndex });
} else {
const id = parseInt(targetKey, 10);
// The scene explicitly targets this display: if it is connected but its
// aux window is off, switch it on — otherwise the cue lands nowhere and
// the monitor silently stays black. The window loads async; 'auxReady'
// re-sends the scene effect once it is up.
const d = auxDisplays.find(x => x.id === id);
if (d && !(auxCfg[id] && auxCfg[id].on)) {
auxCfg[id] = auxCfg[id] || { on: false, mode: 'follow', effectIndex: null, image: null };
auxCfg[id].on = true;
auxSave(); renderAuxList(); auxSyncAll();
}
send({ type: 'auxFx', displayId: id, effectIndex: el.effectIndex });
}
}
function advanceCues(t) {
const efMap = sceneActiveEffects(t);
const force = lastScene.effect === undefined;
const prevMap = (lastScene.effect && typeof lastScene.effect === 'object') ? lastScene.effect : {};
const keys = new Set([...Object.keys(efMap), ...Object.keys(prevMap)]);
keys.forEach(k => {
const el = efMap[k] || null;
if (!force && prevMap[k] === el) return;
if (el) applyEffectTo(k, el); // apply-once semantics: no revert on expiry
});
lastScene.effect = efMap;
const ef = efMap['main'] || efMap['all'] || null;
// Auto-blend: on a video track, when a scene effect is active, blend the video
// ('screen') so the effect shows through underneath. Only when the user left
// the blend at the default 'normal' — a manual choice is always respected.
const curTr = playlist[currentIndex];
if (curTr && curTr.isVideo) {
const effOn = !!(ef && ef.effectIndex != null && EFFECTS.list[ef.effectIndex]);
const wantBlend = (effOn && userTrackBlend === 'normal') ? 'screen' : userTrackBlend;
if (wantBlend !== sentTrackBlend) { sentTrackBlend = wantBlend; send({ type: 'trackVideoBlend', value: wantBlend }); }
}
// Text / image / video: one channel per destination (main or a zone);
// when an element ends, its destination is cleared (unlike effects).
advanceTargeted('text', t, (k, el) => {
if (k === 'main') return applyTextEl(el);
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'text',
data: el && el.text && el.text.trim() ? { text: el.text, dir: el.dir, fx: el.fx, font: el.font,
size: el.size, weight: el.weight, color: el.color, pos: el.pos, speed: el.speed } : null });
});
advanceTargeted('image', t, (k, el) => {
if (k === 'main') return applyImageEl(el);
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'image', data: el && el.image ? { path: el.image } : null });
});
advanceTargeted('video', t, (k, el) => {
if (k === 'main') return; // videos are zone-only
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'video', data: el && el.path ? { path: el.path } : null });
});
const mp = sceneActive('map', t);
if (mp !== lastScene.map) {
lastScene.map = mp;
// Apply-once like effects: when the element ends the state stays as is.
if (mp) {
mapCfg.on = !!mp.on;
$('#map-on').checked = mapCfg.on;
send({ type: 'mapShow', ids: mp.on && Array.isArray(mp.zones) ? mp.zones : null });
send({ type: 'mapOn', on: mapCfg.on });
}
}
}
function applyTextEl(el) {
const show = !!(el && el.text && el.text.trim());
send({ type: 'tickerText', text: show ? el.text : '' });
send({ type: 'tickerOn', on: show });
$('#ticker-text').value = show ? el.text : '';
$('#ticker-on').checked = show;
if (show) {
send({ type: 'tickerDir', value: el.dir });
send({ type: 'tickerFx', value: el.fx });
send({ type: 'tickerFont', value: el.font });
send({ type: 'tickerSize', value: el.size });
send({ type: 'tickerWeight', on: el.weight });
send({ type: 'tickerColor', value: el.color });
send({ type: 'tickerPos', pos: el.pos });
send({ type: 'tickerSpeed', mult: el.speed });
}
}
function applyImageEl(el) {
if (el && el.image) {
const p = IMG_POS[el.imagePos || 'center'] || IMG_POS.center;
send({ type: 'sceneImage', path: el.image, size: el.imageSize || 60, x: p.x, y: p.y });
} else {
send({ type: 'sceneImage', path: null });
}
}
// Live preview: when editing the scene of the current track, re-apply at the
// current playback position so changes show immediately on the output.
function refreshScenePreview(i) {
if (i !== currentIndex) return;
activeCues = (playlist[i].cues || []).slice().sort((a, b) => a.time - b.time);
lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined, video: undefined };
send({ type: 'mapZoneReset' }); // re-applied below from the edited scene
advanceCues(playCur || 0);
}
const baseName = (p) => p.split(/[\\/]/).pop();
function probePaths(paths) {
const todo = paths.filter(p => p && !(p in durations));
if (todo.length) send({ type: 'probeDurations', paths: todo });
}
function addTracks(items) {
// items: array of { path, name }
const start = playlist.length;
items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null, cues: [], isVideo: !!it.isVideo, gap: 0, start: 0, end: 0 }); });
renderPlaylist();
savePlaylistState();
probePaths(items.map(it => it.path));
// Auto-start the first added track if nothing is playing yet.
if (currentIndex < 0 && playlist.length > start) playIndex(start);
}
// Add a visual-only interlude item (effects/text/image between tracks).
function addInterlude() {
playlist.push({ path: '', name: 'Intermezzo', key: null, cues: [], isVideo: false, isInterlude: true, duration: 15, gap: 0 });
sceneEditing = playlist.length - 1; // open its scene editor right away
renderPlaylist();
savePlaylistState();
}
function playIndex(i, fromTime) {
if (i < 0 || i >= playlist.length) return;
clearGap();
const tr = playlist[i];
currentIndex = i;
isPlaying = true;
ended = false;
playbackOwner = 'playlist';
activePad = -1; padPlaying = false; renderPads();
$('#btn-play').disabled = false;
$('#btn-play').textContent = '⏸ Pausa';
if (tr.isInterlude) {
playInterlude(tr);
return;
}
// fromTime (set by clicking the waveform) overrides the trim start for this run.
const startAt = (fromTime != null) ? Math.max(0, fromTime) : (tr.start || 0);
send({ type: tr.isVideo ? 'playVideoTrack' : 'playTrack', path: tr.path, start: startAt, end: tr.end || 0 });
startCues(tr);
renderPlaylist();
}
// Visual-only interlude: silence audio, run this item's scene for its duration.
function playInterlude(tr) {
send({ type: 'playSilence' });
startCues(tr);
const dur = Math.max(1, tr.duration || 15);
runSeg('interlude', dur, 0, () => {
if (tr.gap > 0) startGap(tr, tr.gap);
else if (!nextTrack()) stopPlaylist();
});
}
function nextTrack() {
if (!playlist.length) return false;
let n = currentIndex + 1;
if (n >= playlist.length) { if (!repeat) return false; n = 0; }
playIndex(n);
return true;
}
function stopPlaylist() {
isPlaying = false; ended = true; $('#btn-play').textContent = '▶ Play'; renderPlaylist();
}
// Timed visual segment: drives the scene timeline with no audio for `durSecs`,
// then runs onDone. Used both for inter-track gaps ('gap', continuing the
// previous scene from `base`) and for visual-only interlude items ('interlude',
// their own scene from base 0). Supports pause/resume via the Play button.
let segTimer = null, segMode = null, segRemain = 0;
let segDur = 0, segBase = 0, segElapsed = 0, segT0 = 0, segPaused = false, segDone = null;
function clearGap() { if (segTimer) { clearInterval(segTimer); segTimer = null; } segMode = null; segPaused = false; segRemain = 0; }
const clearSeg = clearGap;
function segTick() {
if (segPaused) return;
const elapsed = segElapsed + (Date.now() - segT0) / 1000;
segRemain = Math.max(0, segDur - elapsed);
playCur = Math.min(segDur, elapsed); playDur = segDur;
advanceCues(segBase + elapsed);
renderPlaylist();
if (elapsed >= segDur) { const d = segDone; clearSeg(); if (d) d(); }
}
function runSeg(mode, durSecs, base, onDone) {
clearSeg();
segMode = mode; segDur = durSecs; segBase = base; segDone = onDone;
segElapsed = 0; segT0 = Date.now(); segPaused = false;
segTimer = setInterval(segTick, 200);
segTick();
}
function pauseSeg() { if (segTimer && !segPaused) { segElapsed += (Date.now() - segT0) / 1000; segPaused = true; } }
function resumeSeg() { if (segTimer && segPaused) { segT0 = Date.now(); segPaused = false; } }
function startGap(tr, secs) {
runSeg('gap', secs, playDur || playCur || 0, () => { if (!nextTrack()) stopPlaylist(); });
}
function removeTrack(i) {
playlist.splice(i, 1);
if (i === currentIndex) currentIndex = -1;
else if (i < currentIndex) currentIndex--;
if (sceneEditing === i) sceneEditing = -1;
renderPlaylist();
savePlaylistState();
}
function moveTrack(from, to) {
if (to < 0 || to >= playlist.length) return;
const [item] = playlist.splice(from, 1);
playlist.splice(to, 0, item);
if (currentIndex === from) currentIndex = to;
else if (from < currentIndex && to >= currentIndex) currentIndex--;
else if (from > currentIndex && to <= currentIndex) currentIndex++;
renderPlaylist();
savePlaylistState();
}
let dragFrom = -1;
let cueDragging = false; // a scene-editor element is being dragged
let plDirty = false; // a re-render was requested mid-drag
let dragMarkEl = null, dragMarkBelow = false;
function clearDropMarks() {
if (dragMarkEl) { dragMarkEl.classList.remove('drop-above', 'drop-below'); dragMarkEl = null; }
}
// Build the ';
EFFECTS.families.forEach((name, i) => {
s += '';
});
return s;
}
// Build the ';
let s = '';
for (let i = 0; i < EFFECTS.list.length; i++) {
if (EFFECTS.list[i].family !== fam) continue;
s += '';
}
return s;
}
// First preset index of a family (used when a family is chosen).
function firstPresetOfFamily(fam) {
for (let i = 0; i < EFFECTS.list.length; i++) if (EFFECTS.list[i].family === fam) return i;
return -1;
}
const EL_ICON = { effect: '🌀', text: '🔤', image: '🖼', map: '🗺', video: '🎞' };
const EL_LABEL = { effect: 'Effetto', text: 'Testo', image: 'Immagine', map: 'Mappatura', video: 'Video' };
// Build the per-track scene editor: a list of independent timed elements
// (effect / text / image), each with its own appearance time and duration.
function buildSceneEditor(tr, i, li) {
const cues = tr.cues = migrateCues(tr.cues || []);
cues.sort((a, b) => a.time - b.time);
const ed = document.createElement('li');
ed.className = 'scene-editor';
let html = '🎬 Scena a tempo — ogni elemento appare al suo tempo; durata 0 = resta fino al successivo
';
if (cues.length === 0) html += 'Nessun elemento. Aggiungi un effetto, un testo o un\'immagine qui sotto.
';
cues.forEach((c, ci) => {
html +=
'' +
'
' +
'⠿' +
'' + EL_ICON[c.type] + '' +
'' + EL_LABEL[c.type] + '' +
'@' +
'' +
'⏱' +
'' +
'' +
'
';
if (c.type === 'effect') {
const ce = (c.effectIndex != null && EFFECTS.list[c.effectIndex]) ? EFFECTS.list[c.effectIndex] : null;
const cFam = ce ? ce.family : -1;
// Destination screen: main output, one aux display, or all of them.
const tgt = String(c.target || 'main');
let tgtOpts = '
';
auxDisplays.forEach(ds => {
tgtOpts += '
';
});
tgtOpts += '
';
mapCfg.zones.forEach(z => {
const v = 'zone:' + z.id;
tgtOpts += '
';
});
if (tgt.startsWith('zone:') && !mapCfg.zones.some(z => 'zone:' + z.id === tgt)) {
tgtOpts += '
';
} else if (tgt !== 'main' && tgt !== 'all' && !tgt.startsWith('zone:') && !auxDisplays.some(ds => String(ds.id) === tgt)) {
tgtOpts += '
';
}
html +=
'
' +
'
' +
'' +
'' +
'
' +
'
' +
'Su' +
'' +
'
' +
'
' +
'' +
'' + (ce ? ce.name : 'nessuno') + '' +
'
' +
'
';
} else if (c.type === 'text') {
html +=
'
';
} else if (c.type === 'video') {
html +=
'
' +
cueTargetRow(c, false) +
'
' +
'' +
'' +
'' + (c.path ? baseName(c.path) : 'nessuno') + '' +
'
' +
'
';
} else if (c.type === 'map') {
html +=
'
' +
'
' +
'' +
'
' +
(c.on ? '
Mostra su' +
(mapCfg.zones.length ? mapCfg.zones.map(z =>
'').join('')
: 'nessuna zona: creale nel tab Mappatura') +
'
' : '') +
'
';
} else {
html +=
'
' +
cueTargetRow(c, true) +
'
' +
'' +
'' +
'' + (c.image ? baseName(c.image) : 'nessuna') + '' +
'
' +
'
Dim
' +
'
';
}
html += '
';
});
html += '' +
(tr.isInterlude
? '' +
'✨ Durata intermezzo s' +
'
'
: '' +
'⏸ Pausa dopo s ' +
'la scena continua durante la pausa' +
'
');
ed.innerHTML = html;
// re-render (used after structural changes)
const save = () => { savePlaylistState(); refreshScenePreview(i); renderPlaylist(); };
// keep the open editor (no re-render) for smooth slider/typing edits
const saveLive = () => { savePlaylistState(); refreshScenePreview(i); li.querySelector('.scene-btn').classList.toggle('active', hasScene(tr)); };
// Scene-element drag & drop: the list is always shown time-sorted, so
// dropping an element between two others moves its appearance time between
// theirs (before the first = half its time, after the last = +5s).
let cueFrom = -1;
ed.querySelectorAll('.cue').forEach(cueEl => {
const ci = parseInt(cueEl.dataset.c, 10);
const c = cues[ci];
// Only the grip arms the drag: the cards contain sliders/inputs whose own
// mouse-drags must keep working.
const grip = cueEl.querySelector('.cue-grip');
grip.addEventListener('mousedown', () => {
cueEl.draggable = true;
document.addEventListener('mouseup', () => { cueEl.draggable = false; }, { once: true });
});
cueEl.addEventListener('dragstart', (e) => {
cueFrom = ci; cueDragging = true;
cueEl.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', 'cue'); } catch (err) {}
});
cueEl.addEventListener('dragend', () => {
cueFrom = -1; cueDragging = false; cueEl.draggable = false;
cueEl.classList.remove('dragging');
clearDropMarks();
if (plDirty) { plDirty = false; renderPlaylist(); }
});
cueEl.addEventListener('dragover', (e) => {
if (cueFrom < 0) return;
e.preventDefault(); e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const r = cueEl.getBoundingClientRect();
const below = (e.clientY - r.top) > r.height / 2;
if (cueEl !== dragMarkEl || below !== dragMarkBelow) {
clearDropMarks();
dragMarkEl = cueEl; dragMarkBelow = below;
cueEl.classList.add(below ? 'drop-below' : 'drop-above');
}
});
cueEl.addEventListener('drop', (e) => {
e.preventDefault(); e.stopPropagation();
const from = cueFrom;
cueFrom = -1; cueDragging = false;
clearDropMarks();
if (from < 0) return;
const r2 = cueEl.getBoundingClientRect();
let pos = ci + ((e.clientY - r2.top) > r2.height / 2 ? 1 : 0);
if (from < pos) pos--;
const moved = cues[from];
const others = cues.filter((_, k) => k !== from);
if (pos < 0 || pos > others.length) return;
let t;
if (!others.length) t = moved.time;
else if (pos === 0) t = Math.max(0, others[0].time / 2);
else if (pos === others.length) t = others[others.length - 1].time + 5;
else t = (others[pos - 1].time + others[pos].time) / 2;
moved.time = Math.round(t * 10) / 10;
save(); // re-sorts by time and re-renders the editor
});
cueEl.querySelector('.cue-time').addEventListener('change', (e) => { c.time = parseTime(e.target.value); save(); });
const tSel = cueEl.querySelector('.cue-target');
if (tSel) tSel.addEventListener('change', (e) => { if (e.target.value) { c.target = e.target.value; saveLive(); } });
cueEl.querySelector('.cue-dur').addEventListener('change', (e) => { c.dur = Math.max(0, parseFloat(e.target.value) || 0); saveLive(); });
cueEl.querySelector('.cue-del').addEventListener('click', () => { cues.splice(ci, 1); save(); });
if (c.type === 'effect') {
cueEl.querySelector('.cue-eff-fam').addEventListener('change', (e) => {
const fam = parseInt(e.target.value, 10);
if (fam < 0) { c.effectIndex = null; c.effectName = ''; }
else { const idx = firstPresetOfFamily(fam); c.effectIndex = idx; c.effectName = idx >= 0 ? EFFECTS.list[idx].name : ''; }
save();
});
cueEl.querySelector('.cue-eff-pre').addEventListener('change', (e) => {
const idx = parseInt(e.target.value, 10);
if (idx >= 0 && EFFECTS.list[idx]) { c.effectIndex = idx; c.effectName = EFFECTS.list[idx].name; save(); }
});
cueEl.querySelector('.cue-eff-cur').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(currentEffect); c.effectName = currentEffect.name; save(); });
cueEl.querySelector('.cue-eff-target').addEventListener('change', (e) => {
const v = e.target.value;
c.target = (v === 'main' || v === 'all' || v.startsWith('zone:')) ? v : parseInt(v, 10);
saveLive();
});
} else if (c.type === 'text') {
const ti = cueEl.querySelector('.cue-text'); ti.value = c.text || '';
ti.addEventListener('input', (e) => { c.text = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-dir').addEventListener('change', (e) => { c.dir = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-fx').addEventListener('change', (e) => { c.fx = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-font').addEventListener('change', (e) => { c.font = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-pos').addEventListener('change', (e) => { c.pos = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-size').addEventListener('input', (e) => { c.size = parseFloat(e.target.value); saveLive(); });
cueEl.querySelector('.cue-tx-bold').addEventListener('change', (e) => { c.weight = e.target.checked; saveLive(); });
cueEl.querySelector('.cue-tx-color').addEventListener('input', (e) => { c.color = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-speed').addEventListener('input', (e) => { c.speed = parseFloat(e.target.value); saveLive(); });
} else if (c.type === 'video') {
cueEl.querySelector('.cue-vid').addEventListener('click', () => {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'video/*';
inp.addEventListener('change', () => {
const f = inp.files[0], p2 = f ? djv.pathForFile(f) : null;
if (p2) { c.path = p2; save(); }
});
inp.click();
});
cueEl.querySelector('.cue-vid-clear').addEventListener('click', () => { c.path = null; save(); });
} else if (c.type === 'map') {
cueEl.querySelector('.cue-map-on').addEventListener('change', (e) => { c.on = e.target.value === '1'; save(); });
cueEl.querySelectorAll('.cue-map-z').forEach(cb => cb.addEventListener('change', () => {
const ids = [...cueEl.querySelectorAll('.cue-map-z')].filter(x => x.checked).map(x => parseInt(x.dataset.id, 10));
// every zone ticked = null, so zones created later are included too
c.zones = ids.length === mapCfg.zones.length ? null : ids;
saveLive();
}));
} else {
cueEl.querySelector('.cue-img').addEventListener('click', () => { sceneImgTarget = i; sceneCueTarget = ci; $('#scene-img-input').click(); });
cueEl.querySelector('.cue-img-clear').addEventListener('click', () => { c.image = null; save(); });
cueEl.querySelector('.cue-img-pos').addEventListener('change', (e) => { c.imagePos = e.target.value; saveLive(); });
cueEl.querySelector('.cue-img-size').addEventListener('input', (e) => { c.imageSize = parseInt(e.target.value, 10); saveLive(); });
}
});
const addEl = (mk) => {
const lastT = cues.length ? cues[cues.length - 1].time : 0;
cues.push(mk(cues.length ? lastT : 0));
save();
};
ed.querySelector('.add-eff').addEventListener('click', () => addEl(newEffectEl));
ed.querySelector('.add-txt').addEventListener('click', () => addEl(newTextEl));
ed.querySelector('.add-img').addEventListener('click', () => addEl(newImageEl));
ed.querySelector('.add-map').addEventListener('click', () => addEl(newMapEl));
ed.querySelector('.add-vid').addEventListener('click', () => addEl(newVideoEl));
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; save(); });
const gapInput = ed.querySelector('.cue-gap');
if (gapInput) gapInput.addEventListener('change', (e) => { tr.gap = Math.max(0, parseFloat(e.target.value) || 0); savePlaylistState(); renderPlaylist(); });
const segInput = ed.querySelector('.cue-segdur');
if (segInput) segInput.addEventListener('change', (e) => { tr.duration = Math.max(1, parseFloat(e.target.value) || 15); savePlaylistState(); renderPlaylist(); });
return ed;
}
// Time labels, progress and trim-range numbers for one track row. Shared by
// the full rebuild and by the in-place patcher so the two can never disagree.
function trackDynamics(tr, i) {
const isCur = i === currentIndex;
const inSeg = isCur && segTimer;
let timeLabel = '';
if (inSeg) timeLabel = (segMode === 'interlude' ? '✨ ' : '⏸ ') + Math.ceil(segRemain) + 's';
else if (tr.isInterlude) timeLabel = '✨ ' + fmtTime(tr.duration || 15);
else if (isCur && playDur > 0) timeLabel = '-' + fmtTime(Math.floor(Math.max(0, playDur - playCur)));
else if (durations[tr.path] > 0) timeLabel = fmtTime(Math.floor(durations[tr.path]));
const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
const dur = durations[tr.path] || (isCur ? playDur : 0);
const tStart = tr.start || 0;
const tEnd = tr.end > 0 ? tr.end : dur;
const total = Math.max(0, tEnd - tStart);
const elapsed = isCur ? Math.max(0, Math.min(total, playCur - tStart)) : 0;
const remaining = Math.max(0, total - elapsed);
return { isCur, timeLabel, prog, tStart, total, elapsed, remaining };
}
// Everything that determines the list's DOM SKELETON (nodes and handlers).
// While a track plays only times/progress change, so the signature stays the
// same and renderPlaylist patches in place instead of rebuilding: a rebuild
// every 400ms progress tick replaced nodes between mousedown and mouseup and
// row buttons stopped receiving clicks.
let plStamp = null;
function plSignature() {
return playlist.map(t =>
t.name + '' + t.path + '' + (t.key || '') + '' + (t.gap || 0) +
'' + (t.start || 0) + '' + (t.end || 0) + '' + (t.duration || 0) +
'' + (t.isInterlude ? 1 : 0) + (t.isVideo ? 1 : 0) + (hasScene(t) ? 1 : 0)
).join('') +
'' + currentIndex + '' + (isPlaying ? 1 : 0) + '' + sceneEditing +
'' + capturingFor +
'' + (sceneEditing >= 0 && playlist[sceneEditing] ? JSON.stringify(playlist[sceneEditing].cues || []) : '');
}
// In-place refresh of the dynamic bits of each row; never touches the skeleton.
function patchPlaylistDynamics(ol) {
ol.querySelectorAll('li.track').forEach(li => {
const i = parseInt(li.dataset.index, 10);
const tr = playlist[i];
if (!tr) return;
const d = trackDynamics(tr, i);
const tt = li.querySelector('.ttime'); if (tt && tt.textContent !== d.timeLabel) tt.textContent = d.timeLabel;
const el = li.querySelector('.tt-el'); if (el) el.textContent = '▶ ' + fmtTime(Math.floor(d.elapsed));
const tot = li.querySelector('.tt-tot'); if (tot) tot.textContent = '⏱ ' + fmtTime(Math.floor(d.total));
const rem = li.querySelector('.tt-rem'); if (rem) rem.textContent = '⧗ -' + fmtTime(Math.ceil(d.remaining));
const pr = li.querySelector('.tprog'); if (pr) pr.style.width = d.prog.toFixed(1) + '%';
const canvas = li.querySelector('.wave'); if (canvas) drawWave(canvas, tr, d.isCur);
});
}
function renderPlaylist() {
// Never rebuild the list mid-drag: replacing the dragged node would cancel
// the browser drag instantly (progress re-renders arrive every ~400ms while
// a track plays, which made reordering impossible). Deferred to dragend.
if (dragFrom >= 0 || cueDragging) {
if (document.querySelector('#playlist .dragging')) { plDirty = true; return; }
// Leaked guard: dragend never fires when the dragged node left the DOM,
// which would freeze the list forever. No .dragging element = no real
// drag in progress, so unlock and render.
dragFrom = -1; cueDragging = false; clearDropMarks();
}
const ol = $('#playlist');
const sig = plSignature();
if (sig === plStamp && ol.querySelector('li.track')) { patchPlaylistDynamics(ol); return; }
plStamp = sig;
ol.innerHTML = '';
playlist.forEach((tr, i) => {
const li = document.createElement('li');
li.className = 'track' + (i === currentIndex ? ' playing' : '');
li.draggable = true;
li.dataset.index = i;
const { isCur, timeLabel, prog, tStart, total, elapsed, remaining } = trackDynamics(tr, i);
const playIcon = (isCur && isPlaying) ? '⏸' : '▶';
const gapBadge = (tr.gap > 0 && !tr.isInterlude) ? '⏸' + tr.gap + 's' : '';
const icon = tr.isInterlude ? '✨ ' : (tr.isVideo ? '🎞 ' : '');
const body = (tr.isInterlude || !tr.path) ? '' :
'' +
'
' +
'▶ ' + fmtTime(Math.floor(elapsed)) + '' +
'⏱ ' + fmtTime(Math.floor(total)) + '' +
'⧗ -' + fmtTime(Math.ceil(remaining)) + '' +
'
' +
'
' +
'
' +
'Inizio' +
'' +
'' +
'Fine' +
'' +
'' +
'' +
'
' +
'
';
li.innerHTML =
'' +
'⠿' +
'' + icon + tr.name + gapBadge + '' +
'' + timeLabel + '' +
'' +
'' +
'' +
'' +
(isCur ? '' : '') +
'
' + body;
// Resolve the track's index at CLICK time by identity: if the list was
// mutated while this DOM was on screen, the render-time index is stale and
// would hit the wrong track (or none).
const liveIdx = () => playlist.indexOf(tr);
// Clicking the name (re)starts the track from the beginning.
li.querySelector('.tname').addEventListener('click', () => playIndex(liveIdx()));
// On the current track the button pauses/resumes/replays; on others it starts that track.
li.querySelector('.play-btn').addEventListener('click', () => {
const k = liveIdx();
if (k < 0) return;
if (k === currentIndex) togglePlayPause();
else playIndex(k);
});
li.querySelector('.del-btn').addEventListener('click', () => {
const k = liveIdx();
if (k >= 0) removeTrack(k);
});
li.querySelector('.key-btn').addEventListener('click', () => {
const k = liveIdx();
capturingFor = capturingFor === k ? -1 : k;
renderPlaylist();
});
li.querySelector('.scene-btn').addEventListener('click', () => {
const k = liveIdx();
sceneEditing = sceneEditing === k ? -1 : k;
renderPlaylist();
});
// Waveform + trim controls.
const canvas = li.querySelector('.wave');
if (canvas) {
ensurePeaks(tr.path);
drawWave(canvas, tr, isCur);
wireTrim(li, canvas, tr, i);
}
// Drag to reorder.
li.addEventListener('dragstart', (e) => {
dragFrom = i; li.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(i)); } catch (err) {}
});
li.addEventListener('dragend', () => {
dragFrom = -1; li.classList.remove('dragging');
clearDropMarks();
if (plDirty) { plDirty = false; renderPlaylist(); }
});
li.addEventListener('dragover', (e) => {
if (dragFrom < 0) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Insertion marker: top half = before this item, bottom half = after.
const r = li.getBoundingClientRect();
const below = (e.clientY - r.top) > r.height / 2;
if (li !== dragMarkEl || below !== dragMarkBelow) {
clearDropMarks();
dragMarkEl = li; dragMarkBelow = below;
li.classList.add(below ? 'drop-below' : 'drop-above');
}
});
li.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation(); // not a file drop: keep it away from the window handler
const from = dragFrom;
dragFrom = -1;
clearDropMarks();
if (from < 0) return;
const r = li.getBoundingClientRect();
let to = i + ((e.clientY - r.top) > r.height / 2 ? 1 : 0);
if (from < to) to--;
if (to !== from) moveTrack(from, to);
else if (plDirty) { plDirty = false; renderPlaylist(); }
});
ol.appendChild(li);
if (sceneEditing === i) ol.appendChild(buildSceneEditor(tr, i, li));
});
$('#playlist-count').textContent = playlist.length + (playlist.length === 1 ? ' brano' : ' brani');
}
$('#btn-add-tracks').addEventListener('click', () => $('#tracks-input').click());
$('#btn-add-interlude').addEventListener('click', addInterlude);
$('#tracks-input').addEventListener('change', (e) => {
const items = [...e.target.files].map(f => ({ path: filePath(f), name: f.name, isVideo: f.type.startsWith('video/') }));
if (items.length) addTracks(items);
e.target.value = ''; // allow re-adding the same file
});
$('#scene-img-input').addEventListener('change', (e) => {
const f = e.target.files[0];
const p = f && filePath(f);
if (p && sceneImgTarget >= 0 && playlist[sceneImgTarget]) {
const tr = playlist[sceneImgTarget];
const el = (tr.cues || [])[sceneCueTarget];
if (el && el.type === 'image') { el.image = p; savePlaylistState(); refreshScenePreview(sceneImgTarget); renderPlaylist(); }
}
sceneImgTarget = -1; sceneCueTarget = -1; e.target.value = '';
});
$('#btn-clear-playlist').addEventListener('click', () => {
playlist = []; currentIndex = -1; sceneEditing = -1; renderPlaylist(); savePlaylistState();
});
$('#repeat-playlist').addEventListener('change', (e) => { repeat = e.target.checked; });
// Export / import the playlist to a file.
$('#btn-pl-save').addEventListener('click', async () => {
if (djv.exportPlaylist) await djv.exportPlaylist(serializePlaylist());
});
$('#btn-pl-load').addEventListener('click', async () => {
if (!djv.importPlaylist) return;
const data = await djv.importPlaylist();
if (Array.isArray(data)) {
playlist = data.map(normalizeTrack);
currentIndex = -1; sceneEditing = -1;
renderPlaylist(); savePlaylistState();
probePaths(playlist.map(t => t.path));
}
});
// Playlist-video rendering (blend / opacity / fit).
$('#tvid-fit').addEventListener('change', (e) => send({ type: 'trackVideoFit', value: e.target.value }));
$('#tvid-blend').addEventListener('change', (e) => { userTrackBlend = e.target.value; sentTrackBlend = e.target.value; send({ type: 'trackVideoBlend', value: e.target.value }); });
$('#tvid-op').addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
$('#tvid-op-val').textContent = v + '%';
send({ type: 'trackVideoOpacity', value: v / 100 });
});
$('#btn-use-input').addEventListener('click', () =>
send({ type: 'useInput', deviceId: $('#device-select').value || null }));
$('#output-select').addEventListener('change', (e) =>
send({ type: 'outputDevice', deviceId: e.target.value || '' }));
// Per-band visual intensity (Bass / Mid / Treble).
[['bass', 'bass-gain'], ['mid', 'mid-gain'], ['treble', 'treble-gain']].forEach(([band, id]) => {
$('#' + id).addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
$('#' + id + '-val').textContent = v.toFixed(1) + '×';
send({ type: 'bandGain', band, value: v });
});
});
// ---------------------------------------------------------------- parameters
$('#gain').addEventListener('input', (e) => {
$('#gain-val').textContent = parseFloat(e.target.value).toFixed(1) + '×';
send({ type: 'gain', value: parseFloat(e.target.value) });
});
$('#speed').addEventListener('input', (e) => {
$('#speed-val').textContent = parseFloat(e.target.value).toFixed(1) + '×';
send({ type: 'speed', value: parseFloat(e.target.value) });
});
// ---------------------------------------------------------------- images
$('#btn-images').addEventListener('click', () => $('#image-input').click());
$('#image-input').addEventListener('change', (e) => {
const paths = [...e.target.files].map(filePath).filter(Boolean);
if (paths.length) send({ type: 'addImages', paths });
});
$('#btn-images-clear').addEventListener('click', () => send({ type: 'clearImages' }));
$('#img-blend').addEventListener('change', (e) => send({ type: 'imgBlend', value: e.target.value }));
$('#img-slideshow').addEventListener('change', (e) => send({ type: 'slideshow', on: e.target.checked }));
$('#img-beat').addEventListener('change', (e) => send({ type: 'imgBeat', on: e.target.checked }));
$('#img-size').addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
$('#img-size-val').textContent = v + '%';
send({ type: 'imgSize', value: v });
});
$('#img-interval').addEventListener('input', (e) => {
const s = parseFloat(e.target.value);
$('#img-interval-val').textContent = s.toFixed(1) + 's';
send({ type: 'slideshowInterval', ms: s * 1000 });
});
// ---------------------------------------------------------------- video
$('#btn-video').addEventListener('click', () => $('#video-input').click());
$('#video-input').addEventListener('change', (e) => {
const f = e.target.files[0];
const p = f && filePath(f);
if (p) send({ type: 'videoLoad', path: p });
e.target.value = '';
});
$('#btn-video-toggle').addEventListener('click', () => send({ type: 'videoToggle' }));
$('#btn-video-clear').addEventListener('click', () => send({ type: 'videoClear' }));
$('#video-op').addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
$('#video-op-val').textContent = v + '%';
send({ type: 'videoOpacity', value: v / 100 });
});
$('#video-fit').addEventListener('change', (e) => send({ type: 'videoFit', value: e.target.value }));
$('#video-blend').addEventListener('change', (e) => send({ type: 'videoBlend', value: e.target.value }));
// ---------------------------------------------------------------- ticker
$('#ticker-text').addEventListener('input', (e) => send({ type: 'tickerText', text: e.target.value.replace(/\s*\n\s*/g, ' · ') }));
$('#ticker-on').addEventListener('change', (e) => send({ type: 'tickerOn', on: e.target.checked }));
$('#ticker-pos').addEventListener('change', (e) => send({ type: 'tickerPos', pos: e.target.value }));
$('#ticker-dir').addEventListener('change', (e) => send({ type: 'tickerDir', value: e.target.value }));
$('#ticker-speed').addEventListener('input', (e) => {
const m = parseFloat(e.target.value);
$('#ticker-speed-val').textContent = m.toFixed(1) + '×';
send({ type: 'tickerSpeed', mult: m });
});
$('#ticker-font').addEventListener('change', (e) => send({ type: 'tickerFont', value: e.target.value }));
$('#ticker-size').addEventListener('input', (e) => {
$('#ticker-size-val').textContent = parseFloat(e.target.value).toFixed(1);
send({ type: 'tickerSize', value: parseFloat(e.target.value) });
});
$('#ticker-bold').addEventListener('change', (e) => send({ type: 'tickerWeight', on: e.target.checked }));
$('#ticker-color').addEventListener('input', (e) => send({ type: 'tickerColor', value: e.target.value }));
$('#ticker-fx').addEventListener('change', (e) => send({ type: 'tickerFx', value: e.target.value }));
// ---------------------------------------------------------------- mapping
// Projection mapping zones: the panel is the source of truth (persisted);
// corner drags on the output flow back via the 'mapZones' report.
let mapCfg = { on: false, zones: [] };
try { mapCfg = Object.assign(mapCfg, JSON.parse(localStorage.getItem('mapcfg') || '{}')); } catch (e) {}
let mapNextId = mapCfg.zones.reduce((m2, z) => Math.max(m2, z.id || 0), 0) + 1;
function mapSave() { localStorage.setItem('mapcfg', JSON.stringify({ on: mapCfg.on, zones: mapCfg.zones })); }
function mapSendZones() { send({ type: 'mapZones', zones: mapCfg.zones }); }
function mapSendAll() {
mapSendZones();
send({ type: 'mapOn', on: mapCfg.on });
send({ type: 'mapEdit', on: $('#map-edit').checked });
}
function mapAddZone(src) {
const n = mapCfg.zones.length;
const off = (n % 4) * 0.05;
mapCfg.zones.push({ id: mapNextId++, name: 'Zona ' + mapNextId,
src, corners: [[0.25+off, 0.25+off], [0.75+off, 0.25+off], [0.75+off, 0.75+off], [0.25+off, 0.75+off]],
srcRect: [0, 0, 1, 1], opacity: 1 });
mapSel = mapCfg.zones.length - 1; mapSelC = -1;
mapSave(); mapSendZones(); renderMapZones();
send({ type: 'mapSel', index: mapSel });
}
function renderMapZones() {
const box = $('#map-zones');
box.innerHTML = '';
if (mapSel >= mapCfg.zones.length) { mapSel = mapCfg.zones.length - 1; mapSelC = -1; }
mapEdDraw();
if (!mapCfg.zones.length) {
box.innerHTML = 'Nessuna zona: aggiungine una qui sotto.
';
return;
}
mapCfg.zones.forEach((z, i) => {
const row = document.createElement('div');
row.className = 'map-zone';
const isImg = z.src && z.src.type === 'image';
const isFx = z.src && z.src.type === 'effect';
let mid;
if (isImg) {
mid = '' +
String(z.src.path || '').split(/[\\/]/).pop() + '';
} else if (isFx) {
const cur = EFFECTS.list[z.src.effectIndex] || EFFECTS.defaults();
let famOpts = '', preOpts = '';
EFFECTS.families.forEach((name, fi) => {
if (/^Interattivo|^Modello 3D/.test(name)) return;
famOpts += '';
});
EFFECTS.list.forEach((e2, ei) => {
if (e2.family !== cur.family) return;
preOpts += '';
});
mid = '' +
'';
}
row.innerHTML =
'' + (isImg ? '🖼' : (isFx ? '✨' : '🌀')) + '' +
'' +
(mid !== undefined ? mid
: 'Porzione ' +
'%,' +
'% ' +
'%×' +
'%') +
'' +
'';
row.querySelector('.mz-name').addEventListener('change', (e) => {
z.name = e.target.value; mapSave(); mapSendZones();
});
const famSel = row.querySelector('.mz-fam');
if (famSel) {
const preSel = row.querySelector('.mz-pre');
famSel.addEventListener('change', () => {
const fam = parseInt(famSel.value, 10);
preSel.innerHTML = '';
EFFECTS.list.forEach((e2, ei) => {
if (e2.family !== fam) return;
const o = document.createElement('option');
o.value = ei;
o.textContent = e2.name.replace(EFFECTS.families[fam] + ' · ', '');
preSel.appendChild(o);
});
z.src.effectIndex = parseInt(preSel.value, 10);
mapSave(); mapSendZones();
});
preSel.addEventListener('change', () => {
z.src.effectIndex = parseInt(preSel.value, 10);
mapSave(); mapSendZones();
});
}
row.querySelectorAll('.mz-r').forEach(inp => inp.addEventListener('change', (e) => {
const k = parseInt(e.target.dataset.k, 10);
z.srcRect[k] = Math.max(0, Math.min(1, (parseInt(e.target.value, 10) || 0)/100));
if (z.srcRect[2] <= 0) z.srcRect[2] = 0.01;
if (z.srcRect[3] <= 0) z.srcRect[3] = 0.01;
mapSave(); mapSendZones();
}));
row.querySelector('.mz-op').addEventListener('input', (e) => {
z.opacity = parseFloat(e.target.value); mapSave(); mapSendZones();
});
row.querySelector('.mz-del').addEventListener('click', () => {
mapCfg.zones.splice(i, 1);
if (mapSel === i) mapSel = -1; else if (mapSel > i) mapSel--;
mapSave(); mapSendZones(); renderMapZones();
});
if (i === mapSel) row.classList.add('sel');
// Clicking the row (not its controls) selects the zone in the editor.
row.addEventListener('mousedown', (e) => {
if (e.target.closest('input, select, button')) return;
mapSelect(i, -1);
});
box.appendChild(row);
});
}
$('#map-on').checked = mapCfg.on;
$('#map-on').addEventListener('change', (e) => {
mapCfg.on = e.target.checked; mapSave();
send({ type: 'mapShow', ids: null }); // manual toggle: every zone visible
send({ type: 'mapOn', on: mapCfg.on });
});
$('#map-edit').addEventListener('change', (e) => send({ type: 'mapEdit', on: e.target.checked }));
$('#map-add-vis').addEventListener('click', () => mapAddZone({ type: 'visual' }));
$('#map-add-fx').addEventListener('click', () =>
mapAddZone({ type: 'effect', effectIndex: EFFECTS.list.indexOf(EFFECTS.defaults()) < 0 ? 12 : EFFECTS.list.indexOf(EFFECTS.defaults()) }));
$('#map-add-img').addEventListener('click', () => $('#map-img-input').click());
$('#map-img-input').addEventListener('change', (e) => {
const f = e.target.files[0];
if (f) {
const p = djv.pathForFile(f);
if (p) mapAddZone({ type: 'image', path: p });
}
e.target.value = '';
});
// ---- Mapping mini-editor: edit zones on a scaled replica of the output ----
// On a huge LED wall the on-output handles are unusable; here the hand stays
// on the laptop and the wall only shows the result. Coordinates are the same
// normalized 0..1 (y-down) corners the output uses.
let mapSel = -1; // selected zone index
let mapSelC = -1; // selected corner (0 TL,1 TR,2 BR,3 BL) or -1 = whole zone
let mapEdDrag = null; // { ci, x0, y0, start: corners snapshot }
// Snap: grid divisions per side + on/off, persisted.
let mapSnap = { on: true, div: 10 };
try { mapSnap = Object.assign(mapSnap, JSON.parse(localStorage.getItem('mapsnap') || '{}')); } catch (e) {}
const MAP_SNAP_PX = 9; // capture radius in editor css pixels
let outBounds = null; // main output display size (set by refreshDisplays)
const MAP_CORNER = ['alto-sx', 'alto-dx', 'basso-dx', 'basso-sx'];
// Size the editor works in = the area the map canvas covers on the output.
function mapEdSize() {
// ledCfg is declared further down: guard the first draw at startup (TDZ).
try { if (ledCfg.on && ledCfg.w > 0 && ledCfg.h > 0) return [ledCfg.w, ledCfg.h]; } catch (e) {}
if (outBounds) return [outBounds.width, outBounds.height];
return [1920, 1080];
}
let mapEdSendPending = false;
function mapEdSync() {
// Coalesce drag/nudge bursts to one IPC per frame.
if (mapEdSendPending) return;
mapEdSendPending = true;
requestAnimationFrame(() => { mapEdSendPending = false; mapSendZones(); });
}
let mapEdSaveT = null;
function mapEdSaveSoon() { clearTimeout(mapEdSaveT); mapEdSaveT = setTimeout(mapSave, 300); }
function mapSelect(zi, ci) {
mapSel = zi; mapSelC = zi < 0 ? -1 : ci;
send({ type: 'mapSel', index: mapSel });
document.querySelectorAll('#map-zones .map-zone').forEach((r, k) => r.classList.toggle('sel', k === mapSel));
mapEdDraw();
}
// Edge straightness in OUTPUT pixels. Edges: top TL→TR and bottom BR→BL
// should be horizontal, right TR→BR and left BL→TL vertical. 'off' = how far
// the far end strays from the axis; under half a pixel the edge IS straight
// on the real screen.
const MAP_EDGES = [
{ name: 'sopra', a: 0, b: 1, horiz: true, arrow: '↑' },
{ name: 'destra', a: 1, b: 2, horiz: false, arrow: '→' },
{ name: 'sotto', a: 2, b: 3, horiz: true, arrow: '↓' },
{ name: 'sinistra', a: 3, b: 0, horiz: false, arrow: '←' }
];
function mapEdgeInfo(z) {
const [ow, oh] = mapEdSize();
return MAP_EDGES.map(ed => {
const A = z.corners[ed.a], B = z.corners[ed.b];
const dx = (B[0] - A[0]) * ow, dy = (B[1] - A[1]) * oh;
const off = ed.horiz ? Math.abs(dy) : Math.abs(dx);
const len = ed.horiz ? Math.abs(dx) : Math.abs(dy);
const deg = Math.atan2(off, Math.max(1e-6, len)) * 180 / Math.PI;
return { ...ed, off, deg, ok: off < 0.5 };
});
}
// Square the zone up: horizontal edges take the average y of their two ends,
// vertical edges the average x — the closest axis-aligned rectangle.
function mapStraighten(zi) {
const z = mapCfg.zones[zi];
if (!z) return;
const c = z.corners;
const top = (c[0][1] + c[1][1]) / 2, bot = (c[2][1] + c[3][1]) / 2;
const left = (c[0][0] + c[3][0]) / 2, right = (c[1][0] + c[2][0]) / 2;
z.corners = [[left, top], [right, top], [right, bot], [left, bot]];
mapSave(); mapSendZones(); mapEdDraw();
}
function mapEdDraw() {
const cv = $('#map-editor');
if (!cv || !cv.clientWidth) return;
const [ow, oh] = mapEdSize();
const dpr = window.devicePixelRatio || 1;
const cssW = cv.clientWidth;
const cssH = Math.min(440, cssW * oh / ow);
// Keep the output aspect even when max-height clamps the box.
const drawW = cssH * ow / oh < cssW ? cssH * ow / oh : cssW;
cv.style.height = cssH + 'px';
if (cv.width !== Math.round(cssW * dpr) || cv.height !== Math.round(cssH * dpr)) {
cv.width = Math.round(cssW * dpr); cv.height = Math.round(cssH * dpr);
}
const ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const ox = (cssW - drawW) / 2, W = drawW, H = cssH;
cv._geo = { ox, W, H };
// output frame + 10% grid
ctx.fillStyle = '#000'; ctx.fillRect(ox, 0, W, H);
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
const dv = mapSnap.div;
ctx.strokeStyle = mapSnap.on ? 'rgba(127,168,255,0.13)' : 'rgba(255,255,255,0.06)';
for (let k = 1; k < dv; k++) {
// the centre lines are slightly stronger: handy for symmetric setups
ctx.globalAlpha = (k * 2 === dv) ? 1.8 : 1;
ctx.beginPath(); ctx.moveTo(ox + W * k / dv, 0); ctx.lineTo(ox + W * k / dv, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ox, H * k / dv); ctx.lineTo(ox + W, H * k / dv); ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.strokeRect(ox + 0.5, 0.5, W - 1, H - 1);
const P = (c) => [ox + c[0] * W, c[1] * H];
mapCfg.zones.forEach((z, zi) => {
const sel = zi === mapSel;
const pts = z.corners.map(P);
ctx.beginPath();
pts.forEach((p, k) => k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]));
ctx.closePath();
const t = z.src && z.src.type;
ctx.fillStyle = sel ? 'rgba(95,140,255,0.28)'
: (t === 'image' ? 'rgba(255,190,90,0.14)' : t === 'effect' ? 'rgba(190,120,255,0.14)' : 'rgba(120,220,200,0.14)');
ctx.fill();
// Edges one by one: perfectly horizontal/vertical ones turn green;
// on the selected zone the crooked ones show their deviation.
mapEdgeInfo(z).forEach(ed => {
const A = pts[ed.a], B = pts[ed.b];
ctx.beginPath(); ctx.moveTo(A[0], A[1]); ctx.lineTo(B[0], B[1]);
ctx.lineWidth = ed.ok ? (sel ? 3 : 2) : (sel ? 2 : 1.2);
ctx.strokeStyle = ed.ok ? (sel ? '#4dff8f' : 'rgba(77,255,143,0.7)')
: (sel ? '#7fa8ff' : 'rgba(255,255,255,0.55)');
ctx.stroke();
if (sel && !ed.ok) {
const mx = (A[0] + B[0]) / 2, my = (A[1] + B[1]) / 2;
const txt = ed.deg.toFixed(1) + '°';
ctx.font = 'bold 10px -apple-system, sans-serif';
const tw = ctx.measureText(txt).width + 8;
ctx.fillStyle = 'rgba(255,170,60,0.92)';
ctx.fillRect(mx - tw / 2, my - 8, tw, 16);
ctx.fillStyle = '#111'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(txt, mx, my);
}
});
// label at the centroid
const cx = pts.reduce((s, p) => s + p[0], 0) / 4, cy = pts.reduce((s, p) => s + p[1], 0) / 4;
ctx.font = (sel ? 'bold ' : '') + '11px -apple-system, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = sel ? '#fff' : 'rgba(255,255,255,0.75)';
ctx.fillText(z.name || 'Zona', cx, cy);
pts.forEach((p, k) => {
const hot = sel && (mapSelC === k);
ctx.beginPath();
ctx.arc(p[0], p[1], hot ? 7 : (sel ? 5 : 3.5), 0, Math.PI * 2);
ctx.fillStyle = hot ? '#ffd24a' : (sel ? '#7fa8ff' : 'rgba(255,255,255,0.7)');
ctx.fill();
});
});
// status line
const st = $('#map-ed-status');
if (mapSel < 0 || !mapCfg.zones[mapSel]) { st.textContent = 'Clicca una zona o un angolo per selezionarlo'; return; }
const z = mapCfg.zones[mapSel];
const fmt = (c) => Math.round(c[0] * ow) + ', ' + Math.round(c[1] * oh) + ' px';
const edges = mapEdgeInfo(z).map(ed => ed.arrow + ' ' + (ed.ok ? '✓' : ed.deg.toFixed(1) + '° (' + ed.off.toFixed(1) + 'px)')).join(' ');
st.textContent = (mapSelC >= 0
? '● ' + z.name + ' · angolo ' + MAP_CORNER[mapSelC] + ': ' + fmt(z.corners[mapSelC])
: '● ' + z.name + ' · zona intera · angolo alto-sx ' + fmt(z.corners[0])) +
' | lati: ' + edges;
}
// Snap targets on one axis: grid lines (screen edges included) and the
// corners of every OTHER zone, so adjacent panels butt together exactly.
function mapSnapTargets(axis, skipZone, ownCorner) {
const out = [];
for (let k = 0; k <= mapSnap.div; k++) out.push(k / mapSnap.div);
mapCfg.zones.forEach((z, zi) => {
if (zi !== skipZone) { z.corners.forEach(c => out.push(c[axis])); return; }
// Dragging ONE corner: the other corners of the same zone are targets
// too, so an edge snaps perfectly straight.
if (ownCorner >= 0) z.corners.forEach((c, ci) => { if (ci !== ownCorner) out.push(c[axis]); });
});
return out;
}
(function mapEdInit() {
$('#map-straighten').addEventListener('click', () => { if (mapSel >= 0) mapStraighten(mapSel); });
$('#map-snap').checked = mapSnap.on;
$('#map-grid').value = String(mapSnap.div);
const snapSave = () => { localStorage.setItem('mapsnap', JSON.stringify(mapSnap)); mapEdDraw(); };
$('#map-snap').addEventListener('change', (e) => { mapSnap.on = e.target.checked; snapSave(); });
$('#map-grid').addEventListener('change', (e) => { mapSnap.div = parseInt(e.target.value, 10) || 10; snapSave(); });
const cv = $('#map-editor');
const toN = (e) => {
const r = cv.getBoundingClientRect(), g = cv._geo || { ox: 0, W: r.width, H: r.height };
return [(e.clientX - r.left - g.ox) / g.W, (e.clientY - r.top) / g.H];
};
const clampC = (v) => Math.min(1.2, Math.max(-0.2, v));
const inside = (c, x, y) => { // even-odd point-in-polygon
let inn = false;
for (let i = 0, j = 3; i < 4; j = i++) {
const xi = c[i][0], yi = c[i][1], xj = c[j][0], yj = c[j][1];
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-9) + xi)) inn = !inn;
}
return inn;
};
cv.addEventListener('mousedown', (e) => {
cv.focus();
const [x, y] = toN(e);
const g = cv._geo || { W: cv.clientWidth, H: cv.clientHeight };
// nearest corner within 10 css px — the selected zone wins ties
let best = null, bd = 10;
const order = mapCfg.zones.map((_, i) => i).sort((a, b) => (b === mapSel) - (a === mapSel));
for (const zi of order) {
mapCfg.zones[zi].corners.forEach((c, ci) => {
const d = Math.hypot((c[0] - x) * g.W, (c[1] - y) * g.H);
if (d < bd) { bd = d; best = { zi, ci }; }
});
}
if (best) {
mapSelect(best.zi, best.ci);
mapEdDrag = { ci: best.ci, x0: x, y0: y, start: mapCfg.zones[best.zi].corners.map(c => c.slice()) };
return;
}
for (let zi = mapCfg.zones.length - 1; zi >= 0; zi--) {
if (inside(mapCfg.zones[zi].corners, x, y)) {
mapSelect(zi, -1);
mapEdDrag = { ci: -1, x0: x, y0: y, start: mapCfg.zones[zi].corners.map(c => c.slice()) };
return;
}
}
mapSelect(-1, -1);
});
window.addEventListener('mousemove', (e) => {
if (!mapEdDrag || mapSel < 0) return;
const z = mapCfg.zones[mapSel];
if (!z) { mapEdDrag = null; return; }
const [x, y] = toN(e);
const g = cv._geo || { W: cv.clientWidth, H: cv.clientHeight };
let dx = x - mapEdDrag.x0, dy = y - mapEdDrag.y0;
const st = mapEdDrag.start;
// Moving points (one corner, or all four for a whole-zone drag) snap by
// shifting the WHOLE delta so the closest candidate lands exactly on a
// target — per axis, so an edge can align horizontally and vertically.
const moving = mapEdDrag.ci >= 0 ? [st[mapEdDrag.ci]] : st;
if (mapSnap.on && !e.altKey) {
const tx = mapSnapTargets(0, mapSel, mapEdDrag.ci), ty = mapSnapTargets(1, mapSel, mapEdDrag.ci);
const fix = (d, axis, targets, tol) => {
let best = null;
for (const p of moving) {
const v = p[axis] + d;
for (const t of targets) {
const off = t - v;
if (Math.abs(off) <= tol && (best === null || Math.abs(off) < Math.abs(best))) best = off;
}
}
return best === null ? d : d + best;
};
dx = fix(dx, 0, tx, MAP_SNAP_PX / g.W);
dy = fix(dy, 1, ty, MAP_SNAP_PX / g.H);
}
const z0 = mapCfg.zones[mapSel];
if (mapEdDrag.ci >= 0) {
z0.corners[mapEdDrag.ci] = [clampC(st[mapEdDrag.ci][0] + dx), clampC(st[mapEdDrag.ci][1] + dy)];
} else {
z0.corners = st.map(c => [clampC(c[0] + dx), clampC(c[1] + dy)]);
}
mapEdDraw(); mapEdSync();
});
window.addEventListener('mouseup', () => {
if (mapEdDrag) { mapEdDrag = null; mapSave(); }
});
cv.addEventListener('keydown', (e) => {
// Keys typed while the editor has focus never reach the global hotkeys
// (arrows = images, 1-9 = effects, F, space…).
e.stopPropagation();
const z = mapCfg.zones[mapSel];
if (e.key === 'Tab') {
if (!z) return;
e.preventDefault();
// TL → TR → BR → BL → whole zone → TL …
const nxt = mapSelC >= 3 ? -1 : mapSelC + 1;
mapSelect(mapSel, e.shiftKey ? (mapSelC <= -1 ? 3 : mapSelC - 1) : nxt);
return;
}
if (e.key === 'Escape') { if (z) mapSelect(mapSel, -1); return; }
if ((e.key === 'r' || e.key === 'R') && z && !e.metaKey && !e.ctrlKey) { mapStraighten(mapSel); return; }
const dir = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!dir || !z) return;
e.preventDefault();
const [ow, oh] = mapEdSize();
const step = e.shiftKey ? 10 : 1; // output pixels
const dx = dir[0] * step / ow, dy = dir[1] * step / oh;
const pts = mapSelC >= 0 ? [z.corners[mapSelC]] : z.corners;
pts.forEach(c => { c[0] = clampC(c[0] + dx); c[1] = clampC(c[1] + dy); });
mapEdDraw(); mapEdSync(); mapEdSaveSoon();
});
window.addEventListener('resize', mapEdDraw);
// The canvas has no width while its tab is hidden: draw when shown.
document.querySelectorAll('#tabs button').forEach(b => b.addEventListener('click', () => {
if (b.dataset.tab === 'mappa') requestAnimationFrame(mapEdDraw);
}));
})();
renderMapZones();
// ---------------------------------------------------------------- displays
async function refreshDisplays() {
const list = await djv.listDisplays();
const sel = $('#display-select');
sel.innerHTML = '';
list.forEach(d => {
const o = document.createElement('option');
o.value = d.id;
o.textContent = d.label + (d.isPrimary ? ' (principale)' : '') + ` — ${d.bounds.width}×${d.bounds.height}`;
sel.appendChild(o);
});
const ext = list.find(d => !d.isPrimary);
if (ext) sel.value = ext.id;
const outD = list.find(d => d.hasOutput);
outBounds = outD ? outD.bounds : null;
mapEdDraw();
// Aux candidates: every display that hosts neither the panel nor the output.
auxDisplays = list.filter(d => !d.isPrimary && !d.hasOutput);
renderAuxList();
}
$('#btn-display').addEventListener('click', async () => {
const id = parseInt($('#display-select').value, 10);
if (!id) return;
// If an aux window occupies the chosen display, turn it off first.
if (auxCfg[id] && auxCfg[id].on) { auxCfg[id].on = false; auxSave(); await auxSyncAll(); }
await djv.moveOutputTo(id);
refreshDisplays(); // the aux candidate list depends on where the output sits
});
// ---- Aux outputs: one independent window per extra display ----
let auxCfg = {}; // displayId -> { on, mode, effectIndex, image }
try { auxCfg = JSON.parse(localStorage.getItem('auxout') || '{}'); } catch (e) {}
let auxDisplays = []; // current candidate displays (neither panel nor output)
function auxSave() { localStorage.setItem('auxout', JSON.stringify(auxCfg)); }
function auxSendCfg(displayId) {
const c = auxCfg[displayId];
if (!c || !c.on) return;
send({ type: 'auxCfg', displayId: parseInt(displayId, 10),
mode: c.mode || 'follow', effectIndex: c.effectIndex, image: c.image || null,
text: c.text || null,
effect: currentEffect }); // so a fresh window in 'follow' mode starts aligned
}
// Create/close aux windows so they match the enabled displays, then push each
// one's content config (late loaders are covered by their 'auxReady' report).
async function auxSyncAll() {
const ids = auxDisplays.filter(d => auxCfg[d.id] && auxCfg[d.id].on).map(d => d.id);
const live = await djv.auxSync(ids);
live.forEach(id => auxSendCfg(id));
}
const AUX_MODES = [['follow', '🔁 Segui il principale'], ['effect', '✨ Effetto dedicato'],
['image', '🖼 Immagine'], ['black', '⬛ Nero']];
function renderAuxList() {
const box = $('#aux-list');
if (!box) return;
box.innerHTML = '';
if (!auxDisplays.length) {
box.innerHTML = 'Nessuno schermo aggiuntivo: qui compaiono i monitor oltre a Controlli e output principale.
';
return;
}
auxDisplays.forEach(d => {
const c = auxCfg[d.id] = auxCfg[d.id] || { on: false, mode: 'follow', effectIndex: null, image: null };
const row = document.createElement('div');
row.className = 'aux-row';
let modeOpts = '';
AUX_MODES.forEach(([v, l]) => {
modeOpts += '';
});
let extra = '';
if (c.mode === 'effect') {
const cur = EFFECTS.list[c.effectIndex] || EFFECTS.defaults();
let famOpts = '', preOpts = '';
EFFECTS.families.forEach((name, fi) => {
if (/^Interattivo|^Modello 3D/.test(name)) return;
famOpts += '';
});
EFFECTS.list.forEach((e2, ei) => {
if (e2.family !== cur.family) return;
preOpts += '';
});
extra = '' +
'';
} else if (c.mode === 'image') {
extra = '' +
(c.image ? c.image.split(/[\\/]/).pop() : 'nessuna') + '';
}
const tx = c.text = c.text || { on: false, value: '', dir: 'h', fx: 'none', font: TXT_FONTS[0][0], pos: 'bottom', size: 6, color: '#ffffff', weight: true, speed: 1 };
if (tx.dir === undefined) { // migrate pre-ticker configs (scroll-flag era)
tx.dir = 'h'; tx.fx = 'none'; tx.font = TXT_FONTS[0][0]; delete tx.scroll;
}
let textRow = '';
if (tx.on) {
textRow =
'' +
'' +
'
' +
'' +
'' +
'' +
'' +
'' +
'
' +
'' +
'Dim' +
'Vel' +
'' +
'' +
'
';
}
row.innerHTML =
'' +
'' + extra +
'' +
textRow;
row.querySelector('.aux-on').addEventListener('change', (e) => {
c.on = e.target.checked; auxSave(); auxSyncAll();
});
row.querySelector('.aux-mode').addEventListener('change', (e) => {
c.mode = e.target.value;
if (c.mode === 'effect' && (c.effectIndex == null || c.effectIndex < 0)) {
const di = EFFECTS.list.indexOf(EFFECTS.defaults());
c.effectIndex = di < 0 ? 12 : di; // defaults() is not in the list
}
auxSave(); auxSendCfg(d.id); renderAuxList();
});
const famSel = row.querySelector('.aux-fam');
if (famSel) {
const preSel = row.querySelector('.aux-pre');
famSel.addEventListener('change', () => {
const fam = parseInt(famSel.value, 10);
preSel.innerHTML = '';
EFFECTS.list.forEach((e2, ei) => {
if (e2.family !== fam) return;
const o = document.createElement('option');
o.value = ei;
o.textContent = e2.name.replace(EFFECTS.families[fam] + ' · ', '');
preSel.appendChild(o);
});
c.effectIndex = parseInt(preSel.value, 10);
auxSave(); auxSendCfg(d.id);
});
preSel.addEventListener('change', () => {
c.effectIndex = parseInt(preSel.value, 10);
auxSave(); auxSendCfg(d.id);
});
}
row.querySelector('.aux-txt').addEventListener('click', () => {
tx.on = !tx.on; auxSave(); auxSendCfg(d.id); renderAuxList();
});
const txVal = row.querySelector('.atx-val');
if (txVal) {
txVal.value = tx.value || '';
const live = () => { auxSave(); auxSendCfg(d.id); };
txVal.addEventListener('input', (e) => { tx.value = e.target.value; live(); });
row.querySelector('.atx-dir').addEventListener('change', (e) => { tx.dir = e.target.value; live(); });
row.querySelector('.atx-fx').addEventListener('change', (e) => { tx.fx = e.target.value; live(); });
row.querySelector('.atx-font').addEventListener('change', (e) => { tx.font = e.target.value; live(); });
row.querySelector('.atx-pos').addEventListener('change', (e) => { tx.pos = e.target.value; live(); });
row.querySelector('.atx-size').addEventListener('input', (e) => { tx.size = parseFloat(e.target.value); live(); });
row.querySelector('.atx-speed').addEventListener('input', (e) => { tx.speed = parseFloat(e.target.value); live(); });
row.querySelector('.atx-color').addEventListener('input', (e) => { tx.color = e.target.value; live(); });
row.querySelector('.atx-bold').addEventListener('change', (e) => { tx.weight = e.target.checked; live(); });
}
const imgBtn = row.querySelector('.aux-img');
if (imgBtn) imgBtn.addEventListener('click', () => {
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = 'image/*';
inp.addEventListener('change', () => {
const f = inp.files[0];
const p = f ? djv.pathForFile(f) : null;
if (p) { c.image = p; auxSave(); auxSendCfg(d.id); renderAuxList(); }
});
inp.click();
});
box.appendChild(row);
});
}
$('#btn-fullscreen').addEventListener('click', () => djv.toggleOutputFullscreen());
// ---- LED wall active area (pixel-mapping processors) ----
// Persisted; re-sent whenever the output window (re)starts (see 'devices').
let ledCfg = { on: false, w: 1024, h: 576, x: 0, y: 0 };
try { ledCfg = Object.assign(ledCfg, JSON.parse(localStorage.getItem('ledarea') || '{}')); } catch (e) {}
function ledSend() { send({ type: 'ledArea', on: ledCfg.on, w: ledCfg.w, h: ledCfg.h, x: ledCfg.x, y: ledCfg.y }); }
function ledSave() { localStorage.setItem('ledarea', JSON.stringify(ledCfg)); ledSend(); if (window.mapEdDraw) mapEdDraw(); }
(function ledInit() {
$('#led-on').checked = ledCfg.on;
const fields = [['#led-w', 'w'], ['#led-h', 'h'], ['#led-x', 'x'], ['#led-y', 'y']];
fields.forEach(([sel, k]) => {
$(sel).value = ledCfg[k];
$(sel).addEventListener('change', (e) => {
ledCfg[k] = Math.max(0, parseInt(e.target.value || '0', 10) || 0);
ledSave();
});
});
$('#led-on').addEventListener('change', (e) => { ledCfg.on = e.target.checked; ledSave(); });
})();
// ---------------------------------------------------------------- recording
let recOn = false, recTimer = null, recStartedAt = 0;
function fmtTime(s) {
const m = Math.floor(s / 60), ss = s % 60;
return String(m).padStart(2, '0') + ':' + String(ss).padStart(2, '0');
}
function recTick() { $('#rec-time').textContent = fmtTime(Math.floor((Date.now() - recStartedAt) / 1000)); }
function stopRecTimer() { if (recTimer) { clearInterval(recTimer); recTimer = null; } }
// Crop position + on-screen guide for formats that differ from the screen
// (e.g. 9:16 reels): the slider picks WHICH strip of the screen is recorded,
// the guide draws that area on the output (overlay only — never recorded).
let recCropCfg = { pos: 50, guide: true };
try { recCropCfg = Object.assign(recCropCfg, JSON.parse(localStorage.getItem('reccrop') || '{}')); } catch (e) {}
function recGuideSend() {
const [w, h] = $('#rec-aspect').value.split('x').map(Number);
send({ type: 'recGuide', on: recCropCfg.guide, w, h, pos: recCropCfg.pos / 100 });
}
function recCropLabel() {
const p = recCropCfg.pos;
$('#rec-crop-val').textContent = p === 50 ? 'centro' : (p < 50 ? '◀ ' + (50 - p) * 2 + '%' : (p - 50) * 2 + '% ▶');
}
(function recCropInit() {
$('#rec-crop').value = recCropCfg.pos;
$('#rec-guide-chk').checked = recCropCfg.guide;
recCropLabel();
$('#rec-crop').addEventListener('input', (e) => {
recCropCfg.pos = parseInt(e.target.value, 10);
localStorage.setItem('reccrop', JSON.stringify(recCropCfg));
recCropLabel(); recGuideSend();
});
$('#rec-guide-chk').addEventListener('change', (e) => {
recCropCfg.guide = e.target.checked;
localStorage.setItem('reccrop', JSON.stringify(recCropCfg));
recGuideSend();
});
$('#rec-aspect').addEventListener('change', recGuideSend);
})();
$('#btn-rec').addEventListener('click', () => {
if (!recOn) {
// Pass format + optional max duration so the output can auto-stop (Reel mode).
const [w, h] = $('#rec-aspect').value.split('x').map(Number);
send({ type: 'recStart', w, h, maxMs: parseInt($('#rec-dur').value, 10) || 0, cropX: recCropCfg.pos / 100 });
recGuideSend();
} else {
const [w, h] = $('#rec-aspect').value.split('x').map(Number);
send({ type: 'recStop', w, h, cropX: recCropCfg.pos / 100 });
stopRecTimer();
$('#rec-label').textContent = 'Conversione MP4…';
$('#btn-rec').disabled = true;
}
});
$('#btn-rec-folder').addEventListener('click', () => djv.openRecordingsFolder());
// ---------------------------------------------------------------- pad bank
const PAD_COUNT = 20; // 5 × 4
let pads = new Array(PAD_COUNT).fill(null); // each: { path, name } or null
let activePad = -1, padPlaying = false, padLoadTarget = -1, padCapturing = -1;
const padGrid = $('#pad-grid');
function savePadState() { if (djv.savePads) djv.savePads(pads); }
function renderPads() {
if (!padGrid) return;
padGrid.innerHTML = '';
pads.forEach((p, i) => {
const d = document.createElement('div');
d.className = 'pad' + (p ? ' filled' : '') + (i === activePad && padPlaying ? ' active' : '');
d.dataset.i = i;
const isActive = i === activePad && padPlaying;
const prog = (isActive && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
const remain = (isActive && playDur > 0) ? '-' + fmtTime(Math.floor(Math.max(0, playDur - playCur)))
: (p && durations[p.path] > 0 ? fmtTime(Math.floor(durations[p.path])) : '');
d.innerHTML = '' + (i + 1) + '' +
(p ? '' + p.name.replace(/&/g, '&').replace(/' +
(remain ? '' + remain + '' : '') +
'' +
'' +
(isActive ? '' : '')
: '+');
padGrid.appendChild(d);
});
}
function assignPad(i, item) { pads[i] = item; renderPads(); savePadState(); if (item) probePaths([item.path]); }
function loadPadFile(i) { padLoadTarget = i; $('#pad-input').click(); }
function playPad(i) {
const p = pads[i];
if (!p) { loadPadFile(i); return; }
if (i === activePad && padPlaying) { // re-press the active pad = stop
send({ type: 'togglePlay' });
padPlaying = false; renderPads();
return;
}
clearGap();
playbackOwner = 'pad';
activePad = i; padPlaying = true;
activeCues = []; firedCue = -1; lastScene = { effect: null, text: null, image: null, map: null, video: null }; // pads don't run playlist cues
playCur = 0; playDur = durations[p.path] || 0;
// The playlist is no longer the active source.
currentIndex = -1; isPlaying = false; renderPlaylist();
send({ type: 'playTrack', path: p.path, crossfade: padCrossfadeMs });
renderPads();
}
function clearPad(i) {
pads[i] = null;
if (i === activePad) { activePad = -1; padPlaying = false; }
renderPads();
savePadState();
}
padGrid.addEventListener('click', (e) => {
const pad = e.target.closest('.pad');
if (!pad) return;
const i = parseInt(pad.dataset.i, 10);
if (e.target.closest('.pad-x')) { e.stopPropagation(); clearPad(i); return; }
if (e.target.closest('.pad-key')) { e.stopPropagation(); padCapturing = padCapturing === i ? -1 : i; renderPads(); return; }
playPad(i);
});
padGrid.addEventListener('contextmenu', (e) => {
const pad = e.target.closest('.pad');
if (!pad) return;
e.preventDefault();
loadPadFile(parseInt(pad.dataset.i, 10));
});
padGrid.addEventListener('dragover', (e) => { if (e.target.closest('.pad')) { e.preventDefault(); e.stopPropagation(); } });
padGrid.addEventListener('drop', (e) => {
const pad = e.target.closest('.pad');
if (!pad) return;
e.preventDefault(); e.stopPropagation();
const f = [...e.dataTransfer.files].find(x => x.type.startsWith('audio/'));
const p = f && filePath(f);
if (p) assignPad(parseInt(pad.dataset.i, 10), { path: p, name: f.name });
});
$('#pad-input').addEventListener('change', (e) => {
const f = e.target.files[0];
const p = f && filePath(f);
if (p && padLoadTarget >= 0) assignPad(padLoadTarget, { path: p, name: f.name });
padLoadTarget = -1; e.target.value = '';
});
$('#btn-pad-stop').addEventListener('click', () => {
if (padPlaying) { send({ type: 'togglePlay' }); padPlaying = false; renderPads(); }
});
$('#btn-pad-clear').addEventListener('click', () => {
pads = new Array(PAD_COUNT).fill(null); activePad = -1; padPlaying = false; renderPads(); savePadState();
});
const padXfade = $('#pad-xfade');
if (padXfade) padXfade.addEventListener('input', (e) => {
const s = parseFloat(e.target.value);
$('#pad-xfade-val').textContent = s.toFixed(1) + 's';
padCrossfadeMs = s * 1000;
});
// Load saved pad assignments on startup.
(async () => {
try {
const saved = djv.loadPads ? await djv.loadPads() : null;
if (Array.isArray(saved)) for (let i = 0; i < PAD_COUNT; i++) pads[i] = saved[i] || null;
} catch (e) { /* ignore */ }
renderPads();
probePaths(pads.filter(Boolean).map(p => p.path));
})();
// Load saved playlist (with per-track scenes/cues) on startup.
(async () => {
try {
const saved = djv.loadPlaylist ? await djv.loadPlaylist() : null;
if (Array.isArray(saved) && saved.length) {
playlist = saved.map(normalizeTrack);
renderPlaylist();
probePaths(playlist.map(t => t.path));
}
} catch (e) { /* ignore */ }
})();
// ---------------------------------------------------------------- tabs
document.querySelectorAll('#tabs button').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.tab;
document.querySelectorAll('#tabs button').forEach(b => b.classList.toggle('active', b === btn));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.dataset.tab === t));
});
});
// ---------------------------------------------------------------- logos
document.querySelectorAll('.logo-load').forEach(b => b.addEventListener('click', () =>
document.querySelector('.logo-input[data-logo="' + b.dataset.logo + '"]').click()));
document.querySelectorAll('.logo-input').forEach(inp => inp.addEventListener('change', (e) => {
const i = parseInt(inp.dataset.logo, 10);
const p = e.target.files[0] && filePath(e.target.files[0]);
if (p) send({ type: 'logo', index: i, path: p });
inp.value = '';
}));
document.querySelectorAll('.logo-clear').forEach(b => b.addEventListener('click', () =>
send({ type: 'logo', index: parseInt(b.dataset.logo, 10), path: null })));
function bindLogoSliders(cls, valCls, type, fmt, xform) {
document.querySelectorAll('.' + cls).forEach(s => s.addEventListener('input', (e) => {
const i = parseInt(s.dataset.logo, 10);
const v = parseFloat(e.target.value);
s.closest('.logo-ctl').querySelector('.' + valCls).textContent = fmt(v);
send({ type, index: i, value: xform ? xform(v) : v });
}));
}
bindLogoSliders('logo-x', 'logo-x-val', 'logoX', v => v + '%');
bindLogoSliders('logo-y', 'logo-y-val', 'logoY', v => v + '%');
bindLogoSliders('logo-size', 'logo-size-val', 'logoSize', v => v + '%');
bindLogoSliders('logo-op', 'logo-op-val', 'logoOpacity', v => v + '%', v => v / 100);
// ---------------------------------------------------------------- drag & drop
window.addEventListener('dragover', (e) => {
e.preventDefault();
// Internal reorder drags (playlist/sequence) must not show the file-drop frame.
if (dragFrom >= 0 || seqDragFrom >= 0) return;
document.body.classList.add('dragover');
});
window.addEventListener('dragleave', (e) => { if (e.relatedTarget === null) document.body.classList.remove('dragover'); });
window.addEventListener('drop', (e) => {
e.preventDefault();
document.body.classList.remove('dragover');
const files = [...e.dataTransfer.files];
// Audio and video files both go into the playlist as tracks.
const mediaItems = files.filter(f => f.type.startsWith('audio/') || f.type.startsWith('video/'))
.map(f => ({ path: filePath(f), name: f.name, isVideo: f.type.startsWith('video/') }));
const imgPaths = files.filter(f => f.type.startsWith('image/')).map(filePath).filter(Boolean);
if (mediaItems.length) addTracks(mediaItems);
if (imgPaths.length) send({ type: 'addImages', paths: imgPaths });
});
// ---------------------------------------------------------------- keyboard
window.addEventListener('keydown', (e) => {
// Assigning a hotkey to a playlist track.
if (capturingFor >= 0) {
e.preventDefault();
if (e.key !== 'Escape' && e.key.length === 1) {
const k = e.key.toLowerCase();
playlist.forEach(t => { if (t.key === k) t.key = null; }); // keys are unique
playlist[capturingFor].key = k;
savePlaylistState();
}
capturingFor = -1;
renderPlaylist();
return;
}
// Assigning a hotkey to a sequence effect.
if (seqCapturing >= 0) {
e.preventDefault();
if (e.key !== 'Escape' && e.key.length === 1) {
const k = e.key.toLowerCase();
sequence.forEach(s => { if (s.key === k) s.key = null; });
sequence[seqCapturing].key = k;
}
seqCapturing = -1;
renderSequence();
return;
}
// Assigning a hotkey to a pad.
if (padCapturing >= 0) {
e.preventDefault();
if (e.key !== 'Escape' && e.key.length === 1) {
const k = e.key.toLowerCase();
pads.forEach(p => { if (p && p.key === k) p.key = null; }); // keys are unique
if (pads[padCapturing]) pads[padCapturing].key = k;
savePadState();
}
padCapturing = -1;
renderPads();
return;
}
if ((e.target.tagName === 'INPUT' && e.target.type === 'text') || e.target.tagName === 'TEXTAREA') return;
const lk = e.key.toLowerCase();
// Track hotkeys take priority, then effect hotkeys.
const hk = playlist.findIndex(t => t.key === lk);
if (hk >= 0) { playIndex(hk); return; }
const ek = sequence.findIndex(s => s.key === lk);
if (ek >= 0) { applySeqIndex(ek); return; }
const pk = pads.findIndex(p => p && p.key === lk);
if (pk >= 0) { playPad(pk); return; }
// Number keys 1-9 apply the first nine sequence slots.
if (e.key >= '1' && e.key <= '9') { applySeqIndex(parseInt(e.key, 10) - 1); return; }
switch (lk) {
case 'f': djv.toggleOutputFullscreen(); break;
case 'o': $('#tracks-input').click(); break;
case 'g': $('#image-input').click(); break;
case 'arrowright': send({ type: 'imgNext' }); break;
case 'arrowleft': send({ type: 'imgPrev' }); break;
case 't': { const cb = $('#ticker-on'); cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); break; }
case ' ': e.preventDefault(); togglePlayPause(); break;
}
});
// ---------------------------------------------------------------- reports in
djv.onReport((m) => {
switch (m.type) {
case 'autoVj':
autoVjOn = !!m.on;
$('#auto-vj').classList.toggle('vj-on', autoVjOn);
$('#auto-vj').textContent = autoVjOn ? '🤖 Auto VJ ●' : '🤖 Auto VJ';
if (autoVjOn && m.name) {
$('#scene-name').textContent = m.name + (m.bpm ? ' · ' + m.bpm + ' BPM' : '');
}
break;
case 'glbClips': renderGlbClips(m.names); break;
case 'auxReady': {
// An aux window finished loading: (re)send its configuration, then any
// scene effect currently aimed at it (it may be the reason the window
// was just auto-activated).
auxSendCfg(m.displayId);
const sc = lastScene.effect && typeof lastScene.effect === 'object'
? lastScene.effect[String(m.displayId)] : null;
if (sc && EFFECTS.list[sc.effectIndex]) {
send({ type: 'auxFx', displayId: m.displayId, effectIndex: sc.effectIndex });
}
break;
}
case 'auxClosed':
if (auxCfg[m.displayId]) { auxCfg[m.displayId].on = false; auxSave(); }
refreshDisplays();
break;
case 'displaysChanged':
// Monitor plugged/unplugged: refresh pickers and drop stale aux flags.
refreshDisplays().then(() => auxSyncAll());
break;
case 'mapZones':
// corner edits made on the output: adopt and persist
mapCfg.zones = m.zones || [];
mapSave();
renderMapZones();
break;
case 'devices': {
// The output reports its devices once at startup: good moment to push
// config the (possibly recreated) output window doesn't have yet.
if (ledCfg.on) ledSend();
glbSend();
mapSendAll();
recGuideSend();
const sel = $('#device-select');
const cur = sel.value;
sel.innerHTML = '';
m.list.forEach(d => {
const o = document.createElement('option');
o.value = d.deviceId;
o.textContent = d.label || 'Input audio';
sel.appendChild(o);
});
sel.value = cur;
break;
}
case 'outputs': {
const sel = $('#output-select');
const cur = sel.value;
sel.innerHTML = '';
m.list.forEach(d => {
const o = document.createElement('option');
o.value = d.deviceId;
o.textContent = d.label || 'Uscita audio';
sel.appendChild(o);
});
sel.value = cur;
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) + '%';
$('#fps').textContent = m.fps.toFixed(0) + ' fps';
break;
case 'playState':
if (playbackOwner === 'pad') {
padPlaying = m.playing;
renderPads();
} else {
isPlaying = m.playing;
if (m.playing) ended = false;
$('#btn-play').disabled = false;
$('#btn-play').textContent = m.playing ? '⏸ Pausa' : '▶ Play';
renderPlaylist();
}
break;
case 'trackEnded':
if (playbackOwner === 'pad') {
padPlaying = false;
renderPads();
} else {
const endedTr = playlist[currentIndex];
const gap = (endedTr && endedTr.gap) || 0;
if (gap > 0) {
startGap(endedTr, gap); // intermission, then advance
} else if (!nextTrack()) {
// No next track: reset to a stopped state so the button shows ▶ again.
isPlaying = false;
ended = true;
$('#btn-play').textContent = '▶ Play';
renderPlaylist();
}
}
break;
case 'beat':
// Auto-cycle effects on the beat (with a small guard against double-fires).
if (autoCycle && onBeat) {
const now = Date.now();
if (now - lastBeatAdvance > 250) { lastBeatAdvance = now; nextInSequence(); }
}
break;
case 'durations':
m.list.forEach(d => { durations[d.path] = d.duration; });
renderPlaylist(); renderPads();
break;
case 'progress':
if (segTimer) break; // a visual segment (interlude/gap) drives time itself
playCur = m.currentTime; playDur = m.duration;
if (playbackOwner === 'pad') { renderPads(); }
else { advanceCues(playCur); renderPlaylist(); }
break;
case 'recState':
recOn = m.recording;
$('#btn-rec').disabled = false;
$('#rec-dot').className = recOn ? 'on' : 'off';
if (recOn) {
$('#btn-rec').textContent = '⏹ Ferma e salva MP4';
$('#rec-label').textContent = 'Registrazione…';
recStartedAt = Date.now(); recTick();
stopRecTimer(); recTimer = setInterval(recTick, 1000);
} else {
$('#btn-rec').textContent = '🔴 Avvia registrazione';
stopRecTimer();
}
break;
case 'recSaved':
$('#rec-label').textContent = 'Salvato ✓';
$('#btn-rec').disabled = false;
break;
case 'recError':
recOn = false; stopRecTimer();
$('#rec-dot').className = 'off';
$('#btn-rec').disabled = false;
$('#btn-rec').textContent = '🔴 Avvia registrazione';
$('#rec-label').textContent = 'Errore: ' + m.message;
console.warn('rec error:', m.message);
break;
case 'fileLoaded':
$('#btn-play').disabled = false;
break;
case 'error':
console.warn('output error:', m.message);
break;
}
});
// ---------------------------------------------------------------- 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();
applyEffect(currentEffect); // push the starting effect to the output window
refreshDisplays().then(() => auxSyncAll()); // recreate saved aux outputs too
window.addEventListener('focus', refreshDisplays);
send({ type: 'refreshDevices' });