diff --git a/src/audio.js b/src/audio.js
index 814ef27..f37a691 100644
--- a/src/audio.js
+++ b/src/audio.js
@@ -68,6 +68,16 @@ class AudioEngine {
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() {
if (this.sourceNode) { try { this.sourceNode.disconnect(); } catch (e) {} this.sourceNode = null; }
if (this.sourceGain) { try { this.sourceGain.disconnect(); } catch (e) {} this.sourceGain = null; }
diff --git a/src/control.html b/src/control.html
index 04fa2ff..986964a 100644
--- a/src/control.html
+++ b/src/control.html
@@ -69,6 +69,7 @@
+
@@ -79,7 +80,7 @@
-
Clic sul nome = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 scena (effetto/testo/immagine, ognuno col proprio tempo)
+
Clic sul nome = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 scena (effetto/testo/immagine, ognuno col proprio tempo) · ✨ Intermezzo = voce solo-effetti (niente audio) tra un brano e l'altro
diff --git a/src/control.js b/src/control.js
index 94b83a2..8b415c3 100644
--- a/src/control.js
+++ b/src/control.js
@@ -183,8 +183,14 @@ $('#seq-interval').addEventListener('input', (e) => {
// ---------------------------------------------------------------- audio
function togglePlayPause() {
// If the track finished, restart it; otherwise pause/resume.
- if (ended && currentIndex >= 0) playIndex(currentIndex);
- else send({ type: 'togglePlay' });
+ if (ended && currentIndex >= 0) { playIndex(currentIndex); return; }
+ 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);
@@ -204,10 +210,10 @@ let sceneImgTarget = -1, sceneCueTarget = -1; // track/cue awaiting an image
let activeCues = [], firedCue = -1; // cue schedule for the playing track
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) {
- 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() {
if (djv.savePlaylist) djv.savePlaylist(serializePlaylist());
@@ -364,21 +370,45 @@ function addTracks(items) {
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) {
if (i < 0 || i >= playlist.length) return;
clearGap();
+ const tr = playlist[i];
currentIndex = i;
isPlaying = true;
ended = false;
playbackOwner = 'playlist';
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').textContent = '⏸ Pausa';
+ if (tr.isInterlude) {
+ playInterlude(tr);
+ return;
+ }
+ send({ type: tr.isVideo ? 'playVideoTrack' : 'playTrack', path: tr.path });
+ startCues(tr);
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() {
if (!playlist.length) return false;
let n = currentIndex + 1;
@@ -387,29 +417,38 @@ function nextTrack() {
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 stopPlaylist() {
+ isPlaying = false; ended = true; $('#btn-play').textContent = '▶ Play'; renderPlaylist();
+}
+
+// Timed visual segment: drives the scene timeline with no audio for `durSecs`,
+// then runs onDone. Used both for inter-track gaps ('gap', continuing the
+// previous scene from `base`) and for visual-only interlude items ('interlude',
+// their own scene from base 0). Supports pause/resume via the Play button.
+let segTimer = null, segMode = null, segRemain = 0;
+let segDur = 0, segBase = 0, segElapsed = 0, segT0 = 0, segPaused = false, segDone = null;
+function clearGap() { if (segTimer) { clearInterval(segTimer); segTimer = null; } segMode = null; segPaused = false; segRemain = 0; }
+const clearSeg = clearGap;
+function segTick() {
+ if (segPaused) return;
+ const elapsed = segElapsed + (Date.now() - segT0) / 1000;
+ segRemain = Math.max(0, segDur - elapsed);
+ playCur = Math.min(segDur, elapsed); playDur = segDur;
+ advanceCues(segBase + elapsed);
+ renderPlaylist();
+ if (elapsed >= segDur) { const d = segDone; clearSeg(); if (d) d(); }
+}
+function runSeg(mode, durSecs, base, onDone) {
+ clearSeg();
+ segMode = mode; segDur = durSecs; segBase = base; segDone = onDone;
+ segElapsed = 0; segT0 = Date.now(); segPaused = false;
+ 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) {
- 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();
+ runSeg('gap', secs, playDur || playCur || 0, () => { if (!nextTrack()) stopPlaylist(); });
}
function removeTrack(i) {
@@ -540,10 +579,14 @@ function buildSceneEditor(tr, i, li) {
'' +
'' +
'' +
- '
' +
- '⏸ Pausa dopo s ' +
- 'la scena continua durante la pausa' +
- '
';
+ (tr.isInterlude
+ ? '
' +
+ '✨ Durata intermezzo s' +
+ '
'
+ : '
' +
+ '⏸ Pausa dopo s ' +
+ 'la scena continua durante la pausa' +
+ '