- 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.
109 lines
4.1 KiB
JavaScript
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;
|
|
}
|
|
}
|