459 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 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"
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 {
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>
);
}