blastoise/public/visualizer.js

782 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// MusicRoom - Audio visualizer
// Pure frontend Web Audio + canvas display for the shared audio element.
(function() {
const M = window.MusicRoom;
const MODES = new Set(["off", "bars", "wave", "radial", "pulse"]);
const COOKIE_MAX_AGE = 60 * 60 * 24 * 400;
const TWO_PI = Math.PI * 2;
const PARTICLE_COUNT = 140;
let mode = "off";
let shell = null;
let canvas = null;
let ctx = null;
let selector = null;
let fullscreenButton = null;
let audioContext = null;
let source = null;
let analyser = null;
let frequencyData = null;
let waveformData = null;
let animationId = 0;
let graphUnavailable = false;
let particles = [];
let rememberedWidth = 0;
let rememberedHeight = 0;
let beatMemory = 0;
let energyMemory = 0;
let frame = 0;
function normalizeMode(value) {
return MODES.has(value) ? value : "off";
}
function decodeStoredValue(value) {
if (!value) return null;
try {
return decodeURIComponent(value);
} catch {
M.clearCookieValue(M.VISUALIZER_STORAGE_KEY);
return null;
}
}
function getSavedMode() {
const cookieMode = normalizeMode(decodeStoredValue(M.getCookieValue(M.VISUALIZER_STORAGE_KEY)));
if (cookieMode !== "off") return cookieMode;
try {
const storedMode = normalizeMode(localStorage.getItem(M.VISUALIZER_STORAGE_KEY));
if (storedMode !== "off") {
M.setCookieValue(M.VISUALIZER_STORAGE_KEY, storedMode, COOKIE_MAX_AGE);
return storedMode;
}
} catch {}
return "off";
}
function rememberMode(nextMode) {
const normalized = normalizeMode(nextMode);
if (normalized === "off") {
M.clearCookieValue(M.VISUALIZER_STORAGE_KEY);
try {
localStorage.removeItem(M.VISUALIZER_STORAGE_KEY);
} catch {}
return;
}
M.setCookieValue(M.VISUALIZER_STORAGE_KEY, normalized, COOKIE_MAX_AGE);
try {
localStorage.setItem(M.VISUALIZER_STORAGE_KEY, normalized);
} catch {}
}
function getColors() {
const styles = getComputedStyle(shell || document.body);
const value = (name) => {
const raw = styles.getPropertyValue(name).trim();
return raw && !raw.includes("var(") ? raw : "";
};
return {
accent: value("--visualizer-accent") || value("--accent") || "#44ee88",
secondary: value("--visualizer-secondary") || value("--warning") || "#66aaff",
background: value("--visualizer-bg") || "rgba(10, 10, 10, 0.82)",
muted: value("--visualizer-muted") || "rgba(255, 255, 255, 0.16)"
};
}
function resizeCanvas() {
if (!canvas || !ctx) return;
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(rect.width * dpr));
const height = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (rememberedWidth !== canvas.clientWidth || rememberedHeight !== canvas.clientHeight) {
rememberedWidth = canvas.clientWidth;
rememberedHeight = canvas.clientHeight;
seedParticles(rememberedWidth, rememberedHeight);
}
}
function clearCanvas() {
if (!canvas || !ctx) return;
resizeCanvas();
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
}
function seedParticles(width, height) {
particles = [];
const safeWidth = Math.max(width, 1);
const safeHeight = Math.max(height, 1);
for (let i = 0; i < PARTICLE_COUNT; i++) {
particles.push({
x: Math.random() * safeWidth,
y: Math.random() * safeHeight,
px: Math.random() * safeWidth,
py: Math.random() * safeHeight,
vx: (Math.random() - 0.5) * 0.55,
vy: (Math.random() - 0.5) * 0.55,
size: 0.8 + Math.random() * 2.4,
phase: Math.random() * TWO_PI,
spin: (Math.random() - 0.5) * 0.04,
lane: Math.random()
});
}
}
function initAudioGraph() {
if (source) return true;
if (graphUnavailable) return false;
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
if (!AudioContextCtor) {
graphUnavailable = true;
return false;
}
try {
audioContext = audioContext || new AudioContextCtor();
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyser.minDecibels = -96;
analyser.maxDecibels = -10;
analyser.smoothingTimeConstant = 0.68;
source = audioContext.createMediaElementSource(M.audio);
source.connect(analyser);
analyser.connect(audioContext.destination);
frequencyData = new Uint8Array(analyser.frequencyBinCount);
waveformData = new Uint8Array(analyser.fftSize);
return true;
} catch (error) {
graphUnavailable = true;
console.warn("[Visualizer] Unable to attach audio graph", error);
return false;
}
}
function startVisualizer() {
if (animationId || mode === "off") return;
animationId = requestAnimationFrame(draw);
}
function stopVisualizer() {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = 0;
}
clearCanvas();
}
function average(data, start, end) {
let total = 0;
const first = Math.max(0, Math.min(start, data.length));
const last = Math.max(first + 1, Math.min(end, data.length));
for (let i = first; i < last; i++) total += data[i];
return total / (last - first);
}
function bandAverage(startRatio, endRatio) {
if (!frequencyData) return 0;
const start = Math.floor(Math.max(0, Math.min(1, startRatio)) * frequencyData.length);
const end = Math.floor(Math.max(0, Math.min(1, endRatio)) * frequencyData.length);
return average(frequencyData, start, Math.max(start + 1, end)) / 255;
}
function bandValue(index, count) {
if (!frequencyData) return 0;
const a = Math.pow(index / count, 1.75);
const b = Math.pow((index + 1) / count, 1.75);
const start = Math.floor(a * frequencyData.length);
const end = Math.max(start + 2, Math.floor(b * frequencyData.length));
const raw = average(frequencyData, start, end) / 255;
return Math.pow(raw, 0.72);
}
function readAudioState(timestamp, hasLiveGraph) {
if (hasLiveGraph) {
analyser.getByteFrequencyData(frequencyData);
analyser.getByteTimeDomainData(waveformData);
}
const bass = hasLiveGraph ? bandAverage(0.005, 0.06) : 0.55 + Math.sin(timestamp / 310) * 0.25;
const mids = hasLiveGraph ? bandAverage(0.06, 0.36) : 0.45 + Math.sin(timestamp / 470 + 1.4) * 0.22;
const treble = hasLiveGraph ? bandAverage(0.36, 0.92) : 0.38 + Math.sin(timestamp / 230 + 2.2) * 0.18;
const energy = Math.max(0, Math.min(1, bass * 0.42 + mids * 0.36 + treble * 0.22));
beatMemory = Math.max(beatMemory * 0.9, bass);
energyMemory = energyMemory * 0.82 + energy * 0.18;
return {
bass,
mids,
treble,
energy,
beat: beatMemory,
smooth: energyMemory,
live: hasLiveGraph,
time: timestamp / 1000
};
}
function fillWithTrails(width, height, colors, state) {
ctx.globalCompositeOperation = "source-over";
ctx.globalAlpha = state.live ? 0.24 : 0.42;
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
const glow = ctx.createRadialGradient(
width * (0.48 + Math.sin(state.time * 0.4) * 0.08),
height * (0.52 + Math.cos(state.time * 0.33) * 0.14),
1,
width * 0.5,
height * 0.5,
Math.max(width, height) * (0.4 + state.energy * 0.25)
);
glow.addColorStop(0, colors.secondary);
glow.addColorStop(0.38, colors.accent);
glow.addColorStop(1, "transparent");
ctx.globalAlpha = 0.08 + state.energy * 0.1;
ctx.fillStyle = glow;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
}
function drawGrid(width, height, colors, state) {
const spacing = Math.max(12, Math.min(34, width / 34));
const offset = (state.time * (30 + state.treble * 80)) % spacing;
ctx.save();
ctx.globalAlpha = 0.11 + state.energy * 0.08;
ctx.strokeStyle = colors.muted;
ctx.lineWidth = 1;
for (let x = -spacing + offset; x < width + spacing; x += spacing) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x + Math.sin(state.time + x * 0.01) * 18, height);
ctx.stroke();
}
for (let y = height + spacing - offset; y > -spacing; y -= spacing) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y + Math.cos(state.time + y * 0.02) * 8);
ctx.stroke();
}
ctx.globalAlpha = 0.09 + state.beat * 0.08;
ctx.strokeStyle = colors.secondary;
for (let y = 0; y < height; y += 4) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function drawParticles(width, height, colors, state, intensity = 1) {
if (!particles.length) seedParticles(width, height);
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (const p of particles) {
p.px = p.x;
p.py = p.y;
const wave = Math.sin(state.time * 2.2 + p.phase) * 0.65 + Math.cos(state.time + p.lane * 8) * 0.35;
const speed = (0.6 + state.energy * 3.2) * intensity;
p.vx += Math.cos(p.phase + state.time * (0.8 + p.lane)) * 0.015 * speed;
p.vy += Math.sin(p.phase * 1.7 + state.time * 1.15) * 0.018 * speed;
p.x += p.vx * speed + wave * state.treble * 1.8;
p.y += p.vy * speed + Math.sin(state.time * 1.4 + p.x * 0.015) * state.bass * 1.6;
p.vx *= 0.965;
p.vy *= 0.965;
p.phase += p.spin + state.energy * 0.012;
if (p.x < -20) p.x = width + 20;
if (p.x > width + 20) p.x = -20;
if (p.y < -20) p.y = height + 20;
if (p.y > height + 20) p.y = -20;
const useSecondary = p.lane > 0.52;
ctx.strokeStyle = useSecondary ? colors.secondary : colors.accent;
ctx.fillStyle = useSecondary ? colors.secondary : colors.accent;
ctx.globalAlpha = 0.14 + state.energy * 0.34;
ctx.lineWidth = Math.max(0.8, p.size * (0.5 + state.beat * 1.4));
ctx.beginPath();
ctx.moveTo(p.px, p.py);
ctx.lineTo(p.x, p.y);
ctx.stroke();
if (frame % 2 === 0 || state.energy > 0.52) {
ctx.globalAlpha = 0.16 + state.treble * 0.45;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * (0.7 + state.beat * 1.8), 0, TWO_PI);
ctx.fill();
}
}
ctx.restore();
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
}
function drawSpectrumRibbon(width, height, colors, state, flipped = false) {
const count = Math.max(44, Math.min(150, Math.floor(width / 7)));
const step = width / count;
const baseY = flipped ? height * 0.74 : height * 0.26;
const direction = flipped ? -1 : 1;
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.strokeStyle = flipped ? colors.secondary : colors.accent;
ctx.fillStyle = flipped ? colors.secondary : colors.accent;
ctx.lineWidth = 1.4 + state.energy * 2.4;
ctx.globalAlpha = 0.33 + state.energy * 0.25;
ctx.beginPath();
for (let i = 0; i <= count; i++) {
const value = state.live ? bandValue(i % count, count) : 0.35 + Math.sin(state.time * 3 + i * 0.27) * 0.26;
const x = i * step;
const y = baseY + direction * (value * height * 0.3 + Math.sin(state.time * 2 + i * 0.18) * height * 0.035);
if (i === 0) ctx.moveTo(x, baseY);
ctx.lineTo(x, y);
}
ctx.lineTo(width, baseY);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 0.78;
ctx.stroke();
ctx.restore();
}
function drawWaveformRibbon(width, height, colors, state, amplitude = 0.32, offset = 0, color = colors.accent) {
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.strokeStyle = color;
ctx.lineWidth = 1.2 + state.energy * 3.2;
ctx.globalAlpha = 0.4 + state.energy * 0.36;
ctx.beginPath();
const samples = state.live && waveformData ? waveformData.length : 720;
const step = Math.max(2, Math.floor(samples / Math.max(90, Math.min(240, width / 4))));
let first = true;
for (let i = 0; i < samples; i += step) {
const t = i / Math.max(1, samples - 1);
const raw = state.live ? (waveformData[i] - 128) / 128 : Math.sin(t * TWO_PI * 5 + state.time * 3 + offset) * 0.55 + Math.sin(t * TWO_PI * 17 - state.time * 2) * 0.15;
const x = t * width;
const y = height * 0.5 + raw * height * amplitude + Math.sin(t * TWO_PI * 2 + state.time + offset) * state.bass * height * 0.08;
if (first) {
ctx.moveTo(x, y);
first = false;
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
ctx.restore();
}
function drawBars(width, height, colors, state) {
drawGrid(width, height, colors, state);
drawSpectrumRibbon(width, height, colors, state, false);
drawSpectrumRibbon(width, height, colors, state, true);
const count = Math.max(56, Math.min(180, Math.floor(width / 5)));
const gap = Math.max(1, Math.min(3, width / 380));
const barWidth = Math.max(2, width / count - gap);
const centerY = height / 2;
const gradient = ctx.createLinearGradient(0, height, 0, 0);
gradient.addColorStop(0, colors.secondary);
gradient.addColorStop(0.5, colors.accent);
gradient.addColorStop(1, colors.secondary);
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.shadowColor = colors.accent;
ctx.shadowBlur = 10 + state.beat * 20;
ctx.fillStyle = gradient;
for (let i = 0; i < count; i++) {
const value = state.live ? bandValue(i, count) : 0.28 + Math.sin(state.time * 4 + i * 0.21) * 0.25 + Math.sin(state.time * 2.1 + i * 0.07) * 0.16;
const pulse = Math.max(0, Math.min(1, value + state.beat * 0.18));
const barHeight = Math.max(2, pulse * height * 0.47);
const x = i * (barWidth + gap);
const skew = Math.sin(state.time * 3 + i * 0.25) * state.treble * 5;
ctx.globalAlpha = 0.45 + pulse * 0.55;
ctx.fillRect(x + skew, centerY - barHeight, barWidth, barHeight);
ctx.fillRect(x - skew, centerY, barWidth, barHeight);
if (i % 3 === 0) {
ctx.globalAlpha = 0.35 + state.treble * 0.4;
ctx.fillRect(x, centerY - barHeight - 4 - state.treble * 9, barWidth, 2);
ctx.fillRect(x, centerY + barHeight + 2 + state.treble * 9, barWidth, 2);
}
}
ctx.restore();
drawWaveformRibbon(width, height, colors, state, 0.18, 0, colors.secondary);
drawWaveformRibbon(width, height, colors, state, 0.1, Math.PI, colors.accent);
drawParticles(width, height, colors, state, 1.1);
}
function drawWave(width, height, colors, state) {
drawGrid(width, height, colors, state);
drawSpectrumRibbon(width, height, colors, state, false);
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (let layer = 0; layer < 6; layer++) {
const color = layer % 2 ? colors.secondary : colors.accent;
drawWaveformRibbon(width, height, colors, state, 0.13 + layer * 0.045, layer * 0.9 + state.time * 0.4, color);
}
ctx.restore();
const nodes = 34;
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < nodes; i++) {
const t = i / (nodes - 1);
const sampleIndex = state.live ? Math.floor(t * (waveformData.length - 1)) : 0;
const wave = state.live ? (waveformData[sampleIndex] - 128) / 128 : Math.sin(t * TWO_PI * 8 + state.time * 3);
const value = state.live ? bandValue(i, nodes) : Math.abs(wave);
const x = t * width;
const y = height * 0.5 + wave * height * 0.28;
const radius = 2 + value * 13 + state.beat * 6;
ctx.globalAlpha = 0.14 + value * 0.55;
ctx.fillStyle = i % 2 ? colors.secondary : colors.accent;
ctx.beginPath();
ctx.arc(x, y, radius, 0, TWO_PI);
ctx.fill();
}
ctx.restore();
drawParticles(width, height, colors, state, 1.35);
}
function drawRadial(width, height, colors, state) {
drawGrid(width, height, colors, state);
const cx = width / 2;
const cy = height / 2;
const radius = Math.max(14, Math.min(width, height) * (0.14 + state.bass * 0.08));
const spokes = 192;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(state.time * (0.18 + state.treble * 0.22));
ctx.globalCompositeOperation = "lighter";
ctx.lineCap = "round";
for (let ring = 0; ring < 3; ring++) {
const ringRadius = radius + ring * Math.min(width, height) * 0.105 + state.beat * 18;
ctx.globalAlpha = 0.18 + ring * 0.08;
ctx.strokeStyle = ring % 2 ? colors.secondary : colors.accent;
ctx.lineWidth = 1 + state.energy * 2.4;
ctx.beginPath();
for (let i = 0; i <= spokes; i++) {
const value = state.live ? bandValue(i % spokes, spokes) : 0.34 + Math.sin(state.time * 4 + i * 0.16 + ring) * 0.24;
const angle = i / spokes * TWO_PI;
const waveRadius = ringRadius + value * Math.min(width, height) * (0.08 + ring * 0.035);
const x = Math.cos(angle) * waveRadius;
const y = Math.sin(angle) * waveRadius;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
for (let i = 0; i < spokes; i++) {
const value = state.live ? bandValue(i, spokes) : 0.28 + Math.sin(state.time * 6 + i * 0.23) * 0.24;
const angle = i / spokes * TWO_PI;
const start = radius * (0.68 + Math.sin(state.time + i) * 0.04);
const end = start + value * Math.min(width, height) * 0.42 + state.beat * 28;
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
ctx.globalAlpha = 0.16 + value * 0.72;
ctx.lineWidth = 0.9 + value * 3.8;
ctx.beginPath();
ctx.moveTo(Math.cos(angle) * start, Math.sin(angle) * start);
ctx.lineTo(Math.cos(angle) * end, Math.sin(angle) * end);
ctx.stroke();
}
ctx.globalAlpha = 0.22 + state.beat * 0.26;
ctx.fillStyle = colors.accent;
ctx.beginPath();
ctx.arc(0, 0, radius * (0.55 + state.beat * 0.45), 0, TWO_PI);
ctx.fill();
ctx.restore();
drawParticles(width, height, colors, state, 1.2);
}
function drawPulse(width, height, colors, state) {
drawGrid(width, height, colors, state);
const cx = width / 2;
const cy = height / 2;
const maxRadius = Math.hypot(width, height) * 0.55;
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < 10; i++) {
const phase = (state.time * (0.18 + state.energy * 0.24) + i / 10) % 1;
const radius = phase * maxRadius;
ctx.globalAlpha = (1 - phase) * (0.1 + state.beat * 0.22);
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
ctx.lineWidth = 1 + state.energy * 5;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, TWO_PI);
ctx.stroke();
}
const rays = 96;
for (let i = 0; i < rays; i++) {
const value = state.live ? bandValue(i, rays) : 0.32 + Math.sin(state.time * 7 + i * 0.31) * 0.25;
const angle = i / rays * TWO_PI + state.time * 0.25;
const length = Math.min(width, height) * (0.16 + value * 0.7 + state.beat * 0.22);
const wobble = Math.sin(state.time * 3 + i * 0.4) * state.treble * 18;
ctx.globalAlpha = 0.15 + value * 0.58;
ctx.strokeStyle = i % 2 ? colors.secondary : colors.accent;
ctx.lineWidth = 0.9 + value * 3.5;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(angle) * 8, cy + Math.sin(angle) * 8);
ctx.lineTo(cx + Math.cos(angle) * (length + wobble), cy + Math.sin(angle) * (length - wobble));
ctx.stroke();
}
const coreRadius = Math.max(8, Math.min(width, height) * (0.1 + state.beat * 0.18));
ctx.globalAlpha = 0.3 + state.beat * 0.45;
ctx.fillStyle = colors.secondary;
ctx.beginPath();
ctx.arc(cx, cy, coreRadius * 2.2, 0, TWO_PI);
ctx.fill();
ctx.globalAlpha = 0.8;
ctx.fillStyle = colors.accent;
ctx.beginPath();
ctx.arc(cx, cy, coreRadius, 0, TWO_PI);
ctx.fill();
ctx.restore();
drawWaveformRibbon(width, height, colors, state, 0.2, state.time, colors.secondary);
drawParticles(width, height, colors, state, 1.6);
}
function drawIdle(width, height, colors, timestamp) {
const state = readAudioState(timestamp, false);
fillWithTrails(width, height, colors, state);
drawGrid(width, height, colors, state);
if (mode === "radial") drawRadial(width, height, colors, state);
else if (mode === "pulse") drawPulse(width, height, colors, state);
else if (mode === "wave") drawWave(width, height, colors, state);
else drawBars(width, height, colors, state);
}
function draw(timestamp) {
animationId = requestAnimationFrame(draw);
if (!canvas || !ctx || mode === "off") return;
frame++;
resizeCanvas();
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const colors = getColors();
const hasLiveGraph = analyser
&& frequencyData
&& waveformData
&& audioContext?.state === "running"
&& !M.audio.paused
&& !!M.audio.src;
if (!hasLiveGraph) {
drawIdle(width, height, colors, timestamp);
return;
}
const state = readAudioState(timestamp, true);
fillWithTrails(width, height, colors, state);
if (mode === "wave") drawWave(width, height, colors, state);
else if (mode === "radial") drawRadial(width, height, colors, state);
else if (mode === "pulse") drawPulse(width, height, colors, state);
else drawBars(width, height, colors, state);
}
function isFullscreen() {
return document.fullscreenElement === shell
|| document.webkitFullscreenElement === shell
|| shell?.classList.contains("is-fullscreen");
}
function updateFullscreenButton() {
if (!fullscreenButton || !shell) return;
const active = isFullscreen();
shell.classList.toggle("is-fullscreen", active);
fullscreenButton.textContent = active ? "×" : "⛶";
fullscreenButton.title = active ? "Exit fullscreen visualizer" : "Fullscreen visualizer";
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
}
async function toggleFullscreen() {
if (!shell) return;
if (mode === "off") M.applyVisualizer("bars");
try {
if (document.fullscreenElement || document.webkitFullscreenElement || shell.classList.contains("is-fullscreen")) {
if (document.fullscreenElement && document.exitFullscreen) await document.exitFullscreen();
else if (document.webkitFullscreenElement && document.webkitExitFullscreen) await document.webkitExitFullscreen();
else shell.classList.remove("is-fullscreen");
} else if (shell.requestFullscreen) {
await shell.requestFullscreen();
} else if (shell.webkitRequestFullscreen) {
await shell.webkitRequestFullscreen();
} else {
shell.classList.add("is-fullscreen");
}
} catch (error) {
shell.classList.toggle("is-fullscreen");
console.warn("[Visualizer] Fullscreen request failed", error);
}
updateFullscreenButton();
resizeCanvas();
M.resumeVisualizer?.();
}
M.applyVisualizer = function(nextMode, remember = true) {
mode = normalizeMode(nextMode);
if (selector) selector.value = mode;
if (shell) shell.classList.toggle("hidden", mode === "off");
document.getElementById("player-content")?.classList.toggle("visualizer-active", mode !== "off");
if (remember) rememberMode(mode);
if (mode === "off") {
stopVisualizer();
} else {
resizeCanvas();
startVisualizer();
}
};
M.resumeVisualizer = async function() {
if (mode === "off") return false;
if (!initAudioGraph()) return false;
try {
if (audioContext.state === "suspended") {
await audioContext.resume();
}
startVisualizer();
return audioContext.state === "running";
} catch (error) {
console.warn("[Visualizer] Unable to resume audio context", error);
return false;
}
};
M.visualizer = {
getMode: () => mode,
hasGraph: () => !!source,
isRunning: () => audioContext?.state === "running",
isFullscreen,
toggleFullscreen
};
document.addEventListener("DOMContentLoaded", () => {
shell = document.getElementById("visualizer-shell");
canvas = document.getElementById("visualizer-canvas");
selector = document.getElementById("visualizer-select");
fullscreenButton = document.getElementById("btn-visualizer-fullscreen");
ctx = canvas?.getContext("2d") || null;
M.applyVisualizer(getSavedMode(), false);
updateFullscreenButton();
if (selector) {
selector.onchange = () => {
M.applyVisualizer(selector.value);
M.resumeVisualizer();
};
}
if (fullscreenButton) {
fullscreenButton.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
toggleFullscreen();
};
}
if (window.ResizeObserver && shell) {
const observer = new ResizeObserver(resizeCanvas);
observer.observe(shell);
}
window.addEventListener("resize", resizeCanvas);
});
document.addEventListener("fullscreenchange", () => {
updateFullscreenButton();
resizeCanvas();
});
document.addEventListener("webkitfullscreenchange", () => {
updateFullscreenButton();
resizeCanvas();
});
document.addEventListener("pointerdown", () => {
if (mode !== "off") M.resumeVisualizer();
}, { passive: true });
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && shell?.classList.contains("is-fullscreen") && !document.fullscreenElement && !document.webkitFullscreenElement) {
shell.classList.remove("is-fullscreen");
updateFullscreenButton();
resizeCanvas();
return;
}
if (mode !== "off") M.resumeVisualizer();
});
M.audio.addEventListener("play", () => {
if (mode !== "off") {
if (audioContext && audioContext.state === "suspended") {
audioContext.resume().catch(() => {});
}
startVisualizer();
}
});
M.audio.addEventListener("pause", () => {
if (mode !== "off") startVisualizer();
});
})();