// 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];
function applyEffect(effect) {
currentEffect = effect;
send({ type: 'effect', effect });
$('#scene-name').textContent = effect.name;
renderLibrary();
}
// --- Library browser (hundreds of presets, filterable) ---
const familySel = $('#effect-family');
EFFECTS.families.forEach((name, i) => {
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 >= 0 && e.family !== fam) continue;
if (q && e.name.toLowerCase().indexOf(q) < 0) continue;
out.push(e);
if (out.length >= LIB_CAP) break;
}
return out;
}
function renderLibrary() {
const list = $('#effect-list');
const items = filteredEffects();
list.innerHTML = '';
items.forEach((e) => {
const row = document.createElement('div');
row.className = 'fx' + (e === currentEffect ? ' active' : '');
row.innerHTML = '' + e.name + '' +
'';
row.querySelector('.fx-name').addEventListener('click', () => applyEffect(e));
row.querySelector('.fx-add').addEventListener('click', () => addToSequence(e));
list.appendChild(row);
});
$('#effects-count').textContent = EFFECTS.count + ' preset' +
(items.length >= LIB_CAP ? ' · mostro i primi ' + LIB_CAP : '');
}
$('#effect-family').addEventListener('change', () => {
renderLibrary();
// Switching to a specific family applies its first preset, so the
// visualization changes category immediately (e.g. away from an SVG).
const fam = parseInt($('#effect-family').value, 10);
if (fam >= 0) {
const first = filteredEffects()[0];
if (first) applyEffect(first);
}
});
$('#effect-search').addEventListener('input', renderLibrary);
// --- SVG / image as a custom effect source ---
function applySvgFamily() {
const fam = EFFECTS.families.indexOf('SVG/Immagine');
const eff = EFFECTS.list.find(e => e.family === fam);
if (eff) applyEffect(eff);
}
function useSvg(dataUrl) {
send({ type: 'svg', dataUrl });
applySvgFamily();
}
const svgSelect = $('#svg-select');
let builtinSvgs = [];
(async () => {
builtinSvgs = (djv.listBuiltinSvgs ? await djv.listBuiltinSvgs() : []) || [];
builtinSvgs
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((s) => {
const o = document.createElement('option');
o.value = builtinSvgs.indexOf(s);
o.textContent = s.name;
svgSelect.appendChild(o);
});
})();
svgSelect.addEventListener('change', (e) => {
const i = parseInt(e.target.value, 10);
if (i >= 0 && builtinSvgs[i]) useSvg(builtinSvgs[i].dataUrl);
});
$('#btn-svg-load').addEventListener('click', () => $('#svg-input').click());
$('#svg-input').addEventListener('change', (e) => {
const f = e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => useSvg(r.result); // data: URL
r.readAsDataURL(f);
e.target.value = '';
});
// --- Effect sequence (playlist of effects with auto-cycle) ---
let sequence = []; // { effect, key }
let seqIndex = -1;
let seqCapturing = -1;
let seqDragFrom = -1;
let autoCycle = false, onBeat = false, shuffle = false, seqMs = 8000;
let autoTimer = null, lastBeatAdvance = 0;
function addToSequence(effect) { sequence.push({ effect, key: null }); renderSequence(); }
function applySeqIndex(i) {
if (i < 0 || i >= sequence.length) return;
seqIndex = i;
applyEffect(sequence[i].effect);
renderSequence();
}
function nextInSequence() {
if (!sequence.length) return;
const n = shuffle ? Math.floor(Math.random() * sequence.length) : (seqIndex + 1) % sequence.length;
applySeqIndex(n);
}
function removeSeq(i) {
sequence.splice(i, 1);
if (i === seqIndex) seqIndex = -1; else if (i < seqIndex) seqIndex--;
renderSequence();
}
function moveSeq(from, to) {
if (to < 0 || to >= sequence.length) return;
const [it] = sequence.splice(from, 1);
sequence.splice(to, 0, it);
if (seqIndex === from) seqIndex = to;
renderSequence();
}
function renderSequence() {
const ol = $('#effect-seq');
ol.innerHTML = '';
sequence.forEach((s, i) => {
const li = document.createElement('li');
li.className = 'track' + (i === seqIndex ? ' playing' : '');
li.draggable = true;
li.innerHTML =
'⠿' +
'' +
(i + 1) + '. ' + s.effect.name + '' +
'' +
'' +
'';
li.querySelector('.play-btn').addEventListener('click', () => applySeqIndex(i));
li.querySelector('.del-btn').addEventListener('click', () => removeSeq(i));
li.querySelector('.key-btn').addEventListener('click', () => {
seqCapturing = seqCapturing === i ? -1 : i; renderSequence();
});
li.addEventListener('dragstart', () => { seqDragFrom = i; li.classList.add('dragging'); });
li.addEventListener('dragend', () => { seqDragFrom = -1; li.classList.remove('dragging'); });
li.addEventListener('dragover', (e) => e.preventDefault());
li.addEventListener('drop', (e) => { e.preventDefault(); if (seqDragFrom >= 0 && seqDragFrom !== i) moveSeq(seqDragFrom, i); });
ol.appendChild(li);
});
$('#seq-count').textContent = sequence.length + ' in sequenza';
}
function restartAuto() {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
if (autoCycle && !onBeat) autoTimer = setInterval(nextInSequence, seqMs);
}
$('#btn-seq-add').addEventListener('click', () => addToSequence(currentEffect));
$('#btn-seq-clear').addEventListener('click', () => { sequence = []; seqIndex = -1; renderSequence(); });
$('#seq-auto').addEventListener('change', (e) => { autoCycle = e.target.checked; restartAuto(); });
$('#seq-beat').addEventListener('change', (e) => { onBeat = e.target.checked; restartAuto(); });
$('#seq-shuffle').addEventListener('change', (e) => { shuffle = e.target.checked; });
$('#seq-interval').addEventListener('input', (e) => {
seqMs = parseFloat(e.target.value) * 1000;
$('#seq-interval-val').textContent = parseFloat(e.target.value).toFixed(1) + 's';
restartAuto();
});
// ---------------------------------------------------------------- audio
function togglePlayPause() {
// If the track finished, restart it; otherwise pause/resume.
if (ended && currentIndex >= 0) playIndex(currentIndex);
else 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)
let playCur = 0, playDur = 0; // live progress of the currently playing track
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 savePlaylistState() {
if (djv.savePlaylist) {
djv.savePlaylist(playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [] })));
}
}
function hasScene(tr) {
return !!(tr.cues && tr.cues.some(c => c.effectIndex != null || (c.text && c.text.trim()) || 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;
}
// Apply one cue (effect optional; text and image always set so a timeline reads
// predictably — empty text / null image hide them).
function applyCue(c) {
if (!c) return;
if (c.effectIndex != null && EFFECTS.list[c.effectIndex]) applyEffect(EFFECTS.list[c.effectIndex]);
const showText = !!(c.text && c.text.trim());
send({ type: 'tickerText', text: c.text || '' });
send({ type: 'tickerOn', on: showText });
$('#ticker-text').value = c.text || '';
$('#ticker-on').checked = showText;
send({ type: 'sceneImage', path: c.image || null });
}
function startCues(tr) {
activeCues = (tr.cues || []).slice().sort((a, b) => a.time - b.time);
firedCue = -1;
advanceCues(0); // apply the base cue (time 0)
}
function advanceCues(t) {
while (firedCue + 1 < activeCues.length && t >= activeCues[firedCue + 1].time) {
firedCue++;
applyCue(activeCues[firedCue]);
}
}
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 }); });
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);
}
function playIndex(i) {
if (i < 0 || i >= playlist.length) return;
currentIndex = i;
isPlaying = true;
ended = false;
playbackOwner = 'playlist';
activePad = -1; padPlaying = false; renderPads();
send({ type: 'playTrack', path: playlist[i].path });
startCues(playlist[i]);
$('#btn-play').disabled = false;
$('#btn-play').textContent = '⏸ Pausa';
renderPlaylist();
}
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 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;
function renderPlaylist() {
const ol = $('#playlist');
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 = i === currentIndex;
const playIcon = (isCur && isPlaying) ? '⏸' : '▶';
let timeLabel = '';
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;
li.innerHTML =
'⠿' +
'' + tr.name + '' +
'' + timeLabel + '' +
'' +
'' +
'' +
'' +
(isCur ? '' : '');
// Clicking the name (re)starts the track from the beginning.
li.querySelector('.tname').addEventListener('click', () => playIndex(i));
// On the current track the button pauses/resumes/replays; on others it starts that track.
li.querySelector('.play-btn').addEventListener('click', () => {
if (i === currentIndex) togglePlayPause();
else playIndex(i);
});
li.querySelector('.del-btn').addEventListener('click', () => removeTrack(i));
li.querySelector('.key-btn').addEventListener('click', () => {
capturingFor = capturingFor === i ? -1 : i;
renderPlaylist();
});
li.querySelector('.scene-btn').addEventListener('click', () => {
sceneEditing = sceneEditing === i ? -1 : i;
renderPlaylist();
});
// Drag to reorder.
li.addEventListener('dragstart', () => { dragFrom = i; li.classList.add('dragging'); });
li.addEventListener('dragend', () => { dragFrom = -1; li.classList.remove('dragging'); });
li.addEventListener('dragover', (e) => e.preventDefault());
li.addEventListener('drop', (e) => {
e.preventDefault();
if (dragFrom >= 0 && dragFrom !== i) moveTrack(dragFrom, i);
});
ol.appendChild(li);
if (sceneEditing === i) {
const cues = tr.cues || (tr.cues = []);
if (cues.length === 0) cues.push({ time: 0, effectIndex: null, effectName: '', text: '', image: null });
cues.sort((a, b) => a.time - b.time);
const ed = document.createElement('li');
ed.className = 'scene-editor';
let html = '
🎬 Scene a tempo del brano
';
cues.forEach((c, ci) => {
html += '' +
'
@' +
'' +
'' +
'' + (c.effectName || '—') + '' +
(ci > 0 ? '' : '') +
'
' +
'
' +
'
' +
'' +
'' + (c.image ? baseName(c.image) : 'nessuna') + '
' +
'
';
});
html += '' +
'
' +
'La cue @00:00 parte col brano; le altre scattano al loro tempo.
';
ed.innerHTML = html;
ed.querySelectorAll('.cue').forEach(cueEl => {
const ci = parseInt(cueEl.dataset.c, 10);
const c = cues[ci];
cueEl.querySelector('.cue-text').value = c.text || '';
cueEl.querySelector('.cue-time').addEventListener('change', (e) => { c.time = parseTime(e.target.value); savePlaylistState(); renderPlaylist(); });
cueEl.querySelector('.cue-eff').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(currentEffect); c.effectName = currentEffect.name; savePlaylistState(); renderPlaylist(); });
cueEl.querySelector('.cue-text').addEventListener('input', (e) => { c.text = e.target.value; li.querySelector('.scene-btn').classList.toggle('active', hasScene(tr)); savePlaylistState(); });
cueEl.querySelector('.cue-img').addEventListener('click', () => { sceneImgTarget = i; sceneCueTarget = ci; $('#scene-img-input').click(); });
cueEl.querySelector('.cue-img-clear').addEventListener('click', () => { c.image = null; savePlaylistState(); renderPlaylist(); });
const del = cueEl.querySelector('.cue-del');
if (del) del.addEventListener('click', () => { cues.splice(ci, 1); savePlaylistState(); renderPlaylist(); });
});
ed.querySelector('.cue-add').addEventListener('click', () => {
const lastT = cues.length ? cues[cues.length - 1].time : 0;
cues.push({ time: lastT + 30, effectIndex: null, effectName: '', text: '', image: null });
savePlaylistState(); renderPlaylist();
});
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; savePlaylistState(); renderPlaylist(); });
ol.appendChild(ed);
}
});
$('#playlist-count').textContent = playlist.length + (playlist.length === 1 ? ' brano' : ' brani');
}
$('#btn-add-tracks').addEventListener('click', () => $('#tracks-input').click());
$('#tracks-input').addEventListener('change', (e) => {
const items = [...e.target.files].map(f => ({ path: filePath(f), name: f.name }));
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];
tr.cues = tr.cues || [];
if (!tr.cues[sceneCueTarget]) sceneCueTarget = 0;
if (!tr.cues[sceneCueTarget]) tr.cues.push({ time: 0, effectIndex: null, effectName: '', text: '', image: null });
tr.cues[sceneCueTarget].image = p;
savePlaylistState(); 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; });
$('#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-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 }));
// ---------------------------------------------------------------- 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;
}
$('#btn-display').addEventListener('click', () => {
const id = parseInt($('#display-select').value, 10);
if (id) djv.moveOutputTo(id);
});
$('#btn-fullscreen').addEventListener('click', () => djv.toggleOutputFullscreen());
// ---------------------------------------------------------------- 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) {
send({ type: 'recStart' });
} 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 = '' + (i + 1) + '' +
(p ? '' + p.name.replace(/&/g, '&').replace(/' +
(remain ? '' + remain + '' : '') +
'' +
'' +
(isActive ? '' : '')
: '+');
padGrid.appendChild(d);
});
}
function assignPad(i, item) { pads[i] = item; renderPads(); savePadState(); if (item) probePaths([item.path]); }
function loadPadFile(i) { padLoadTarget = i; $('#pad-input').click(); }
function playPad(i) {
const p = pads[i];
if (!p) { loadPadFile(i); return; }
if (i === activePad && padPlaying) { // re-press the active pad = stop
send({ type: 'togglePlay' });
padPlaying = false; renderPads();
return;
}
playbackOwner = 'pad';
activePad = i; padPlaying = true;
activeCues = []; firedCue = -1; // 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(t => ({
path: t.path, name: t.name || baseName(t.path),
key: t.key || null, cues: Array.isArray(t.cues) ? t.cues : []
}));
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(); 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];
const audioItems = files.filter(f => f.type.startsWith('audio/'))
.map(f => ({ path: filePath(f), name: f.name }));
const imgPaths = files.filter(f => f.type.startsWith('image/')).map(filePath).filter(Boolean);
const videoFile = files.find(f => f.type.startsWith('video/'));
const videoPath = videoFile && filePath(videoFile);
if (audioItems.length) addTracks(audioItems);
if (imgPaths.length) send({ type: 'addImages', paths: imgPaths });
if (videoPath) send({ type: 'videoLoad', path: videoPath });
});
// ---------------------------------------------------------------- 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 'devices': {
const sel = $('#device-select');
const cur = sel.value;
sel.innerHTML = '';
m.list.forEach(d => {
const o = document.createElement('option');
o.value = d.deviceId;
o.textContent = d.label || 'Input audio';
sel.appendChild(o);
});
sel.value = cur;
break;
}
case 'outputs': {
const sel = $('#output-select');
const cur = sel.value;
sel.innerHTML = '';
m.list.forEach(d => {
const o = document.createElement('option');
o.value = d.deviceId;
o.textContent = d.label || 'Uscita audio';
sel.appendChild(o);
});
sel.value = cur;
break;
}
case 'meters':
$('#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 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':
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();
window.addEventListener('focus', refreshDisplays);
send({ type: 'refreshDevices' });