diff --git a/public/index.html b/public/index.html
index 8f540cb..5b58ad1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,7 +4,7 @@
@@ -171,6 +171,7 @@
+
diff --git a/public/styles.css b/public/styles.css
index f58d230..055493c 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -196,8 +196,24 @@ 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-shell { --visualizer-bg: rgba(10, 14, 12, 0.9); --visualizer-accent: #44ee88; --visualizer-secondary: #66aaff; --visualizer-muted: rgba(255, 255, 255, 0.16); position: relative; isolation: isolate; width: 100%; max-width: 1600px; height: 118px; margin: 0 auto 0.5rem auto; background: #121816; border: 1px solid #26382f; border-radius: 6px; overflow: hidden; min-width: 0; flex: 0 0 auto; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03), 0 12px 30px rgba(0,0,0,0.18); }
#visualizer-canvas { display: block; width: 100%; height: 100%; }
+.visualizer-fullscreen-btn { position: absolute; top: 0.45rem; right: 0.45rem; z-index: 2; width: 30px; height: 30px; padding: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.42); border: 1px solid rgba(255,255,255,0.24); border-radius: 4px; color: #fff; font-size: 1rem; line-height: 1; opacity: 0; transition: opacity 0.15s, background 0.15s, transform 0.15s; backdrop-filter: blur(4px); }
+.visualizer-shell:hover .visualizer-fullscreen-btn,
+.visualizer-shell:focus-within .visualizer-fullscreen-btn,
+.visualizer-shell.is-fullscreen .visualizer-fullscreen-btn,
+.visualizer-shell:fullscreen .visualizer-fullscreen-btn { opacity: 0.86; }
+.visualizer-fullscreen-btn:hover { background: rgba(0,0,0,0.68); transform: scale(1.04); }
+.visualizer-shell.is-fullscreen,
+.visualizer-shell:fullscreen,
+.visualizer-shell:-webkit-full-screen { width: 100vw; height: 100vh; max-width: none; margin: 0; border: 0; border-radius: 0; background: #050505; box-shadow: none; }
+.visualizer-shell.is-fullscreen { position: fixed; inset: 0; z-index: 3000; }
+.visualizer-shell.is-fullscreen #visualizer-canvas,
+.visualizer-shell:fullscreen #visualizer-canvas,
+.visualizer-shell:-webkit-full-screen #visualizer-canvas { width: 100vw; height: 100vh; }
+.visualizer-shell.is-fullscreen .visualizer-fullscreen-btn,
+.visualizer-shell:fullscreen .visualizer-fullscreen-btn,
+.visualizer-shell:-webkit-full-screen .visualizer-fullscreen-btn { top: 1rem; right: 1rem; width: 40px; height: 40px; font-size: 1.4rem; }
#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; }
@@ -545,7 +561,7 @@ button:hover { background: #333; }
/* Player bar - stacked layout */
.visualizer-shell {
- height: 54px;
+ height: 74px;
margin: 0 0 0.3rem 0;
max-width: 100%;
}
@@ -711,6 +727,12 @@ body[data-ui-theme]:not([data-ui-theme="default"]) .track-actions .track-preview
color: var(--control-color, var(--text-color, #eee));
}
+body[data-ui-theme]:not([data-ui-theme="default"]) .visualizer-fullscreen-btn {
+ background: rgba(0,0,0,0.48);
+ border-color: rgba(255,255,255,0.28);
+ color: #fff;
+}
+
body[data-ui-theme]:not([data-ui-theme="default"]) .track:hover,
body[data-ui-theme]:not([data-ui-theme="default"]) .playlist-item:hover,
body[data-ui-theme]:not([data-ui-theme="default"]) .context-menu-item:hover {
diff --git a/public/visualizer.js b/public/visualizer.js
index 1581a12..ad083ff 100644
--- a/public/visualizer.js
+++ b/public/visualizer.js
@@ -6,12 +6,15 @@
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;
@@ -19,6 +22,12 @@
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";
@@ -71,12 +80,13 @@
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 };
+ 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() {
@@ -92,6 +102,12 @@
}
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() {
@@ -100,6 +116,27 @@
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;
@@ -113,10 +150,10 @@
try {
audioContext = audioContext || new AudioContextCtor();
analyser = audioContext.createAnalyser();
- analyser.fftSize = 1024;
- analyser.minDecibels = -92;
- analyser.maxDecibels = -12;
- analyser.smoothingTimeConstant = 0.82;
+ analyser.fftSize = 2048;
+ analyser.minDecibels = -96;
+ analyser.maxDecibels = -10;
+ analyser.smoothingTimeConstant = 0.68;
source = audioContext.createMediaElementSource(M.audio);
source.connect(analyser);
@@ -147,151 +184,427 @@
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);
+ 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 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);
+ 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;
+ }
- 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);
+ 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 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));
+ 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.translate(cx, cy);
- ctx.lineCap = "round";
+ ctx.globalAlpha = 0.11 + state.energy * 0.08;
+ ctx.strokeStyle = colors.muted;
+ ctx.lineWidth = 1;
- 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);
+ for (let x = -spacing + offset; x < width + spacing; x += spacing) {
ctx.beginPath();
- ctx.moveTo(radius, 0);
- ctx.lineTo(length, 0);
+ 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.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);
+ 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 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));
+ const radius = Math.max(14, Math.min(width, height) * (0.14 + state.bass * 0.08));
+ const spokes = 192;
- ctx.globalAlpha = 0.28;
- ctx.fillStyle = colors.secondary;
- ctx.beginPath();
- ctx.arc(cx, cy, radius * 1.75, 0, Math.PI * 2);
- ctx.fill();
+ ctx.save();
+ ctx.translate(cx, cy);
+ ctx.rotate(state.time * (0.18 + state.treble * 0.22));
+ ctx.globalCompositeOperation = "lighter";
+ ctx.lineCap = "round";
- ctx.globalAlpha = 0.9;
+ 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(cx, cy, radius, 0, Math.PI * 2);
+ 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.75;
- ctx.strokeStyle = colors.secondary;
- ctx.lineWidth = 1 + mids * 4;
+ ctx.globalAlpha = 0.8;
+ ctx.fillStyle = colors.accent;
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;
+ 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;
@@ -303,19 +616,59 @@
&& !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);
+ 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) {
@@ -351,16 +704,20 @@
M.visualizer = {
getMode: () => mode,
hasGraph: () => !!source,
- isRunning: () => audioContext?.state === "running"
+ 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 = () => {
@@ -369,18 +726,42 @@
};
}
+ 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", () => {
+ 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();
});