From 4afaac08dd7610df9605c5c72815030eefa88733 Mon Sep 17 00:00:00 2001 From: luciano Date: Mon, 22 Jun 2026 15:16:32 +0200 Subject: [PATCH] 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 --- src/audio.js | 10 ++++ src/control.html | 3 +- src/control.js | 124 +++++++++++++++++++++++++++++++++-------------- src/output.js | 10 ++++ src/style.css | 1 + 5 files changed, 110 insertions(+), 38 deletions(-) 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' + + '
    '); ed.innerHTML = html; // 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-img').addEventListener('click', () => addEl(newImageEl)); 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; } @@ -612,17 +658,19 @@ function renderPlaylist() { li.dataset.index = i; const isCur = i === currentIndex; - const inGap = isCur && gapTimer; + const inSeg = isCur && segTimer; const playIcon = (isCur && isPlaying) ? '⏸' : '▶'; 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 (durations[tr.path] > 0) timeLabel = fmtTime(Math.floor(durations[tr.path])); const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0; - const gapBadge = (tr.gap > 0) ? '⏸' + tr.gap + 's' : ''; + const gapBadge = (tr.gap > 0 && !tr.isInterlude) ? '⏸' + tr.gap + 's' : ''; + const icon = tr.isInterlude ? '✨ ' : (tr.isVideo ? '🎞 ' : ''); li.innerHTML = '⠿' + - '' + (tr.isVideo ? '🎞 ' : '') + tr.name + gapBadge + '' + + '' + icon + tr.name + gapBadge + '' + '' + timeLabel + '' + '' + @@ -665,6 +713,7 @@ function renderPlaylist() { } $('#btn-add-tracks').addEventListener('click', () => $('#tracks-input').click()); +$('#btn-add-interlude').addEventListener('click', addInterlude); $('#tracks-input').addEventListener('change', (e) => { const items = [...e.target.files].map(f => ({ path: filePath(f), name: f.name, isVideo: f.type.startsWith('video/') })); if (items.length) addTracks(items); @@ -1150,6 +1199,7 @@ djv.onReport((m) => { renderPlaylist(); renderPads(); break; case 'progress': + if (segTimer) break; // a visual segment (interlude/gap) drives time itself playCur = m.currentTime; playDur = m.duration; if (playbackOwner === 'pad') { renderPads(); } else { advanceCues(playCur); renderPlaylist(); } diff --git a/src/output.js b/src/output.js index b321af9..56da96b 100644 --- a/src/output.js +++ b/src/output.js @@ -345,6 +345,16 @@ djv.onControl(async (m) => { djv.report({ type: 'playState', playing: true }); } catch (e) { djv.report({ type: 'error', message: e.message }); } 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 'togglePlay': { const playing = audio.togglePlay(); diff --git a/src/style.css b/src/style.css index 7731d54..9ea865f 100644 --- a/src/style.css +++ b/src/style.css @@ -405,6 +405,7 @@ textarea.textarea-lg { .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; } +.playlist .tname.is-interlude { color: #c9b6ff; font-style: italic; } /* ============ OUTPUT WINDOW ============ */ body.output { cursor: none; background: #000; }