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.mode = 'none';
|
||||
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.
|
||||
this.gain = 1.0; // master reactivity multiplier
|
||||
@@ -145,6 +148,28 @@ class AudioEngine {
|
||||
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() {
|
||||
// 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) {}
|
||||
|
||||
+2
-2
@@ -47,11 +47,11 @@
|
||||
<section class="span2">
|
||||
<label>Playlist <span id="playlist-count">0 brani</span></label>
|
||||
<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>
|
||||
<label class="chk"><input type="checkbox" id="repeat-playlist" /> Ripeti</label>
|
||||
</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 />
|
||||
<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>
|
||||
|
||||
+10
-12
@@ -205,7 +205,7 @@ let activeCues = [], firedCue = -1; // cue schedule for the playing t
|
||||
|
||||
function savePlaylistState() {
|
||||
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) {
|
||||
// items: array of { path, name }
|
||||
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();
|
||||
savePlaylistState();
|
||||
probePaths(items.map(it => it.path));
|
||||
@@ -267,7 +267,7 @@ function playIndex(i) {
|
||||
ended = false;
|
||||
playbackOwner = 'playlist';
|
||||
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]);
|
||||
$('#btn-play').disabled = false;
|
||||
$('#btn-play').textContent = '⏸ Pausa';
|
||||
@@ -321,7 +321,7 @@ function renderPlaylist() {
|
||||
const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0;
|
||||
li.innerHTML =
|
||||
'<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>' +
|
||||
'<button class="key-btn" title="Assegna tasto rapido">' +
|
||||
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
|
||||
@@ -409,7 +409,7 @@ function renderPlaylist() {
|
||||
|
||||
$('#btn-add-tracks').addEventListener('click', () => $('#tracks-input').click());
|
||||
$('#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);
|
||||
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) {
|
||||
playlist = saved.map(t => ({
|
||||
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();
|
||||
probePaths(playlist.map(t => t.path));
|
||||
@@ -723,14 +723,12 @@ window.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
document.body.classList.remove('dragover');
|
||||
const files = [...e.dataTransfer.files];
|
||||
const audioItems = files.filter(f => f.type.startsWith('audio/'))
|
||||
.map(f => ({ path: filePath(f), name: f.name }));
|
||||
// Audio and video files both go into the playlist as tracks.
|
||||
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 videoFile = files.find(f => f.type.startsWith('video/'));
|
||||
const videoPath = videoFile && filePath(videoFile);
|
||||
if (audioItems.length) addTracks(audioItems);
|
||||
if (mediaItems.length) addTracks(mediaItems);
|
||||
if (imgPaths.length) send({ type: 'addImages', paths: imgPaths });
|
||||
if (videoPath) send({ type: 'videoLoad', path: videoPath });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- keyboard
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<!-- Video layer (VJ loop) that blends with the generated visuals -->
|
||||
<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 -->
|
||||
<div id="overlay-images"></div>
|
||||
|
||||
|
||||
+21
-1
@@ -94,6 +94,10 @@ function composeFrame() {
|
||||
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) {
|
||||
if (!im.classList.contains('show')) continue;
|
||||
const r = im.getBoundingClientRect();
|
||||
@@ -210,6 +214,7 @@ function loadCustomTexture(dataUrl) {
|
||||
}
|
||||
|
||||
const bgVideo = $('#bg-video');
|
||||
const trackVideo = $('#track-video');
|
||||
const sceneImage = $('#scene-image');
|
||||
|
||||
// Two independent logos.
|
||||
@@ -249,7 +254,8 @@ function probeDurations(paths) {
|
||||
let pending = paths.length;
|
||||
const finish = () => { if (--pending === 0) djv.report({ type: 'durations', list: results }); };
|
||||
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.onloadedmetadata = () => { results.push({ path: p, duration: isFinite(a.duration) ? a.duration : 0 }); finish(); };
|
||||
a.onerror = () => { results.push({ path: p, duration: 0 }); finish(); };
|
||||
@@ -291,12 +297,26 @@ djv.onControl(async (m) => {
|
||||
break;
|
||||
case 'playTrack':
|
||||
try {
|
||||
// Switching to an audio track: stop/hide any playing video track.
|
||||
trackVideo.pause(); trackVideo.classList.remove('show');
|
||||
audio.onEnded = () => djv.report({ type: 'trackEnded' });
|
||||
await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 });
|
||||
hideHint();
|
||||
djv.report({ type: 'playState', playing: true });
|
||||
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||||
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 'togglePlay': {
|
||||
const playing = audio.togglePlay();
|
||||
|
||||
@@ -417,6 +417,19 @@ body.output { cursor: none; background: #000; }
|
||||
}
|
||||
.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 ---- */
|
||||
.scene-image {
|
||||
position: fixed;
|
||||
|
||||
Reference in new issue
Block a user