Playlist: visual-only 'Intermezzo' items (effects/text/image between tracks)
Add interlude playlist entries with no audio that run their own scene for a set duration, then auto-advance. Shares a unified timed-segment runner with the inter-track gap (pause/resume via the Play button). New 'playSilence' output message + audio.silence() to stop audio while keeping meters/recording alive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed73c016f3
commit
4afaac08dd
5 files changed
+108
-36
No files matched your search
@@ -68,6 +68,16 @@ class AudioEngine {
|
|||||||
|
|
||||||
resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); }
|
resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); }
|
||||||
|
|
||||||
|
// Stop any current audio/video source but keep the context & analyser alive
|
||||||
|
// (meters read 0, recording keep-alive continues). Used for visual-only
|
||||||
|
// playlist interludes.
|
||||||
|
silence() {
|
||||||
|
this.resume();
|
||||||
|
this._disconnectSource();
|
||||||
|
this.mediaEl = null;
|
||||||
|
this.mode = 'silence';
|
||||||
|
}
|
||||||
|
|
||||||
_disconnectSource() {
|
_disconnectSource() {
|
||||||
if (this.sourceNode) { try { this.sourceNode.disconnect(); } catch (e) {} this.sourceNode = null; }
|
if (this.sourceNode) { try { this.sourceNode.disconnect(); } catch (e) {} this.sourceNode = null; }
|
||||||
if (this.sourceGain) { try { this.sourceGain.disconnect(); } catch (e) {} this.sourceGain = null; }
|
if (this.sourceGain) { try { this.sourceGain.disconnect(); } catch (e) {} this.sourceGain = null; }
|
||||||
|
|||||||
+2
-1
@@ -69,6 +69,7 @@
|
|||||||
<label>Playlist <span id="playlist-count">0 brani</span></label>
|
<label>Playlist <span id="playlist-count">0 brani</span></label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="btn-add-tracks">➕ Aggiungi brani/video</button>
|
<button id="btn-add-tracks">➕ Aggiungi brani/video</button>
|
||||||
|
<button id="btn-add-interlude">✨ Intermezzo effetti</button>
|
||||||
<button id="btn-clear-playlist">🗑 Svuota</button>
|
<button id="btn-clear-playlist">🗑 Svuota</button>
|
||||||
<label class="chk"><input type="checkbox" id="repeat-playlist" /> Ripeti</label>
|
<label class="chk"><input type="checkbox" id="repeat-playlist" /> Ripeti</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
<input type="file" id="tracks-input" accept="audio/*,video/*" multiple hidden />
|
<input type="file" id="tracks-input" accept="audio/*,video/*" multiple hidden />
|
||||||
<input type="file" id="scene-img-input" accept="image/*" hidden />
|
<input type="file" id="scene-img-input" accept="image/*" hidden />
|
||||||
<ol id="playlist" class="playlist"></ol>
|
<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, ognuno col proprio tempo)</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) · ✨ <b>Intermezzo</b> = voce solo-effetti (niente audio) tra un brano e l'altro</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="span2">
|
<section class="span2">
|
||||||
|
|||||||
+85
-35
@@ -183,8 +183,14 @@ $('#seq-interval').addEventListener('input', (e) => {
|
|||||||
// ---------------------------------------------------------------- audio
|
// ---------------------------------------------------------------- audio
|
||||||
function togglePlayPause() {
|
function togglePlayPause() {
|
||||||
// If the track finished, restart it; otherwise pause/resume.
|
// If the track finished, restart it; otherwise pause/resume.
|
||||||
if (ended && currentIndex >= 0) playIndex(currentIndex);
|
if (ended && currentIndex >= 0) { playIndex(currentIndex); return; }
|
||||||
else send({ type: 'togglePlay' });
|
if (segTimer) { // a visual segment (interlude/gap) is running — pause/resume it
|
||||||
|
if (segPaused) { resumeSeg(); isPlaying = true; $('#btn-play').textContent = '⏸ Pausa'; }
|
||||||
|
else { pauseSeg(); isPlaying = false; $('#btn-play').textContent = '▶ Play'; }
|
||||||
|
renderPlaylist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send({ type: 'togglePlay' });
|
||||||
}
|
}
|
||||||
$('#btn-play').addEventListener('click', togglePlayPause);
|
$('#btn-play').addEventListener('click', togglePlayPause);
|
||||||
|
|
||||||
@@ -204,10 +210,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, gap: t.gap || 0 }));
|
return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 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, gap: t.gap || 0 };
|
return { path: t.path || '', name: t.name || baseName(t.path || '') || 'Intermezzo', key: t.key || null, cues: migrateCues(Array.isArray(t.cues) ? t.cues : []), isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0 };
|
||||||
}
|
}
|
||||||
function savePlaylistState() {
|
function savePlaylistState() {
|
||||||
if (djv.savePlaylist) djv.savePlaylist(serializePlaylist());
|
if (djv.savePlaylist) djv.savePlaylist(serializePlaylist());
|
||||||
@@ -364,21 +370,45 @@ function addTracks(items) {
|
|||||||
if (currentIndex < 0 && playlist.length > start) playIndex(start);
|
if (currentIndex < 0 && playlist.length > start) playIndex(start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add a visual-only interlude item (effects/text/image between tracks).
|
||||||
|
function addInterlude() {
|
||||||
|
playlist.push({ path: '', name: 'Intermezzo', key: null, cues: [], isVideo: false, isInterlude: true, duration: 15, gap: 0 });
|
||||||
|
sceneEditing = playlist.length - 1; // open its scene editor right away
|
||||||
|
renderPlaylist();
|
||||||
|
savePlaylistState();
|
||||||
|
}
|
||||||
|
|
||||||
function playIndex(i) {
|
function playIndex(i) {
|
||||||
if (i < 0 || i >= playlist.length) return;
|
if (i < 0 || i >= playlist.length) return;
|
||||||
clearGap();
|
clearGap();
|
||||||
|
const tr = playlist[i];
|
||||||
currentIndex = i;
|
currentIndex = i;
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
ended = false;
|
ended = false;
|
||||||
playbackOwner = 'playlist';
|
playbackOwner = 'playlist';
|
||||||
activePad = -1; padPlaying = false; renderPads();
|
activePad = -1; padPlaying = false; renderPads();
|
||||||
send({ type: playlist[i].isVideo ? 'playVideoTrack' : 'playTrack', path: playlist[i].path });
|
|
||||||
startCues(playlist[i]);
|
|
||||||
$('#btn-play').disabled = false;
|
$('#btn-play').disabled = false;
|
||||||
$('#btn-play').textContent = '⏸ Pausa';
|
$('#btn-play').textContent = '⏸ Pausa';
|
||||||
|
if (tr.isInterlude) {
|
||||||
|
playInterlude(tr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send({ type: tr.isVideo ? 'playVideoTrack' : 'playTrack', path: tr.path });
|
||||||
|
startCues(tr);
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visual-only interlude: silence audio, run this item's scene for its duration.
|
||||||
|
function playInterlude(tr) {
|
||||||
|
send({ type: 'playSilence' });
|
||||||
|
startCues(tr);
|
||||||
|
const dur = Math.max(1, tr.duration || 15);
|
||||||
|
runSeg('interlude', dur, 0, () => {
|
||||||
|
if (tr.gap > 0) startGap(tr, tr.gap);
|
||||||
|
else if (!nextTrack()) stopPlaylist();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function nextTrack() {
|
function nextTrack() {
|
||||||
if (!playlist.length) return false;
|
if (!playlist.length) return false;
|
||||||
let n = currentIndex + 1;
|
let n = currentIndex + 1;
|
||||||
@@ -387,29 +417,38 @@ function nextTrack() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inter-track gap: after a track ends, keep its scene timeline running for
|
function stopPlaylist() {
|
||||||
// `secs` seconds (so scene elements scheduled past the song length show as an
|
isPlaying = false; ended = true; $('#btn-play').textContent = '▶ Play'; renderPlaylist();
|
||||||
// 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; }
|
// Timed visual segment: drives the scene timeline with no audio for `durSecs`,
|
||||||
function startGap(tr, secs) {
|
// then runs onDone. Used both for inter-track gaps ('gap', continuing the
|
||||||
clearGap();
|
// previous scene from `base`) and for visual-only interlude items ('interlude',
|
||||||
send({ type: 'tickerText', text: '' }); // any live ticker handled by scene cues below
|
// their own scene from base 0). Supports pause/resume via the Play button.
|
||||||
const base = playDur || playCur || 0;
|
let segTimer = null, segMode = null, segRemain = 0;
|
||||||
const t0 = Date.now();
|
let segDur = 0, segBase = 0, segElapsed = 0, segT0 = 0, segPaused = false, segDone = null;
|
||||||
gapRemain = secs;
|
function clearGap() { if (segTimer) { clearInterval(segTimer); segTimer = null; } segMode = null; segPaused = false; segRemain = 0; }
|
||||||
const tick = () => {
|
const clearSeg = clearGap;
|
||||||
const elapsed = (Date.now() - t0) / 1000;
|
function segTick() {
|
||||||
gapRemain = Math.max(0, secs - elapsed);
|
if (segPaused) return;
|
||||||
advanceCues(base + elapsed); // drive scene elements placed after the song end
|
const elapsed = segElapsed + (Date.now() - segT0) / 1000;
|
||||||
|
segRemain = Math.max(0, segDur - elapsed);
|
||||||
|
playCur = Math.min(segDur, elapsed); playDur = segDur;
|
||||||
|
advanceCues(segBase + elapsed);
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
if (elapsed >= secs) {
|
if (elapsed >= segDur) { const d = segDone; clearSeg(); if (d) d(); }
|
||||||
clearGap();
|
}
|
||||||
if (!nextTrack()) { isPlaying = false; ended = true; $('#btn-play').textContent = '▶ Play'; renderPlaylist(); }
|
function runSeg(mode, durSecs, base, onDone) {
|
||||||
}
|
clearSeg();
|
||||||
};
|
segMode = mode; segDur = durSecs; segBase = base; segDone = onDone;
|
||||||
gapTimer = setInterval(tick, 200);
|
segElapsed = 0; segT0 = Date.now(); segPaused = false;
|
||||||
tick();
|
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(tr, secs) {
|
||||||
|
runSeg('gap', secs, playDur || playCur || 0, () => { if (!nextTrack()) stopPlaylist(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTrack(i) {
|
function removeTrack(i) {
|
||||||
@@ -540,10 +579,14 @@ function buildSceneEditor(tr, i, li) {
|
|||||||
'<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>' +
|
||||||
'<div class="se-gap" title="Pausa dopo questo brano: la scena continua per questi secondi prima di passare al prossimo">' +
|
(tr.isInterlude
|
||||||
|
? '<div class="se-gap" title="Durata dell\'intermezzo (solo scena, niente audio)">' +
|
||||||
|
'✨ Durata intermezzo <input class="cue-segdur" type="number" min="1" step="1" value="' + (tr.duration || 15) + '" /> s' +
|
||||||
|
'</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 ' +
|
'⏸ 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>' +
|
'<small>la scena continua durante la pausa</small>' +
|
||||||
'</div>';
|
'</div>');
|
||||||
ed.innerHTML = html;
|
ed.innerHTML = html;
|
||||||
|
|
||||||
// re-render (used after structural changes)
|
// re-render (used after structural changes)
|
||||||
@@ -598,7 +641,10 @@ 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(); });
|
const gapInput = ed.querySelector('.cue-gap');
|
||||||
|
if (gapInput) gapInput.addEventListener('change', (e) => { tr.gap = Math.max(0, parseFloat(e.target.value) || 0); savePlaylistState(); renderPlaylist(); });
|
||||||
|
const segInput = ed.querySelector('.cue-segdur');
|
||||||
|
if (segInput) segInput.addEventListener('change', (e) => { tr.duration = Math.max(1, parseFloat(e.target.value) || 15); savePlaylistState(); renderPlaylist(); });
|
||||||
return ed;
|
return ed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,17 +658,19 @@ function renderPlaylist() {
|
|||||||
li.dataset.index = i;
|
li.dataset.index = i;
|
||||||
|
|
||||||
const isCur = i === currentIndex;
|
const isCur = i === currentIndex;
|
||||||
const inGap = isCur && gapTimer;
|
const inSeg = isCur && segTimer;
|
||||||
const playIcon = (isCur && isPlaying) ? '⏸' : '▶';
|
const playIcon = (isCur && isPlaying) ? '⏸' : '▶';
|
||||||
let timeLabel = '';
|
let timeLabel = '';
|
||||||
if (inGap) timeLabel = '⏸ ' + Math.ceil(gapRemain) + 's';
|
if (inSeg) timeLabel = (segMode === 'interlude' ? '✨ ' : '⏸ ') + Math.ceil(segRemain) + 's';
|
||||||
|
else if (tr.isInterlude) timeLabel = '✨ ' + fmtTime(tr.duration || 15);
|
||||||
else if (isCur && playDur > 0) timeLabel = '-' + fmtTime(Math.floor(Math.max(0, playDur - playCur)));
|
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>' : '';
|
const gapBadge = (tr.gap > 0 && !tr.isInterlude) ? '<span class="gap-badge" title="Pausa di ' + tr.gap + 's dopo questo brano">⏸' + tr.gap + 's</span>' : '';
|
||||||
|
const icon = tr.isInterlude ? '✨ ' : (tr.isVideo ? '🎞 ' : '');
|
||||||
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 + gapBadge + '</span>' +
|
'<span class="tname' + (tr.isInterlude ? ' is-interlude' : '') + '" title="Avvia / riavvia dall\'inizio — ' + tr.name.replace(/"/g, '"') + '">' + icon + 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>' +
|
||||||
@@ -665,6 +713,7 @@ function renderPlaylist() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$('#btn-add-tracks').addEventListener('click', () => $('#tracks-input').click());
|
$('#btn-add-tracks').addEventListener('click', () => $('#tracks-input').click());
|
||||||
|
$('#btn-add-interlude').addEventListener('click', addInterlude);
|
||||||
$('#tracks-input').addEventListener('change', (e) => {
|
$('#tracks-input').addEventListener('change', (e) => {
|
||||||
const items = [...e.target.files].map(f => ({ path: filePath(f), name: f.name, isVideo: f.type.startsWith('video/') }));
|
const items = [...e.target.files].map(f => ({ path: filePath(f), name: f.name, isVideo: f.type.startsWith('video/') }));
|
||||||
if (items.length) addTracks(items);
|
if (items.length) addTracks(items);
|
||||||
@@ -1150,6 +1199,7 @@ djv.onReport((m) => {
|
|||||||
renderPlaylist(); renderPads();
|
renderPlaylist(); renderPads();
|
||||||
break;
|
break;
|
||||||
case 'progress':
|
case 'progress':
|
||||||
|
if (segTimer) break; // a visual segment (interlude/gap) drives time itself
|
||||||
playCur = m.currentTime; playDur = m.duration;
|
playCur = m.currentTime; playDur = m.duration;
|
||||||
if (playbackOwner === 'pad') { renderPads(); }
|
if (playbackOwner === 'pad') { renderPads(); }
|
||||||
else { advanceCues(playCur); renderPlaylist(); }
|
else { advanceCues(playCur); renderPlaylist(); }
|
||||||
|
|||||||
@@ -345,6 +345,16 @@ djv.onControl(async (m) => {
|
|||||||
djv.report({ type: 'playState', playing: true });
|
djv.report({ type: 'playState', playing: true });
|
||||||
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||||||
break;
|
break;
|
||||||
|
case 'playSilence':
|
||||||
|
// Visual-only interlude: stop audio + hide the video, keep visuals going.
|
||||||
|
try {
|
||||||
|
trackVideo.pause(); trackVideo.classList.remove('show');
|
||||||
|
audio.onEnded = null;
|
||||||
|
audio.silence();
|
||||||
|
hideHint();
|
||||||
|
djv.report({ type: 'playState', playing: true });
|
||||||
|
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||||||
|
break;
|
||||||
case 'probeDurations': probeDurations(m.paths || []); break;
|
case 'probeDurations': probeDurations(m.paths || []); break;
|
||||||
case 'togglePlay': {
|
case 'togglePlay': {
|
||||||
const playing = audio.togglePlay();
|
const playing = audio.togglePlay();
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ textarea.textarea-lg {
|
|||||||
.scene-editor .se-gap input { width: 56px; text-align: center; padding: 6px 4px; }
|
.scene-editor .se-gap input { width: 56px; text-align: center; padding: 6px 4px; }
|
||||||
.scene-editor .se-gap small { opacity: 0.5; font-size: 10px; }
|
.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; }
|
.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; }
|
||||||
|
.playlist .tname.is-interlude { color: #c9b6ff; font-style: italic; }
|
||||||
|
|
||||||
/* ============ OUTPUT WINDOW ============ */
|
/* ============ OUTPUT WINDOW ============ */
|
||||||
body.output { cursor: none; background: #000; }
|
body.output { cursor: none; background: #000; }
|
||||||
|
|||||||
Reference in new issue
Block a user