Playlist supports video tracks (full-screen video + audio through the analyser)

Audio and video files can both be added to the playlist; video tracks play
full-screen with their audio routed through the analyser (visuals/waveform
react), have duration/progress, and are included in recordings. Persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.8 committed 2026-06-19 16:46:44 +02:00
1 parent 1ae8fa81a7
commit 0f0accc67d
6 files changed
+74 -15

No files matched your search

+25
View File
@@ -34,6 +34,9 @@ class AudioEngine {
this.stream = null; // MediaStream when using live input this.stream = null; // MediaStream when using live input
this.mode = 'none'; this.mode = 'none';
this.onEnded = null; // callback fired when a (non-looping) track ends this.onEnded = null; // callback fired when a (non-looping) track ends
this._videoSrc = null; // persistent source for the playlist video element
this._videoGain = null;
this._videoSrcEl = null;
// Smoothed band values and beat state. // Smoothed band values and beat state.
this.gain = 1.0; // master reactivity multiplier this.gain = 1.0; // master reactivity multiplier
@@ -145,6 +148,28 @@ class AudioEngine {
this.mode = 'input'; this.mode = 'input';
} }
// Play a playlist video track: route the given <video> element's audio
// through the analyser/output so visuals react and it's audible/recordable.
attachVideo(videoEl) {
this.resume();
this._disconnectSource();
if (this._videoSrcEl !== videoEl) {
// createMediaElementSource may only be called once per element.
this._videoSrc = this.ctx.createMediaElementSource(videoEl);
this._videoGain = this.ctx.createGain();
this._videoSrc.connect(this._videoGain);
this._videoGain.connect(this.analyser);
this._videoGain.connect(this.outGain);
this._videoGain.connect(this.recordDest);
this._videoSrcEl = videoEl;
}
videoEl.onended = () => { if (this.onEnded) this.onEnded(); };
this.mediaEl = videoEl;
this.sourceNode = null;
this.sourceGain = null;
this.mode = 'video';
}
async listInputDevices() { async listInputDevices() {
// Labels are only populated after permission is granted. // Labels are only populated after permission is granted.
try { await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); } catch (e) {} try { await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); } catch (e) {}
+2 -2
View File
@@ -47,11 +47,11 @@
<section class="span2"> <section class="span2">
<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</button> <button id="btn-add-tracks">➕ Aggiungi brani/video</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>
<input type="file" id="tracks-input" accept="audio/*" 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 applicati quando parte il brano)</div> <div class="hint-mini">Clic sul <b>nome</b> = avvia/riavvia · ▶/⏸ pausa · ⠿ riordina · ⌨ tasto rapido · 🎬 <b>scena</b> (effetto+testo+immagine applicati quando parte il brano)</div>
+10 -12
View File
@@ -205,7 +205,7 @@ let activeCues = [], firedCue = -1; // cue schedule for the playing t
function savePlaylistState() { function savePlaylistState() {
if (djv.savePlaylist) { if (djv.savePlaylist) {
djv.savePlaylist(playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [] }))); djv.savePlaylist(playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo })));
} }
} }
@@ -252,7 +252,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 }); }); items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null, cues: [], isVideo: !!it.isVideo }); });
renderPlaylist(); renderPlaylist();
savePlaylistState(); savePlaylistState();
probePaths(items.map(it => it.path)); probePaths(items.map(it => it.path));
@@ -267,7 +267,7 @@ function playIndex(i) {
ended = false; ended = false;
playbackOwner = 'playlist'; playbackOwner = 'playlist';
activePad = -1; padPlaying = false; renderPads(); activePad = -1; padPlaying = false; renderPads();
send({ type: 'playTrack', path: playlist[i].path }); send({ type: playlist[i].isVideo ? 'playVideoTrack' : 'playTrack', path: playlist[i].path });
startCues(playlist[i]); startCues(playlist[i]);
$('#btn-play').disabled = false; $('#btn-play').disabled = false;
$('#btn-play').textContent = '⏸ Pausa'; $('#btn-play').textContent = '⏸ Pausa';
@@ -321,7 +321,7 @@ function renderPlaylist() {
const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0; const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
li.innerHTML = li.innerHTML =
'<span class="grip">⠿</span>' + '<span class="grip">⠿</span>' +
'<span class="tname" title="Avvia / riavvia dall\'inizio — ' + tr.name.replace(/"/g, '&quot;') + '">' + tr.name + '</span>' + '<span class="tname" title="Avvia / riavvia dall\'inizio — ' + tr.name.replace(/"/g, '&quot;') + '">' + (tr.isVideo ? '🎞 ' : '') + tr.name + '</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>' +
@@ -409,7 +409,7 @@ function renderPlaylist() {
$('#btn-add-tracks').addEventListener('click', () => $('#tracks-input').click()); $('#btn-add-tracks').addEventListener('click', () => $('#tracks-input').click());
$('#tracks-input').addEventListener('change', (e) => { $('#tracks-input').addEventListener('change', (e) => {
const items = [...e.target.files].map(f => ({ path: filePath(f), name: f.name })); 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);
e.target.value = ''; // allow re-adding the same file e.target.value = ''; // allow re-adding the same file
}); });
@@ -672,7 +672,7 @@ if (padXfade) padXfade.addEventListener('input', (e) => {
if (Array.isArray(saved) && saved.length) { if (Array.isArray(saved) && saved.length) {
playlist = saved.map(t => ({ playlist = saved.map(t => ({
path: t.path, name: t.name || baseName(t.path), path: t.path, name: t.name || baseName(t.path),
key: t.key || null, cues: Array.isArray(t.cues) ? t.cues : [] key: t.key || null, cues: Array.isArray(t.cues) ? t.cues : [], isVideo: !!t.isVideo
})); }));
renderPlaylist(); renderPlaylist();
probePaths(playlist.map(t => t.path)); probePaths(playlist.map(t => t.path));
@@ -723,14 +723,12 @@ window.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
document.body.classList.remove('dragover'); document.body.classList.remove('dragover');
const files = [...e.dataTransfer.files]; const files = [...e.dataTransfer.files];
const audioItems = files.filter(f => f.type.startsWith('audio/')) // Audio and video files both go into the playlist as tracks.
.map(f => ({ path: filePath(f), name: f.name })); const mediaItems = files.filter(f => f.type.startsWith('audio/') || f.type.startsWith('video/'))
.map(f => ({ path: filePath(f), name: f.name, isVideo: f.type.startsWith('video/') }));
const imgPaths = files.filter(f => f.type.startsWith('image/')).map(filePath).filter(Boolean); const imgPaths = files.filter(f => f.type.startsWith('image/')).map(filePath).filter(Boolean);
const videoFile = files.find(f => f.type.startsWith('video/')); if (mediaItems.length) addTracks(mediaItems);
const videoPath = videoFile && filePath(videoFile);
if (audioItems.length) addTracks(audioItems);
if (imgPaths.length) send({ type: 'addImages', paths: imgPaths }); if (imgPaths.length) send({ type: 'addImages', paths: imgPaths });
if (videoPath) send({ type: 'videoLoad', path: videoPath });
}); });
// ---------------------------------------------------------------- keyboard // ---------------------------------------------------------------- keyboard
+3
View File
@@ -13,6 +13,9 @@
<!-- Video layer (VJ loop) that blends with the generated visuals --> <!-- Video layer (VJ loop) that blends with the generated visuals -->
<video id="bg-video" class="bg-video" loop muted playsinline></video> <video id="bg-video" class="bg-video" loop muted playsinline></video>
<!-- Playlist video track (full screen, with its own audio) -->
<video id="track-video" class="track-video" playsinline></video>
<!-- Image overlay layer --> <!-- Image overlay layer -->
<div id="overlay-images"></div> <div id="overlay-images"></div>
+21 -1
View File
@@ -94,6 +94,10 @@ function composeFrame() {
recCtx.globalCompositeOperation = 'source-over'; recCtx.globalCompositeOperation = 'source-over';
} }
if (trackVideo.classList.contains('show') && trackVideo.readyState >= 2 && trackVideo.videoWidth) {
try { drawMediaFit(recCtx, trackVideo, W, H, 'contain'); } catch (e) {}
}
for (const im of images) { for (const im of images) {
if (!im.classList.contains('show')) continue; if (!im.classList.contains('show')) continue;
const r = im.getBoundingClientRect(); const r = im.getBoundingClientRect();
@@ -210,6 +214,7 @@ function loadCustomTexture(dataUrl) {
} }
const bgVideo = $('#bg-video'); const bgVideo = $('#bg-video');
const trackVideo = $('#track-video');
const sceneImage = $('#scene-image'); const sceneImage = $('#scene-image');
// Two independent logos. // Two independent logos.
@@ -249,7 +254,8 @@ function probeDurations(paths) {
let pending = paths.length; let pending = paths.length;
const finish = () => { if (--pending === 0) djv.report({ type: 'durations', list: results }); }; const finish = () => { if (--pending === 0) djv.report({ type: 'durations', list: results }); };
paths.forEach(p => { paths.forEach(p => {
const a = new Audio(); // A <video> element reads duration for both audio and video files.
const a = document.createElement('video');
a.preload = 'metadata'; a.preload = 'metadata';
a.onloadedmetadata = () => { results.push({ path: p, duration: isFinite(a.duration) ? a.duration : 0 }); finish(); }; a.onloadedmetadata = () => { results.push({ path: p, duration: isFinite(a.duration) ? a.duration : 0 }); finish(); };
a.onerror = () => { results.push({ path: p, duration: 0 }); finish(); }; a.onerror = () => { results.push({ path: p, duration: 0 }); finish(); };
@@ -291,12 +297,26 @@ djv.onControl(async (m) => {
break; break;
case 'playTrack': case 'playTrack':
try { try {
// Switching to an audio track: stop/hide any playing video track.
trackVideo.pause(); trackVideo.classList.remove('show');
audio.onEnded = () => djv.report({ type: 'trackEnded' }); audio.onEnded = () => djv.report({ type: 'trackEnded' });
await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 }); await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 });
hideHint(); hideHint();
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 'playVideoTrack':
try {
audio.onEnded = () => djv.report({ type: 'trackEnded' });
trackVideo.loop = false; trackVideo.muted = false;
trackVideo.src = toFileURL(m.path);
trackVideo.classList.add('show');
audio.attachVideo(trackVideo);
await trackVideo.play();
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();
+13
View File
@@ -417,6 +417,19 @@ body.output { cursor: none; background: #000; }
} }
.bg-video.show { display: block; } .bg-video.show { display: block; }
.track-video {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
object-fit: contain;
background: #000;
z-index: 2; /* main visual when a video track plays */
pointer-events: none;
display: none;
}
.track-video.show { display: block; }
/* ---- Per-track scene image ---- */ /* ---- Per-track scene image ---- */
.scene-image { .scene-image {
position: fixed; position: fixed;