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.
This commit is contained in:
108
web/master/visualizer.js
Normal file
108
web/master/visualizer.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user