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:
1 parent
8925fa50f9
commit
e0856866d2
4 files changed
+127
-41
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in new issue
Block a user