Files
radio-explorer/web/master/visualizer.js
Marco Mooren 29423288ca feat: add multi-user support for favorites management and room clock synchronization
- Implemented a new API endpoint for retrieving and managing user favorites in /api/users.
- Added functionality for admins to edit the shared "main" user's favorites.
- Created a one-shot DB smoke test script for verifying multi-user kiosk migrations.
- Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
2026-05-13 13:53:12 +02:00

109 lines
4.1 KiB
JavaScript

// Live spectrum analyser for the Master view.
//
// The audio graph (AudioContext + MediaElementSource + AnalyserNode + DelayNode)
// is owned by `web/player.js`. We only ask the player for the analyser node
// and draw it. This way `createMediaElementSource` is called exactly once per
// <audio> element regardless of who needs WebAudio.
//
// Whether the visualiser actually animates depends on the audio source being
// CORS-clean:
// - In Electron, the main process injects `Access-Control-Allow-Origin: *`
// on media responses, so direct Icecast streams work.
// - In a plain browser, the player routes through the same-origin
// `/api/stations/:id/proxy` endpoint whenever room sync is active, which
// also makes the analyser readable. Solo-mode playback in a plain browser
// uses the direct stream and the analyser will read silence-of-data —
// that's a fundamental browser CORS constraint, not a bug here.
let rafId = null;
let analyser = null;
let dataArr = null;
let currentCanvas = null;
let activePlayer = null;
function resizeCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth || canvas.width || 1;
const cssH = canvas.clientHeight || canvas.height || 1;
const w = Math.max(1, Math.round(cssW * dpr));
const h = Math.max(1, Math.round(cssH * dpr));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
}
function draw(canvas) {
// Late binding: the player builds its graph lazily on first 'playing'
// event, so the analyser may only appear a moment after we mount.
if (!analyser && activePlayer) {
analyser = activePlayer.getAnalyser();
if (analyser) dataArr = new Uint8Array(analyser.frequencyBinCount);
}
if (!analyser || !dataArr) return;
resizeCanvas(canvas);
const ctx2d = canvas.getContext('2d');
if (!ctx2d) return;
analyser.getByteFrequencyData(dataArr);
const w = canvas.width;
const h = canvas.height;
ctx2d.clearRect(0, 0, w, h);
// Aggregate the ~512 raw bins into ~64 visual bars for legibility on the
// master stage. We weight lower frequencies (musically more interesting)
// by sampling logarithmically.
const bars = 64;
const binCount = dataArr.length;
const gap = Math.max(1, Math.floor(w / bars / 6));
const barW = (w - gap * (bars - 1)) / bars;
const grad = ctx2d.createLinearGradient(0, h, 0, 0);
grad.addColorStop(0, 'rgba(80, 220, 255, 0.85)');
grad.addColorStop(0.6, 'rgba(140, 120, 255, 0.85)');
grad.addColorStop(1, 'rgba(255, 100, 200, 0.95)');
ctx2d.fillStyle = grad;
for (let i = 0; i < bars; i++) {
const t0 = i / bars;
const t1 = (i + 1) / bars;
const lo = Math.floor(Math.pow(t0, 2) * binCount);
const hi = Math.max(lo + 1, Math.floor(Math.pow(t1, 2) * binCount));
let sum = 0;
for (let j = lo; j < hi && j < binCount; j++) sum += dataArr[j];
const avg = sum / (hi - lo); // 0..255
const norm = Math.pow(avg / 255, 0.7);
const barH = Math.max(2, Math.round(norm * h));
const x = Math.round(i * (barW + gap));
ctx2d.fillRect(x, h - barH, Math.max(1, Math.round(barW)), barH);
}
}
function loop() {
if (!currentCanvas) { rafId = null; return; }
draw(currentCanvas);
rafId = requestAnimationFrame(loop);
}
/**
* Attach the analyser tap to `player.audio` (via the player's shared graph)
* and start drawing into `canvas`. Idempotent.
*/
export function mountVisualizer(canvas, player) {
if (!canvas || !player?.audio) return;
activePlayer = player;
// Tickle the graph so the analyser is wired up before audio starts.
if (typeof player._ensureAudioGraph === 'function') {
try { player._ensureAudioGraph(); } catch { /* CORS — see file header */ }
}
analyser = player.getAnalyser();
if (analyser) dataArr = new Uint8Array(analyser.frequencyBinCount);
currentCanvas = canvas;
if (rafId == null) rafId = requestAnimationFrame(loop);
}
export function unmountVisualizer() {
currentCanvas = null;
if (rafId != null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}