flnxi-Wu/components/AudioVisualizer.tsx
2026-05-28 23:27:15 +08:00

262 lines
7.8 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: 120, bars: 36, maxBarHeight: 52 },
md: { width: 240, height: 180, bars: 48, maxBarHeight: 76 },
lg: { width: 320, height: 220, bars: 64, maxBarHeight: 96 },
}
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, maxBarHeight } = config
const centerY = height / 2
const isDarkMode = document.documentElement.classList.contains('dark')
// 清除画布
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = isDarkMode ? 'rgba(15, 23, 42, 1)' : 'rgba(248, 250, 252, 1)'
ctx.fillRect(0, 0, width, height)
// 绘制频谱条
const usableData = Math.floor(bufferLength * 0.7)
const gap = size === 'sm' ? 2 : 3
const barWidth = Math.max(2, (width - (bars - 1) * gap) / bars)
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(6, (value / 255) * maxBarHeight)
const x = i * (barWidth + gap)
const y = centerY - barHeight / 2
const intensity = value / 255
ctx.fillStyle = isDarkMode
? `rgba(148, 163, 184, ${0.34 + intensity * 0.42})`
: `rgba(71, 85, 105, ${0.26 + intensity * 0.42})`
ctx.fillRect(x, y, barWidth, barHeight)
}
animationRef.current = requestAnimationFrame(draw)
}, [config, size])
// 处理播放状态变化
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-[120px]',
md: 'w-[240px] h-[180px]',
lg: 'w-[320px] h-[220px]',
}
return (
<div className={cn('relative overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700', 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-lg"
/>
) : (
<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>
)
}