318 lines
9.9 KiB
TypeScript
318 lines
9.9 KiB
TypeScript
'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 (
|
|
<div
|
|
className={cn(
|
|
sizeClasses[size],
|
|
'relative overflow-hidden rounded-lg border',
|
|
'bg-slate-100 border-slate-200 dark:bg-slate-800 dark:border-slate-700'
|
|
)}
|
|
>
|
|
{/* 中心音符图标 */}
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="p-4 rounded-md bg-white border border-slate-200 dark:bg-slate-900 dark:border-slate-700">
|
|
<svg
|
|
className={cn(iconSizes[size], 'text-slate-500 dark:text-slate-300')}
|
|
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]
|
|
|
|
// 初始化音频分析器
|
|
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
|
|
const isDarkMode = document.documentElement.classList.contains('dark')
|
|
|
|
// 清除画布
|
|
ctx.clearRect(0, 0, width, height)
|
|
|
|
// 绘制中性背景圆
|
|
const bgGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, innerRadius + maxBarHeight + 20)
|
|
if (isDarkMode) {
|
|
bgGradient.addColorStop(0, 'rgba(30, 41, 59, 0.98)')
|
|
bgGradient.addColorStop(0.7, 'rgba(15, 23, 42, 0.96)')
|
|
bgGradient.addColorStop(1, 'rgba(15, 23, 42, 0.9)')
|
|
} else {
|
|
bgGradient.addColorStop(0, 'rgba(248, 250, 252, 0.98)')
|
|
bgGradient.addColorStop(0.7, 'rgba(241, 245, 249, 0.94)')
|
|
bgGradient.addColorStop(1, 'rgba(226, 232, 240, 0.9)')
|
|
}
|
|
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 barHeight = Math.max(3, (value / 255) * maxBarHeight)
|
|
|
|
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 intensity = value / 255
|
|
|
|
// 绘制线条
|
|
ctx.beginPath()
|
|
ctx.strokeStyle = isDarkMode
|
|
? `rgba(148, 163, 184, ${0.34 + intensity * 0.42})`
|
|
: `rgba(37, 99, 235, ${0.28 + intensity * 0.42})`
|
|
ctx.lineWidth = 1.5
|
|
ctx.lineCap = 'round'
|
|
ctx.moveTo(x1, y1)
|
|
ctx.lineTo(x2, y2)
|
|
ctx.stroke()
|
|
}
|
|
|
|
// 绘制中心圆
|
|
const centerGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, innerRadius - 5)
|
|
if (isDarkMode) {
|
|
centerGradient.addColorStop(0, 'rgba(15, 23, 42, 1)')
|
|
centerGradient.addColorStop(0.7, 'rgba(30, 41, 59, 1)')
|
|
centerGradient.addColorStop(1, 'rgba(51, 65, 85, 0.96)')
|
|
} else {
|
|
centerGradient.addColorStop(0, 'rgba(255, 255, 255, 1)')
|
|
centerGradient.addColorStop(0.7, 'rgba(248, 250, 252, 1)')
|
|
centerGradient.addColorStop(1, 'rgba(226, 232, 240, 0.96)')
|
|
}
|
|
ctx.fillStyle = centerGradient
|
|
ctx.beginPath()
|
|
ctx.arc(centerX, centerY, innerRadius - 5, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
|
|
// 绘制中心圆边框
|
|
ctx.strokeStyle = isDarkMode ? 'rgba(100, 116, 139, 0.75)' : 'rgba(148, 163, 184, 0.55)'
|
|
ctx.lineWidth = 1.5
|
|
ctx.beginPath()
|
|
ctx.arc(centerX, centerY, innerRadius - 5, 0, Math.PI * 2)
|
|
ctx.stroke()
|
|
|
|
// 绘制中心音符图标
|
|
ctx.fillStyle = isDarkMode ? 'rgba(203, 213, 225, 0.9)' : 'rgba(71, 85, 105, 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 (
|
|
<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>
|
|
)
|
|
}
|