Add frontend audio visualizer

This commit is contained in:
peterino2 2026-07-02 15:22:52 -07:00
parent 7740643421
commit 6b6fb26a6e
5 changed files with 489 additions and 4 deletions

2
public/controls.js vendored
View File

@ -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) {

View File

@ -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,

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blastoise! A very special music server</title>
<link rel="stylesheet" href="/styles.css?v=20">
<link rel="stylesheet" href="/styles.css?v=21">
</head>
<body>
<div id="app">
@ -70,6 +70,13 @@
<option value="sideways">Sideways Doomscroll</option>
</optgroup>
</select>
<select id="visualizer-select" title="Visualizer">
<option value="off">Visualizer off</option>
<option value="bars">Bars</option>
<option value="wave">Waveform</option>
<option value="radial">Radial</option>
<option value="pulse">Pulse</option>
</select>
</div>
</div>
@ -162,6 +169,10 @@
</div>
</div>
<div id="visualizer-shell" class="visualizer-shell hidden">
<canvas id="visualizer-canvas" aria-hidden="true"></canvas>
</div>
<div id="player-bar">
<div id="now-playing">
<div id="channel-name"></div>
@ -204,6 +215,7 @@
<script src="/core.js"></script>
<script src="/themes.js"></script>
<script src="/utils.js"></script>
<script src="/visualizer.js"></script>
<script src="/trackComponent.js"></script>
<script src="/trackContainer.js"></script>
<script src="/audioCache.js"></script>

View File

@ -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 {

399
public/visualizer.js Normal file
View File

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