forked from 77media/video-flow
519 lines
18 KiB
TypeScript
519 lines
18 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, message } from "antd";
|
||
import { InboxOutlined } from "@ant-design/icons";
|
||
import WaveSurfer from "wavesurfer.js";
|
||
|
||
import { getAudioDuration, 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,
|
||
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 = async () => {
|
||
const audioBlob = new Blob(chunksRef.current, { type: "audio/wav" });
|
||
const audioUrl = URL.createObjectURL(audioBlob);
|
||
const duration = await getAudioDuration(audioBlob);
|
||
|
||
// 无论时长是否符合要求,都要释放麦克风权限
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
|
||
if(duration>20||duration<10){
|
||
message.warning("Please keep your audio between 10 and 20 seconds to help us better learn your voice.")
|
||
return;
|
||
}
|
||
|
||
onAudioRecorded(audioBlob, audioUrl);
|
||
};
|
||
|
||
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 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"
|
||
classNames={{ root: "max-w-xs" }}
|
||
>
|
||
<div>
|
||
<AntdUpload.Dragger
|
||
accept="audio/*"
|
||
beforeUpload={async(file) => {
|
||
console.log("beforeUpload 被调用:", file);
|
||
// 移除 return false,让文件能够正常进入 customRequest
|
||
const duration = await getAudioDuration(file);
|
||
if(duration>20||duration<10){
|
||
message.warning("Please keep your audio between 10 and 20 seconds to help us better learn your voice.")
|
||
return false;
|
||
}
|
||
return true;
|
||
}}
|
||
customRequest={async ({ file, onSuccess, onError }) => {
|
||
console.log("customRequest 被调用,文件:", file);
|
||
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}
|
||
onChange={(info) => {
|
||
console.log("Upload onChange 事件:", info);
|
||
}}
|
||
onDrop={(e) => {
|
||
console.log("文件拖拽事件:", e);
|
||
}}
|
||
>
|
||
<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"
|
||
classNames={{ root: "max-w-xs" }}
|
||
>
|
||
<button
|
||
onClick={startRecording}
|
||
className="flex items-center justify-center w-12 h-12 bg-red-500 text-white rounded-full hover:bg-red-600 transition-all duration-200 shadow-lg hover:shadow-xl mx-auto"
|
||
data-alt="record-button"
|
||
>
|
||
<Mic className="w-5 h-5" />
|
||
</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-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 className="text-xs text-white/40">1x</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 音频波形播放器组件的属性接口
|
||
*/
|
||
interface WaveformPlayerProps {
|
||
/** 音频文件的URL地址 */
|
||
audioUrl: string;
|
||
/** 当前播放状态,true表示正在播放,false表示暂停或停止 */
|
||
isPlaying: boolean;
|
||
/** 播放状态变化时的回调函数,用于同步外部播放状态 */
|
||
onPlayStateChange: (isPlaying: boolean) => void;
|
||
/** 音量大小,范围0-1,默认为1(最大音量) */
|
||
volume?: number;
|
||
/** 是否静音,true表示静音,false表示有声音,默认为false */
|
||
isMuted?: boolean;
|
||
}
|
||
|
||
/**
|
||
* 音频波形播放器组件
|
||
*
|
||
* 使用 WaveSurfer.js 库创建可视化的音频波形播放器,支持播放控制、音量调节和静音功能。
|
||
* 组件会自动同步外部传入的播放状态,确保播放控制的一致性。
|
||
*
|
||
* @param {string} audioUrl - 音频文件的URL地址
|
||
* @param {boolean} isPlaying - 当前播放状态
|
||
* @param {function} onPlayStateChange - 播放状态变化回调函数
|
||
* @param {number} [volume=1] - 音量大小,范围0-1
|
||
* @param {boolean} [isMuted=false] - 是否静音
|
||
* @returns {JSX.Element} 渲染的波形播放器组件
|
||
* ```
|
||
*/
|
||
function WaveformPlayer({
|
||
audioUrl,
|
||
isPlaying,
|
||
onPlayStateChange,
|
||
volume = 1,
|
||
isMuted = false,
|
||
}: WaveformPlayerProps) {
|
||
/** 容器DOM元素的引用,用于挂载WaveSurfer实例 */
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
/** WaveSurfer实例的引用,用于控制音频播放 */
|
||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||
|
||
/**
|
||
* 初始化WaveSurfer实例
|
||
* 当audioUrl变化时重新创建实例
|
||
*/
|
||
useEffect(() => {
|
||
if (!containerRef.current || !audioUrl) return;
|
||
|
||
// 创建WaveSurfer实例并配置样式
|
||
const ws = WaveSurfer.create({
|
||
container: containerRef.current,
|
||
waveColor: "#3b82f6", // 波形颜色
|
||
progressColor: "#1d4ed8", // 播放进度颜色
|
||
cursorColor: "#1e40af", // 播放光标颜色
|
||
height: 64, // 波形高度
|
||
barWidth: 2, // 波形条宽度
|
||
barGap: 1, // 波形条间距
|
||
normalize: true, // 自动标准化波形
|
||
url: audioUrl, // 音频文件URL
|
||
});
|
||
|
||
// 监听播放状态变化事件,同步到外部状态
|
||
ws.on("play", () => onPlayStateChange(true)); // 开始播放
|
||
ws.on("pause", () => onPlayStateChange(false)); // 暂停播放
|
||
ws.on("finish", () => onPlayStateChange(false)); // 播放结束
|
||
|
||
wavesurferRef.current = ws;
|
||
|
||
// 组件卸载时销毁WaveSurfer实例,释放资源
|
||
return () => {
|
||
ws.destroy();
|
||
};
|
||
}, [audioUrl, onPlayStateChange]);
|
||
|
||
/**
|
||
* 同步外部播放状态和音量设置
|
||
* 当isPlaying、volume或isMuted变化时,同步到WaveSurfer实例
|
||
*/
|
||
useEffect(() => {
|
||
if (!wavesurferRef.current) return;
|
||
|
||
// 同步播放状态:如果外部状态为播放但WaveSurfer未播放,则开始播放
|
||
if (isPlaying && !wavesurferRef.current.isPlaying()) {
|
||
wavesurferRef.current.play();
|
||
}
|
||
// 如果外部状态为暂停但WaveSurfer正在播放,则暂停播放
|
||
else if (!isPlaying && wavesurferRef.current.isPlaying()) {
|
||
wavesurferRef.current.pause();
|
||
}
|
||
|
||
// 设置音量:静音时设为0,否则使用传入的音量值
|
||
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>
|
||
);
|
||
}
|