forked from 77media/video-flow
473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useRef, useEffect } from "react";
|
||
import { Mic, MicOff, Upload, Play, Pause, Trash2, X } from "lucide-react";
|
||
import { Tooltip, Upload as AntdUpload } from "antd";
|
||
import { InboxOutlined } from "@ant-design/icons";
|
||
import WaveSurfer from "wavesurfer.js";
|
||
|
||
import { useUploadFile } from "../../app/service/domain/service";
|
||
|
||
// 自定义样式
|
||
const audioRecorderStyles = `
|
||
.slider {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.slider::-webkit-slider-track {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
height: 4px;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
background: #3b82f6;
|
||
height: 12px;
|
||
width: 12px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.slider::-webkit-slider-thumb:hover {
|
||
background: #2563eb;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.slider::-moz-range-track {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
height: 4px;
|
||
border-radius: 2px;
|
||
border: none;
|
||
}
|
||
|
||
.slider::-moz-range-thumb {
|
||
background: #3b82f6;
|
||
height: 12px;
|
||
width: 12px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
border: none;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.slider::-moz-range-thumb:hover {
|
||
background: #2563eb;
|
||
transform: scale(1.1);
|
||
}
|
||
`;
|
||
|
||
interface AudioRecorderProps {
|
||
/** 当前音频URL */
|
||
audioUrl?: string;
|
||
/** 录制完成回调 */
|
||
onAudioRecorded: (audioBlob: Blob, audioUrl: string) => void;
|
||
/** 删除音频回调 */
|
||
onAudioDeleted: () => void;
|
||
/** 组件标题 */
|
||
title?: string;
|
||
/** 是否显示关闭按钮 */
|
||
showCloseButton?: boolean;
|
||
/** 关闭回调 */
|
||
onClose?: () => void;
|
||
}
|
||
|
||
/**
|
||
* 音频录制组件,支持录制、上传和播放功能
|
||
*/
|
||
export function AudioRecorder({
|
||
audioUrl,
|
||
onAudioRecorded,
|
||
onAudioDeleted,
|
||
title = "请上传参考音频",
|
||
showCloseButton = false,
|
||
onClose,
|
||
}: AudioRecorderProps) {
|
||
const [mode, setMode] = useState<"upload" | "record">("upload"); // 当前模式:上传或录制
|
||
const [isRecording, setIsRecording] = useState(false);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [volume, setVolume] = useState(1);
|
||
const [isMuted, setIsMuted] = useState(false);
|
||
const { uploadFile, isUploading } = useUploadFile();
|
||
|
||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||
const chunksRef = useRef<Blob[]>([]);
|
||
|
||
// 开始录制
|
||
const startRecording = async () => {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
const mediaRecorder = new MediaRecorder(stream);
|
||
mediaRecorderRef.current = mediaRecorder;
|
||
chunksRef.current = [];
|
||
|
||
mediaRecorder.ondataavailable = (event) => {
|
||
if (event.data.size > 0) {
|
||
chunksRef.current.push(event.data);
|
||
}
|
||
};
|
||
|
||
mediaRecorder.onstop = () => {
|
||
const audioBlob = new Blob(chunksRef.current, { type: "audio/wav" });
|
||
const audioUrl = URL.createObjectURL(audioBlob);
|
||
onAudioRecorded(audioBlob, audioUrl);
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
};
|
||
|
||
mediaRecorder.start();
|
||
setIsRecording(true);
|
||
} catch (error) {
|
||
console.error("录制失败:", error);
|
||
}
|
||
};
|
||
|
||
// 停止录制
|
||
const stopRecording = () => {
|
||
if (mediaRecorderRef.current && isRecording) {
|
||
mediaRecorderRef.current.stop();
|
||
setIsRecording(false);
|
||
}
|
||
};
|
||
|
||
// 播放/暂停控制
|
||
const togglePlay = () => {
|
||
setIsPlaying(!isPlaying);
|
||
};
|
||
|
||
// 音量控制
|
||
const toggleMute = () => {
|
||
setIsMuted(!isMuted);
|
||
};
|
||
|
||
// 音量调节
|
||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const newVolume = parseFloat(e.target.value);
|
||
setVolume(newVolume);
|
||
if (isMuted && newVolume > 0) {
|
||
setIsMuted(false);
|
||
}
|
||
};
|
||
|
||
// 删除音频
|
||
const handleDelete = () => {
|
||
setIsPlaying(false);
|
||
onAudioDeleted();
|
||
};
|
||
|
||
// 渲染上传/录制状态
|
||
if (!audioUrl) {
|
||
return (
|
||
<>
|
||
<style>{audioRecorderStyles}</style>
|
||
<div className="relative bg-white/[0.05] border border-white/[0.1] rounded-lg p-4">
|
||
{/* 头部 - 只显示关闭按钮 */}
|
||
{showCloseButton && (
|
||
<div className="flex justify-end mb-2">
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white/60 hover:text-white/80 transition-colors"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 主要内容区域 */}
|
||
<div className="flex items-center justify-center min-h-[60px]">
|
||
{mode === "upload" ? (
|
||
// 上传模式
|
||
<div className="text-center w-full">
|
||
<Tooltip
|
||
title="Please clearly read the story description above and record a 15-second audio file for upload"
|
||
placement="top"
|
||
overlayClassName="max-w-xs"
|
||
>
|
||
<div>
|
||
<AntdUpload.Dragger
|
||
accept="audio/*"
|
||
beforeUpload={() => false}
|
||
customRequest={async ({ file, onSuccess, onError }) => {
|
||
try {
|
||
const fileObj = file as File;
|
||
console.log("开始上传文件:", fileObj.name, fileObj.type, fileObj.size);
|
||
|
||
if (fileObj && fileObj.type.startsWith("audio/")) {
|
||
// 使用 hook 上传文件到七牛云
|
||
console.log("调用 uploadFile hook...");
|
||
const uploadedUrl = await uploadFile(fileObj);
|
||
console.log("上传成功,URL:", uploadedUrl);
|
||
|
||
// 上传成功后,调用回调函数
|
||
onAudioRecorded(fileObj, uploadedUrl);
|
||
onSuccess?.(uploadedUrl);
|
||
} else {
|
||
console.log("文件类型不是音频:", fileObj?.type);
|
||
const error = new Error("文件类型不是音频文件");
|
||
onError?.(error);
|
||
}
|
||
} catch (error) {
|
||
console.error("上传失败:", error);
|
||
// 上传失败时直接报告错误,不使用本地文件作为备选
|
||
onError?.(error as Error);
|
||
}
|
||
}}
|
||
showUploadList={false}
|
||
className="bg-transparent border-dashed border-white/20 hover:border-white/40"
|
||
disabled={isUploading}
|
||
>
|
||
<div className="text-2xl text-white/40 mb-2">
|
||
<InboxOutlined />
|
||
</div>
|
||
<div className="text-xs text-white/60">
|
||
{isUploading
|
||
? "Uploading..."
|
||
: "Drag audio file here or click to upload"}
|
||
</div>
|
||
</AntdUpload.Dragger>
|
||
</div>
|
||
</Tooltip>
|
||
</div>
|
||
) : (
|
||
// 录制模式
|
||
<div className="text-center w-full">
|
||
{isRecording ? (
|
||
// 录制中状态
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-center gap-3">
|
||
<button
|
||
onClick={stopRecording}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm"
|
||
>
|
||
<MicOff className="w-3 h-3" />
|
||
<span>Stop</span>
|
||
</button>
|
||
<div className="text-xs text-white/60">Recording...</div>
|
||
</div>
|
||
|
||
{/* 录制状态指示器 */}
|
||
<div className="w-full h-12 bg-white/[0.05] rounded-lg flex items-center justify-center">
|
||
<div className="flex items-center gap-2 text-white/60">
|
||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
||
<span className="text-xs">Recording...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// 录制准备状态
|
||
<div className="text-center">
|
||
<div className="text-2xl text-white/40 mb-2">🎙️</div>
|
||
<div className="text-xs text-white/60 mb-3">
|
||
Click to start recording
|
||
</div>
|
||
<Tooltip
|
||
title="Please clearly read the story description above and record a 15-second audio"
|
||
placement="top"
|
||
overlayClassName="max-w-xs"
|
||
>
|
||
<button
|
||
onClick={startRecording}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors text-sm mx-auto"
|
||
>
|
||
<Mic className="w-3 h-3" />
|
||
<span>Record</span>
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 底部模式切换图标 */}
|
||
<div className="flex justify-center gap-6 mt-3 pt-3 border-t border-white/[0.1]">
|
||
<Tooltip title="Switch to upload mode" placement="top">
|
||
<button
|
||
onClick={() => setMode("upload")}
|
||
className={`transition-colors ${
|
||
mode === "upload"
|
||
? "text-blue-300"
|
||
: "text-white/40 hover:text-white/60"
|
||
}`}
|
||
>
|
||
<Upload className="w-5 h-5" />
|
||
</button>
|
||
</Tooltip>
|
||
|
||
<Tooltip title="Switch to recording mode" placement="top">
|
||
<button
|
||
onClick={() => setMode("record")}
|
||
className={`transition-colors ${
|
||
mode === "record"
|
||
? "text-green-300"
|
||
: "text-white/40 hover:text-white/60"
|
||
}`}
|
||
>
|
||
<Mic className="w-5 h-5" />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 渲染播放状态
|
||
return (
|
||
<>
|
||
<style>{audioRecorderStyles}</style>
|
||
<div className="relative bg-white/[0.05] border border-white/[0.1] rounded-lg p-4">
|
||
{/* 头部 - 只显示操作按钮 */}
|
||
<div className="flex justify-end gap-2 mb-3">
|
||
<button
|
||
onClick={handleDelete}
|
||
className="text-white/60 hover:text-red-400 transition-colors"
|
||
title="Delete audio"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
{showCloseButton && (
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white/60 hover:text-white/80 transition-colors"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* WaveSurfer 波形图区域 */}
|
||
<div className="mb-4">
|
||
<div className="h-16 bg-white/[0.05] rounded-lg overflow-hidden">
|
||
<WaveformPlayer
|
||
audioUrl={audioUrl}
|
||
isPlaying={isPlaying}
|
||
onPlayStateChange={setIsPlaying}
|
||
volume={volume}
|
||
isMuted={isMuted}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 播放控制 */}
|
||
<div className="flex items-center justify-center gap-4 mb-4">
|
||
<button
|
||
onClick={togglePlay}
|
||
className="flex items-center justify-center w-12 h-12 bg-blue-500 text-white rounded-full hover:bg-blue-600 transition-colors"
|
||
>
|
||
{isPlaying ? (
|
||
<Pause className="w-5 h-5" />
|
||
) : (
|
||
<Play className="w-5 h-5" />
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{/* 音频设置 */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={toggleMute}
|
||
className="text-white/60 hover:text-white/80 transition-colors"
|
||
title={isMuted ? "Unmute" : "Mute"}
|
||
>
|
||
{isMuted ? (
|
||
<MicOff className="w-4 h-4" />
|
||
) : (
|
||
<Mic className="w-4 h-4" />
|
||
)}
|
||
</button>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
value={volume}
|
||
onChange={handleVolumeChange}
|
||
className="w-16 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer slider"
|
||
/>
|
||
<span className="text-xs text-white/60 w-8">
|
||
{Math.round(volume * 100)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-xs text-white/40">1x</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
interface WaveformPlayerProps {
|
||
audioUrl: string;
|
||
isPlaying: boolean;
|
||
onPlayStateChange: (isPlaying: boolean) => void;
|
||
volume?: number;
|
||
isMuted?: boolean;
|
||
}
|
||
|
||
function WaveformPlayer({
|
||
audioUrl,
|
||
isPlaying,
|
||
onPlayStateChange,
|
||
volume = 1,
|
||
isMuted = false,
|
||
}: WaveformPlayerProps) {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!containerRef.current || !audioUrl) return;
|
||
|
||
const ws = WaveSurfer.create({
|
||
container: containerRef.current,
|
||
waveColor: "#3b82f6",
|
||
progressColor: "#1d4ed8",
|
||
cursorColor: "#1e40af",
|
||
height: 64,
|
||
barWidth: 2,
|
||
barGap: 1,
|
||
normalize: true,
|
||
url: audioUrl,
|
||
});
|
||
|
||
// 监听播放状态变化
|
||
ws.on("play", () => onPlayStateChange(true));
|
||
ws.on("pause", () => onPlayStateChange(false));
|
||
ws.on("finish", () => onPlayStateChange(false));
|
||
|
||
wavesurferRef.current = ws;
|
||
|
||
return () => {
|
||
ws.destroy();
|
||
};
|
||
}, [audioUrl, onPlayStateChange]);
|
||
|
||
// 同步外部播放状态和音量
|
||
useEffect(() => {
|
||
if (!wavesurferRef.current) return;
|
||
|
||
if (isPlaying && !wavesurferRef.current.isPlaying()) {
|
||
wavesurferRef.current.play();
|
||
} else if (!isPlaying && wavesurferRef.current.isPlaying()) {
|
||
wavesurferRef.current.pause();
|
||
}
|
||
|
||
// 设置音量
|
||
const currentVolume = isMuted ? 0 : volume;
|
||
wavesurferRef.current.setVolume(currentVolume);
|
||
}, [isPlaying, volume, isMuted]);
|
||
|
||
return (
|
||
<div className="w-full h-full">
|
||
<div ref={containerRef} className="w-full h-full" />
|
||
</div>
|
||
);
|
||
}
|