forked from 77media/video-flow
311 lines
9.2 KiB
TypeScript
311 lines
9.2 KiB
TypeScript
'use client';
|
|
|
|
import React, { useRef, useEffect, useState } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Play, Pause, Volume2, VolumeX, AlertCircle } from 'lucide-react';
|
|
import { cn } from '@/public/lib/utils';
|
|
import WaveSurfer from 'wavesurfer.js';
|
|
|
|
interface AudioVisualizerProps {
|
|
audioUrl?: string;
|
|
title?: string;
|
|
volume?: number;
|
|
isActive?: boolean;
|
|
className?: string;
|
|
onVolumeChange?: (volume: number) => void;
|
|
}
|
|
|
|
// 模拟波形数据生成器
|
|
const generateMockWaveform = (width = 300, height = 50) => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d')!;
|
|
|
|
// 绘制模拟波形
|
|
ctx.fillStyle = '#6b7280';
|
|
const barWidth = 2;
|
|
const gap = 1;
|
|
const numBars = Math.floor(width / (barWidth + gap));
|
|
|
|
for (let i = 0; i < numBars; i++) {
|
|
const x = i * (barWidth + gap);
|
|
const barHeight = Math.random() * height * 0.8 + height * 0.1;
|
|
const y = (height - barHeight) / 2;
|
|
ctx.fillRect(x, y, barWidth, barHeight);
|
|
}
|
|
|
|
return canvas.toDataURL();
|
|
};
|
|
|
|
export function AudioVisualizer({
|
|
audioUrl = '/audio/demo.mp3',
|
|
title = 'Background Music',
|
|
volume = 75,
|
|
isActive = false,
|
|
className,
|
|
onVolumeChange
|
|
}: AudioVisualizerProps) {
|
|
const waveformRef = useRef<HTMLDivElement>(null);
|
|
const wavesurfer = useRef<WaveSurfer | null>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const [duration, setDuration] = useState(120); // 默认2分钟
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [hasError, setHasError] = useState(false);
|
|
const [mockWaveformUrl, setMockWaveformUrl] = useState<string>('');
|
|
|
|
// 生成模拟波形
|
|
useEffect(() => {
|
|
if (waveformRef.current) {
|
|
const width = waveformRef.current.clientWidth || 300;
|
|
const mockUrl = generateMockWaveform(width, 50);
|
|
setMockWaveformUrl(mockUrl);
|
|
}
|
|
}, [isActive]);
|
|
|
|
// 初始化 Wavesurfer
|
|
useEffect(() => {
|
|
if (!waveformRef.current) return;
|
|
|
|
// 创建 wavesurfer 实例
|
|
wavesurfer.current = WaveSurfer.create({
|
|
container: waveformRef.current,
|
|
waveColor: isActive ? '#3b82f6' : '#6b7280',
|
|
progressColor: isActive ? '#1d4ed8' : '#374151',
|
|
cursorColor: '#ffffff',
|
|
barWidth: 2,
|
|
barRadius: 3,
|
|
height: 50,
|
|
normalize: true,
|
|
backend: 'WebAudio',
|
|
mediaControls: false,
|
|
});
|
|
|
|
// 尝试加载音频,如果失败则使用模拟数据
|
|
setIsLoading(true);
|
|
setHasError(false);
|
|
|
|
wavesurfer.current.load(audioUrl).catch(() => {
|
|
console.warn('音频文件加载失败,使用模拟数据');
|
|
setHasError(true);
|
|
setIsLoading(false);
|
|
setDuration(120); // Set a default duration for demo mode
|
|
});
|
|
|
|
// 事件监听
|
|
wavesurfer.current.on('ready', () => {
|
|
setDuration(wavesurfer.current?.getDuration() || 120);
|
|
setIsLoading(false);
|
|
if (wavesurfer.current) {
|
|
wavesurfer.current.setVolume(volume / 100);
|
|
}
|
|
});
|
|
|
|
wavesurfer.current.on('timeupdate', () => {
|
|
setCurrentTime(wavesurfer.current?.getCurrentTime() || 0);
|
|
});
|
|
|
|
wavesurfer.current.on('play', () => {
|
|
setIsPlaying(true);
|
|
});
|
|
|
|
wavesurfer.current.on('pause', () => {
|
|
setIsPlaying(false);
|
|
});
|
|
|
|
wavesurfer.current.on('finish', () => {
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
});
|
|
|
|
wavesurfer.current.on('error', (error) => {
|
|
console.warn('Wavesurfer error:', error);
|
|
setHasError(true);
|
|
setIsLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
if (wavesurfer.current) {
|
|
wavesurfer.current.destroy();
|
|
}
|
|
};
|
|
}, [audioUrl]);
|
|
|
|
// 更新波形颜色当 isActive 改变时
|
|
// Note: setOptions method might not be available in all WaveSurfer versions
|
|
// Colors are already set during initialization
|
|
|
|
// 更新音量
|
|
useEffect(() => {
|
|
if (wavesurfer.current && !hasError) {
|
|
wavesurfer.current.setVolume(isMuted ? 0 : volume / 100);
|
|
}
|
|
}, [volume, isMuted, hasError]);
|
|
|
|
// 模拟播放进度(当使用模拟数据时)
|
|
useEffect(() => {
|
|
let interval: NodeJS.Timeout;
|
|
if (isPlaying && hasError) {
|
|
interval = setInterval(() => {
|
|
setCurrentTime(prev => {
|
|
const next = prev + 1;
|
|
if (next >= duration) {
|
|
setIsPlaying(false);
|
|
return 0;
|
|
}
|
|
return next;
|
|
});
|
|
}, 1000);
|
|
}
|
|
return () => clearInterval(interval);
|
|
}, [isPlaying, hasError, duration]);
|
|
|
|
const togglePlayPause = () => {
|
|
if (hasError) {
|
|
// 模拟播放/暂停
|
|
setIsPlaying(!isPlaying);
|
|
} else if (wavesurfer.current) {
|
|
wavesurfer.current.playPause();
|
|
}
|
|
};
|
|
|
|
const toggleMute = () => {
|
|
setIsMuted(!isMuted);
|
|
};
|
|
|
|
const formatTime = (time: number) => {
|
|
const minutes = Math.floor(time / 60);
|
|
const seconds = Math.floor(time % 60);
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
|
|
return (
|
|
<motion.div
|
|
className={cn(
|
|
'p-4 rounded-lg border-2 bg-white/5 transition-all duration-300',
|
|
isActive ? 'border-blue-500 bg-blue-500/10' : 'border-white/20',
|
|
className
|
|
)}
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div className="space-y-3">
|
|
{/* 标题和音量 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 rounded-full bg-white/10">
|
|
{hasError ? (
|
|
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
|
) : (
|
|
<Volume2 className="w-4 h-4" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium text-white">
|
|
Audio & SFX
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 波形可视化 */}
|
|
<div className="relative">
|
|
{hasError ? (
|
|
// 显示模拟波形图片
|
|
<div className="w-full h-[50px] bg-white/5 rounded flex items-center justify-center relative overflow-hidden">
|
|
{mockWaveformUrl && (
|
|
<img
|
|
src={mockWaveformUrl}
|
|
alt="Audio waveform"
|
|
className="w-full h-full object-cover opacity-70"
|
|
/>
|
|
)}
|
|
{/* 进度条覆盖层 */}
|
|
<div
|
|
className="absolute left-0 top-0 h-full bg-blue-500/30 transition-all duration-300"
|
|
style={{ width: `${progressPercentage}%` }}
|
|
/>
|
|
{/* 播放游标 */}
|
|
<div
|
|
className="absolute top-0 bottom-0 w-0.5 bg-white transition-all duration-300"
|
|
style={{ left: `${progressPercentage}%` }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={waveformRef}
|
|
className={cn(
|
|
"w-full transition-opacity duration-300",
|
|
isLoading ? "opacity-50" : "opacity-100"
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{isLoading && !hasError && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 控制栏 */}
|
|
<div className="flex items-center gap-3">
|
|
{/* 播放/暂停按钮 */}
|
|
<motion.button
|
|
className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
|
onClick={togglePlayPause}
|
|
disabled={isLoading && !hasError}
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
>
|
|
{isPlaying ? (
|
|
<Pause className="w-4 h-4" />
|
|
) : (
|
|
<Play className="w-4 h-4" />
|
|
)}
|
|
</motion.button>
|
|
|
|
{/* 静音按钮 */}
|
|
<motion.button
|
|
className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
|
onClick={toggleMute}
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
>
|
|
{isMuted ? (
|
|
<VolumeX className="w-4 h-4" />
|
|
) : (
|
|
<Volume2 className="w-4 h-4" />
|
|
)}
|
|
</motion.button>
|
|
|
|
{/* 时间显示 */}
|
|
<div className="flex-1 text-center">
|
|
<span className="text-xs text-white/70">
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 播放状态指示器 */}
|
|
{isPlaying && (
|
|
<motion.div
|
|
className="h-0.5 bg-gradient-to-r from-blue-500 via-purple-500 to-blue-500 rounded-full"
|
|
initial={{ scaleX: 0 }}
|
|
animate={{ scaleX: [0, 1, 0] }}
|
|
transition={{
|
|
duration: 2,
|
|
repeat: Infinity,
|
|
ease: "easeInOut"
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|