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