diff --git a/public/controls.js b/public/controls.js index 0f4487b..fb8d7c0 100644 --- a/public/controls.js +++ b/public/controls.js @@ -24,6 +24,7 @@ // Toggle play/pause function togglePlayback() { if (!M.currentTrackId) return; + M.resumeVisualizer?.(); if (M.synced) { if (blockSyncedControlIfNeeded()) return; @@ -48,6 +49,7 @@ // Jump to a specific track index async function jumpToTrack(index) { if (M.queue.length === 0) return; + M.resumeVisualizer?.(); const newIndex = (index + M.queue.length) % M.queue.length; if (M.synced && M.currentChannelId) { diff --git a/public/core.js b/public/core.js index 4f10a10..3978363 100644 --- a/public/core.js +++ b/public/core.js @@ -27,6 +27,7 @@ window.MusicRoom = { STORAGE_KEY: "blastoise_volume", CHANNEL_STORAGE_KEY: "blastoise_channel", THEME_STORAGE_KEY: "blastoise_theme", + VISUALIZER_STORAGE_KEY: "blastoise_visualizer", // Playback state localTimestamp: 0, diff --git a/public/index.html b/public/index.html index b92eb64..8f540cb 100644 --- a/public/index.html +++ b/public/index.html @@ -4,7 +4,7 @@ Blastoise! A very special music server - +
@@ -70,6 +70,13 @@ +
@@ -162,6 +169,10 @@ + +
@@ -204,6 +215,7 @@ + diff --git a/public/styles.css b/public/styles.css index 86a2c70..f58d230 100644 --- a/public/styles.css +++ b/public/styles.css @@ -196,6 +196,8 @@ h3 { font-size: 0.8rem; color: #999; margin-bottom: 0.3rem; text-transform: uppe .context-menu-item.danger:hover { background: #3a2a2a; } /* Player bar */ +.visualizer-shell { --visualizer-bg: rgba(10, 14, 12, 0.9); --visualizer-accent: #44ee88; --visualizer-secondary: #66aaff; --visualizer-muted: rgba(255, 255, 255, 0.16); width: 100%; max-width: 1600px; height: 76px; margin: 0 auto 0.5rem auto; background: #121816; border: 1px solid #26382f; border-radius: 6px; overflow: hidden; min-width: 0; flex: 0 0 auto; } +#visualizer-canvas { display: block; width: 100%; height: 100%; } #player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; min-width: 0; max-width: 100%; overflow-x: hidden; } #now-playing { width: 180px; flex: 0 1 180px; min-width: 0; } #channel-name { font-size: 0.7rem; color: #666; margin-bottom: 0.1rem; } @@ -542,6 +544,12 @@ button:hover { background: #333; } .track-title { font-size: 0.85rem; } /* Player bar - stacked layout */ + .visualizer-shell { + height: 54px; + margin: 0 0 0.3rem 0; + max-width: 100%; + } + #player-bar { flex-direction: column; gap: 0.4rem; @@ -664,13 +672,15 @@ button:hover { background: #333; } } /* Theme selector */ -#stream-select { margin-left: auto; } +#stream-select { margin-left: auto; display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; } #theme-select { min-width: 170px; } +#visualizer-select { min-width: 140px; } /* Shared theme surfaces */ body[data-ui-theme]:not([data-ui-theme="default"]) #channels-panel, body[data-ui-theme]:not([data-ui-theme="default"]) #library-panel, body[data-ui-theme]:not([data-ui-theme="default"]) #queue-panel, +body[data-ui-theme]:not([data-ui-theme="default"]) .visualizer-shell, body[data-ui-theme]:not([data-ui-theme="default"]) #player-bar, body[data-ui-theme]:not([data-ui-theme="default"]) #login-panel, body[data-ui-theme]:not([data-ui-theme="default"]) .panel-views, @@ -743,6 +753,10 @@ body[data-ui-theme="phosphor"] { --active-color: #8cffad; --accent: #51f080; --warning: #f5de72; + --visualizer-bg: rgba(3, 13, 8, 0.95); + --visualizer-accent: #51f080; + --visualizer-secondary: #f5de72; + --visualizer-muted: rgba(81, 240, 128, 0.18); background: #020806; color: var(--text-color); } @@ -764,6 +778,10 @@ body[data-ui-theme="vaporwave"] { --active-color: #05ffa1; --accent: #01cdfe; --warning: #fffb96; + --visualizer-bg: rgba(25, 9, 45, 0.92); + --visualizer-accent: #01cdfe; + --visualizer-secondary: #ff71ce; + --visualizer-muted: rgba(255, 113, 206, 0.22); background: linear-gradient(135deg, #12091f 0%, #2f1856 46%, #0b4857 100%); color: var(--text-color); } @@ -787,6 +805,10 @@ body[data-ui-theme="sunrise"] { --active-color: #ffd166; --accent: #ff9f1c; --warning: #2ec4b6; + --visualizer-bg: rgba(36, 20, 43, 0.92); + --visualizer-accent: #ff9f1c; + --visualizer-secondary: #2ec4b6; + --visualizer-muted: rgba(255, 207, 86, 0.18); background: radial-gradient(circle at 20% 0%, #7a2f5c 0, #2b1838 45%, #101018 100%); color: var(--text-color); } @@ -804,6 +826,10 @@ body[data-ui-theme="paper"] { --active-color: #1d1a17; --accent: #285f47; --warning: #a75a00; + --visualizer-bg: rgba(255, 250, 240, 0.95); + --visualizer-accent: #285f47; + --visualizer-secondary: #a75a00; + --visualizer-muted: rgba(29, 26, 23, 0.16); background: #d7c9aa; color: var(--text-color); } @@ -829,6 +855,10 @@ body[data-ui-theme="contrast"] { --active-color: #000; --accent: #00ffff; --warning: #ff00ff; + --visualizer-bg: #000000; + --visualizer-accent: #00ffff; + --visualizer-secondary: #ffff00; + --visualizer-muted: rgba(255, 255, 255, 0.32); background: #000; color: #fff; } @@ -851,6 +881,10 @@ body[data-ui-theme="aquarium"] { --active-color: #adfff0; --accent: #52e0c4; --warning: #ffd166; + --visualizer-bg: rgba(5, 35, 49, 0.82); + --visualizer-accent: #52e0c4; + --visualizer-secondary: #59bde0; + --visualizer-muted: rgba(163, 255, 241, 0.18); background: linear-gradient(180deg, #062c45, #03161d); color: var(--text-color); } @@ -874,6 +908,10 @@ body[data-ui-theme="amber"] { --active-color: #ffc857; --accent: #ffb000; --warning: #f77f00; + --visualizer-bg: rgba(18, 12, 3, 0.94); + --visualizer-accent: #ffb000; + --visualizer-secondary: #f77f00; + --visualizer-muted: rgba(255, 176, 0, 0.2); background: #080502; color: var(--text-color); font-family: "Cascadia Mono", "Consolas", monospace; @@ -895,6 +933,10 @@ body[data-ui-theme="compact"] { --active-color: #9fc7ff; --accent: #79c0ff; --warning: #e3b341; + --visualizer-bg: rgba(13, 17, 23, 0.94); + --visualizer-accent: #79c0ff; + --visualizer-secondary: #e3b341; + --visualizer-muted: rgba(121, 192, 255, 0.18); background: #0d1117; color: var(--text-color); } @@ -922,6 +964,10 @@ body[data-ui-theme="cinema"] { --active-color: #f6c65b; --accent: #f6c65b; --warning: #db5461; + --visualizer-bg: rgba(5, 5, 5, 0.95); + --visualizer-accent: #f6c65b; + --visualizer-secondary: #db5461; + --visualizer-muted: rgba(246, 198, 91, 0.18); background: #050505; color: var(--text-color); } @@ -944,6 +990,10 @@ body[data-ui-theme="hotdog"] { --active-color: #ffffff; --accent: #ff0000; --warning: #000000; + --visualizer-bg: #ffff00; + --visualizer-accent: #ff0000; + --visualizer-secondary: #000000; + --visualizer-muted: rgba(0, 0, 0, 0.22); background: #ff0000; color: #000000; font-family: "MS Sans Serif", "Arial", sans-serif; @@ -960,6 +1010,7 @@ body[data-ui-theme="hotdog"] .panel-tab.active { body[data-ui-theme="hotdog"] #channels-panel, body[data-ui-theme="hotdog"] #library-panel, body[data-ui-theme="hotdog"] #queue-panel, +body[data-ui-theme="hotdog"] .visualizer-shell, body[data-ui-theme="hotdog"] #player-bar, body[data-ui-theme="hotdog"] #login-panel, body[data-ui-theme="hotdog"] .panel-views, @@ -1022,6 +1073,10 @@ body[data-ui-theme="hotdog"] .track.cached .cache-indicator { background: #ff0000; } +body[data-ui-theme="hotdog"] .visualizer-shell { + background: #ffff00; +} + html[data-ui-theme="spreadsheet"], html[data-ui-theme="myspace"], html[data-ui-theme="sideways"] { @@ -1054,6 +1109,10 @@ body[data-ui-theme="spreadsheet"] { --active-color: #000000; --accent: #0000ff; --warning: #ff0000; + --visualizer-bg: #ffffff; + --visualizer-accent: #0000ff; + --visualizer-secondary: #ff0000; + --visualizer-muted: rgba(0, 0, 0, 0.18); background: #fff; color: #000; font-family: "Times New Roman", serif; @@ -1061,6 +1120,7 @@ body[data-ui-theme="spreadsheet"] { body[data-ui-theme="spreadsheet"] #app { width: 2100px; max-width: none; overflow-x: visible; } body[data-ui-theme="spreadsheet"] #main-content { width: 2050px; max-width: none; overflow-x: visible; gap: 0; } +body[data-ui-theme="spreadsheet"] .visualizer-shell { width: 2050px; max-width: none; } body[data-ui-theme="spreadsheet"] #channels-panel, body[data-ui-theme="spreadsheet"] #library-panel, body[data-ui-theme="spreadsheet"] #queue-panel, @@ -1086,6 +1146,10 @@ body[data-ui-theme="myspace"] { --active-color: #000000; --accent: #00ff66; --warning: #ff6600; + --visualizer-bg: rgba(34, 0, 68, 0.92); + --visualizer-accent: #00ff66; + --visualizer-secondary: #ff00ff; + --visualizer-muted: rgba(255, 255, 0, 0.22); background: repeating-linear-gradient(45deg, #ff00ff 0 18px, #00ffff 18px 36px, #ffff00 36px 54px); color: var(--text-color); font-family: Impact, "Arial Black", sans-serif; @@ -1093,6 +1157,7 @@ body[data-ui-theme="myspace"] { body[data-ui-theme="myspace"] #app { width: 1850px; max-width: none; overflow-x: visible; transform: rotate(-0.35deg); } body[data-ui-theme="myspace"] #main-content { width: 1780px; max-width: none; overflow-x: visible; gap: 1.25rem; } +body[data-ui-theme="myspace"] .visualizer-shell { width: 1780px; max-width: none; } body[data-ui-theme="myspace"] #channels-panel, body[data-ui-theme="myspace"] #library-panel, body[data-ui-theme="myspace"] #queue-panel, @@ -1118,11 +1183,16 @@ body[data-ui-theme="sideways"] { --active-color: #ffb199; --accent: #ff6b35; --warning: #ffd166; + --visualizer-bg: rgba(25, 11, 11, 0.94); + --visualizer-accent: #ff6b35; + --visualizer-secondary: #ffd166; + --visualizer-muted: rgba(248, 220, 220, 0.16); background: #190b0b; color: var(--text-color); } body[data-ui-theme="sideways"] #app { width: 2600px; max-width: none; overflow-x: visible; } +body[data-ui-theme="sideways"] .visualizer-shell { width: 2480px; max-width: none; } body[data-ui-theme="sideways"] #main-content { width: 2480px; max-width: none; @@ -1136,8 +1206,9 @@ body[data-ui-theme="sideways"] #queue-panel { max-height: 42vh; } body[data-ui-theme="sideways"] #player-bar { width: 2480px; max-width: none; } @media (max-width: 768px) { - #stream-select { width: 100%; margin-left: 0; } - #theme-select { width: 100%; min-height: 44px; } + #stream-select { width: 100%; margin-left: 0; justify-content: stretch; } + #theme-select, + #visualizer-select { flex: 1 1 150px; min-height: 44px; min-width: 0; } body[data-ui-theme="compact"] #app, body[data-ui-theme="cinema"] #app { diff --git a/public/visualizer.js b/public/visualizer.js new file mode 100644 index 0000000..1581a12 --- /dev/null +++ b/public/visualizer.js @@ -0,0 +1,399 @@ +// 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; + + let mode = "off"; + let shell = null; + let canvas = null; + let ctx = null; + let selector = null; + let audioContext = null; + let source = null; + let analyser = null; + let frequencyData = null; + let waveformData = null; + let animationId = 0; + let graphUnavailable = false; + + 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 : ""; + }; + const accent = value("--visualizer-accent") || value("--accent") || "#44ee88"; + const secondary = value("--visualizer-secondary") || value("--warning") || "#66aaff"; + const background = value("--visualizer-bg") || "rgba(10, 10, 10, 0.82)"; + const muted = value("--visualizer-muted") || "rgba(255, 255, 255, 0.16)"; + + return { accent, secondary, background, muted }; + } + + 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); + } + + function clearCanvas() { + if (!canvas || !ctx) return; + resizeCanvas(); + ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); + } + + 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 = 1024; + analyser.minDecibels = -92; + analyser.maxDecibels = -12; + analyser.smoothingTimeConstant = 0.82; + + 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 last = Math.min(end, data.length); + for (let i = start; i < last; i++) total += data[i]; + return total / Math.max(1, last - start); + } + + function drawIdle(width, height, colors, timestamp) { + const bars = Math.max(12, Math.floor(width / 18)); + const gap = 3; + const barWidth = Math.max(3, (width - gap * (bars - 1)) / bars); + + ctx.globalAlpha = 0.7; + ctx.fillStyle = colors.muted; + for (let i = 0; i < bars; i++) { + const phase = timestamp / 600 + i * 0.55; + const value = 0.16 + (Math.sin(phase) + 1) * 0.08; + const barHeight = Math.max(2, height * value); + const x = i * (barWidth + gap); + ctx.fillRect(x, height - barHeight, barWidth, barHeight); + } + ctx.globalAlpha = 1; + } + + function drawBars(width, height, colors) { + analyser.getByteFrequencyData(frequencyData); + + const barCount = Math.max(20, Math.min(72, Math.floor(width / 8))); + const gap = 2; + const barWidth = Math.max(2, (width - gap * (barCount - 1)) / barCount); + const sampleSize = Math.max(1, Math.floor(frequencyData.length / barCount)); + const gradient = ctx.createLinearGradient(0, height, 0, 0); + gradient.addColorStop(0, colors.accent); + gradient.addColorStop(1, colors.secondary); + ctx.fillStyle = gradient; + + for (let i = 0; i < barCount; i++) { + const value = average(frequencyData, i * sampleSize, (i + 1) * sampleSize) / 255; + const barHeight = Math.max(2, value * height * 0.9); + const x = i * (barWidth + gap); + ctx.fillRect(x, height - barHeight, barWidth, barHeight); + } + } + + function drawWave(width, height, colors) { + analyser.getByteTimeDomainData(waveformData); + + ctx.lineWidth = 2; + ctx.strokeStyle = colors.accent; + ctx.beginPath(); + + for (let i = 0; i < waveformData.length; i++) { + const x = i / (waveformData.length - 1) * width; + const y = waveformData[i] / 255 * height; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + + ctx.stroke(); + + ctx.globalAlpha = 0.35; + ctx.strokeStyle = colors.secondary; + ctx.beginPath(); + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + ctx.stroke(); + ctx.globalAlpha = 1; + } + + function drawRadial(width, height, colors) { + analyser.getByteFrequencyData(frequencyData); + + const cx = width / 2; + const cy = height / 2; + const radius = Math.max(12, Math.min(width, height) * 0.22); + const spokes = 96; + const sampleSize = Math.max(1, Math.floor(frequencyData.length / spokes)); + + ctx.save(); + ctx.translate(cx, cy); + ctx.lineCap = "round"; + + for (let i = 0; i < spokes; i++) { + const value = average(frequencyData, i * sampleSize, (i + 1) * sampleSize) / 255; + const length = radius + value * Math.min(width, height) * 0.32; + const angle = i / spokes * Math.PI * 2; + const hueColor = i % 2 === 0 ? colors.accent : colors.secondary; + + ctx.rotate(angle - (i === 0 ? 0 : (i - 1) / spokes * Math.PI * 2)); + ctx.strokeStyle = hueColor; + ctx.lineWidth = Math.max(1, 1 + value * 3); + ctx.beginPath(); + ctx.moveTo(radius, 0); + ctx.lineTo(length, 0); + ctx.stroke(); + } + + ctx.globalAlpha = 0.35; + ctx.strokeStyle = colors.muted; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(0, 0, radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + ctx.globalAlpha = 1; + } + + function drawPulse(width, height, colors) { + analyser.getByteFrequencyData(frequencyData); + analyser.getByteTimeDomainData(waveformData); + + const bass = average(frequencyData, 1, 18) / 255; + const mids = average(frequencyData, 18, 96) / 255; + const cx = width / 2; + const cy = height / 2; + const radius = Math.max(8, Math.min(width, height) * (0.16 + bass * 0.26)); + + ctx.globalAlpha = 0.28; + ctx.fillStyle = colors.secondary; + ctx.beginPath(); + ctx.arc(cx, cy, radius * 1.75, 0, Math.PI * 2); + ctx.fill(); + + ctx.globalAlpha = 0.9; + ctx.fillStyle = colors.accent; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fill(); + + ctx.globalAlpha = 0.75; + ctx.strokeStyle = colors.secondary; + ctx.lineWidth = 1 + mids * 4; + ctx.beginPath(); + for (let i = 0; i < waveformData.length; i += 4) { + const x = i / (waveformData.length - 1) * width; + const y = height * 0.5 + (waveformData[i] - 128) / 128 * height * 0.32; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.globalAlpha = 1; + } + + function draw(timestamp) { + animationId = requestAnimationFrame(draw); + if (!canvas || !ctx || mode === "off") return; + + 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; + + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = colors.background; + ctx.fillRect(0, 0, width, height); + + if (!hasLiveGraph) { + drawIdle(width, height, colors, timestamp); + return; + } + + if (mode === "wave") drawWave(width, height, colors); + else if (mode === "radial") drawRadial(width, height, colors); + else if (mode === "pulse") drawPulse(width, height, colors); + else drawBars(width, height, colors); + } + + M.applyVisualizer = function(nextMode, remember = true) { + mode = normalizeMode(nextMode); + if (selector) selector.value = mode; + if (shell) shell.classList.toggle("hidden", 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" + }; + + document.addEventListener("DOMContentLoaded", () => { + shell = document.getElementById("visualizer-shell"); + canvas = document.getElementById("visualizer-canvas"); + selector = document.getElementById("visualizer-select"); + ctx = canvas?.getContext("2d") || null; + + M.applyVisualizer(getSavedMode(), false); + + if (selector) { + selector.onchange = () => { + M.applyVisualizer(selector.value); + M.resumeVisualizer(); + }; + } + + if (window.ResizeObserver && shell) { + const observer = new ResizeObserver(resizeCanvas); + observer.observe(shell); + } + window.addEventListener("resize", resizeCanvas); + }); + + document.addEventListener("pointerdown", () => { + if (mode !== "off") M.resumeVisualizer(); + }, { passive: true }); + + document.addEventListener("keydown", () => { + 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(); + }); +})();