'use client' import { useRef, useEffect, useState, useCallback } from 'react' // 简单的className合并函数 function cn(...classes: (string | undefined | null | false)[]) { return classes.filter(Boolean).join(' ') } // 全局 WeakMap 跟踪所有 audio 元素的初始化状态(跨组件实例共享) const globalAudioInitState = new WeakMap() interface AudioVisualizerProps { audioElement: HTMLAudioElement | null isPlaying: boolean size?: 'sm' | 'md' | 'lg' coverImage?: string | null showCoverOnPause?: boolean className?: string } // 默认封面图组件 - Apple Music 风格 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: 160, bars: 64, innerRadius: 25, maxBarHeight: 35 }, md: { width: 280, height: 280, bars: 96, innerRadius: 45, maxBarHeight: 65 }, lg: { width: 400, height: 400, bars: 128, innerRadius: 70, maxBarHeight: 95 }, } const config = sizeConfig[size] // 使用全局 WeakMap 跟踪 audio 元素的初始化状态 // 这样可以确保所有 AudioVisualizer 实例共享同一个初始化状态 // 绘制尖刺放射状频谱 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, innerRadius, maxBarHeight } = config const centerX = width / 2 const centerY = height / 2 // 清除画布 ctx.clearRect(0, 0, width, height) // 绘制深色背景圆 const bgGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, innerRadius + maxBarHeight + 20) bgGradient.addColorStop(0, 'rgba(15, 23, 42, 0.95)') bgGradient.addColorStop(0.4, 'rgba(15, 23, 42, 0.9)') bgGradient.addColorStop(1, 'rgba(15, 23, 42, 0.7)') ctx.fillStyle = bgGradient ctx.beginPath() ctx.arc(centerX, centerY, innerRadius + maxBarHeight + 15, 0, Math.PI * 2) ctx.fill() // 绘制频谱尖刺 const angleStep = (Math.PI * 2) / bars const usableData = Math.floor(bufferLength * 0.7) 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 randomFactor = 0.9 + Math.random() * 0.2 const barHeight = Math.max(3, (value / 255) * maxBarHeight * randomFactor) const angle = i * angleStep - Math.PI / 2 // 从顶部开始 // 计算尖刺的起点和终点 const x1 = centerX + Math.cos(angle) * innerRadius const y1 = centerY + Math.sin(angle) * innerRadius const x2 = centerX + Math.cos(angle) * (innerRadius + barHeight) const y2 = centerY + Math.sin(angle) * (innerRadius + barHeight) // 创建渐变色 - 使用项目的蓝色/青色主题 const gradient = ctx.createLinearGradient(x1, y1, x2, y2) const intensity = value / 255 // 根据强度调整颜色 if (intensity > 0.7) { // 高强度 - 亮青色到白色 gradient.addColorStop(0, 'rgba(34, 211, 238, 0.9)') gradient.addColorStop(0.5, 'rgba(56, 189, 248, 1)') gradient.addColorStop(1, 'rgba(255, 255, 255, 1)') } else if (intensity > 0.4) { // 中等强度 - 蓝色到青色 gradient.addColorStop(0, 'rgba(59, 130, 246, 0.8)') gradient.addColorStop(0.5, 'rgba(34, 211, 238, 0.9)') gradient.addColorStop(1, 'rgba(56, 189, 248, 1)') } else { // 低强度 - 深蓝到蓝色 gradient.addColorStop(0, 'rgba(30, 64, 175, 0.6)') gradient.addColorStop(0.5, 'rgba(59, 130, 246, 0.7)') gradient.addColorStop(1, 'rgba(96, 165, 250, 0.8)') } // 绘制尖刺线条 ctx.beginPath() ctx.strokeStyle = gradient ctx.lineWidth = 2 ctx.lineCap = 'round' ctx.moveTo(x1, y1) ctx.lineTo(x2, y2) ctx.stroke() // 在高强度时添加发光效果 if (intensity > 0.5) { ctx.beginPath() ctx.strokeStyle = `rgba(56, 189, 248, ${intensity * 0.3})` ctx.lineWidth = 4 ctx.lineCap = 'round' ctx.moveTo(x1, y1) ctx.lineTo(x2, y2) ctx.stroke() } } // 绘制内圈发光效果 const innerGlow = ctx.createRadialGradient(centerX, centerY, innerRadius - 10, centerX, centerY, innerRadius + 5) innerGlow.addColorStop(0, 'rgba(56, 189, 248, 0.1)') innerGlow.addColorStop(0.5, 'rgba(34, 211, 238, 0.15)') innerGlow.addColorStop(1, 'rgba(34, 211, 238, 0)') ctx.fillStyle = innerGlow ctx.beginPath() ctx.arc(centerX, centerY, innerRadius + 5, 0, Math.PI * 2) ctx.fill() // 绘制中心圆 - 深色背景 const centerGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, innerRadius - 5) centerGradient.addColorStop(0, 'rgba(15, 23, 42, 1)') centerGradient.addColorStop(0.7, 'rgba(15, 23, 42, 0.95)') centerGradient.addColorStop(1, 'rgba(30, 41, 59, 0.9)') ctx.fillStyle = centerGradient ctx.beginPath() ctx.arc(centerX, centerY, innerRadius - 5, 0, Math.PI * 2) ctx.fill() // 绘制中心圆边框 ctx.strokeStyle = 'rgba(56, 189, 248, 0.3)' ctx.lineWidth = 1.5 ctx.beginPath() ctx.arc(centerX, centerY, innerRadius - 5, 0, Math.PI * 2) ctx.stroke() // 绘制中心音符图标 ctx.fillStyle = 'rgba(56, 189, 248, 0.9)' ctx.font = `${innerRadius * 0.5}px sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText('♪', centerX, centerY) animationRef.current = requestAnimationFrame(draw) }, [config]) // 处理播放状态变化 useEffect(() => { if (isPlaying && audioElement) { setShowCover(false) // 使用全局 WeakMap 来跟踪初始化状态,确保所有实例共享状态 let audioState = globalAudioInitState.get(audioElement) if (!audioState) { audioState = { source: null, analyser: null, initialized: false, failed: false, initializing: false, } globalAudioInitState.set(audioElement, audioState) } const audioElementChanged = connectedAudioRef.current !== audioElement // 检查是否需要初始化 const needsInit = !audioState.failed && // 没有失败过 !audioState.initialized && // 没有成功初始化过 !audioState.initializing && // 没有正在初始化 (audioElementChanged || !sourceRef.current) // 需要初始化 if (needsInit) { // 标记正在初始化(全局状态) audioState.initializing = true try { if (!audioContextRef.current) { audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)() } const audioContext = audioContextRef.current // 如果音频元素改变,需要清理旧的连接并创建新的 if (audioElementChanged || !analyserRef.current) { // 清理旧的 source 连接 if (sourceRef.current) { try { sourceRef.current.disconnect() } catch (e) { // 忽略断开连接的错误 } sourceRef.current = null } // 清理旧的 analyser if (analyserRef.current) { try { analyserRef.current.disconnect() } catch (e) { // 忽略断开连接的错误 } analyserRef.current = null } // 创建新的 analyser const analyser = audioContext.createAnalyser() analyser.fftSize = 512 analyser.smoothingTimeConstant = 0.75 // 尝试创建 source 连接 try { const source = audioContext.createMediaElementSource(audioElement) source.connect(analyser) analyser.connect(audioContext.destination) // 更新全局状态 audioState.source = source audioState.analyser = analyser audioState.initialized = true audioState.failed = false audioState.initializing = false // 更新当前实例的 refs sourceRef.current = source analyserRef.current = analyser connectedAudioRef.current = audioElement console.log('[AudioVisualizer] Audio context initialized for new audio element') setIsVisualizerActive(true) } catch (sourceError: any) { // 如果创建 source 失败(通常是已经连接过),只记录警告但不抛出错误 if (sourceError.name === 'InvalidStateError' || sourceError.message?.includes('already connected')) { console.warn('[AudioVisualizer] Audio element already connected to another source, skipping visualization') // 更新全局状态 audioState.source = null audioState.analyser = null audioState.initialized = false audioState.failed = true audioState.initializing = false // 更新当前实例的 refs analyserRef.current = null connectedAudioRef.current = audioElement // 不设置 isVisualizerActive,保持封面显示 } else { // 其他错误仍然抛出 audioState.initializing = false throw sourceError } } } } catch (error) { console.error('[AudioVisualizer] Failed to initialize audio context:', error) audioState.initializing = false } } else if (audioState.initialized && audioState.source) { // 已经初始化过,复用全局的 source 和 analyser sourceRef.current = audioState.source analyserRef.current = audioState.analyser connectedAudioRef.current = audioElement setIsVisualizerActive(true) } // 恢复 audioContext(如果需要) 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, draw, showCoverOnPause]) // 清理 useEffect(() => { return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current) } } }, []) const containerClasses = { sm: 'w-40 h-40', md: 'w-[280px] h-[280px]', lg: 'w-[400px] h-[400px]', } return (
{/* 封面图层 */}
{coverImage ? ( // eslint-disable-next-line @next/next/no-img-element Audio cover ) : ( )}
{/* 可视化层 */}
{/* 未播放时的默认背景(当没有封面图时) */} {!coverImage && !isPlaying && !showCover && ( )}
) }