A cue aimed at a connected display whose aux window was off landed nowhere and the monitor stayed black. The cue now switches that output on (persisted, row updates in the panel) and 'auxReady' re-sends the scene effect once the freshly created window has loaded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2294 lines
103 KiB
JavaScript
2294 lines
103 KiB
JavaScript
// 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, '"') + '">' +
|
||
(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, '"') + '"' + (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, '"') + '">' + 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 });
|
||
mapSave(); mapSendZones(); renderMapZones();
|
||
}
|
||
function renderMapZones() {
|
||
const box = $('#map-zones');
|
||
box.innerHTML = '';
|
||
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); mapSave(); mapSendZones(); renderMapZones();
|
||
});
|
||
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 = '';
|
||
});
|
||
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;
|
||
// 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(); }
|
||
(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; } }
|
||
|
||
$('#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 });
|
||
} else {
|
||
const [w, h] = $('#rec-aspect').value.split('x').map(Number);
|
||
send({ type: 'recStop', w, h });
|
||
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, '&').replace(/</g, '<') + '</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();
|
||
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' });
|