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