class OrbVisualizer { constructor(canvas, audio) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.audio = audio; this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); this.analyser = this.audioCtx.createAnalyser(); this.analyser.fftSize = 256; try { this.source = this.audioCtx.createMediaElementSource(this.audio); this.source.connect(this.analyser); this.analyser.connect(this.audioCtx.destination); } catch (e) { console.warn("Audio source already connected"); } this.bufferLength = this.analyser.frequencyBinCount; this.dataArray = new Uint8Array(this.bufferLength); this.isPlaying = false; this.animationId = null; this.particles = []; this.resize(); window.addEventListener('resize', () => this.resize()); } resize() { const rect = this.canvas.parentElement.getBoundingClientRect(); this.canvas.width = rect.width * 1.5; // Higher res this.canvas.height = rect.height * 1.5; this.canvas.style.width = `${rect.width}px`; this.canvas.style.height = `${rect.height}px`; } play() { if (this.audioCtx.state === 'suspended') { this.audioCtx.resume(); } this.isPlaying = true; this.draw(); } pause() { this.isPlaying = false; cancelAnimationFrame(this.animationId); this.drawIdle(); } destroy() { this.pause(); } drawIdle() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); const cx = this.canvas.width / 2; const cy = this.canvas.height / 2; const radius = Math.min(cx, cy) * 0.4; // Inner core this.ctx.beginPath(); this.ctx.arc(cx, cy, radius, 0, 2 * Math.PI); const gradient = this.ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); gradient.addColorStop(0, '#7c3aed'); gradient.addColorStop(1, '#2563eb'); this.ctx.fillStyle = gradient; this.ctx.fill(); // Idle glow this.ctx.shadowBlur = 40; this.ctx.shadowColor = 'rgba(124, 58, 237, 0.4)'; this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; this.ctx.lineWidth = 2; this.ctx.stroke(); this.ctx.shadowBlur = 0; } draw() { if (!this.isPlaying) return; this.animationId = requestAnimationFrame(() => this.draw()); this.analyser.getByteFrequencyData(this.dataArray); this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); const cx = this.canvas.width / 2; const cy = this.canvas.height / 2; // Split frequencies into bass, mid, treble let bass = 0, mid = 0, treble = 0; for(let i=0; i<40; i++) bass += this.dataArray[i]; for(let i=40; i<80; i++) mid += this.dataArray[i]; for(let i=80; i<120; i++) treble += this.dataArray[i]; bass /= 40; mid /= 40; treble /= 40; const baseRadius = Math.min(cx, cy) * 0.35; // Draw Outer Ring (Treble) this.ctx.beginPath(); this.ctx.arc(cx, cy, baseRadius + (treble * 0.5) + 30, 0, 2 * Math.PI); this.ctx.strokeStyle = `rgba(37, 99, 235, ${treble/255})`; this.ctx.lineWidth = 4; this.ctx.stroke(); // Draw Mid Ring this.ctx.beginPath(); this.ctx.arc(cx, cy, baseRadius + (mid * 0.4) + 15, 0, 2 * Math.PI); this.ctx.strokeStyle = `rgba(244, 63, 94, ${mid/255})`; this.ctx.lineWidth = 6; this.ctx.stroke(); // Draw Core (Bass) const coreRadius = baseRadius + (bass * 0.3); this.ctx.beginPath(); this.ctx.arc(cx, cy, coreRadius, 0, 2 * Math.PI); const gradient = this.ctx.createRadialGradient(cx, cy, 0, cx, cy, coreRadius); gradient.addColorStop(0, '#7c3aed'); gradient.addColorStop(0.7, '#2563eb'); gradient.addColorStop(1, 'transparent'); this.ctx.fillStyle = gradient; this.ctx.shadowBlur = bass; this.ctx.shadowColor = '#7c3aed'; this.ctx.fill(); this.ctx.shadowBlur = 0; // reset // Particles emission on heavy bass if (bass > 180 && Math.random() > 0.5) { this.particles.push({ x: cx, y: cy, vx: (Math.random() - 0.5) * 10, vy: (Math.random() - 0.5) * 10, life: 1.0, color: Math.random() > 0.5 ? '#7c3aed' : '#2563eb' }); } // Draw and update particles for (let i = this.particles.length - 1; i >= 0; i--) { let p = this.particles[i]; p.x += p.vx; p.y += p.vy; p.life -= 0.05; if (p.life <= 0) { this.particles.splice(i, 1); continue; } this.ctx.beginPath(); this.ctx.arc(p.x, p.y, 3 * p.life, 0, 2 * Math.PI); this.ctx.fillStyle = p.color; this.ctx.globalAlpha = p.life; this.ctx.fill(); this.ctx.globalAlpha = 1.0; } } }