Mobile: timed per-track scenes + visual interludes on the remote
Port the Mac app's scene system to the phone control. Each queue entry is now an independent clone carrying a `cues` list of timed elements (effect / text / image), each with its own appearance time and visible duration; the three channels are driven independently from playback progress (advanceCues). Add visual-only "Intermezzo" queue items (silence audio, run their scene for a set duration) and make the inter-track gap keep the scene running. New mobile scene editor lives in the queue card detail (⚙). The queue (with scenes) is persisted via saveState so it survives a phone reload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
75627242fe
commit
ec4aecaf7c
3 files changed
+312
-22
No files matched your search
@@ -66,6 +66,36 @@ textarea { width: 100%; min-height: 90px; resize: vertical; }
|
||||
.qgap { display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 11px; opacity: .85; }
|
||||
.qgap input { width: 56px; text-align: center; padding: 6px 4px; }
|
||||
|
||||
/* ---- Scene editor (timed elements) ---- */
|
||||
.lbl .mini { float: right; font-size: 11px; padding: 4px 9px; border-radius: 8px; text-transform: none; letter-spacing: 0; opacity: 1; }
|
||||
.scbadge { font-style: normal; opacity: .85; margin-left: 2px; }
|
||||
.scene { margin-top: 10px; border-top: 1px dashed var(--line); padding-top: 9px; }
|
||||
.sc-title { font-size: 11px; opacity: .8; margin-bottom: 7px; }
|
||||
.sc-title small { display: block; opacity: .6; font-size: 10px; }
|
||||
.sc-empty { font-size: 11px; opacity: .55; padding: 6px 0; }
|
||||
.cue { background: rgba(255,255,255,.04); border: 1px solid var(--line); border-radius: 9px; padding: 7px; margin-bottom: 7px; }
|
||||
.cue-effect { border-left: 3px solid #8cb6ff; }
|
||||
.cue-text { border-left: 3px solid #ffd479; }
|
||||
.cue-image { border-left: 3px solid #9be6a0; }
|
||||
.cue-head { display: flex; align-items: center; gap: 5px; font-size: 11px; flex-wrap: wrap; }
|
||||
.cue-head > span:first-child { flex: 1; min-width: 70px; }
|
||||
.cue-head .cue-at { opacity: .5; }
|
||||
.cue-head .cue-time { width: 52px; text-align: center; padding: 5px 3px; }
|
||||
.cue-head .cue-dur { width: 46px; text-align: center; padding: 5px 3px; }
|
||||
.cue-head .cue-del { padding: 5px 9px; }
|
||||
.cue-body { margin-top: 7px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.cue-body input.cue-text { width: 100%; }
|
||||
.cue-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.cue-row > select { flex: 1; min-width: 0; }
|
||||
.cue-row > span { font-size: 11px; opacity: .7; }
|
||||
.cue-row input[type=range] { flex: 1; }
|
||||
.cue-eff-name, .cue-name { font-size: 11px; opacity: .7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cue-row .chk { display: flex; align-items: center; gap: 3px; font-size: 11px; flex: 0 0 auto; }
|
||||
.cue-row .chk input { width: auto; }
|
||||
.sc-foot { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.sc-foot button { flex: 1; padding: 9px 6px; font-size: 14px; }
|
||||
.sc-foot .sc-clear { flex: 0 0 auto; }
|
||||
|
||||
.nowbar { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 12px; margin-bottom: 12px; }
|
||||
.nowbar.hidden { display: none; }
|
||||
.np-top { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
|
||||
|
||||
+3
-1
@@ -48,7 +48,9 @@
|
||||
<button id="np-next">⏭</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="lbl">Coda <span id="queue-count">0</span></label>
|
||||
<label class="lbl">Coda <span id="queue-count">0</span>
|
||||
<button id="add-interlude" class="mini" title="Aggiungi un intermezzo visivo (solo scena, niente audio)">➕ Intermezzo</button>
|
||||
</label>
|
||||
<div id="queue" class="list"></div>
|
||||
<label class="lbl">Libreria sul dispositivo</label>
|
||||
<div id="lib" class="list"></div>
|
||||
|
||||
+279
-21
@@ -64,9 +64,163 @@ const durCache = {}; // url -> seconds
|
||||
const wavePeaks = {}; // file(base) -> peaks[] | 'loading' | null
|
||||
const WAVE_BUCKETS = 400;
|
||||
let openDetail = -1; // queue index whose trim/waveform panel is open
|
||||
let gapTimer = null;
|
||||
const baseName = (u) => decodeURIComponent((u || '').split('/').pop());
|
||||
|
||||
// ---- Scene model (timed elements: effect / text / image) -------------------
|
||||
// Each queue item may carry a `cues` list of independent timed elements. The
|
||||
// three channels (effect, text, image) appear/disappear on their own schedule;
|
||||
// dur 0 = stays until the next element of the same type. Mirrors the Mac app.
|
||||
const TXT_DIRS = [['h', '➡️ Orizzontale'], ['vup', '⬆️ Vert. su'], ['vdown', '⬇️ Vert. giù'], ['sides', '↕️ Bordi']];
|
||||
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 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'],
|
||||
['Verdana, Geneva, sans-serif', 'Verdana'],
|
||||
];
|
||||
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' };
|
||||
const EL_ICON = { effect: '🌀', text: '🔤', image: '🖼' };
|
||||
const EL_LABEL = { effect: 'Effetto', text: 'Testo', image: 'Immagine' };
|
||||
|
||||
function optList(pairs, sel) {
|
||||
return pairs.map(([v, l]) => '<option value="' + String(v).replace(/"/g, '"') + '"' + (v === sel ? ' selected' : '') + '>' + l + '</option>').join('');
|
||||
}
|
||||
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;
|
||||
}
|
||||
function cueFamilyOptions(selFam) {
|
||||
let o = '<option value="-1"' + (selFam < 0 ? ' selected' : '') + '>— nessuno —</option>';
|
||||
EFFECTS.families.forEach((n, i) => { o += '<option value="' + i + '"' + (i === selFam ? ' selected' : '') + '>' + n + '</option>'; });
|
||||
return o;
|
||||
}
|
||||
function firstPresetOfFamily(fam) { return EFFECTS.list.findIndex((e) => e.family === fam); }
|
||||
function cuePresetOptions(fam, selIdx) {
|
||||
let o = '';
|
||||
EFFECTS.list.forEach((e, idx) => { if (e.family !== fam) return; o += '<option value="' + idx + '"' + (idx === selIdx ? ' selected' : '') + '>' + e.name + '</option>'; });
|
||||
return o;
|
||||
}
|
||||
function newEffectEl(time) { return { type: 'effect', time, effectIndex: EFFECTS.list.indexOf(curEffect), effectName: curEffect.name, dur: 0 }; }
|
||||
function newTextEl(time) { return { type: 'text', time, text: '', dir: 'h', fx: 'none', font: TXT_FONTS[0][0], size: 6, weight: true, color: '#ffffff', pos: 'bottom', speed: 1, dur: 0 }; }
|
||||
function newImageEl(time) { return { type: 'image', time, image: null, imageSize: 60, imagePos: 'center', dur: 0 }; }
|
||||
function hasScene(it) {
|
||||
return !!(it && it.cues && it.cues.some((c) =>
|
||||
(c.type === 'effect' && c.effectIndex != null) ||
|
||||
(c.type === 'text' && c.text && c.text.trim()) ||
|
||||
(c.type === 'image' && c.image)));
|
||||
}
|
||||
|
||||
// Runtime: re-evaluate the active element per channel and diff-apply it.
|
||||
let activeCues = [];
|
||||
let lastScene = { effect: null, text: null, image: null };
|
||||
let userTrackBlend = 'normal', sentTrackBlend = null;
|
||||
function startCues(it) {
|
||||
activeCues = (it.cues || []).slice().sort((a, b) => a.time - b.time);
|
||||
sentTrackBlend = null;
|
||||
// `undefined` (not null) forces advanceCues to APPLY each channel once, so a
|
||||
// stale text/image from the previous track is cleared even if nothing is active.
|
||||
lastScene = { effect: undefined, text: undefined, image: undefined };
|
||||
advanceCues(0);
|
||||
}
|
||||
function sceneActive(type, t) {
|
||||
let best = null;
|
||||
for (const el of activeCues) {
|
||||
if (el.type !== type || el.time > t) continue;
|
||||
if (el.dur && el.time + el.dur <= t) continue;
|
||||
if (!best || el.time >= best.time) best = el;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function advanceCues(t) {
|
||||
const ef = sceneActive('effect', t);
|
||||
if (ef !== lastScene.effect) { lastScene.effect = ef; if (ef && EFFECTS.list[ef.effectIndex]) applyEffect(EFFECTS.list[ef.effectIndex]); }
|
||||
const curIt = queue[qIndex];
|
||||
if (curIt && curIt.kind === 'video') {
|
||||
const effOn = !!(ef && ef.effectIndex != null && EFFECTS.list[ef.effectIndex]);
|
||||
const wantBlend = (effOn && userTrackBlend === 'normal') ? 'screen' : userTrackBlend;
|
||||
if (wantBlend !== sentTrackBlend) { sentTrackBlend = wantBlend; send({ type: 'trackVideoBlend', value: wantBlend }); }
|
||||
}
|
||||
const tx = sceneActive('text', t);
|
||||
if (tx !== lastScene.text) { lastScene.text = tx; applyTextEl(tx); }
|
||||
const im = sceneActive('image', t);
|
||||
if (im !== lastScene.image) { lastScene.image = im; applyImageEl(im); }
|
||||
}
|
||||
function applyTextEl(el) {
|
||||
const show = !!(el && el.text && el.text.trim());
|
||||
send({ type: 'tickerText', text: show ? el.text : '' });
|
||||
send({ type: 'tickerOn', on: show });
|
||||
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 item, re-apply at now.
|
||||
function refreshScenePreview(i) {
|
||||
if (i !== qIndex) return;
|
||||
activeCues = (queue[i].cues || []).slice().sort((a, b) => a.time - b.time);
|
||||
lastScene = { effect: undefined, text: undefined, image: undefined };
|
||||
advanceCues(curCur || 0);
|
||||
}
|
||||
|
||||
// ---- Timed visual segment (gap / interlude), drives the scene with no audio --
|
||||
let segTimer = null, segMode = null, segDur = 0, segBase = 0, segElapsed = 0, segT0 = 0, segPaused = false, segDone = null;
|
||||
function clearGap() { if (segTimer) { clearInterval(segTimer); segTimer = null; } segMode = null; segPaused = false; }
|
||||
function segTick() {
|
||||
if (segPaused) return;
|
||||
const elapsed = segElapsed + (Date.now() - segT0) / 1000;
|
||||
curCur = Math.min(segDur, elapsed); curDur = segDur;
|
||||
$('#np-time').textContent = fmt(curCur) + ' / ' + fmt(curDur);
|
||||
advanceCues(segBase + elapsed);
|
||||
if (elapsed >= segDur) { const d = segDone; clearGap(); if (d) d(); }
|
||||
}
|
||||
function runSeg(mode, durSecs, base, onDone) {
|
||||
clearGap();
|
||||
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(it, secs) { runSeg('gap', secs, curDur || curCur || 0, () => advanceQueue()); }
|
||||
function playInterlude(it) {
|
||||
send({ type: 'playSilence' });
|
||||
startCues(it);
|
||||
const dur = Math.max(1, it.duration || 15);
|
||||
runSeg('interlude', dur, 0, () => { if (it.gap > 0) startGap(it, it.gap); else advanceQueue(); });
|
||||
}
|
||||
|
||||
// Each queue entry is an independent CLONE so its trim/gap/scene don't leak
|
||||
// back into the shared library object (or other queue rows of the same file).
|
||||
function enqueue(it) {
|
||||
const q = Object.assign({}, it, {
|
||||
start: it.start || 0, end: it.end || 0, gap: it.gap || 0,
|
||||
cues: (it.cues || []).map((c) => Object.assign({}, c))
|
||||
});
|
||||
queue.push(q); saveQueue(); return q;
|
||||
}
|
||||
function makeInterlude() {
|
||||
return { name: 'Intermezzo', url: '', kind: 'interlude', isInterlude: true, duration: 15, gap: 0, cues: [] };
|
||||
}
|
||||
let _saveQT = null;
|
||||
function saveQueue() { clearTimeout(_saveQT); _saveQT = setTimeout(() => djv.saveState('queue', queue), 400); }
|
||||
|
||||
function probeDur(url) { if (url && !(url in durCache)) send({ type: 'probeDurations', paths: [url] }); }
|
||||
function ensureWave(it) {
|
||||
const file = baseName(it.url);
|
||||
@@ -124,11 +278,10 @@ function wireWave(canvas, it, i) {
|
||||
canvas.addEventListener('pointerup', end);
|
||||
canvas.addEventListener('pointercancel', () => { mode = null; });
|
||||
}
|
||||
function clearGap() { if (gapTimer) { clearTimeout(gapTimer); gapTimer = null; } }
|
||||
function advanceQueue() { if (qIndex + 1 < queue.length) playAt(qIndex + 1); else { playing = false; $('#np-play').textContent = '▶'; } }
|
||||
function afterTrackEnd() {
|
||||
const it = queue[qIndex];
|
||||
if (it && it.gap > 0) gapTimer = setTimeout(() => { gapTimer = null; advanceQueue(); }, it.gap * 1000);
|
||||
if (it && it.gap > 0) startGap(it, it.gap); // gap: scene keeps running with no audio
|
||||
else advanceQueue();
|
||||
}
|
||||
function updateOpenDetail() {
|
||||
@@ -142,7 +295,7 @@ function updateOpenDetail() {
|
||||
det.querySelector('.tt-rem').textContent = '⧗ -' + fmt(Math.max(0, tot - el));
|
||||
}
|
||||
function loadLibrary() { djv.listMedia().then((items) => { library = items; renderLib(); }); }
|
||||
function mediaIcon(k) { return k === 'video' ? '🎞 ' : (k === 'image' ? '🖼 ' : '🎵 '); }
|
||||
function mediaIcon(k) { return k === 'video' ? '🎞 ' : (k === 'image' ? '🖼 ' : (k === 'interlude' ? '✨ ' : '🎵 ')); }
|
||||
function renderLib() {
|
||||
const el = $('#lib'); el.innerHTML = '';
|
||||
if (!library.length) { el.innerHTML = '<div class="empty">Nessun file. Caricane con ⬆️ o copiali nella cartella <b>media/</b> del dispositivo.</div>'; return; }
|
||||
@@ -150,12 +303,12 @@ function renderLib() {
|
||||
const d = document.createElement('div'); d.className = 'item';
|
||||
d.innerHTML = '<span>' + mediaIcon(it.kind) + it.name + '</span>';
|
||||
const add = document.createElement('button'); add.textContent = '+'; add.title = 'In coda';
|
||||
add.addEventListener('click', (ev) => { ev.stopPropagation(); queue.push(it); renderQueue(); });
|
||||
add.addEventListener('click', (ev) => { ev.stopPropagation(); enqueue(it); renderQueue(); });
|
||||
d.appendChild(add);
|
||||
d.addEventListener('click', () => {
|
||||
if (assignPad >= 0) { assignToPad(assignPad, it); assignPad = -1; switchTab('pad'); return; }
|
||||
if (it.kind === 'image') { send({ type: 'sceneImage', path: it.url, size: 60, x: 50, y: 50 }); return; }
|
||||
queue.push(it); playAt(queue.length - 1);
|
||||
enqueue(it); playAt(queue.length - 1);
|
||||
});
|
||||
el.appendChild(d);
|
||||
});
|
||||
@@ -166,24 +319,27 @@ function renderQueue() {
|
||||
queue.forEach((it, i) => {
|
||||
const card = document.createElement('div'); card.className = 'qcard' + (i === qIndex ? ' active' : '');
|
||||
const head = document.createElement('div'); head.className = 'item';
|
||||
head.innerHTML = '<span>' + mediaIcon(it.kind) + it.name + '</span>';
|
||||
head.innerHTML = '<span>' + mediaIcon(it.kind) + it.name + (hasScene(it) ? ' <em class="scbadge">🎬</em>' : '') + '</span>';
|
||||
head.addEventListener('click', () => playAt(i));
|
||||
if (it.kind !== 'image') {
|
||||
const cog = document.createElement('button'); cog.textContent = '⚙'; cog.title = 'Trim / forma d\'onda';
|
||||
const cog = document.createElement('button'); cog.textContent = '⚙'; cog.title = 'Trim / scena';
|
||||
cog.addEventListener('click', (ev) => { ev.stopPropagation(); openDetail = openDetail === i ? -1 : i; renderQueue(); });
|
||||
head.appendChild(cog);
|
||||
}
|
||||
if (it.kind !== 'image') head.appendChild(cog);
|
||||
const del = document.createElement('button'); del.textContent = '✕';
|
||||
del.addEventListener('click', (ev) => { ev.stopPropagation(); queue.splice(i, 1); if (i < qIndex) qIndex--; if (openDetail === i) openDetail = -1; renderQueue(); });
|
||||
del.addEventListener('click', (ev) => { ev.stopPropagation(); queue.splice(i, 1); if (i < qIndex) qIndex--; if (openDetail === i) openDetail = -1; saveQueue(); renderQueue(); });
|
||||
head.appendChild(del);
|
||||
card.appendChild(head);
|
||||
|
||||
if (openDetail === i && it.kind !== 'image') {
|
||||
const det = document.createElement('div'); det.className = 'qdetail';
|
||||
if (it.isInterlude) {
|
||||
det.innerHTML = '<div class="qgap">✨ Durata intermezzo <input class="segv" type="number" min="1" step="1" value="' + (it.duration || 15) + '"> s</div>';
|
||||
card.appendChild(det);
|
||||
det.querySelector('.segv').addEventListener('change', (e) => { it.duration = Math.max(1, parseFloat(e.target.value) || 15); saveQueue(); });
|
||||
} else {
|
||||
probeDur(it.url); ensureWave(it);
|
||||
const dur = durCache[it.url] || (i === qIndex ? curDur : 0);
|
||||
const elp = i === qIndex ? Math.max(0, curCur - (it.start || 0)) : 0;
|
||||
const tot = Math.max(0, (it.end > 0 ? it.end : dur) - (it.start || 0));
|
||||
const det = document.createElement('div'); det.className = 'qdetail';
|
||||
det.innerHTML =
|
||||
'<div class="qtimes"><span class="tt-el">▶ ' + fmt(elp) + '</span>' +
|
||||
'<span class="tt-tot">⏱ ' + fmt(tot) + '</span>' +
|
||||
@@ -198,27 +354,126 @@ function renderQueue() {
|
||||
card.appendChild(det);
|
||||
const canvas = det.querySelector('.wave');
|
||||
drawWave(canvas, it, i === qIndex); wireWave(canvas, it, i);
|
||||
det.querySelector('.here-s').addEventListener('click', () => { if (i === qIndex) { it.start = Math.max(0, curCur); send({ type: 'setTrim', start: it.start, end: it.end || 0 }); renderQueue(); } });
|
||||
det.querySelector('.here-e').addEventListener('click', () => { if (i === qIndex) { it.end = Math.max(0.5, curCur); send({ type: 'setTrim', start: it.start || 0, end: it.end }); renderQueue(); } });
|
||||
det.querySelector('.rst').addEventListener('click', () => { it.start = 0; it.end = 0; if (i === qIndex) send({ type: 'setTrim', start: 0, end: 0 }); renderQueue(); });
|
||||
det.querySelector('.gapv').addEventListener('change', (e) => { it.gap = Math.max(0, parseFloat(e.target.value) || 0); });
|
||||
det.querySelector('.here-s').addEventListener('click', () => { if (i === qIndex) { it.start = Math.max(0, curCur); send({ type: 'setTrim', start: it.start, end: it.end || 0 }); saveQueue(); renderQueue(); } });
|
||||
det.querySelector('.here-e').addEventListener('click', () => { if (i === qIndex) { it.end = Math.max(0.5, curCur); send({ type: 'setTrim', start: it.start || 0, end: it.end }); saveQueue(); renderQueue(); } });
|
||||
det.querySelector('.rst').addEventListener('click', () => { it.start = 0; it.end = 0; if (i === qIndex) send({ type: 'setTrim', start: 0, end: 0 }); saveQueue(); renderQueue(); });
|
||||
det.querySelector('.gapv').addEventListener('change', (e) => { it.gap = Math.max(0, parseFloat(e.target.value) || 0); saveQueue(); });
|
||||
}
|
||||
det.appendChild(buildScene(it, i));
|
||||
}
|
||||
el.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile scene editor: a list of timed elements (effect / text / image), each
|
||||
// with its own appearance time and visible duration. Appended into the detail.
|
||||
function buildScene(it, i) {
|
||||
const cues = it.cues = (it.cues || []); cues.sort((a, b) => a.time - b.time);
|
||||
const wrap = document.createElement('div'); wrap.className = 'scene';
|
||||
const imgOpts = () => library.filter((x) => x.kind === 'image')
|
||||
.map((x) => '<option value="' + x.url + '">' + x.name + '</option>').join('');
|
||||
let html = '<div class="sc-title">🎬 Scena a tempo <small>ogni elemento appare al suo tempo · durata 0 = resta</small></div>';
|
||||
if (!cues.length) html += '<div class="sc-empty">Nessun elemento. Aggiungi effetto / testo / immagine qui sotto.</div>';
|
||||
cues.forEach((c, ci) => {
|
||||
html += '<div class="cue cue-' + c.type + '" data-c="' + ci + '">' +
|
||||
'<div class="cue-head"><span>' + EL_ICON[c.type] + ' ' + EL_LABEL[c.type] + '</span>' +
|
||||
'<span class="cue-at">@</span><input class="cue-time" type="text" value="' + fmt(Math.floor(c.time)) + '" />' +
|
||||
'<span>⏱</span><input class="cue-dur" type="number" min="0" step="1" value="' + (c.dur || 0) + '" />' +
|
||||
'<button class="cue-del">✕</button></div>';
|
||||
if (c.type === 'effect') {
|
||||
const ce = (c.effectIndex != null && EFFECTS.list[c.effectIndex]) ? EFFECTS.list[c.effectIndex] : null;
|
||||
const cFam = ce ? ce.family : -1;
|
||||
html += '<div class="cue-body">' +
|
||||
'<div class="cue-row"><select class="cue-eff-fam">' + cueFamilyOptions(cFam) + '</select>' +
|
||||
'<select class="cue-eff-pre"' + (cFam < 0 ? ' disabled' : '') + '>' + cuePresetOptions(cFam, c.effectIndex) + '</select></div>' +
|
||||
'<div class="cue-row"><button class="cue-eff-cur">🎯 Usa corrente</button><span class="cue-eff-name">' + (ce ? ce.name : 'nessuno') + '</span></div></div>';
|
||||
} else if (c.type === 'text') {
|
||||
html += '<div class="cue-body"><input class="cue-text" placeholder="Testo da mostrare" />' +
|
||||
'<div class="cue-row"><select class="cue-tx-dir">' + optList(TXT_DIRS, c.dir) + '</select><select class="cue-tx-fx">' + optList(TXT_FX, c.fx) + '</select></div>' +
|
||||
'<div class="cue-row"><select class="cue-tx-font">' + optList(TXT_FONTS, c.font) + '</select><select class="cue-tx-pos">' + optList(TXT_POS, c.pos) + '</select></div>' +
|
||||
'<div class="cue-row"><span>Dim</span><input class="cue-tx-size" type="range" min="2" max="18" step="0.5" value="' + c.size + '" />' +
|
||||
'<label class="chk"><input class="cue-tx-bold" type="checkbox"' + (c.weight ? ' checked' : '') + '/>B</label>' +
|
||||
'<input class="cue-tx-color" type="color" value="' + c.color + '" /></div>' +
|
||||
'<div class="cue-row"><span>Vel</span><input class="cue-tx-speed" type="range" min="0.2" max="4" step="0.1" value="' + c.speed + '" /></div></div>';
|
||||
} else {
|
||||
html += '<div class="cue-body"><div class="cue-row"><select class="cue-img-sel"><option value="">— scegli immagine —</option>' + imgOpts() + '</select></div>' +
|
||||
'<div class="cue-row"><span class="cue-name">' + (c.image ? baseName(c.image) : 'nessuna') + '</span></div>' +
|
||||
'<div class="cue-row"><select class="cue-img-pos">' +
|
||||
Object.keys(IMG_POS_LABELS).map((p) => '<option value="' + p + '"' + ((c.imagePos || 'center') === p ? ' selected' : '') + '>' + IMG_POS_LABELS[p] + '</option>').join('') +
|
||||
'</select><span>Dim</span><input class="cue-img-size" type="range" min="10" max="100" step="1" value="' + (c.imageSize || 60) + '" /></div></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
html += '<div class="sc-foot"><button class="add-eff">➕🌀</button><button class="add-txt">➕🔤</button>' +
|
||||
'<button class="add-img">➕🖼</button><button class="sc-clear">🗑 Scena</button></div>';
|
||||
wrap.innerHTML = html;
|
||||
|
||||
const save = () => { saveQueue(); refreshScenePreview(i); renderQueue(); };
|
||||
const saveLive = () => { saveQueue(); refreshScenePreview(i); };
|
||||
wrap.querySelectorAll('.cue').forEach((cueEl) => {
|
||||
const ci = parseInt(cueEl.dataset.c, 10); const c = cues[ci];
|
||||
cueEl.querySelector('.cue-time').addEventListener('change', (e) => { c.time = parseTime(e.target.value); save(); });
|
||||
cueEl.querySelector('.cue-dur').addEventListener('change', (e) => { c.dur = Math.max(0, parseFloat(e.target.value) || 0); saveLive(); });
|
||||
cueEl.querySelector('.cue-del').addEventListener('click', () => { cues.splice(ci, 1); save(); });
|
||||
if (c.type === 'effect') {
|
||||
cueEl.querySelector('.cue-eff-fam').addEventListener('change', (e) => {
|
||||
const fam = parseInt(e.target.value, 10);
|
||||
if (fam < 0) { c.effectIndex = null; c.effectName = ''; }
|
||||
else { const idx = firstPresetOfFamily(fam); c.effectIndex = idx; c.effectName = idx >= 0 ? EFFECTS.list[idx].name : ''; }
|
||||
save();
|
||||
});
|
||||
cueEl.querySelector('.cue-eff-pre').addEventListener('change', (e) => {
|
||||
const idx = parseInt(e.target.value, 10);
|
||||
if (idx >= 0 && EFFECTS.list[idx]) { c.effectIndex = idx; c.effectName = EFFECTS.list[idx].name; save(); }
|
||||
});
|
||||
cueEl.querySelector('.cue-eff-cur').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(curEffect); c.effectName = curEffect.name; save(); });
|
||||
} 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 {
|
||||
const sel = cueEl.querySelector('.cue-img-sel'); sel.value = c.image || '';
|
||||
sel.addEventListener('change', (e) => { c.image = e.target.value || 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(); };
|
||||
wrap.querySelector('.add-eff').addEventListener('click', () => addEl(newEffectEl));
|
||||
wrap.querySelector('.add-txt').addEventListener('click', () => addEl(newTextEl));
|
||||
wrap.querySelector('.add-img').addEventListener('click', () => addEl(newImageEl));
|
||||
wrap.querySelector('.sc-clear').addEventListener('click', () => { it.cues = []; save(); });
|
||||
return wrap;
|
||||
}
|
||||
function playAt(i, fromTime) {
|
||||
if (i < 0 || i >= queue.length) return;
|
||||
clearGap();
|
||||
qIndex = i; const it = queue[i]; playing = true;
|
||||
$('#np-name').textContent = it.name; $('#np-play').textContent = '⏸';
|
||||
$('#nowbar').classList.remove('hidden');
|
||||
if (it.isInterlude) { renderQueue(); playInterlude(it); return; }
|
||||
probeDur(it.url);
|
||||
const startAt = fromTime != null ? Math.max(0, fromTime) : (it.start || 0);
|
||||
send({ type: it.kind === 'video' ? 'playVideoTrack' : 'playTrack', path: it.url, start: startAt, end: it.end || 0 });
|
||||
$('#np-name').textContent = it.name; $('#np-play').textContent = '⏸';
|
||||
$('#nowbar').classList.remove('hidden'); renderQueue();
|
||||
startCues(it); // drive the timed scene from playback progress
|
||||
renderQueue();
|
||||
}
|
||||
$('#np-next').addEventListener('click', () => { if (qIndex + 1 < queue.length) playAt(qIndex + 1); });
|
||||
$('#np-next').addEventListener('click', () => { if (qIndex + 1 < queue.length) playAt(qIndex + 1); else { clearGap(); advanceQueue(); } });
|
||||
$('#np-prev').addEventListener('click', () => { if (qIndex > 0) playAt(qIndex - 1); });
|
||||
$('#np-play').addEventListener('click', () => send({ type: 'togglePlay' }));
|
||||
$('#np-play').addEventListener('click', () => {
|
||||
if (segTimer) { // a gap/interlude segment is running: pause/resume it locally
|
||||
if (segPaused) resumeSeg(); else pauseSeg();
|
||||
$('#np-play').textContent = segPaused ? '▶' : '⏸';
|
||||
} else send({ type: 'togglePlay' });
|
||||
});
|
||||
$('#add-interlude').addEventListener('click', () => { queue.push(makeInterlude()); openDetail = queue.length - 1; saveQueue(); renderQueue(); });
|
||||
$('#seek').addEventListener('input', (e) => { if (curDur > 0) send({ type: 'seek', time: e.target.value / 1000 * curDur }); });
|
||||
$('#media-refresh').addEventListener('click', loadLibrary);
|
||||
$('#media-upload-btn').addEventListener('click', () => $('#media-upload').click());
|
||||
@@ -235,6 +490,7 @@ $('#media-upload').addEventListener('change', (e) => {
|
||||
.catch((err) => { reset('❌ Errore: ' + err.message); });
|
||||
});
|
||||
loadLibrary();
|
||||
djv.loadState('queue').then((s) => { if (Array.isArray(s) && s.length) { queue = s; renderQueue(); } });
|
||||
|
||||
// ---- Testo (ticker) --------------------------------------------------------
|
||||
const tk = (id) => $('#tk-' + id);
|
||||
@@ -304,9 +560,11 @@ function setMeter(id, v) { const e = $('#' + id); if (e) e.style.width = Math.mi
|
||||
djv.onReport((m) => {
|
||||
switch (m.type) {
|
||||
case 'progress':
|
||||
if (segTimer) break; // a gap/interlude segment drives time itself
|
||||
curCur = m.currentTime; curDur = m.duration;
|
||||
$('#np-time').textContent = fmt(curCur) + ' / ' + fmt(curDur);
|
||||
if (curDur > 0) $('#seek').value = Math.round(curCur / curDur * 1000);
|
||||
if (qIndex >= 0) advanceCues(curCur); // drive the timed scene
|
||||
updateOpenDetail();
|
||||
break;
|
||||
case 'durations':
|
||||
|
||||
Reference in new issue
Block a user