Timed scene cues per track + persistent playlist/scenes

- Each playlist track holds a timeline of cues (@time → effect/text/image);
  cues fire automatically as the track plays, driven by progress reports.
- Playlist (tracks, hotkeys, cues) is saved to userData and restored on startup,
  like the pad bank.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.8 committed 2026-06-19 16:15:40 +02:00
1 parent 8925fa50f9
commit e0856866d2
4 files changed
+127 -41

No files matched your search

+8
View File
@@ -181,6 +181,14 @@ ipcMain.handle('pads:save', (_e, data) => {
try { fs.writeFileSync(padsFile(), JSON.stringify(data)); return true; } catch (e) { return false; } try { fs.writeFileSync(padsFile(), JSON.stringify(data)); return true; } catch (e) { return false; }
}); });
const playlistFile = () => path.join(app.getPath('userData'), 'playlist.json');
ipcMain.handle('playlist:load', () => {
try { return JSON.parse(fs.readFileSync(playlistFile(), 'utf8')); } catch (e) { return null; }
});
ipcMain.handle('playlist:save', (_e, data) => {
try { fs.writeFileSync(playlistFile(), JSON.stringify(data)); return true; } catch (e) { return false; }
});
// Read the bundled svg/ folder and return each SVG as a data: URL. // Read the bundled svg/ folder and return each SVG as a data: URL.
ipcMain.handle('svg:listBuiltin', () => { ipcMain.handle('svg:listBuiltin', () => {
try { try {
+3 -1
View File
@@ -30,5 +30,7 @@ contextBridge.exposeInMainWorld('djv', {
openRecordingsFolder: () => ipcRenderer.invoke('rec:openFolder'), openRecordingsFolder: () => ipcRenderer.invoke('rec:openFolder'),
// Pad bank persistence. // Pad bank persistence.
loadPads: () => ipcRenderer.invoke('pads:load'), loadPads: () => ipcRenderer.invoke('pads:load'),
savePads: (data) => ipcRenderer.invoke('pads:save', data) savePads: (data) => ipcRenderer.invoke('pads:save', data),
loadPlaylist: () => ipcRenderer.invoke('playlist:load'),
savePlaylist: (data) => ipcRenderer.invoke('playlist:save', data)
}); });
+104 -40
View File
@@ -200,24 +200,46 @@ let playCur = 0, playDur = 0; // live progress of the currently playing track
let padCrossfadeMs = 0; // crossfade duration for pad triggers let padCrossfadeMs = 0; // crossfade duration for pad triggers
let capturingFor = -1; // index of the track awaiting a hotkey, or -1 let capturingFor = -1; // index of the track awaiting a hotkey, or -1
let sceneEditing = -1; // playlist index whose scene editor is open let sceneEditing = -1; // playlist index whose scene editor is open
let sceneImgTarget = -1; // playlist index awaiting a scene image let sceneImgTarget = -1, sceneCueTarget = -1; // track/cue awaiting an image
let activeCues = [], firedCue = -1; // cue schedule for the playing track
function hasScene(tr) { function savePlaylistState() {
const s = tr.scene; if (djv.savePlaylist) {
return !!(s && (s.effectIndex != null || (s.text && s.text.trim()) || s.image)); djv.savePlaylist(playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [] })));
}
} }
// Apply a track's scene (effect + text + image) when it starts. function hasScene(tr) {
function applyScene(tr) { return !!(tr.cues && tr.cues.some(c => c.effectIndex != null || (c.text && c.text.trim()) || c.image));
const s = tr.scene; }
if (!s) return; function parseTime(str) {
if (s.effectIndex != null && EFFECTS.list[s.effectIndex]) applyEffect(EFFECTS.list[s.effectIndex]); str = String(str).trim();
const showText = !!(s.text && s.text.trim()); if (str.indexOf(':') >= 0) { const p = str.split(':').map(Number); return (p[0] || 0) * 60 + (p[1] || 0); }
send({ type: 'tickerText', text: s.text || '' }); return parseFloat(str) || 0;
}
// Apply one cue (effect optional; text and image always set so a timeline reads
// predictably — empty text / null image hide them).
function applyCue(c) {
if (!c) return;
if (c.effectIndex != null && EFFECTS.list[c.effectIndex]) applyEffect(EFFECTS.list[c.effectIndex]);
const showText = !!(c.text && c.text.trim());
send({ type: 'tickerText', text: c.text || '' });
send({ type: 'tickerOn', on: showText }); send({ type: 'tickerOn', on: showText });
$('#ticker-text').value = s.text || ''; $('#ticker-text').value = c.text || '';
$('#ticker-on').checked = showText; $('#ticker-on').checked = showText;
send({ type: 'sceneImage', path: s.image || null }); send({ type: 'sceneImage', path: c.image || null });
}
function startCues(tr) {
activeCues = (tr.cues || []).slice().sort((a, b) => a.time - b.time);
firedCue = -1;
advanceCues(0); // apply the base cue (time 0)
}
function advanceCues(t) {
while (firedCue + 1 < activeCues.length && t >= activeCues[firedCue + 1].time) {
firedCue++;
applyCue(activeCues[firedCue]);
}
} }
const baseName = (p) => p.split('/').pop(); const baseName = (p) => p.split('/').pop();
@@ -232,6 +254,7 @@ function addTracks(items) {
const start = playlist.length; const start = playlist.length;
items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null }); }); items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null }); });
renderPlaylist(); renderPlaylist();
savePlaylistState();
probePaths(items.map(it => it.path)); probePaths(items.map(it => it.path));
// Auto-start the first added track if nothing is playing yet. // Auto-start the first added track if nothing is playing yet.
if (currentIndex < 0 && playlist.length > start) playIndex(start); if (currentIndex < 0 && playlist.length > start) playIndex(start);
@@ -245,7 +268,7 @@ function playIndex(i) {
playbackOwner = 'playlist'; playbackOwner = 'playlist';
activePad = -1; padPlaying = false; renderPads(); activePad = -1; padPlaying = false; renderPads();
send({ type: 'playTrack', path: playlist[i].path }); send({ type: 'playTrack', path: playlist[i].path });
if (playlist[i].scene) applyScene(playlist[i]); startCues(playlist[i]);
$('#btn-play').disabled = false; $('#btn-play').disabled = false;
$('#btn-play').textContent = '⏸ Pausa'; $('#btn-play').textContent = '⏸ Pausa';
renderPlaylist(); renderPlaylist();
@@ -263,7 +286,9 @@ function removeTrack(i) {
playlist.splice(i, 1); playlist.splice(i, 1);
if (i === currentIndex) currentIndex = -1; if (i === currentIndex) currentIndex = -1;
else if (i < currentIndex) currentIndex--; else if (i < currentIndex) currentIndex--;
if (sceneEditing === i) sceneEditing = -1;
renderPlaylist(); renderPlaylist();
savePlaylistState();
} }
function moveTrack(from, to) { function moveTrack(from, to) {
@@ -274,6 +299,7 @@ function moveTrack(from, to) {
else if (from < currentIndex && to >= currentIndex) currentIndex--; else if (from < currentIndex && to >= currentIndex) currentIndex--;
else if (from > currentIndex && to <= currentIndex) currentIndex++; else if (from > currentIndex && to <= currentIndex) currentIndex++;
renderPlaylist(); renderPlaylist();
savePlaylistState();
} }
let dragFrom = -1; let dragFrom = -1;
@@ -333,31 +359,48 @@ function renderPlaylist() {
ol.appendChild(li); ol.appendChild(li);
if (sceneEditing === i) { if (sceneEditing === i) {
const s = tr.scene || (tr.scene = { effectIndex: null, effectName: '', text: '', image: null }); const cues = tr.cues || (tr.cues = []);
if (cues.length === 0) cues.push({ time: 0, effectIndex: null, effectName: '', text: '', image: null });
cues.sort((a, b) => a.time - b.time);
const ed = document.createElement('li'); const ed = document.createElement('li');
ed.className = 'scene-editor'; ed.className = 'scene-editor';
ed.innerHTML = let html = '<div class="se-title">🎬 Scene a tempo del brano</div>';
'<div class="se-row"><button class="se-effect">🌀 Usa effetto corrente</button>' + cues.forEach((c, ci) => {
'<span class="se-name">' + (s.effectName || '— nessun effetto —') + '</span></div>' + html += '<div class="cue" data-c="' + ci + '">' +
'<input class="se-text textfield" placeholder="Testo da mostrare (vuoto = nessuno)" />' + '<div class="se-row"><span class="cue-at">@</span>' +
'<div class="se-row"><button class="se-img">🖼 Immagine…</button>' + '<input class="cue-time" type="text" value="' + fmtTime(Math.floor(c.time)) + '" title="Tempo (mm:ss)" />' +
'<button class="se-img-clear">✕</button>' + '<button class="cue-eff">🌀 effetto</button>' +
'<span class="se-name">' + (s.image ? baseName(s.image) : 'nessuna immagine') + '</span></div>' + '<span class="se-name">' + (c.effectName || '—') + '</span>' +
'<div class="se-row"><button class="se-clear">Rimuovi scena</button>' + (ci > 0 ? '<button class="cue-del" title="Rimuovi cue">✕</button>' : '') +
'<span class="hint-mini" style="margin:0">Applicata quando parte il brano</span></div>'; '</div>' +
ed.querySelector('.se-text').value = s.text || ''; '<input class="cue-text textfield" placeholder="Testo (vuoto = nascondi)" />' +
ed.querySelector('.se-effect').addEventListener('click', () => { '<div class="se-row"><button class="cue-img">🖼 Immagine</button>' +
s.effectIndex = EFFECTS.list.indexOf(currentEffect); '<button class="cue-img-clear">✕</button>' +
s.effectName = currentEffect.name; '<span class="se-name">' + (c.image ? baseName(c.image) : 'nessuna') + '</span></div>' +
renderPlaylist(); '</div>';
}); });
ed.querySelector('.se-text').addEventListener('input', (e) => { html += '<div class="se-row"><button class="cue-add">➕ Aggiungi cue</button>' +
s.text = e.target.value; '<button class="se-clear">Rimuovi scena</button></div>' +
li.querySelector('.scene-btn').classList.toggle('active', hasScene(tr)); '<div class="hint-mini" style="margin:4px 0 0">La cue @00:00 parte col brano; le altre scattano al loro tempo.</div>';
ed.innerHTML = html;
ed.querySelectorAll('.cue').forEach(cueEl => {
const ci = parseInt(cueEl.dataset.c, 10);
const c = cues[ci];
cueEl.querySelector('.cue-text').value = c.text || '';
cueEl.querySelector('.cue-time').addEventListener('change', (e) => { c.time = parseTime(e.target.value); savePlaylistState(); renderPlaylist(); });
cueEl.querySelector('.cue-eff').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(currentEffect); c.effectName = currentEffect.name; savePlaylistState(); renderPlaylist(); });
cueEl.querySelector('.cue-text').addEventListener('input', (e) => { c.text = e.target.value; li.querySelector('.scene-btn').classList.toggle('active', hasScene(tr)); savePlaylistState(); });
cueEl.querySelector('.cue-img').addEventListener('click', () => { sceneImgTarget = i; sceneCueTarget = ci; $('#scene-img-input').click(); });
cueEl.querySelector('.cue-img-clear').addEventListener('click', () => { c.image = null; savePlaylistState(); renderPlaylist(); });
const del = cueEl.querySelector('.cue-del');
if (del) del.addEventListener('click', () => { cues.splice(ci, 1); savePlaylistState(); renderPlaylist(); });
}); });
ed.querySelector('.se-img').addEventListener('click', () => { sceneImgTarget = i; $('#scene-img-input').click(); }); ed.querySelector('.cue-add').addEventListener('click', () => {
ed.querySelector('.se-img-clear').addEventListener('click', () => { s.image = null; renderPlaylist(); }); const lastT = cues.length ? cues[cues.length - 1].time : 0;
ed.querySelector('.se-clear').addEventListener('click', () => { tr.scene = null; sceneEditing = -1; renderPlaylist(); }); cues.push({ time: lastT + 30, effectIndex: null, effectName: '', text: '', image: null });
savePlaylistState(); renderPlaylist();
});
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; savePlaylistState(); renderPlaylist(); });
ol.appendChild(ed); ol.appendChild(ed);
} }
}); });
@@ -375,13 +418,16 @@ $('#scene-img-input').addEventListener('change', (e) => {
const p = f && filePath(f); const p = f && filePath(f);
if (p && sceneImgTarget >= 0 && playlist[sceneImgTarget]) { if (p && sceneImgTarget >= 0 && playlist[sceneImgTarget]) {
const tr = playlist[sceneImgTarget]; const tr = playlist[sceneImgTarget];
(tr.scene = tr.scene || { effectIndex: null, effectName: '', text: '', image: null }).image = p; tr.cues = tr.cues || [];
renderPlaylist(); if (!tr.cues[sceneCueTarget]) sceneCueTarget = 0;
if (!tr.cues[sceneCueTarget]) tr.cues.push({ time: 0, effectIndex: null, effectName: '', text: '', image: null });
tr.cues[sceneCueTarget].image = p;
savePlaylistState(); renderPlaylist();
} }
sceneImgTarget = -1; e.target.value = ''; sceneImgTarget = -1; sceneCueTarget = -1; e.target.value = '';
}); });
$('#btn-clear-playlist').addEventListener('click', () => { $('#btn-clear-playlist').addEventListener('click', () => {
playlist = []; currentIndex = -1; renderPlaylist(); playlist = []; currentIndex = -1; sceneEditing = -1; renderPlaylist(); savePlaylistState();
}); });
$('#repeat-playlist').addEventListener('change', (e) => { repeat = e.target.checked; }); $('#repeat-playlist').addEventListener('change', (e) => { repeat = e.target.checked; });
$('#btn-use-input').addEventListener('click', () => $('#btn-use-input').addEventListener('click', () =>
@@ -533,6 +579,7 @@ function playPad(i) {
} }
playbackOwner = 'pad'; playbackOwner = 'pad';
activePad = i; padPlaying = true; activePad = i; padPlaying = true;
activeCues = []; firedCue = -1; // pads don't run playlist cues
playCur = 0; playDur = durations[p.path] || 0; playCur = 0; playDur = durations[p.path] || 0;
// The playlist is no longer the active source. // The playlist is no longer the active source.
currentIndex = -1; isPlaying = false; renderPlaylist(); currentIndex = -1; isPlaying = false; renderPlaylist();
@@ -600,6 +647,21 @@ if (padXfade) padXfade.addEventListener('input', (e) => {
probePaths(pads.filter(Boolean).map(p => p.path)); probePaths(pads.filter(Boolean).map(p => p.path));
})(); })();
// Load saved playlist (with per-track scenes/cues) on startup.
(async () => {
try {
const saved = djv.loadPlaylist ? await djv.loadPlaylist() : null;
if (Array.isArray(saved) && saved.length) {
playlist = saved.map(t => ({
path: t.path, name: t.name || baseName(t.path),
key: t.key || null, cues: Array.isArray(t.cues) ? t.cues : []
}));
renderPlaylist();
probePaths(playlist.map(t => t.path));
}
} catch (e) { /* ignore */ }
})();
// ---------------------------------------------------------------- tabs // ---------------------------------------------------------------- tabs
document.querySelectorAll('#tabs button').forEach(btn => { document.querySelectorAll('#tabs button').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -659,6 +721,7 @@ window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase(); const k = e.key.toLowerCase();
playlist.forEach(t => { if (t.key === k) t.key = null; }); // keys are unique playlist.forEach(t => { if (t.key === k) t.key = null; }); // keys are unique
playlist[capturingFor].key = k; playlist[capturingFor].key = k;
savePlaylistState();
} }
capturingFor = -1; capturingFor = -1;
renderPlaylist(); renderPlaylist();
@@ -786,7 +849,8 @@ djv.onReport((m) => {
break; break;
case 'progress': case 'progress':
playCur = m.currentTime; playDur = m.duration; playCur = m.currentTime; playDur = m.duration;
if (playbackOwner === 'pad') renderPads(); else renderPlaylist(); if (playbackOwner === 'pad') { renderPads(); }
else { advanceCues(playCur); renderPlaylist(); }
break; break;
case 'recState': case 'recState':
recOn = m.recording; recOn = m.recording;
+12
View File
@@ -364,6 +364,18 @@ textarea.textarea-lg {
.scene-editor .se-name { font-size: 10.5px; opacity: 0.7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .scene-editor .se-name { font-size: 10.5px; opacity: 0.7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scene-editor .se-text { margin: 0 0 6px; } .scene-editor .se-text { margin: 0 0 6px; }
.scene-editor .se-img-clear, .scene-editor .se-clear { flex: 0 0 auto; } .scene-editor .se-img-clear, .scene-editor .se-clear { flex: 0 0 auto; }
.scene-editor .se-title { font-size: 11px; opacity: 0.75; margin-bottom: 8px; }
.scene-editor .cue {
border-top: 1px dashed rgba(255,255,255,0.12);
padding-top: 7px;
margin-top: 7px;
}
.scene-editor .cue:first-of-type { border-top: none; padding-top: 0; margin-top: 0; }
.scene-editor .cue-at { opacity: 0.5; flex: 0 0 auto; }
.scene-editor .cue-time { width: 58px; flex: 0 0 auto; text-align: center; padding: 6px 4px; }
.scene-editor .cue-text { width: 100%; margin: 0 0 6px; }
.scene-editor .cue-eff, .scene-editor .cue-img, .scene-editor .cue-img-clear,
.scene-editor .cue-del, .scene-editor .cue-add { flex: 0 0 auto; }
/* ============ OUTPUT WINDOW ============ */ /* ============ OUTPUT WINDOW ============ */
body.output { cursor: none; background: #000; } body.output { cursor: none; background: #000; }