Scenes: independent per-element timing (effect/text/image) with full inline options + duration; dedicated Playlist tab

Each scene element (effect, text, image) now has its own appearance time and
optional duration, with all styling options inline (no more capture-from-panel).
Live preview applies edits immediately on the playing track. Moved the playlist
and its video-render controls into a new Playlist tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.8 committed 2026-06-22 14:53:05 +02:00
1 parent dc37e4ab3f
commit 56230cb3b0
3 files changed
+256 -162

No files matched your search

+23 -19
View File
@@ -15,6 +15,7 @@
<nav id="tabs">
<button data-tab="audio" class="active">🎵 Audio</button>
<button data-tab="playlist">🎶 Playlist</button>
<button data-tab="effetti">🌀 Effetti</button>
<button data-tab="pad">🎹 Pad</button>
<button data-tab="immagini">🖼 Immagini</button>
@@ -44,6 +45,26 @@
<div class="meter"><span>HIGH</span><div class="bar"><i id="m-treble"></i></div></div>
</section>
<section>
<label>Reattività <span id="gain-val">1.0×</span></label>
<input type="range" id="gain" min="0" max="3" step="0.05" value="1" />
<label>Velocità <span id="speed-val">1.0×</span></label>
<input type="range" id="speed" min="0" max="3" step="0.05" value="1" />
</section>
<section>
<label>Equalizzatore visual</label>
<label class="band band-bass">Bassi <span id="bass-gain-val">1.0×</span></label>
<input type="range" id="bass-gain" min="0" max="3" step="0.05" value="1" />
<label class="band band-mid">Medi <span id="mid-gain-val">1.0×</span></label>
<input type="range" id="mid-gain" min="0" max="3" step="0.05" value="1" />
<label class="band band-treble">Alti <span id="treble-gain-val">1.0×</span></label>
<input type="range" id="treble-gain" min="0" max="3" step="0.05" value="1" />
</section>
</div>
<!-- ============ PLAYLIST ============ -->
<div class="tab-panel" data-tab="playlist">
<section class="span2">
<label>Playlist <span id="playlist-count">0 brani</span></label>
<div class="row">
@@ -58,10 +79,10 @@
<input type="file" id="tracks-input" accept="audio/*,video/*" multiple hidden />
<input type="file" id="scene-img-input" accept="image/*" hidden />
<ol id="playlist" class="playlist"></ol>
<div class="hint-mini">Clic sul <b>nome</b> = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 <b>scena</b> (effetto+testo+immagine applicati quando parte il brano)</div>
<div class="hint-mini">Clic sul <b>nome</b> = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 <b>scena</b> (effetto/testo/immagine, ognuno col proprio tempo)</div>
</section>
<section>
<section class="span2">
<label>Resa video in playlist</label>
<select id="tvid-fit">
<option value="contain">Adatta (bordi neri)</option>
@@ -79,23 +100,6 @@
<input type="range" id="tvid-op" min="0" max="100" step="1" value="100" />
<div class="hint-mini">Si applica al video riprodotto dalla playlist (per fonderlo con gli effetti).</div>
</section>
<section>
<label>Reattività <span id="gain-val">1.0×</span></label>
<input type="range" id="gain" min="0" max="3" step="0.05" value="1" />
<label>Velocità <span id="speed-val">1.0×</span></label>
<input type="range" id="speed" min="0" max="3" step="0.05" value="1" />
</section>
<section>
<label>Equalizzatore visual</label>
<label class="band band-bass">Bassi <span id="bass-gain-val">1.0×</span></label>
<input type="range" id="bass-gain" min="0" max="3" step="0.05" value="1" />
<label class="band band-mid">Medi <span id="mid-gain-val">1.0×</span></label>
<input type="range" id="mid-gain" min="0" max="3" step="0.05" value="1" />
<label class="band band-treble">Alti <span id="treble-gain-val">1.0×</span></label>
<input type="range" id="treble-gain" min="0" max="3" step="0.05" value="1" />
</section>
</div>
<!-- ============ EFFETTI ============ -->
+210 -130
View File
@@ -207,14 +207,17 @@ function serializePlaylist() {
return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo }));
}
function normalizeTrack(t) {
return { path: t.path, name: t.name || baseName(t.path), key: t.key || null, cues: Array.isArray(t.cues) ? t.cues : [], isVideo: !!t.isVideo };
return { path: t.path, name: t.name || baseName(t.path), key: t.key || null, cues: migrateCues(Array.isArray(t.cues) ? t.cues : []), isVideo: !!t.isVideo };
}
function savePlaylistState() {
if (djv.savePlaylist) djv.savePlaylist(serializePlaylist());
}
function hasScene(tr) {
return !!(tr.cues && tr.cues.some(c => c.effectIndex != null || (c.text && c.text.trim()) || c.image));
return !!(tr.cues && tr.cues.some(c =>
(c.type === 'effect' && c.effectIndex != null) ||
(c.type === 'text' && c.text && c.text.trim()) ||
(c.type === 'image' && c.image)));
}
function parseTime(str) {
str = String(str).trim();
@@ -222,69 +225,110 @@ function parseTime(str) {
return parseFloat(str) || 0;
}
// Snapshot the current Text-tab styling for a cue.
function captureTextStyle() {
return {
dir: $('#ticker-dir').value,
fx: $('#ticker-fx').value,
font: $('#ticker-font').value,
size: parseFloat($('#ticker-size').value),
weight: $('#ticker-bold').checked,
color: $('#ticker-color').value
};
}
const FX_NAMES = { none: 'Nessuno', updown: 'Su-giù', wave: 'Onda', zoom: 'Zoom', flash: 'Flash', rotate: 'Rotazione' };
function textStyleSummary(ts) {
if (!ts) return 'stile attuale del pannello';
const dir = ts.dir === 'h' ? 'Orizz.' : (ts.dir === 'vup' ? 'Vert.↑' : 'Vert.↓');
return dir + ' · ' + (FX_NAMES[ts.fx] || ts.fx) + ' · ' + ts.size + 'vh';
}
// ---- 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' };
// Apply one cue (effect optional; text and image always set so a timeline reads
// predictably — empty text / null image hide them).
function applyCue(c) {
if (!c) return;
if (c.effectIndex != null && EFFECTS.list[c.effectIndex]) applyEffect(EFFECTS.list[c.effectIndex]);
function optList(pairs, sel) {
return pairs.map(([v, l]) => '<option value="' + String(v).replace(/"/g, '&quot;') + '"' + (v === sel ? ' selected' : '') + '>' + l + '</option>').join('');
}
function newEffectEl(time) { return { type: 'effect', time, effectIndex: EFFECTS.list.indexOf(currentEffect), effectName: currentEffect.name, dur: 0 }; }
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 }; }
// Text + its full styling
const showText = !!(c.text && c.text.trim());
send({ type: 'tickerText', text: c.text || '' });
send({ type: 'tickerOn', on: showText });
$('#ticker-text').value = c.text || '';
$('#ticker-on').checked = showText;
const ts = c.textStyle;
if (ts) {
send({ type: 'tickerDir', value: ts.dir });
send({ type: 'tickerFx', value: ts.fx });
send({ type: 'tickerFont', value: ts.font });
send({ type: 'tickerSize', value: ts.size });
send({ type: 'tickerWeight', on: ts.weight });
send({ type: 'tickerColor', value: ts.color });
$('#ticker-dir').value = ts.dir; $('#ticker-fx').value = ts.fx;
$('#ticker-font').value = ts.font; $('#ticker-size').value = ts.size;
$('#ticker-size-val').textContent = (+ts.size).toFixed(1);
$('#ticker-bold').checked = ts.weight; $('#ticker-color').value = ts.color;
// 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 };
function startCues(tr) {
tr.cues = migrateCues(tr.cues || []);
activeCues = tr.cues.slice().sort((a, b) => a.time - b.time);
lastScene = { effect: null, text: null, image: null };
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;
}
// Image + size/position
if (c.image) {
const p = IMG_POS[c.imagePos || 'center'] || IMG_POS.center;
send({ type: 'sceneImage', path: c.image, size: c.imageSize || 60, x: p.x, y: p.y });
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 tx = sceneActive('text', t);
if (tx !== lastScene.text) { lastScene.text = tx; applyTextEl(tx); }
const im = sceneActive('image', t);
if (im !== lastScene.image) { lastScene.image = im; applyImageEl(im); }
}
function applyTextEl(el) {
const show = !!(el && el.text && el.text.trim());
send({ type: 'tickerText', text: show ? el.text : '' });
send({ type: 'tickerOn', on: show });
$('#ticker-text').value = show ? el.text : '';
$('#ticker-on').checked = show;
if (show) {
send({ type: 'tickerDir', value: el.dir });
send({ type: 'tickerFx', value: el.fx });
send({ type: 'tickerFont', value: el.font });
send({ type: 'tickerSize', value: el.size });
send({ type: 'tickerWeight', on: el.weight });
send({ type: 'tickerColor', value: el.color });
send({ type: 'tickerPos', pos: el.pos });
send({ type: 'tickerSpeed', mult: el.speed });
}
}
function applyImageEl(el) {
if (el && el.image) {
const p = IMG_POS[el.imagePos || 'center'] || IMG_POS.center;
send({ type: 'sceneImage', path: el.image, size: el.imageSize || 60, x: p.x, y: p.y });
} else {
send({ type: 'sceneImage', path: null });
}
}
function startCues(tr) {
activeCues = (tr.cues || []).slice().sort((a, b) => a.time - b.time);
firedCue = -1;
advanceCues(0); // apply the base cue (time 0)
}
function advanceCues(t) {
while (firedCue + 1 < activeCues.length && t >= activeCues[firedCue + 1].time) {
firedCue++;
applyCue(activeCues[firedCue]);
}
// 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: null, text: null, image: null };
advanceCues(playCur || 0);
}
const baseName = (p) => p.split('/').pop();
@@ -349,10 +393,6 @@ function moveTrack(from, to) {
let dragFrom = -1;
function defaultCue(time) {
return { time, effectIndex: null, effectName: '', text: '', textStyle: null, image: null, imageSize: 60, imagePos: 'center' };
}
// Build the <option> list of effect families for a cue's effect picker.
function cueFamilyOptions(selFam) {
let s = '<option value="-1"' + (selFam < 0 ? ' selected' : '') + '>— nessun effetto —</option>';
@@ -377,99 +417,142 @@ function firstPresetOfFamily(fam) {
return -1;
}
// Build the per-track scene editor (timeline of cues with text + image styling).
const EL_ICON = { effect: '🌀', text: '🔤', image: '🖼' };
const EL_LABEL = { effect: 'Effetto', text: 'Testo', image: 'Immagine' };
// Build the per-track scene editor: a list of independent timed elements
// (effect / text / image), each with its own appearance time and duration.
function buildSceneEditor(tr, i, li) {
const cues = tr.cues || (tr.cues = []);
if (cues.length === 0) cues.push(defaultCue(0));
const cues = tr.cues = migrateCues(tr.cues || []);
cues.sort((a, b) => a.time - b.time);
const ed = document.createElement('li');
ed.className = 'scene-editor';
const posLabels = { center: 'Centro', top: 'Alto', bottom: 'Basso', left: 'Sinistra', right: 'Destra' };
let html = '<div class="se-title">🎬 Scene a tempo <small>— la cue @00:00 parte col brano, le altre scattano al loro tempo</small></div>';
let html = '<div class="se-title">🎬 Scena a tempo <small>— ogni elemento appare al suo tempo; durata 0 = resta fino al successivo</small></div>';
if (cues.length === 0) html += '<div class="se-empty">Nessun elemento. Aggiungi un effetto, un testo o un\'immagine qui sotto.</div>';
cues.forEach((c, ci) => {
const ce = (c.effectIndex != null && EFFECTS.list[c.effectIndex]) ? EFFECTS.list[c.effectIndex] : null;
const cFam = ce ? ce.family : -1;
html +=
'<div class="cue" data-c="' + ci + '">' +
'<div class="cue cue-' + c.type + '" data-c="' + ci + '">' +
'<div class="cue-head">' +
'<span class="cue-ico">' + EL_ICON[c.type] + '</span>' +
'<span class="cue-type">' + EL_LABEL[c.type] + '</span>' +
'<span class="cue-at">@</span>' +
'<input class="cue-time" type="text" value="' + fmtTime(Math.floor(c.time)) + '" title="Tempo (mm:ss)" />' +
(ci > 0 ? '<button class="cue-del" title="Rimuovi cue">✕</button>' : '<span class="cue-base">base</span>') +
'</div>' +
'<div class="cue-block">' +
'<div class="cue-bh">🌀 Effetto</div>' +
'<input class="cue-time" type="text" value="' + fmtTime(Math.floor(c.time)) + '" title="Quando appare (mm:ss)" />' +
'<span class="cue-durl">⏱</span>' +
'<input class="cue-dur" type="number" min="0" step="1" value="' + (c.dur || 0) + '" title="Durata visibile in secondi (0 = resta fino al prossimo)" />' +
'<button class="cue-del" title="Rimuovi elemento">✕</button>' +
'</div>';
if (c.type === 'effect') {
const ce = (c.effectIndex != null && EFFECTS.list[c.effectIndex]) ? EFFECTS.list[c.effectIndex] : null;
const cFam = ce ? ce.family : -1;
html +=
'<div class="cue-body">' +
'<div class="cue-row">' +
'<select class="cue-eff-fam" title="Categoria effetto">' + cueFamilyOptions(cFam) + '</select>' +
'<select class="cue-eff-fam" title="Categoria">' + cueFamilyOptions(cFam) + '</select>' +
'<select class="cue-eff-pre" title="Preset"' + (cFam < 0 ? ' disabled' : '') + '>' + cuePresetOptions(cFam, c.effectIndex) + '</select>' +
'</div>' +
'<div class="cue-row">' +
'<button class="cue-eff-cur" title="Usa l\'effetto attualmente visibile">🎯 Usa corrente</button>' +
'<button class="cue-eff-cur" title="Usa l\'effetto visibile ora">🎯 Usa corrente</button>' +
'<span class="cue-eff-name">' + (ce ? ce.name : 'nessuno') + '</span>' +
'</div>' +
'</div>' +
'<div class="cue-block">' +
'<div class="cue-bh">🔤 Testo</div>' +
'<input class="cue-text textfield" placeholder="Testo (vuoto = nessuno)" />' +
'</div>';
} else if (c.type === 'text') {
html +=
'<div class="cue-body">' +
'<input class="cue-text textfield" placeholder="Testo da mostrare" />' +
'<div class="cue-row">' +
'<button class="cue-tstyle">🎨 Cattura stile</button>' +
'<span class="cue-tinfo">' + textStyleSummary(c.textStyle) + '</span>' +
(c.textStyle ? '<button class="cue-tstyle-clear" title="Stile predefinito">✕</button>' : '') +
'<select class="cue-tx-dir" title="Direzione">' + optList(TXT_DIRS, c.dir) + '</select>' +
'<select class="cue-tx-fx" title="Effetto">' + optList(TXT_FX, c.fx) + '</select>' +
'</div>' +
'</div>' +
'<div class="cue-block">' +
'<div class="cue-bh">🖼 Immagine</div>' +
'<div class="cue-row">' +
'<select class="cue-tx-font" title="Font">' + optList(TXT_FONTS, c.font) + '</select>' +
'<select class="cue-tx-pos" title="Posizione">' + optList(TXT_POS, c.pos) + '</select>' +
'</div>' +
'<div class="cue-row">' +
'<span class="cue-dim">Dim</span><input class="cue-tx-size" type="range" min="2" max="18" step="0.5" value="' + c.size + '" />' +
'<label class="chk"><input class="cue-tx-bold" type="checkbox"' + (c.weight ? ' checked' : '') + ' /> B</label>' +
'<input class="cue-tx-color" type="color" value="' + c.color + '" />' +
'</div>' +
'<div class="cue-row"><span class="cue-dim">Vel</span><input class="cue-tx-speed" type="range" min="0.2" max="4" step="0.1" value="' + c.speed + '" /></div>' +
'</div>';
} else {
html +=
'<div class="cue-body">' +
'<div class="cue-row">' +
'<button class="cue-img">Carica…</button>' +
'<button class="cue-img-clear">✕</button>' +
'<span class="cue-name">' + (c.image ? baseName(c.image) : 'nessuna') + '</span>' +
'</div>' +
(c.image ?
'<div class="cue-row"><select class="cue-img-pos">' +
Object.keys(posLabels).map(p => '<option value="' + p + '"' + ((c.imagePos || 'center') === p ? ' selected' : '') + '>' + posLabels[p] + '</option>').join('') +
'</select><span class="cue-dim">Dim</span><input class="cue-img-size" type="range" min="10" max="100" step="1" value="' + (c.imageSize || 60) + '" /></div>'
: '') +
'</div>' +
'</div>';
'<div class="cue-row"><select class="cue-img-pos">' +
Object.keys(IMG_POS_LABELS).map(p => '<option value="' + p + '"' + ((c.imagePos || 'center') === p ? ' selected' : '') + '>' + IMG_POS_LABELS[p] + '</option>').join('') +
'</select><span class="cue-dim">Dim</span><input class="cue-img-size" type="range" min="10" max="100" step="1" value="' + (c.imageSize || 60) + '" /></div>' +
'</div>';
}
html += '</div>';
});
html += '<div class="se-foot"><button class="cue-add">➕ Aggiungi cue</button><button class="se-clear">🗑 Rimuovi scena</button></div>';
html += '<div class="se-foot">' +
'<button class="add-eff" title="Aggiungi effetto">➕🌀</button>' +
'<button class="add-txt" title="Aggiungi testo">➕🔤</button>' +
'<button class="add-img" title="Aggiungi immagine">➕🖼</button>' +
'<button class="se-clear">🗑 Scena</button>' +
'</div>';
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)); };
ed.querySelectorAll('.cue').forEach(cueEl => {
const ci = parseInt(cueEl.dataset.c, 10);
const c = cues[ci];
cueEl.querySelector('.cue-text').value = c.text || '';
cueEl.querySelector('.cue-time').addEventListener('change', (e) => { c.time = parseTime(e.target.value); savePlaylistState(); renderPlaylist(); });
cueEl.querySelector('.cue-eff-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 : ''; }
savePlaylistState(); renderPlaylist();
});
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; savePlaylistState(); renderPlaylist(); }
});
cueEl.querySelector('.cue-eff-cur').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(currentEffect); c.effectName = currentEffect.name; savePlaylistState(); renderPlaylist(); });
cueEl.querySelector('.cue-text').addEventListener('input', (e) => { c.text = e.target.value; li.querySelector('.scene-btn').classList.toggle('active', hasScene(tr)); savePlaylistState(); });
cueEl.querySelector('.cue-tstyle').addEventListener('click', () => { c.textStyle = captureTextStyle(); savePlaylistState(); renderPlaylist(); });
const tsc = cueEl.querySelector('.cue-tstyle-clear');
if (tsc) tsc.addEventListener('click', () => { c.textStyle = null; savePlaylistState(); renderPlaylist(); });
cueEl.querySelector('.cue-img').addEventListener('click', () => { sceneImgTarget = i; sceneCueTarget = ci; $('#scene-img-input').click(); });
cueEl.querySelector('.cue-img-clear').addEventListener('click', () => { c.image = null; savePlaylistState(); renderPlaylist(); });
const posSel = cueEl.querySelector('.cue-img-pos');
if (posSel) posSel.addEventListener('change', (e) => { c.imagePos = e.target.value; savePlaylistState(); });
const sizeSl = cueEl.querySelector('.cue-img-size');
if (sizeSl) sizeSl.addEventListener('input', (e) => { c.imageSize = parseInt(e.target.value, 10); savePlaylistState(); });
const del = cueEl.querySelector('.cue-del');
if (del) del.addEventListener('click', () => { cues.splice(ci, 1); savePlaylistState(); renderPlaylist(); });
cueEl.querySelector('.cue-time').addEventListener('change', (e) => { c.time = parseTime(e.target.value); save(); });
cueEl.querySelector('.cue-dur').addEventListener('change', (e) => { c.dur = Math.max(0, parseFloat(e.target.value) || 0); saveLive(); });
cueEl.querySelector('.cue-del').addEventListener('click', () => { cues.splice(ci, 1); save(); });
if (c.type === 'effect') {
cueEl.querySelector('.cue-eff-fam').addEventListener('change', (e) => {
const fam = parseInt(e.target.value, 10);
if (fam < 0) { c.effectIndex = null; c.effectName = ''; }
else { const idx = firstPresetOfFamily(fam); c.effectIndex = idx; c.effectName = idx >= 0 ? EFFECTS.list[idx].name : ''; }
save();
});
cueEl.querySelector('.cue-eff-pre').addEventListener('change', (e) => {
const idx = parseInt(e.target.value, 10);
if (idx >= 0 && EFFECTS.list[idx]) { c.effectIndex = idx; c.effectName = EFFECTS.list[idx].name; save(); }
});
cueEl.querySelector('.cue-eff-cur').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(currentEffect); c.effectName = currentEffect.name; save(); });
} else if (c.type === 'text') {
const ti = cueEl.querySelector('.cue-text'); ti.value = c.text || '';
ti.addEventListener('input', (e) => { c.text = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-dir').addEventListener('change', (e) => { c.dir = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-fx').addEventListener('change', (e) => { c.fx = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-font').addEventListener('change', (e) => { c.font = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-pos').addEventListener('change', (e) => { c.pos = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-size').addEventListener('input', (e) => { c.size = parseFloat(e.target.value); saveLive(); });
cueEl.querySelector('.cue-tx-bold').addEventListener('change', (e) => { c.weight = e.target.checked; saveLive(); });
cueEl.querySelector('.cue-tx-color').addEventListener('input', (e) => { c.color = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-speed').addEventListener('input', (e) => { c.speed = parseFloat(e.target.value); saveLive(); });
} else {
cueEl.querySelector('.cue-img').addEventListener('click', () => { sceneImgTarget = i; sceneCueTarget = ci; $('#scene-img-input').click(); });
cueEl.querySelector('.cue-img-clear').addEventListener('click', () => { c.image = null; save(); });
cueEl.querySelector('.cue-img-pos').addEventListener('change', (e) => { c.imagePos = e.target.value; saveLive(); });
cueEl.querySelector('.cue-img-size').addEventListener('input', (e) => { c.imageSize = parseInt(e.target.value, 10); saveLive(); });
}
});
ed.querySelector('.cue-add').addEventListener('click', () => {
const addEl = (mk) => {
const lastT = cues.length ? cues[cues.length - 1].time : 0;
cues.push(defaultCue(lastT + 30));
savePlaylistState(); renderPlaylist();
});
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; savePlaylistState(); renderPlaylist(); });
cues.push(mk(cues.length ? lastT : 0));
save();
};
ed.querySelector('.add-eff').addEventListener('click', () => addEl(newEffectEl));
ed.querySelector('.add-txt').addEventListener('click', () => addEl(newTextEl));
ed.querySelector('.add-img').addEventListener('click', () => addEl(newImageEl));
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; save(); });
return ed;
}
@@ -543,11 +626,8 @@ $('#scene-img-input').addEventListener('change', (e) => {
const p = f && filePath(f);
if (p && sceneImgTarget >= 0 && playlist[sceneImgTarget]) {
const tr = playlist[sceneImgTarget];
tr.cues = tr.cues || [];
if (!tr.cues[sceneCueTarget]) sceneCueTarget = 0;
if (!tr.cues[sceneCueTarget]) tr.cues.push(defaultCue(0));
tr.cues[sceneCueTarget].image = p;
savePlaylistState(); renderPlaylist();
const el = (tr.cues || [])[sceneCueTarget];
if (el && el.type === 'image') { el.image = p; savePlaylistState(); refreshScenePreview(sceneImgTarget); renderPlaylist(); }
}
sceneImgTarget = -1; sceneCueTarget = -1; e.target.value = '';
});
@@ -747,7 +827,7 @@ function playPad(i) {
}
playbackOwner = 'pad';
activePad = i; padPlaying = true;
activeCues = []; firedCue = -1; // pads don't run playlist cues
activeCues = []; firedCue = -1; lastScene = { effect: null, text: null, image: null }; // pads don't run playlist cues
playCur = 0; playDur = durations[p.path] || 0;
// The playlist is no longer the active source.
currentIndex = -1; isPlaying = false; renderPlaylist();
+23 -13
View File
@@ -362,35 +362,45 @@ textarea.textarea-lg {
.scene-editor .se-title { font-size: 12px; font-weight: 600; opacity: 0.92; margin-bottom: 10px; }
.scene-editor .se-title small { font-weight: 400; opacity: 0.55; }
.scene-editor .se-empty { font-size: 11px; opacity: 0.5; padding: 6px 2px 10px; }
.scene-editor .cue {
background: rgba(0,0,0,0.22);
border: 1px solid rgba(255,255,255,0.08);
border-left: 3px solid rgba(255,255,255,0.18);
border-radius: 10px;
padding: 9px;
margin-bottom: 9px;
}
.scene-editor .cue-effect { border-left-color: #8fb6ff; }
.scene-editor .cue-text { border-left-color: #ffd27a; }
.scene-editor .cue-image { border-left-color: #9be6a0; }
.scene-editor .cue-head { display: flex; align-items: center; gap: 6px; }
.scene-editor .cue-at { opacity: 0.5; flex: 0 0 auto; }
.scene-editor .cue-time { width: 60px; flex: 0 0 auto; text-align: center; padding: 6px 4px; font-variant-numeric: tabular-nums; }
.scene-editor .cue-ico { flex: 0 0 auto; }
.scene-editor .cue-type { flex: 0 0 auto; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.85; }
.scene-editor .cue-at { opacity: 0.5; flex: 0 0 auto; margin-left: 4px; }
.scene-editor .cue-time { width: 58px; flex: 0 0 auto; text-align: center; padding: 6px 4px; font-variant-numeric: tabular-nums; }
.scene-editor .cue-durl { flex: 0 0 auto; opacity: 0.5; font-size: 11px; }
.scene-editor .cue-dur { width: 46px; flex: 0 0 auto; text-align: center; padding: 6px 4px; }
.scene-editor .cue-del { flex: 0 0 auto; color: #ff9a9a; margin-left: auto; }
.scene-editor .cue-body { margin-top: 8px; }
.scene-editor .cue-eff-fam, .scene-editor .cue-eff-pre { flex: 1; min-width: 0; }
.scene-editor .cue-eff-cur { flex: 0 0 auto; }
.scene-editor .cue-eff-name { flex: 1; min-width: 0; font-size: 10px; opacity: 0.72; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scene-editor .cue-base { flex: 0 0 auto; font-size: 9px; opacity: 0.45; padding: 0 4px; }
.scene-editor .cue-block { background: rgba(255,255,255,0.035); border-radius: 8px; padding: 7px 8px; margin-top: 7px; }
.scene-editor .cue-bh { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.55; margin-bottom: 5px; }
.scene-editor .cue-text { width: 100%; margin: 0 0 6px; }
.scene-editor .cue-text { width: 100%; margin: 0 0 7px; }
.scene-editor .cue-row { display: flex; align-items: center; gap: 6px; }
.scene-editor .cue-row + .cue-row { margin-top: 6px; }
.scene-editor .cue-tstyle, .scene-editor .cue-img,
.scene-editor .cue-img-clear, .scene-editor .cue-tstyle-clear { flex: 0 0 auto; }
.scene-editor .cue-tinfo, .scene-editor .cue-name { flex: 1; min-width: 0; font-size: 10px; opacity: 0.72; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scene-editor .cue-tx-dir, .scene-editor .cue-tx-fx,
.scene-editor .cue-tx-font, .scene-editor .cue-tx-pos { flex: 1; min-width: 0; }
.scene-editor .cue-tx-size, .scene-editor .cue-tx-speed, .scene-editor .cue-img-size { flex: 1; }
.scene-editor .cue-tx-color { flex: 0 0 auto; width: 34px; padding: 0; }
.scene-editor .cue-tx-bold { width: auto; }
.scene-editor .cue-img, .scene-editor .cue-img-clear { flex: 0 0 auto; }
.scene-editor .cue-name { flex: 1; min-width: 0; font-size: 10px; opacity: 0.72; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scene-editor .cue-img-pos { flex: 0 0 auto; width: 92px; }
.scene-editor .cue-dim { flex: 0 0 auto; font-size: 10px; opacity: 0.55; }
.scene-editor .cue-img-size { flex: 1; }
.scene-editor .se-foot { display: flex; gap: 6px; margin-top: 2px; }
.scene-editor .se-foot .se-clear { color: #ff9a9a; }
.scene-editor .se-foot { display: flex; gap: 6px; margin-top: 2px; flex-wrap: wrap; }
.scene-editor .se-foot .se-clear { color: #ff9a9a; margin-left: auto; }
/* ============ OUTPUT WINDOW ============ */
body.output { cursor: none; background: #000; }