diff --git a/frontend/src/components/AudioPlayer.tsx b/frontend/src/components/AudioPlayer.tsx index 4cc428e..f25f7e6 100644 --- a/frontend/src/components/AudioPlayer.tsx +++ b/frontend/src/components/AudioPlayer.tsx @@ -30,14 +30,24 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating useEffect(() => { setUserId(userId ?? null); }, [userId, setUserId]); + // IntersectionObserver with debounce to prevent flicker + const miniTimer = useRef | null>(null); useEffect(() => { if (mode !== 'inline' || !inlineRef.current) return; const obs = new IntersectionObserver( - ([entry]) => setShowMiniBar(!entry.isIntersecting), - { threshold: 0, rootMargin: '-40px 0px 0px 0px' } + ([entry]) => { + if (miniTimer.current) clearTimeout(miniTimer.current); + miniTimer.current = setTimeout(() => { + setShowMiniBar(!entry.isIntersecting); + }, 250); + }, + { threshold: 0.1, rootMargin: '-80px 0px 0px 0px' } ); obs.observe(inlineRef.current); - return () => obs.disconnect(); + return () => { + obs.disconnect(); + if (miniTimer.current) clearTimeout(miniTimer.current); + }; }, [mode, episode?._id]); const podcast = typeof episode?.podcastId === 'object' ? episode.podcastId : null; @@ -172,7 +182,7 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating {/* Audio Visualizer */}
- +
{/* Progress */} @@ -268,7 +278,7 @@ export default function AudioPlayer({ episode, onClose, userId, mode = 'floating

- +
diff --git a/frontend/src/components/AudioVisualizer.tsx b/frontend/src/components/AudioVisualizer.tsx index c671521..86eb836 100644 --- a/frontend/src/components/AudioVisualizer.tsx +++ b/frontend/src/components/AudioVisualizer.tsx @@ -2,87 +2,118 @@ import { useEffect, useRef, useState } from 'react'; import { useAudio } from '../context/AudioContext'; interface AudioVisualizerProps { - barCount?: number; + width?: number; height?: number; - gap?: number; } export default function AudioVisualizer({ - barCount = 40, - height = 96, - gap = 2, + width, + height = 64, }: AudioVisualizerProps) { const { isPlaying, getFrequencyData } = useAudio(); - const [heights, setHeights] = useState(() => Array(barCount).fill(3)); + const canvasRef = useRef(null); const rafRef = useRef(0); const playingRef = useRef(isPlaying); const getDataRef = useRef(getFrequencyData); + const historyRef = useRef([]); useEffect(() => { playingRef.current = isPlaying; }, [isPlaying]); useEffect(() => { getDataRef.current = getFrequencyData; }, [getFrequencyData]); useEffect(() => { - const half = Math.floor(barCount / 2); + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Set canvas size accounting for device pixel ratio + const dpr = window.devicePixelRatio || 1; + const displayW = width || canvas.parentElement?.clientWidth || 300; + const displayH = height; + canvas.width = displayW * dpr; + canvas.height = displayH * dpr; + canvas.style.width = `${displayW}px`; + canvas.style.height = `${displayH}px`; + ctx.scale(dpr, dpr); + + // Init history with flatline + if (historyRef.current.length === 0) { + historyRef.current = Array.from({ length: 8 }, () => + Array(Math.floor(displayW / 3)).fill(displayH / 2) + ); + } const animate = () => { - const rawData = getDataRef.current?.(); - if (rawData && playingRef.current) { - const newHeights: number[] = []; - for (let i = 0; i < barCount; i++) { - const distFromCenter = Math.abs(i - (barCount - 1) / 2); - const halfIdx = Math.floor(distFromCenter); - const dataIndex = Math.min(halfIdx, rawData.length - 1); - const value = rawData[dataIndex] ?? 0; + if (!ctx || !canvas) return; + const raw = getDataRef.current?.(); + const w = displayW; + const h = displayH; + const mid = h / 2; - // Smoother edge fade — keep center bars taller - const edgeFade = 1 - (distFromCenter / half) * 0.2; + // Compute a new row from frequency data + const sampleCount = Math.floor(w / 3); + const newRow: number[] = []; - // Mid boost for vocals/speech - const midBoost = distFromCenter < half * 0.6 ? 1.15 : 1; - - // More dynamic range — minimum height of 6% - const h = Math.max(6, Math.min(100, (value / 255) * 100 * edgeFade * midBoost)); - newHeights.push(h); + if (raw && playingRef.current) { + for (let i = 0; i < sampleCount; i++) { + // Map sample across the frequency bins + const idx = Math.floor((i / sampleCount) * raw.length); + const val = raw[idx] ?? 0; + // Convert to y offset from center — stronger signal = bigger wave + const amplitude = (val / 255) * (h * 0.42); + newRow.push(mid + (Math.random() - 0.5) * amplitude * 2); } - setHeights(newHeights); } else { - // Gentle decay when paused — slower falloff for smoother look - setHeights(prev => - prev.map(h => { - const next = h * 0.92; - return next < 3.5 ? 3 : next; - }) - ); + // When paused, flatline decays to center + for (let i = 0; i < sampleCount; i++) { + newRow.push(mid); + } } + + // Push new row, keep last 8 rows + historyRef.current.push(newRow); + if (historyRef.current.length > 12) historyRef.current.shift(); + + // Draw + ctx.clearRect(0, 0, w, h); + + // Draw each row as a thin horizontal line, fading older ones + const rows = historyRef.current; + for (let r = 0; r < rows.length; r++) { + const row = rows[r]; + const age = rows.length - 1 - r; + const alpha = 0.08 + (age / rows.length) * 0.75; + const thickness = 1 + (age / rows.length) * 1.5; + + ctx.strokeStyle = r === rows.length - 1 + ? `rgba(108, 111, 247, ${alpha + 0.15})` + : age < 3 + ? `rgba(13, 197, 224, ${alpha})` + : `rgba(108, 111, 247, ${alpha * 0.6})`; + + ctx.lineWidth = thickness; + ctx.beginPath(); + for (let i = 0; i < row.length; i++) { + const x = (i / row.length) * w; + const y = row[i]; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + } + rafRef.current = requestAnimationFrame(animate); }; rafRef.current = requestAnimationFrame(animate); return () => cancelAnimationFrame(rafRef.current); - }, [barCount]); - - const barWidth = Math.max(2, (100 - gap * (barCount - 1)) / barCount); + }, [width, height]); return ( -