'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 } // 默认封面图组件 - 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] // 初始化音频分析器 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, 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) 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-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 && ( )}
) }