'use client' import { useRef, useEffect, useState, useCallback } from 'react' import { cn } from '@/lib/ui-styles' interface AudioVisualizerProps { audioElement: HTMLAudioElement | null isPlaying: boolean size?: 'sm' | 'md' | 'lg' coverImage?: string | null showCoverOnPause?: boolean className?: string } // 默认音频封面图 function DefaultAudioCover({ size }: { size: 'sm' | 'md' | 'lg' }) { const sizeClasses = { sm: 'w-full h-full', md: 'w-full h-full', lg: 'w-full h-full', } const iconSizes = { sm: 'w-8 h-8', md: 'w-12 h-12', lg: 'w-16 h-16', } return (
{/* 中心音符图标 */}
) } // 音频频谱可视化器 export default function AudioVisualizer({ audioElement, isPlaying, size = 'md', coverImage, showCoverOnPause = true, className, }: AudioVisualizerProps) { const canvasRef = useRef(null) const animationRef = useRef(null) const audioContextRef = useRef(null) const analyserRef = useRef(null) const sourceRef = useRef(null) const connectedAudioRef = useRef(null) const [isVisualizerActive, setIsVisualizerActive] = useState(false) const [showCover, setShowCover] = useState(true) // 尺寸配置 const sizeConfig = { sm: { width: 160, height: 120, bars: 36, maxBarHeight: 52 }, md: { width: 240, height: 180, bars: 48, maxBarHeight: 76 }, lg: { width: 320, height: 220, bars: 64, maxBarHeight: 96 }, } const config = sizeConfig[size] // 初始化音频分析器 const initAudioContext = useCallback(() => { if (!audioElement) return const audioElementChanged = connectedAudioRef.current !== audioElement try { if (!audioContextRef.current) { audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)() } const audioContext = audioContextRef.current if (audioElementChanged || !analyserRef.current) { if (sourceRef.current) { try { sourceRef.current.disconnect() } catch (e) { // 忽略断开连接的错误 } sourceRef.current = null } const analyser = audioContext.createAnalyser() analyser.fftSize = 512 // 增加 FFT 大小以获得更精细的频率数据 analyser.smoothingTimeConstant = 0.75 const source = audioContext.createMediaElementSource(audioElement) source.connect(analyser) analyser.connect(audioContext.destination) sourceRef.current = source analyserRef.current = analyser connectedAudioRef.current = audioElement console.log('[AudioVisualizer] Audio context initialized for new audio element') } setIsVisualizerActive(true) } catch (error) { console.error('[AudioVisualizer] Failed to initialize audio context:', error) } }, [audioElement]) // 绘制低调的条形频谱 const draw = useCallback(() => { const canvas = canvasRef.current const analyser = analyserRef.current if (!canvas || !analyser) return const ctx = canvas.getContext('2d', { willReadFrequently: true }) if (!ctx) return const bufferLength = analyser.frequencyBinCount const dataArray = new Uint8Array(bufferLength) analyser.getByteFrequencyData(dataArray) const { width, height, bars, maxBarHeight } = config const centerY = height / 2 const isDarkMode = document.documentElement.classList.contains('dark') // 清除画布 ctx.clearRect(0, 0, width, height) ctx.fillStyle = isDarkMode ? 'rgba(15, 23, 42, 1)' : 'rgba(248, 250, 252, 1)' ctx.fillRect(0, 0, width, height) // 绘制频谱条 const usableData = Math.floor(bufferLength * 0.7) const gap = size === 'sm' ? 2 : 3 const barWidth = Math.max(2, (width - (bars - 1) * gap) / bars) for (let i = 0; i < bars; i++) { // 使用多个频率数据点的平均值来平滑 const dataIndex = Math.floor((i / bars) * usableData) const nextIndex = Math.min(dataIndex + 1, usableData - 1) const value = (dataArray[dataIndex] + dataArray[nextIndex]) / 2 const barHeight = Math.max(6, (value / 255) * maxBarHeight) const x = i * (barWidth + gap) const y = centerY - barHeight / 2 const intensity = value / 255 ctx.fillStyle = isDarkMode ? `rgba(148, 163, 184, ${0.34 + intensity * 0.42})` : `rgba(71, 85, 105, ${0.26 + intensity * 0.42})` ctx.fillRect(x, y, barWidth, barHeight) } animationRef.current = requestAnimationFrame(draw) }, [config, size]) // 处理播放状态变化 useEffect(() => { if (isPlaying && audioElement) { setShowCover(false) if (!audioContextRef.current || connectedAudioRef.current !== audioElement) { initAudioContext() } if (audioContextRef.current?.state === 'suspended') { audioContextRef.current.resume() } requestAnimationFrame(() => { if (analyserRef.current) { draw() } }) } else if (!isPlaying) { if (animationRef.current) { cancelAnimationFrame(animationRef.current) animationRef.current = null } if (showCoverOnPause) { setShowCover(true) } } return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current) } } }, [isPlaying, audioElement, initAudioContext, draw, showCoverOnPause]) // 清理 useEffect(() => { return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current) } } }, []) const containerClasses = { sm: 'w-40 h-[120px]', md: 'w-[240px] h-[180px]', lg: 'w-[320px] h-[220px]', } return (
{/* 封面图层 */}
{coverImage ? ( // eslint-disable-next-line @next/next/no-img-element Audio cover ) : ( )}
{/* 可视化层 */}
{/* 未播放时的默认背景(当没有封面图时) */} {!coverImage && !isPlaying && !showCover && ( )}
) }