437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
'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>
|
||
)
|
||
}
|