135 lines
4.6 KiB
JavaScript
135 lines
4.6 KiB
JavaScript
// Audio-reactive visualizer for the player bar.
|
|
// Uses Canvas2D (simpler + lighter than Three.js for a tiny radial visualizer).
|
|
// Feeds bass energy to the synthwave grid background via window.briTunesBg.
|
|
|
|
const canvas = document.getElementById('player-viz');
|
|
const audio = document.getElementById('player-audio');
|
|
if (canvas && audio) {
|
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
let audioCtx = null;
|
|
let analyser = null;
|
|
let freqData = null;
|
|
let hooked = false;
|
|
let rafId = null;
|
|
let visible = true;
|
|
|
|
function resize() {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
|
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
}
|
|
resize();
|
|
window.addEventListener('resize', resize);
|
|
|
|
function hookAudio() {
|
|
if (hooked) return;
|
|
try {
|
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const src = audioCtx.createMediaElementSource(audio);
|
|
analyser = audioCtx.createAnalyser();
|
|
analyser.fftSize = 128; // 64 freq bins
|
|
analyser.smoothingTimeConstant = 0.82;
|
|
freqData = new Uint8Array(analyser.frequencyBinCount);
|
|
src.connect(analyser);
|
|
analyser.connect(audioCtx.destination);
|
|
hooked = true;
|
|
window.briTunesAnalyser = analyser;
|
|
// Resume the context the moment the OS suspends it mid-playback
|
|
// (screen lock on iOS/Android silences output via AudioContext suspension).
|
|
audioCtx.addEventListener('statechange', () => {
|
|
if (audioCtx.state === 'suspended' && !audio.paused) {
|
|
audioCtx.resume().catch(() => {});
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.warn('[player-viz] could not hook audio:', err.message);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('briTunes:play', () => {
|
|
hookAudio();
|
|
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
|
|
if (!rafId && !reduced) rafId = requestAnimationFrame(frame);
|
|
});
|
|
|
|
audio.addEventListener('play', () => {
|
|
// Covers the case where native <audio> controls are used.
|
|
hookAudio();
|
|
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
|
|
if (!rafId && !reduced) rafId = requestAnimationFrame(frame);
|
|
});
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
visible = !document.hidden;
|
|
if (!document.hidden && audioCtx && audioCtx.state === 'suspended' && !audio.paused) {
|
|
audioCtx.resume().catch(() => {});
|
|
}
|
|
});
|
|
|
|
function frame() {
|
|
rafId = requestAnimationFrame(frame);
|
|
if (!visible || !analyser) return;
|
|
|
|
analyser.getByteFrequencyData(freqData);
|
|
|
|
const w = canvas.clientWidth;
|
|
const h = canvas.clientHeight;
|
|
const cx = w / 2;
|
|
const cy = h / 2;
|
|
const innerR = Math.min(w, h) * 0.10; // small hub — no cover to ring
|
|
const maxBarLen = Math.min(w, h) * 0.40;
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
const bars = 36;
|
|
const binStep = Math.floor(freqData.length / bars);
|
|
let bassSum = 0;
|
|
const bassBins = 6;
|
|
for (let i = 0; i < bassBins; i++) bassSum += freqData[i];
|
|
const bassEnergy = (bassSum / (bassBins * 255));
|
|
|
|
// Pass bass energy to the background grid animator.
|
|
if (window.briTunesBg && typeof window.briTunesBg.setBeat === 'function') {
|
|
window.briTunesBg.setBeat(bassEnergy);
|
|
}
|
|
|
|
// Draw each bar as a short line sprouting outward from innerR.
|
|
for (let i = 0; i < bars; i++) {
|
|
const v = freqData[i * binStep] / 255;
|
|
if (v < 0.02) continue;
|
|
const angle = (i / bars) * Math.PI * 2 - Math.PI / 2;
|
|
const len = v * maxBarLen;
|
|
const x1 = cx + Math.cos(angle) * innerR;
|
|
const y1 = cy + Math.sin(angle) * innerR;
|
|
const x2 = cx + Math.cos(angle) * (innerR + len);
|
|
const y2 = cy + Math.sin(angle) * (innerR + len);
|
|
|
|
// Color by frequency band: bass → magenta, mid → violet, high → cyan
|
|
let color;
|
|
if (i < bars * 0.25) color = 'rgba(255, 43, 214, 1)'; // magenta
|
|
else if (i < bars * 0.6) color = 'rgba(143, 91, 255, 1)'; // violet
|
|
else color = 'rgba(0, 229, 255, 1)'; // cyan
|
|
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 2;
|
|
ctx.shadowColor = color;
|
|
ctx.shadowBlur = 8;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
if (!reduced) {
|
|
// Idle ambient frame (does nothing until audio hooks up).
|
|
rafId = requestAnimationFrame(frame);
|
|
}
|
|
}
|