Files
djaudiovisualizer/src/control.js
T
lucianoandClaude Opus 5.5 9af9d26b2b Mapping editor: snap to grid and to other zones' corners
'Aggancia' toggle + grid step (5%, 10%, 2.5%, 1/8, 1/16), persisted. While
dragging a corner or a whole zone the delta is shifted so the closest
moving point lands exactly on a grid line or on another zone's corner coordinate
(per axis, 9 css px capture radius) — adjacent LED panels butt together to
the pixel. Hold Alt to drag freely; keyboard nudge stays unsnapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc
2026-10-02 09:15:56 +02:00

2576 lines
115 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
const send = (msg) => 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 = '<span class="fx-name">' + e.name + '</span>' +
'<button class="fx-add" title="Aggiungi alla sequenza">➕</button>';
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 = '<span class="hint-mini">Carica un modello riggato per vedere le mosse.</span>';
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 = '<span class="hint-mini">I modelli caricati appariranno qui per il cambio rapido.</span>';
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 =
'<span class="grip">⠿</span>' +
'<span class="tname" title="' + s.effect.name.replace(/"/g, '&quot;') + '">' +
(i + 1) + '. ' + s.effect.name + '</span>' +
'<button class="key-btn" title="Tasto rapido">' +
(seqCapturing === i ? '…' : (s.key ? s.key.toUpperCase() : '⌨')) + '</button>' +
'<button class="play-btn" title="Applica">▶</button>' +
'<button class="del-btn" title="Rimuovi">✕</button>';
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)));
}
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]) => '<option value="' + String(v).replace(/"/g, '&quot;') + '"' + (v === sel ? ' selected' : '') + '>' + l + '</option>').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 }; }
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 };
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 };
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) {
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 }); }
}
const tx = sceneActive('text', t);
if (tx !== lastScene.text) { lastScene.text = tx; applyTextEl(tx); }
const im = sceneActive('image', t);
if (im !== lastScene.image) { lastScene.image = im; applyImageEl(im); }
}
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 };
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 <option> list of effect families for a cue's effect picker.
function cueFamilyOptions(selFam) {
let s = '<option value="-1"' + (selFam < 0 ? ' selected' : '') + '>— nessun effetto —</option>';
EFFECTS.families.forEach((name, i) => {
s += '<option value="' + i + '"' + (i === selFam ? ' selected' : '') + '>' + name + '</option>';
});
return s;
}
// Build the <option> list of presets within a family for a cue's effect picker.
function cuePresetOptions(fam, selIdx) {
if (fam < 0) return '<option value="">—</option>';
let s = '';
for (let i = 0; i < EFFECTS.list.length; i++) {
if (EFFECTS.list[i].family !== fam) continue;
s += '<option value="' + i + '"' + (i === selIdx ? ' selected' : '') + '>' + EFFECTS.list[i].name + '</option>';
}
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: '🖼' };
const EL_LABEL = { effect: 'Effetto', text: 'Testo', image: 'Immagine' };
// 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 = '<div class="se-title">🎬 Scena a tempo <small>— ogni elemento appare al suo tempo; durata 0 = resta fino al successivo</small></div>';
if (cues.length === 0) html += '<div class="se-empty">Nessun elemento. Aggiungi un effetto, un testo o un\'immagine qui sotto.</div>';
cues.forEach((c, ci) => {
html +=
'<div class="cue cue-' + c.type + '" data-c="' + ci + '">' +
'<div class="cue-head">' +
'<span class="cue-grip" title="Trascina per riordinare (il tempo si adatta)">⠿</span>' +
'<span class="cue-ico">' + EL_ICON[c.type] + '</span>' +
'<span class="cue-type">' + EL_LABEL[c.type] + '</span>' +
'<span class="cue-at">@</span>' +
'<input class="cue-time" type="text" value="' + fmtTime(Math.floor(c.time)) + '" title="Quando appare (mm:ss)" />' +
'<span class="cue-durl">⏱</span>' +
'<input class="cue-dur" type="number" min="0" step="1" value="' + (c.dur || 0) + '" title="Durata visibile in secondi (0 = resta fino al prossimo)" />' +
'<button class="cue-del" title="Rimuovi elemento">✕</button>' +
'</div>';
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 = '<option value="main"' + (tgt === 'main' ? ' selected' : '') + '>🖥 Principale</option>';
auxDisplays.forEach(ds => {
tgtOpts += '<option value="' + ds.id + '"' + (tgt === String(ds.id) ? ' selected' : '') + '>🖥 ' + ds.label + '</option>';
});
tgtOpts += '<option value="all"' + (tgt === 'all' ? ' selected' : '') + '>🖥🖥 Tutti gli schermi</option>';
if (tgt !== 'main' && tgt !== 'all' && !auxDisplays.some(ds => String(ds.id) === tgt)) {
tgtOpts += '<option value="' + tgt + '" selected>🖥 Schermo scollegato</option>';
}
html +=
'<div class="cue-body">' +
'<div class="cue-row">' +
'<select class="cue-eff-fam" title="Categoria">' + cueFamilyOptions(cFam) + '</select>' +
'<select class="cue-eff-pre" title="Preset"' + (cFam < 0 ? ' disabled' : '') + '>' + cuePresetOptions(cFam, c.effectIndex) + '</select>' +
'</div>' +
'<div class="cue-row">' +
'<span class="cue-dim">Su</span>' +
'<select class="cue-eff-target" title="Su quale schermo applicare l\'effetto">' + tgtOpts + '</select>' +
'</div>' +
'<div class="cue-row">' +
'<button class="cue-eff-cur" title="Usa l\'effetto visibile ora">🎯 Usa corrente</button>' +
'<span class="cue-eff-name">' + (ce ? ce.name : 'nessuno') + '</span>' +
'</div>' +
'</div>';
} else if (c.type === 'text') {
html +=
'<div class="cue-body">' +
'<input class="cue-text textfield" placeholder="Testo da mostrare" />' +
'<div class="cue-row">' +
'<select class="cue-tx-dir" title="Direzione">' + optList(TXT_DIRS, c.dir) + '</select>' +
'<select class="cue-tx-fx" title="Effetto">' + optList(TXT_FX, c.fx) + '</select>' +
'</div>' +
'<div class="cue-row">' +
'<select class="cue-tx-font" title="Font">' + optList(TXT_FONTS, c.font) + '</select>' +
'<select class="cue-tx-pos" title="Posizione">' + optList(TXT_POS, c.pos) + '</select>' +
'</div>' +
'<div class="cue-row">' +
'<span class="cue-dim">Dim</span><input class="cue-tx-size" type="range" min="2" max="18" step="0.5" value="' + c.size + '" />' +
'<label class="chk"><input class="cue-tx-bold" type="checkbox"' + (c.weight ? ' checked' : '') + ' /> B</label>' +
'<input class="cue-tx-color" type="color" value="' + c.color + '" />' +
'</div>' +
'<div class="cue-row"><span class="cue-dim">Vel</span><input class="cue-tx-speed" type="range" min="0.2" max="4" step="0.1" value="' + c.speed + '" /></div>' +
'</div>';
} else {
html +=
'<div class="cue-body">' +
'<div class="cue-row">' +
'<button class="cue-img">Carica…</button>' +
'<button class="cue-img-clear">✕</button>' +
'<span class="cue-name">' + (c.image ? baseName(c.image) : 'nessuna') + '</span>' +
'</div>' +
'<div class="cue-row"><select class="cue-img-pos">' +
Object.keys(IMG_POS_LABELS).map(p => '<option value="' + p + '"' + ((c.imagePos || 'center') === p ? ' selected' : '') + '>' + IMG_POS_LABELS[p] + '</option>').join('') +
'</select><span class="cue-dim">Dim</span><input class="cue-img-size" type="range" min="10" max="100" step="1" value="' + (c.imageSize || 60) + '" /></div>' +
'</div>';
}
html += '</div>';
});
html += '<div class="se-foot">' +
'<button class="add-eff" title="Aggiungi effetto">➕🌀</button>' +
'<button class="add-txt" title="Aggiungi testo">➕🔤</button>' +
'<button class="add-img" title="Aggiungi immagine">➕🖼</button>' +
'<button class="se-clear">🗑 Scena</button>' +
'</div>' +
(tr.isInterlude
? '<div class="se-gap" title="Durata dell\'intermezzo (solo scena, niente audio)">' +
'✨ Durata intermezzo <input class="cue-segdur" type="number" min="1" step="1" value="' + (tr.duration || 15) + '" /> s' +
'</div>'
: '<div class="se-gap" title="Pausa dopo questo brano: la scena continua per questi secondi prima di passare al prossimo">' +
'⏸ Pausa dopo <input class="cue-gap" type="number" min="0" step="1" value="' + (tr.gap || 0) + '" /> s ' +
'<small>la scena continua durante la pausa</small>' +
'</div>');
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(); });
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 : 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 {
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('.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) ? '<span class="gap-badge" title="Pausa di ' + tr.gap + 's dopo questo brano">⏸' + tr.gap + 's</span>' : '';
const icon = tr.isInterlude ? '✨ ' : (tr.isVideo ? '🎞 ' : '');
const body = (tr.isInterlude || !tr.path) ? '' :
'<div class="track-body">' +
'<div class="track-times">' +
'<span class="tt-el" title="Trascorso">▶ ' + fmtTime(Math.floor(elapsed)) + '</span>' +
'<span class="tt-tot" title="Durata totale">⏱ ' + fmtTime(Math.floor(total)) + '</span>' +
'<span class="tt-rem" title="Rimasto">⧗ -' + fmtTime(Math.ceil(remaining)) + '</span>' +
'</div>' +
'<canvas class="wave" width="' + WAVE_BUCKETS + '" height="46" title="Clic/trascina = sposta il punto di riproduzione · maniglie verde/rossa = inizio/fine"></canvas>' +
'<div class="track-trim">' +
'<span class="trim-lbl trim-lbl-s">Inizio</span>' +
'<input class="trim-start" type="text" value="' + fmtTime(Math.floor(tStart)) + '" title="Punto di inizio (mm:ss)" />' +
'<button class="trim-here-s" title="Imposta inizio al punto attuale">📍</button>' +
'<span class="trim-lbl trim-lbl-e">Fine</span>' +
'<input class="trim-end" type="text" value="' + (tr.end > 0 ? fmtTime(Math.floor(tr.end)) : '') + '" placeholder="—" title="Punto di fine (mm:ss, vuoto = fine brano)" />' +
'<button class="trim-here-e" title="Imposta fine al punto attuale">📍</button>' +
'<button class="trim-reset" title="Azzera inizio/fine">↺</button>' +
'</div>' +
'</div>';
li.innerHTML =
'<div class="track-head">' +
'<span class="grip">⠿</span>' +
'<span class="tname' + (tr.isInterlude ? ' is-interlude' : '') + '" title="Avvia / riavvia dall\'inizio — ' + tr.name.replace(/"/g, '&quot;') + '">' + icon + tr.name + gapBadge + '</span>' +
'<span class="ttime">' + timeLabel + '</span>' +
'<button class="key-btn" title="Assegna tasto rapido">' +
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
'<button class="play-btn" title="' + (isCur && isPlaying ? 'Pausa' : 'Avvia') + '">' + playIcon + '</button>' +
'<button class="scene-btn' + (hasScene(tr) ? ' active' : '') + '" title="Scena del brano (effetto/testo/immagine)">🎬</button>' +
'<button class="del-btn" title="Rimuovi">✕</button>' +
(isCur ? '<i class="tprog" style="width:' + prog.toFixed(1) + '%"></i>' : '') +
'</div>' + 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 = '<div class="hint-mini">Nessuna zona: aggiungine una qui sotto.</div>';
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 = '<span class="mz-src" title="' + (z.src.path || '') + '">' +
String(z.src.path || '').split(/[\\/]/).pop() + '</span>';
} 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 += '<option value="' + fi + '"' + (fi === cur.family ? ' selected' : '') + '>' + name + '</option>';
});
EFFECTS.list.forEach((e2, ei) => {
if (e2.family !== cur.family) return;
preOpts += '<option value="' + ei + '"' + (ei === z.src.effectIndex ? ' selected' : '') + '>' +
e2.name.replace(EFFECTS.families[e2.family] + ' · ', '') + '</option>';
});
mid = '<span class="mz-fx"><select class="mz-fam">' + famOpts + '</select>' +
'<select class="mz-pre">' + preOpts + '</select></span>';
}
row.innerHTML =
'<span class="mz-ico">' + (isImg ? '🖼' : (isFx ? '✨' : '🌀')) + '</span>' +
'<input class="mz-name" type="text" value="' + (z.name || 'Zona') + '" />' +
(mid !== undefined ? mid
: '<span class="mz-rect">Porzione ' +
'<input class="mz-r" data-k="0" type="number" min="0" max="100" value="' + Math.round(z.srcRect[0]*100) + '" />%,' +
'<input class="mz-r" data-k="1" type="number" min="0" max="100" value="' + Math.round(z.srcRect[1]*100) + '" />% ' +
'<input class="mz-r" data-k="2" type="number" min="1" max="100" value="' + Math.round(z.srcRect[2]*100) + '" />%×' +
'<input class="mz-r" data-k="3" type="number" min="1" max="100" value="' + Math.round(z.srcRect[3]*100) + '" />%</span>') +
'<input class="mz-op" type="range" min="0.1" max="1" step="0.05" value="' + z.opacity + '" title="Opacità" />' +
'<button class="mz-del" title="Elimina zona">✕</button>';
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: '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();
}
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();
ctx.lineWidth = sel ? 2 : 1.2;
ctx.strokeStyle = sel ? '#7fa8ff' : 'rgba(255,255,255,0.55)';
ctx.stroke();
// 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';
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]);
}
// 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) {
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 out;
}
(function mapEdInit() {
$('#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), ty = mapSnapTargets(1, mapSel);
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; }
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 = '<div class="hint-mini">Nessuno schermo aggiuntivo: qui compaiono i monitor oltre a Controlli e output principale.</div>';
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 += '<option value="' + v + '"' + (v === (c.mode || 'follow') ? ' selected' : '') + '>' + l + '</option>';
});
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 += '<option value="' + fi + '"' + (fi === cur.family ? ' selected' : '') + '>' + name + '</option>';
});
EFFECTS.list.forEach((e2, ei) => {
if (e2.family !== cur.family) return;
preOpts += '<option value="' + ei + '"' + (ei === c.effectIndex ? ' selected' : '') + '>' +
e2.name.replace(EFFECTS.families[e2.family] + ' · ', '') + '</option>';
});
extra = '<span class="aux-fx"><select class="aux-fam">' + famOpts + '</select>' +
'<select class="aux-pre">' + preOpts + '</select></span>';
} else if (c.mode === 'image') {
extra = '<button class="aux-img">Carica…</button><span class="aux-img-name">' +
(c.image ? c.image.split(/[\\/]/).pop() : 'nessuna') + '</span>';
}
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 =
'<div class="aux-text-row">' +
'<input class="atx-val" type="text" placeholder="Testo da mostrare su questo schermo…" />' +
'</div>' +
'<div class="aux-text-row">' +
'<select class="atx-dir" title="Direzione">' + optList(TXT_DIRS, tx.dir) + '</select>' +
'<select class="atx-fx" title="Effetto lettere">' + optList(TXT_FX, tx.fx) + '</select>' +
'<select class="atx-font" title="Font">' + optList(TXT_FONTS, tx.font) + '</select>' +
'<select class="atx-pos" title="Posizione">' + optList(TXT_POS, tx.pos) + '</select>' +
'</div>' +
'<div class="aux-text-row">' +
'<span class="atx-lbl">Dim</span><input class="atx-size" type="range" min="2" max="18" step="0.5" value="' + tx.size + '" />' +
'<span class="atx-lbl">Vel</span><input class="atx-speed" type="range" min="0.2" max="4" step="0.1" value="' + tx.speed + '" />' +
'<input class="atx-color" type="color" value="' + tx.color + '" />' +
'<label class="atx-b"><input type="checkbox" class="atx-bold"' + (tx.weight ? ' checked' : '') + ' /> B</label>' +
'</div>';
}
row.innerHTML =
'<label class="aux-chk"><input type="checkbox" class="aux-on"' + (c.on ? ' checked' : '') + ' /> ' +
d.label + ' <small>' + d.bounds.width + '×' + d.bounds.height + '</small></label>' +
'<select class="aux-mode">' + modeOpts + '</select>' + extra +
'<button class="aux-txt' + (tx.on ? ' active' : '') + '" title="Scritta su questo schermo (sopra qualsiasi contenuto)">🔤</button>' +
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 = '<span class="pad-num">' + (i + 1) + '</span>' +
(p ? '<span class="pad-name">' + p.name.replace(/&/g, '&amp;').replace(/</g, '&lt;') + '</span>' +
(remain ? '<span class="pad-time">' + remain + '</span>' : '') +
'<button class="pad-x" title="Svuota">✕</button>' +
'<button class="pad-key" title="Assegna tasto rapido">' +
(padCapturing === i ? '…' : (p.key ? p.key.toUpperCase() : '⌨')) + '</button>' +
(isActive ? '<i class="pad-prog" style="width:' + prog.toFixed(1) + '%"></i>' : '')
: '<span class="pad-plus">+</span>');
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 }; // 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 = '<option value="">— Input live (mic/line/BlackHole) —</option>';
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 = '<option value="">— Uscita predefinita —</option>';
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':
$('#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;
}
});
// ---------------------------------------------------------------- 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' });