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:
1 parent
1ae8fa81a7
commit
0f0accc67d
6 files changed
+74
-15
No files matched your search
@@ -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
@@ -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
@@ -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, '"') + '">' + tr.name + '</span>' +
|
'<span class="tname" title="Avvia / riavvia dall\'inizio — ' + tr.name.replace(/"/g, '"') + '">' + (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
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user