Add frontend audio visualizer
This commit is contained in:
parent
038851813c
commit
a9b3421402
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
})();
|
||||
Loading…
Reference in New Issue