Playlist: auto-blend video under active scene effect; inter-track pause/intermission with continuing scene
- When a playlist video has an active scene effect, auto-apply 'screen' blend so the effect shows through (only if the user left blend at default 'normal'; manual choices are respected). Restores on effect-off / next track. - Per-track 'Pausa dopo' (seconds): after a track ends, keep its scene timeline running for N seconds as an intermission (scene elements placed past the song length appear during the pause), then advance. Countdown + badge in the row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
57a6bea3b4
commit
ed73c016f3
2 files changed
+63
-6
No files matched your search
+59
-6
@@ -204,10 +204,10 @@ let sceneImgTarget = -1, sceneCueTarget = -1; // track/cue awaiting an image
|
|||||||
let activeCues = [], firedCue = -1; // cue schedule for the playing track
|
let activeCues = [], firedCue = -1; // cue schedule for the playing track
|
||||||
|
|
||||||
function serializePlaylist() {
|
function serializePlaylist() {
|
||||||
return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo }));
|
return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo, gap: t.gap || 0 }));
|
||||||
}
|
}
|
||||||
function normalizeTrack(t) {
|
function normalizeTrack(t) {
|
||||||
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 };
|
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, gap: t.gap || 0 };
|
||||||
}
|
}
|
||||||
function savePlaylistState() {
|
function savePlaylistState() {
|
||||||
if (djv.savePlaylist) djv.savePlaylist(serializePlaylist());
|
if (djv.savePlaylist) djv.savePlaylist(serializePlaylist());
|
||||||
@@ -273,10 +273,13 @@ function migrateCues(cues) {
|
|||||||
|
|
||||||
// Runtime: re-evaluate which element is active per channel and diff-apply it.
|
// Runtime: re-evaluate which element is active per channel and diff-apply it.
|
||||||
let lastScene = { effect: null, text: null, image: null };
|
let lastScene = { effect: null, text: null, image: null };
|
||||||
|
let userTrackBlend = 'normal'; // playlist-video blend chosen by the user
|
||||||
|
let sentTrackBlend = null; // last blend pushed to the output (auto or manual)
|
||||||
|
|
||||||
function startCues(tr) {
|
function startCues(tr) {
|
||||||
tr.cues = migrateCues(tr.cues || []);
|
tr.cues = migrateCues(tr.cues || []);
|
||||||
activeCues = tr.cues.slice().sort((a, b) => a.time - b.time);
|
activeCues = tr.cues.slice().sort((a, b) => a.time - b.time);
|
||||||
|
sentTrackBlend = null; // re-evaluate auto-blend for the new track
|
||||||
// `undefined` (not null) forces advanceCues to APPLY every channel once, so a
|
// `undefined` (not null) forces advanceCues to APPLY every channel once, so a
|
||||||
// stale text/image from the previous track is cleared even when the new scene
|
// stale text/image from the previous track is cleared even when the new scene
|
||||||
// has nothing active at t=0.
|
// has nothing active at t=0.
|
||||||
@@ -295,6 +298,15 @@ function sceneActive(type, t) {
|
|||||||
function advanceCues(t) {
|
function advanceCues(t) {
|
||||||
const ef = sceneActive('effect', t);
|
const ef = sceneActive('effect', t);
|
||||||
if (ef !== lastScene.effect) { lastScene.effect = ef; if (ef && EFFECTS.list[ef.effectIndex]) applyEffect(EFFECTS.list[ef.effectIndex]); }
|
if (ef !== lastScene.effect) { lastScene.effect = ef; if (ef && EFFECTS.list[ef.effectIndex]) applyEffect(EFFECTS.list[ef.effectIndex]); }
|
||||||
|
// Auto-blend: on a video track, when a scene effect is active, blend the video
|
||||||
|
// ('screen') so the effect shows through underneath. Only when the user left
|
||||||
|
// the blend at the default 'normal' — a manual choice is always respected.
|
||||||
|
const curTr = playlist[currentIndex];
|
||||||
|
if (curTr && curTr.isVideo) {
|
||||||
|
const effOn = !!(ef && ef.effectIndex != null && EFFECTS.list[ef.effectIndex]);
|
||||||
|
const wantBlend = (effOn && userTrackBlend === 'normal') ? 'screen' : userTrackBlend;
|
||||||
|
if (wantBlend !== sentTrackBlend) { sentTrackBlend = wantBlend; send({ type: 'trackVideoBlend', value: wantBlend }); }
|
||||||
|
}
|
||||||
const tx = sceneActive('text', t);
|
const tx = sceneActive('text', t);
|
||||||
if (tx !== lastScene.text) { lastScene.text = tx; applyTextEl(tx); }
|
if (tx !== lastScene.text) { lastScene.text = tx; applyTextEl(tx); }
|
||||||
const im = sceneActive('image', t);
|
const im = sceneActive('image', t);
|
||||||
@@ -344,7 +356,7 @@ function probePaths(paths) {
|
|||||||
function addTracks(items) {
|
function addTracks(items) {
|
||||||
// items: array of { path, name }
|
// items: array of { path, name }
|
||||||
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, cues: [], isVideo: !!it.isVideo }); });
|
items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null, cues: [], isVideo: !!it.isVideo, gap: 0 }); });
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
savePlaylistState();
|
savePlaylistState();
|
||||||
probePaths(items.map(it => it.path));
|
probePaths(items.map(it => it.path));
|
||||||
@@ -354,6 +366,7 @@ function addTracks(items) {
|
|||||||
|
|
||||||
function playIndex(i) {
|
function playIndex(i) {
|
||||||
if (i < 0 || i >= playlist.length) return;
|
if (i < 0 || i >= playlist.length) return;
|
||||||
|
clearGap();
|
||||||
currentIndex = i;
|
currentIndex = i;
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
ended = false;
|
ended = false;
|
||||||
@@ -374,6 +387,31 @@ function nextTrack() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inter-track gap: after a track ends, keep its scene timeline running for
|
||||||
|
// `secs` seconds (so scene elements scheduled past the song length show as an
|
||||||
|
// intermission) while the audio is silent, then advance to the next track.
|
||||||
|
let gapTimer = null, gapRemain = 0;
|
||||||
|
function clearGap() { if (gapTimer) { clearInterval(gapTimer); gapTimer = null; } gapRemain = 0; }
|
||||||
|
function startGap(tr, secs) {
|
||||||
|
clearGap();
|
||||||
|
send({ type: 'tickerText', text: '' }); // any live ticker handled by scene cues below
|
||||||
|
const base = playDur || playCur || 0;
|
||||||
|
const t0 = Date.now();
|
||||||
|
gapRemain = secs;
|
||||||
|
const tick = () => {
|
||||||
|
const elapsed = (Date.now() - t0) / 1000;
|
||||||
|
gapRemain = Math.max(0, secs - elapsed);
|
||||||
|
advanceCues(base + elapsed); // drive scene elements placed after the song end
|
||||||
|
renderPlaylist();
|
||||||
|
if (elapsed >= secs) {
|
||||||
|
clearGap();
|
||||||
|
if (!nextTrack()) { isPlaying = false; ended = true; $('#btn-play').textContent = '▶ Play'; renderPlaylist(); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
gapTimer = setInterval(tick, 200);
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
function removeTrack(i) {
|
function removeTrack(i) {
|
||||||
playlist.splice(i, 1);
|
playlist.splice(i, 1);
|
||||||
if (i === currentIndex) currentIndex = -1;
|
if (i === currentIndex) currentIndex = -1;
|
||||||
@@ -501,6 +539,10 @@ function buildSceneEditor(tr, i, li) {
|
|||||||
'<button class="add-txt" title="Aggiungi testo">➕🔤</button>' +
|
'<button class="add-txt" title="Aggiungi testo">➕🔤</button>' +
|
||||||
'<button class="add-img" title="Aggiungi immagine">➕🖼</button>' +
|
'<button class="add-img" title="Aggiungi immagine">➕🖼</button>' +
|
||||||
'<button class="se-clear">🗑 Scena</button>' +
|
'<button class="se-clear">🗑 Scena</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="se-gap" title="Pausa dopo questo brano: la scena continua per questi secondi prima di passare al prossimo">' +
|
||||||
|
'⏸ Pausa dopo <input class="cue-gap" type="number" min="0" step="1" value="' + (tr.gap || 0) + '" /> s ' +
|
||||||
|
'<small>la scena continua durante la pausa</small>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
ed.innerHTML = html;
|
ed.innerHTML = html;
|
||||||
|
|
||||||
@@ -556,6 +598,7 @@ function buildSceneEditor(tr, i, li) {
|
|||||||
ed.querySelector('.add-txt').addEventListener('click', () => addEl(newTextEl));
|
ed.querySelector('.add-txt').addEventListener('click', () => addEl(newTextEl));
|
||||||
ed.querySelector('.add-img').addEventListener('click', () => addEl(newImageEl));
|
ed.querySelector('.add-img').addEventListener('click', () => addEl(newImageEl));
|
||||||
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; save(); });
|
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; save(); });
|
||||||
|
ed.querySelector('.cue-gap').addEventListener('change', (e) => { tr.gap = Math.max(0, parseFloat(e.target.value) || 0); savePlaylistState(); renderPlaylist(); });
|
||||||
return ed;
|
return ed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,14 +612,17 @@ function renderPlaylist() {
|
|||||||
li.dataset.index = i;
|
li.dataset.index = i;
|
||||||
|
|
||||||
const isCur = i === currentIndex;
|
const isCur = i === currentIndex;
|
||||||
|
const inGap = isCur && gapTimer;
|
||||||
const playIcon = (isCur && isPlaying) ? '⏸' : '▶';
|
const playIcon = (isCur && isPlaying) ? '⏸' : '▶';
|
||||||
let timeLabel = '';
|
let timeLabel = '';
|
||||||
if (isCur && playDur > 0) timeLabel = '-' + fmtTime(Math.floor(Math.max(0, playDur - playCur)));
|
if (inGap) timeLabel = '⏸ ' + Math.ceil(gapRemain) + 's';
|
||||||
|
else if (isCur && playDur > 0) timeLabel = '-' + fmtTime(Math.floor(Math.max(0, playDur - playCur)));
|
||||||
else if (durations[tr.path] > 0) timeLabel = fmtTime(Math.floor(durations[tr.path]));
|
else if (durations[tr.path] > 0) timeLabel = fmtTime(Math.floor(durations[tr.path]));
|
||||||
const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
|
const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
|
||||||
|
const gapBadge = (tr.gap > 0) ? '<span class="gap-badge" title="Pausa di ' + tr.gap + 's dopo questo brano">⏸' + tr.gap + 's</span>' : '';
|
||||||
li.innerHTML =
|
li.innerHTML =
|
||||||
'<span class="grip">⠿</span>' +
|
'<span class="grip">⠿</span>' +
|
||||||
'<span class="tname" title="Avvia / riavvia dall\'inizio — ' + tr.name.replace(/"/g, '"') + '">' + (tr.isVideo ? '🎞 ' : '') + tr.name + '</span>' +
|
'<span class="tname" title="Avvia / riavvia dall\'inizio — ' + tr.name.replace(/"/g, '"') + '">' + (tr.isVideo ? '🎞 ' : '') + tr.name + gapBadge + '</span>' +
|
||||||
'<span class="ttime">' + timeLabel + '</span>' +
|
'<span class="ttime">' + timeLabel + '</span>' +
|
||||||
'<button class="key-btn" title="Assegna tasto rapido">' +
|
'<button class="key-btn" title="Assegna tasto rapido">' +
|
||||||
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
|
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
|
||||||
@@ -656,7 +702,7 @@ $('#btn-pl-load').addEventListener('click', async () => {
|
|||||||
|
|
||||||
// Playlist-video rendering (blend / opacity / fit).
|
// Playlist-video rendering (blend / opacity / fit).
|
||||||
$('#tvid-fit').addEventListener('change', (e) => send({ type: 'trackVideoFit', value: e.target.value }));
|
$('#tvid-fit').addEventListener('change', (e) => send({ type: 'trackVideoFit', value: e.target.value }));
|
||||||
$('#tvid-blend').addEventListener('change', (e) => send({ type: 'trackVideoBlend', value: e.target.value }));
|
$('#tvid-blend').addEventListener('change', (e) => { userTrackBlend = e.target.value; sentTrackBlend = e.target.value; send({ type: 'trackVideoBlend', value: e.target.value }); });
|
||||||
$('#tvid-op').addEventListener('input', (e) => {
|
$('#tvid-op').addEventListener('input', (e) => {
|
||||||
const v = parseInt(e.target.value, 10);
|
const v = parseInt(e.target.value, 10);
|
||||||
$('#tvid-op-val').textContent = v + '%';
|
$('#tvid-op-val').textContent = v + '%';
|
||||||
@@ -828,6 +874,7 @@ function playPad(i) {
|
|||||||
padPlaying = false; renderPads();
|
padPlaying = false; renderPads();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
clearGap();
|
||||||
playbackOwner = 'pad';
|
playbackOwner = 'pad';
|
||||||
activePad = i; padPlaying = true;
|
activePad = i; padPlaying = true;
|
||||||
activeCues = []; firedCue = -1; lastScene = { effect: null, text: null, image: null }; // pads don't run playlist cues
|
activeCues = []; firedCue = -1; lastScene = { effect: null, text: null, image: null }; // pads don't run playlist cues
|
||||||
@@ -1077,6 +1124,11 @@ djv.onReport((m) => {
|
|||||||
if (playbackOwner === 'pad') {
|
if (playbackOwner === 'pad') {
|
||||||
padPlaying = false;
|
padPlaying = false;
|
||||||
renderPads();
|
renderPads();
|
||||||
|
} else {
|
||||||
|
const endedTr = playlist[currentIndex];
|
||||||
|
const gap = (endedTr && endedTr.gap) || 0;
|
||||||
|
if (gap > 0) {
|
||||||
|
startGap(endedTr, gap); // intermission, then advance
|
||||||
} else if (!nextTrack()) {
|
} else if (!nextTrack()) {
|
||||||
// No next track: reset to a stopped state so the button shows ▶ again.
|
// No next track: reset to a stopped state so the button shows ▶ again.
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
@@ -1084,6 +1136,7 @@ djv.onReport((m) => {
|
|||||||
$('#btn-play').textContent = '▶ Play';
|
$('#btn-play').textContent = '▶ Play';
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'beat':
|
case 'beat':
|
||||||
// Auto-cycle effects on the beat (with a small guard against double-fires).
|
// Auto-cycle effects on the beat (with a small guard against double-fires).
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ textarea.textarea-lg {
|
|||||||
.scene-editor .cue-dim { flex: 0 0 auto; font-size: 10px; opacity: 0.55; }
|
.scene-editor .cue-dim { flex: 0 0 auto; font-size: 10px; opacity: 0.55; }
|
||||||
.scene-editor .se-foot { display: flex; gap: 6px; margin-top: 2px; flex-wrap: wrap; }
|
.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; }
|
.scene-editor .se-foot .se-clear { color: #ff9a9a; margin-left: auto; }
|
||||||
|
.scene-editor .se-gap { display: flex; align-items: center; gap: 6px; margin-top: 9px; font-size: 12px; opacity: 0.9; }
|
||||||
|
.scene-editor .se-gap input { width: 56px; text-align: center; padding: 6px 4px; }
|
||||||
|
.scene-editor .se-gap small { opacity: 0.5; font-size: 10px; }
|
||||||
|
.playlist .gap-badge { margin-left: 6px; font-size: 9.5px; padding: 1px 5px; border-radius: 6px; background: rgba(255,200,120,0.18); color: #ffd27a; vertical-align: middle; }
|
||||||
|
|
||||||
/* ============ OUTPUT WINDOW ============ */
|
/* ============ OUTPUT WINDOW ============ */
|
||||||
body.output { cursor: none; background: #000; }
|
body.output { cursor: none; background: #000; }
|
||||||
|
|||||||
Reference in new issue
Block a user