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 @@ Blastoise! A very special music server - +
@@ -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(); });