gongxiangp2/components/AudioVisualizer.tsx

437 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<HTMLAudioElement, {
source: MediaElementAudioSourceNode | null
analyser: AnalyserNode | null
initialized: boolean
failed: boolean
initializing: boolean
}>()
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 (
<div
className={cn(
sizeClasses[size],
'relative overflow-hidden rounded-lg',
'bg-gradient-to-br from-[#1a1a2e] via-[#16213e] to-[#0f3460]'
)}
>
{/* 装饰光晕 */}
<div className="absolute top-1/4 left-1/4 w-1/2 h-1/2 bg-blue-500/20 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-1/3 h-1/3 bg-purple-500/20 rounded-full blur-2xl" />
{/* 中心音符图标 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="p-4 rounded-full bg-white/10 backdrop-blur-sm border border-white/20">
<svg
className={cn(iconSizes[size], 'text-white/80')}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" />
</svg>
</div>
</div>
</div>
)
}
// 圆形放射状频谱可视化器 - 尖刺风格
export default function AudioVisualizer({
audioElement,
isPlaying,
size = 'md',
coverImage,
showCoverOnPause = true,
className,
}: AudioVisualizerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const animationRef = useRef<number | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null)
const connectedAudioRef = useRef<HTMLAudioElement | null>(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 (
<div className={cn('relative overflow-hidden rounded-2xl', containerClasses[size], className)}>
{/* 封面图层 */}
<div
className={cn(
'absolute inset-0 transition-opacity duration-300 z-10',
showCover && !isPlaying ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
>
{coverImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={coverImage}
alt="Audio cover"
className="w-full h-full object-cover rounded-2xl"
/>
) : (
<DefaultAudioCover size={size} />
)}
</div>
{/* 可视化层 */}
<div
className={cn(
'absolute inset-0 transition-opacity duration-300',
isPlaying || isVisualizerActive ? 'opacity-100' : 'opacity-0'
)}
>
<canvas
ref={canvasRef}
width={config.width}
height={config.height}
className="w-full h-full"
/>
</div>
{/* 未播放时的默认背景(当没有封面图时) */}
{!coverImage && !isPlaying && !showCover && (
<DefaultAudioCover size={size} />
)}
</div>
)
}