// 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(); }); })();