Files
djaudiovisualizer/src/output.js
T
lucianoandClaude Fable 5 19aab4e61e Reels: timed recording with auto-stop
recStart now carries the format and an optional maxMs; the output stops
itself when the time is up (15/30/60/90s picker in the control UI), so a
reel doesn't depend on a manual stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:51:51 +02:00

560 lines
23 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// OUTPUT window: renders the visuals + overlays and runs the audio engine.
// It receives commands from the control window via window.djv.onControl and
// sends back reports (meters, fps, device list, play state).
const $ = (s) => document.querySelector(s);
const canvas = $('#gl');
let viz;
try {
viz = new Visualizer(canvas);
} catch (e) {
document.body.innerHTML = '<div style="color:#fff;padding:40px;font-family:sans-serif">' +
'Errore grafica:<br><pre>' + e.message + '</pre></div>';
throw e;
}
const audio = new AudioEngine();
let speed = 1.0;
// Convert an absolute filesystem path into a file:// URL the renderer can load.
function toFileURL(p) {
return encodeURI('file://' + p).replace(/#/g, '%23').replace(/\?/g, '%3F');
}
// ---------------------------------------------------------------- overlays
const imgLayer = $('#overlay-images');
let images = [];
let imgIndex = 0;
let slideshow = false, slideshowTimer = null, slideshowMs = 5000;
let beatPulse = true;
let imgBlend = 'screen';
function addImages(paths) {
paths.forEach(p => {
const img = document.createElement('img');
img.src = toFileURL(p);
img.style.mixBlendMode = imgBlend;
imgLayer.appendChild(img);
images.push(img);
});
if (images.length) showImage(images.length - paths.length);
hideHint();
}
function showImage(i) {
if (!images.length) return;
imgIndex = (i + images.length) % images.length;
images.forEach((im, idx) => im.classList.toggle('show', idx === imgIndex));
}
function clearImages() {
images.forEach(im => im.remove());
images = []; imgIndex = 0;
}
function restartSlideshow() {
if (slideshowTimer) { clearInterval(slideshowTimer); slideshowTimer = null; }
if (slideshow) slideshowTimer = setInterval(() => showImage(imgIndex + 1), slideshowMs);
}
// ---------------------------------------------------------------- recording
// Records the output canvas (video) + the audio tap into a WebM stream that
// main transcodes to MP4 at the chosen aspect ratio.
let recorder = null, recording = false, pendingRecOpts = null, recAutoStop = null;
let recCanvas = null, recCtx = null, composeRAF = null;
function pickMime() {
const cands = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
for (const m of cands) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
return 'video/webm';
}
// Composite the WebGL visual + DOM overlays (images, logos, ticker) into a 2D
// canvas so the recording captures everything that's on screen, not just WebGL.
function drawMediaFit(ctx, media, W, H, fit) {
const mw = media.videoWidth || media.naturalWidth, mh = media.videoHeight || media.naturalHeight;
if (!mw || !mh) { ctx.drawImage(media, 0, 0, W, H); return; }
const scale = fit === 'contain' ? Math.min(W / mw, H / mh) : Math.max(W / mw, H / mh);
const dw = mw * scale, dh = mh * scale;
ctx.drawImage(media, (W - dw) / 2, (H - dh) / 2, dw, dh);
}
function composeFrame() {
const W = recCanvas.width, H = recCanvas.height;
const sx = W / window.innerWidth, sy = H / window.innerHeight;
recCtx.globalAlpha = 1;
recCtx.globalCompositeOperation = 'source-over';
recCtx.clearRect(0, 0, W, H);
recCtx.drawImage(canvas, 0, 0, W, H);
if (bgVideo.classList.contains('show') && bgVideo.readyState >= 2 && bgVideo.videoWidth) {
recCtx.globalAlpha = parseFloat(getComputedStyle(bgVideo).opacity) || 1;
const bm = bgVideo.style.mixBlendMode;
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
try { drawMediaFit(recCtx, bgVideo, W, H, bgVideo.style.objectFit || 'cover'); } catch (e) {}
recCtx.globalAlpha = 1;
recCtx.globalCompositeOperation = 'source-over';
}
if (trackVideo.classList.contains('show') && trackVideo.readyState >= 2 && trackVideo.videoWidth) {
recCtx.globalAlpha = parseFloat(getComputedStyle(trackVideo).opacity) || 1;
const bm = trackVideo.style.mixBlendMode;
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
try { drawMediaFit(recCtx, trackVideo, W, H, trackVideo.style.objectFit || 'contain'); } catch (e) {}
recCtx.globalAlpha = 1;
recCtx.globalCompositeOperation = 'source-over';
}
for (const im of images) {
if (!im.classList.contains('show')) continue;
const r = im.getBoundingClientRect();
recCtx.globalAlpha = parseFloat(getComputedStyle(im).opacity) || 1;
const bm = im.style.mixBlendMode;
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
try { recCtx.drawImage(im, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
}
recCtx.globalAlpha = 1;
recCtx.globalCompositeOperation = 'source-over';
if (sceneImage.classList.contains('show') && sceneImage.src) {
const r = sceneImage.getBoundingClientRect();
try { recCtx.drawImage(sceneImage, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
}
for (const l of logos) {
if (!l.classList.contains('show')) continue;
const r = l.getBoundingClientRect();
recCtx.globalAlpha = parseFloat(l.style.opacity || '1');
try { recCtx.drawImage(l, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
}
recCtx.globalAlpha = 1;
if (ticker.classList.contains('show') || sideText.classList.contains('show')) {
recCtx.textBaseline = 'middle';
for (const span of tickerLetters()) {
const txt = span.textContent;
if (!txt || txt === ' ') continue;
const r = span.getBoundingClientRect();
if (r.right < 0 || r.left > window.innerWidth) continue;
const cs = getComputedStyle(span);
recCtx.globalAlpha = parseFloat(cs.opacity) || 1;
recCtx.font = cs.fontWeight + ' ' + (parseFloat(cs.fontSize) * sy) + 'px ' + cs.fontFamily;
recCtx.fillStyle = cs.color;
recCtx.shadowColor = 'rgba(140,180,255,0.9)';
recCtx.shadowBlur = 14 * sy;
recCtx.fillText(txt, r.x * sx, (r.y + r.height / 2) * sy);
recCtx.shadowBlur = 0;
}
recCtx.globalAlpha = 1;
}
composeRAF = requestAnimationFrame(composeFrame);
}
function stopCompose() {
if (composeRAF) { cancelAnimationFrame(composeRAF); composeRAF = null; }
}
async function startRecording(opts) {
if (recording) return;
try {
await djv.recStart();
recCanvas = document.createElement('canvas');
recCanvas.width = canvas.width;
recCanvas.height = canvas.height;
recCtx = recCanvas.getContext('2d');
composeFrame();
const vstream = recCanvas.captureStream(30);
const astream = audio.recordDest.stream;
const stream = new MediaStream([...vstream.getVideoTracks(), ...astream.getAudioTracks()]);
recorder = new MediaRecorder(stream, { mimeType: pickMime(), videoBitsPerSecond: 12e6 });
// Serialise chunk delivery: ondataavailable is async, so without a chain the
// header chunk could be sent after a later one and corrupt the WebM.
let chain = Promise.resolve();
recorder.ondataavailable = (e) => {
if (!e.data || !e.data.size) return;
chain = chain.then(async () => djv.recChunk(new Uint8Array(await e.data.arrayBuffer())));
};
recorder.onstop = async () => {
stopCompose();
await chain; // ensure every chunk is flushed, in order, before muxing
const res = await djv.recStop(pendingRecOpts || {});
recording = false;
djv.report({ type: 'recState', recording: false });
if (res && res.ok) djv.report({ type: 'recSaved', path: res.path, name: res.name, url: res.url });
else djv.report({ type: 'recError', message: (res && res.error) || 'errore sconosciuto' });
};
recorder.start(2000); // emit a chunk every 2s
recording = true;
// Reel mode: auto-stop after maxMs using the format chosen at start.
if (opts && opts.maxMs > 0) {
clearTimeout(recAutoStop);
recAutoStop = setTimeout(() => stopRecording({ w: opts.w, h: opts.h }), opts.maxMs);
}
djv.report({ type: 'recState', recording: true });
} catch (e) {
stopCompose();
djv.report({ type: 'recError', message: e.message });
}
}
function stopRecording(opts) {
clearTimeout(recAutoStop); recAutoStop = null;
if (!recording || !recorder) return;
pendingRecOpts = { w: opts && opts.w, h: opts && opts.h };
recorder.stop();
}
// Custom SVG/image source for the "SVG/Immagine" effect family. The image
// arrives as a data: URL (same-origin) so the canvas isn't tainted and the
// pixels can be uploaded to a WebGL texture.
function loadCustomTexture(dataUrl) {
const img = new Image();
img.onload = () => {
const size = 1024;
const cv = document.createElement('canvas');
cv.width = cv.height = size;
const ctx = cv.getContext('2d');
const iw = img.width || 512, ih = img.height || 512;
const s = Math.min(size / iw, size / ih);
const w = iw * s, h = ih * s;
ctx.clearRect(0, 0, size, size);
ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
try { viz.setTexture(cv); hideHint(); }
catch (e) { djv.report({ type: 'error', message: 'Texture SVG: ' + e.message }); }
};
img.onerror = () => djv.report({ type: 'error', message: 'SVG/immagine non caricata' });
img.src = dataUrl;
}
const bgVideo = $('#bg-video');
const trackVideo = $('#track-video');
const sceneImage = $('#scene-image');
// Two independent logos.
const logos = [$('#logo-0'), $('#logo-1')];
function setLogo(i, url) {
if (!logos[i]) return;
if (url) { logos[i].src = url; logos[i].classList.add('show'); hideHint(); }
else { logos[i].classList.remove('show'); logos[i].removeAttribute('src'); }
}
const ticker = $('#ticker');
const tickerTrack = $('#ticker-track');
const tickCopies = ticker.querySelectorAll('.tick-copy');
const sideText = $('#side-text');
const sideCopies = sideText.querySelectorAll('.side-copy');
let tickerVisible = false, tickerDir = 'h';
function buildLetters(txt) {
const full = (txt || '') + ' • ';
let html = '';
for (let i = 0; i < full.length; i++) {
const c = full[i];
const ch = c === ' ' ? ' ' : c.replace(/&/g, '&amp;').replace(/</g, '&lt;');
html += '<span class="tl" style="--i:' + i + '">' + ch + '</span>';
}
return html;
}
function setTickerText(t) {
const h = buildLetters(t);
tickCopies.forEach(c => c.innerHTML = h);
sideCopies.forEach(c => c.innerHTML = h);
}
function setTickerSpeed(m) { document.documentElement.style.setProperty('--ticker-dur', (18 / m) + 's'); }
function tickerLetters() {
let out = [];
if (ticker.classList.contains('show')) out = out.concat([...tickerTrack.querySelectorAll('.tl')]);
if (sideText.classList.contains('show')) out = out.concat([...sideText.querySelectorAll('.tl')]);
return out;
}
function styleBoth(prop, value) { tickerTrack.style[prop] = value; sideText.style[prop] = value; }
function updateTickerVis() {
const sides = tickerDir === 'sides';
ticker.classList.toggle('show', tickerVisible && !sides);
sideText.classList.toggle('show', tickerVisible && sides);
}
ticker.classList.add('pos-bottom');
ticker.classList.add('dir-h');
setTickerSpeed(1);
function hideHint() { $('#drop-hint').classList.add('hidden'); }
// ---------------------------------------------------------------- audio devices
// Probe duration (metadata only) for a list of file paths.
function probeDurations(paths) {
if (!paths.length) return;
const results = [];
let pending = paths.length;
const finish = () => { if (--pending === 0) djv.report({ type: 'durations', list: results }); };
paths.forEach(p => {
// 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(); };
a.src = toFileURL(p);
});
}
async function reportDevices() {
try {
const inputs = await audio.listInputDevices();
djv.report({ type: 'devices', list: inputs.map(d => ({ deviceId: d.deviceId, label: d.label })) });
const outputs = await audio.listOutputDevices();
djv.report({ type: 'outputs', list: outputs.map(d => ({ deviceId: d.deviceId, label: d.label })) });
} catch (e) { /* ignore */ }
}
// ---------------------------------------------------------------- command bus
djv.onControl(async (m) => {
switch (m.type) {
case 'effect':
// A manual pick takes over from the Auto VJ.
if (autoVj) { autoVj = false; djv.report({ type: 'autoVj', on: false }); }
viz.setEffect(m.effect);
break;
case 'autoVj':
autoVj = !!m.on;
if (m.mode) avMode = m.mode;
avFamilies = Array.isArray(m.families) ? m.families : avFamilies;
if (autoVj) { avLastSwitch = 0; avBeatMark = audio.beatCount || 0; }
djv.report({ type: 'autoVj', on: autoVj });
break;
case 'svg': loadCustomTexture(m.dataUrl); break;
case 'recStart': startRecording(m); break;
case 'recStop': stopRecording(m); break;
case 'gain': audio.gain = m.value; break;
case 'speed': speed = m.value; audio.scrollRate = m.value; break;
case 'bandGain': audio[m.band + 'Gain'] = m.value; break;
case 'outputDevice':
try { await audio.setOutputDevice(m.deviceId || ''); }
catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'loadFile':
try {
await audio.loadFile(toFileURL(m.path));
hideHint();
djv.report({ type: 'fileLoaded' });
djv.report({ type: 'playState', playing: true });
} catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'playTrack':
try {
// Switching to an audio track: stop/hide any playing video track.
trackVideo.pause(); trackVideo.classList.remove('show');
audio.setTrim(m.start || 0, m.end || 0);
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.setTrim(m.start || 0, m.end || 0);
audio.onEnded = () => djv.report({ type: 'trackEnded' });
audio.attachVideo(trackVideo);
trackVideo.loop = false; trackVideo.muted = false;
// Force a fresh start even when re-selecting the same file:
// reassigning the same src may not reload, so pause + load() resets it.
trackVideo.pause();
trackVideo.src = toFileURL(m.path);
trackVideo.load();
trackVideo.classList.add('show');
await trackVideo.play();
audio.seekToTrimStart(trackVideo);
hideHint();
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 'setTrim': audio.setTrim(m.start || 0, m.end || 0); break;
case 'seek': {
const el = audio.mediaEl;
if (el && isFinite(el.duration) && el.duration > 0) {
el.currentTime = Math.max(0, Math.min(el.duration - 0.05, m.time || 0));
audio._trimFired = false; // re-arm the trim-end check after seeking
}
break;
}
case 'probeDurations': probeDurations(m.paths || []); break;
case 'togglePlay': {
const playing = audio.togglePlay();
if (playing !== null) djv.report({ type: 'playState', playing });
break;
}
case 'useInput':
try { await audio.useInput(m.deviceId || null); hideHint(); await reportDevices(); }
catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'refreshDevices': reportDevices(); break;
case 'addImages': addImages(m.paths); break;
case 'clearImages': clearImages(); break;
case 'imgBlend': imgBlend = m.value; images.forEach(im => im.style.mixBlendMode = m.value); break;
case 'slideshow': slideshow = m.on; restartSlideshow(); break;
case 'slideshowInterval': slideshowMs = m.ms; restartSlideshow(); break;
case 'imgSize': imgLayer.style.setProperty('--img-size', m.value); break;
case 'imgBeat': beatPulse = m.on; break;
case 'imgNext': showImage(imgIndex + 1); break;
case 'imgPrev': showImage(imgIndex - 1); break;
case 'videoLoad':
bgVideo.src = toFileURL(m.path);
bgVideo.loop = true; bgVideo.muted = true;
bgVideo.classList.add('show');
bgVideo.play().catch(() => {});
hideHint();
break;
case 'videoToggle':
if (bgVideo.paused) bgVideo.play().catch(() => {}); else bgVideo.pause();
break;
case 'videoClear':
bgVideo.pause(); bgVideo.removeAttribute('src'); bgVideo.load();
bgVideo.classList.remove('show');
break;
case 'videoOpacity': bgVideo.style.opacity = m.value; break;
case 'videoFit': bgVideo.style.objectFit = m.value; break;
case 'videoBlend': bgVideo.style.mixBlendMode = m.value; break;
case 'trackVideoBlend': trackVideo.style.mixBlendMode = m.value; break;
case 'trackVideoOpacity': trackVideo.style.opacity = m.value; break;
case 'trackVideoFit': trackVideo.style.objectFit = m.value; break;
case 'sceneImage':
if (m.path) {
sceneImage.src = toFileURL(m.path);
const s = m.size || 60;
sceneImage.style.maxWidth = s + 'vw';
sceneImage.style.maxHeight = s + 'vh';
sceneImage.style.left = (m.x != null ? m.x : 50) + '%';
sceneImage.style.top = (m.y != null ? m.y : 50) + '%';
sceneImage.classList.add('show'); hideHint();
} else { sceneImage.classList.remove('show'); sceneImage.removeAttribute('src'); }
break;
case 'logo': setLogo(m.index, m.path ? toFileURL(m.path) : ''); break;
case 'logoX': logos[m.index].style.left = m.value + '%'; break;
case 'logoY': logos[m.index].style.top = m.value + '%'; break;
case 'logoSize': logos[m.index].style.width = m.value + 'vw'; break;
case 'logoOpacity': logos[m.index].style.opacity = m.value; break;
case 'tickerText': setTickerText(m.text); break;
case 'tickerOn': tickerVisible = m.on; updateTickerVis(); break;
case 'tickerPos':
ticker.classList.remove('pos-bottom', 'pos-top', 'pos-middle');
ticker.classList.add('pos-' + m.pos);
break;
case 'tickerSpeed': setTickerSpeed(m.mult); break;
case 'tickerDir':
tickerDir = m.value;
if (m.value !== 'sides') {
ticker.classList.remove('dir-h', 'dir-vup', 'dir-vdown');
ticker.classList.add('dir-' + m.value);
}
updateTickerVis();
break;
case 'tickerFont': styleBoth('fontFamily', m.value); break;
case 'tickerSize': styleBoth('fontSize', m.value + 'vh'); break;
case 'tickerWeight': styleBoth('fontWeight', m.on ? '800' : '400'); break;
case 'tickerColor': styleBoth('color', m.value); break;
case 'tickerFx':
[tickerTrack, sideText].forEach(el => {
el.classList.remove('fx-updown', 'fx-wave', 'fx-zoom', 'fx-flash', 'fx-rotate');
if (m.value && m.value !== 'none') el.classList.add('fx-' + m.value);
});
break;
}
});
// ---------------------------------------------------------------- render loop
const startTime = performance.now();
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0;
// ---- Auto VJ: a director that adapts the visuals to the playing track ------
// Uses the engine's musical features (mood/bpm/drop/beatCount): switches preset
// every 16 beats (or ~14s without beats), immediately on a detected drop, and
// draws from pools of families that fit the current mood.
let autoVj = false, avLastSwitch = 0, avBeatMark = 0, avRecent = [];
const AV_POOLS = {
quiet: ['Cielo', 'Aurora', 'Mare', 'Montagne', 'Galassia', 'Nuvole', 'Fluido', 'Onde'],
groove: ['Plasma', 'Vortice', 'Truchet', 'Esagoni', 'Spirale', 'Cellule', 'Gyroide',
'Fluido', 'Fluido Onda', 'Fluido Vortici', 'Griglia Neon', 'Solidi 3D'],
peak: ['Julia', 'Iperspazio', 'Tunnel', 'Moiré', 'Fluido Anello', 'Fluido Fuoco',
'Vortice', 'Griglia Neon', 'Cristalli']
};
let avMode = 'smart', avFamilies = []; // 'smart' | 'all' | 'custom'
function avPick(mood) {
// Family pool: smart = curated per-mood lists; custom = the user's checklist
// (mood still shapes the speed variant); all = no family restriction.
let fams = null;
if (avMode === 'smart') fams = AV_POOLS[mood] || AV_POOLS.groove;
else if (avMode === 'custom' && avFamilies.length) fams = avFamilies;
const speedOk = (e) => (mood === 'peak' ? e.speed >= 1.0 : e.speed <= 1.4);
let cands = window.EFFECTS.list.filter(e => (!fams || fams.indexOf(e.familyName) >= 0) &&
speedOk(e) && avRecent.indexOf(e.name) < 0);
// A tight custom list can exhaust the speed filter: retry without it.
if (!cands.length) cands = window.EFFECTS.list.filter(e =>
(!fams || fams.indexOf(e.familyName) >= 0) && avRecent.indexOf(e.name) < 0);
const e = cands.length ? cands[Math.floor(Math.random() * cands.length)] : window.EFFECTS.list[0];
avRecent = [e.name].concat(avRecent).slice(0, 6); // don't repeat the last few
return e;
}
function avTick(now) {
if (!autoVj) return;
const dropNow = audio.drop === 1;
const due = (audio.beatCount - avBeatMark) >= 16 || (now - avLastSwitch) > 14000;
if (!due && !dropNow) return;
avBeatMark = audio.beatCount; avLastSwitch = now;
const mood = dropNow ? 'peak' : audio.mood;
const e = avPick(mood);
viz.setEffect(e);
djv.report({ type: 'autoVj', on: true, name: e.name, mood, bpm: audio.bpm || 0 });
}
function frame() {
const t = (performance.now() - startTime) / 1000;
audio.update();
const a = audio.values;
avTick(performance.now());
viz.render(t * speed, a);
// Report beat rising-edges so the control window can auto-cycle effects.
if (a.beat > 0.6 && prevBeat <= 0.6) djv.report({ type: 'beat' });
prevBeat = a.beat;
// Overlays react to audio.
imgLayer.style.setProperty('--beat-scale',
(beatPulse && images.length ? 1 + a.beat * 0.12 + a.bass * 0.05 : 1).toFixed(3));
tickerTrack.style.setProperty('--ticker-glow', (a.level * 24 + a.beat * 20).toFixed(0) + 'px');
// FPS + meter report to the control window (throttled to ~15 Hz).
frames++;
const now = performance.now();
if (now - fpsT > 500) { fps = frames * 1000 / (now - fpsT); frames = 0; fpsT = now; }
if (now - lastReport > 66) {
lastReport = now;
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps });
}
// Stop at the trim end point if one is set.
audio.checkTrim();
// Playback progress for the currently playing file.
if (now - lastProgress > 400) {
lastProgress = now;
const el = audio.mediaEl;
if (el && !el.paused && isFinite(el.duration) && el.duration > 0) {
djv.report({ type: 'progress', currentTime: el.currentTime, duration: el.duration });
}
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Populate the device list once at startup so the control dropdown fills in.
reportDevices();