forked from 77media/video-flow
859 lines
29 KiB
TypeScript
859 lines
29 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useRef, useEffect } from "react";
|
||
import {
|
||
Mic,
|
||
MicOff,
|
||
Upload,
|
||
Play,
|
||
Pause,
|
||
Trash2,
|
||
X,
|
||
Volume2,
|
||
} 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);
|
||
}
|
||
|
||
/* 音量滑块样式 */
|
||
.volume-slider {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.volume-slider::-webkit-slider-track {
|
||
background: transparent;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.volume-slider::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 0;
|
||
height: 0;
|
||
box-shadow: -100px 0 5px 100px #92ff77, -100px 0px 20px 100px #92ff77;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.volume-slider::-moz-range-track {
|
||
background: transparent;
|
||
height: 16px;
|
||
border-radius: 8px;
|
||
border: none;
|
||
}
|
||
|
||
.volume-slider::-moz-range-thumb {
|
||
width: 0;
|
||
height: 0;
|
||
border-radius: 0;
|
||
border: none;
|
||
box-shadow: -100px 0 5px 100px #92ff77, -100px 0px 20px 100px #92ff77;
|
||
}
|
||
`;
|
||
|
||
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 [recordingTime, setRecordingTime] = useState(0); // 录制时长(秒)
|
||
const [isUploadingRecording, setIsUploadingRecording] = useState(false); // 录制音频上传状态
|
||
const { uploadFile, isUploading } = useUploadFile();
|
||
|
||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||
const chunksRef = useRef<Blob[]>([]);
|
||
const recordingTimerRef = useRef<NodeJS.Timeout | null>(null); // 录制计时器引用
|
||
const actualRecordingTimeRef = useRef<number>(0); // 实际录制时长引用,用于准确获取时长
|
||
|
||
// 清理计时器
|
||
useEffect(() => {
|
||
return () => {
|
||
if (recordingTimerRef.current) {
|
||
clearInterval(recordingTimerRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 开始录制
|
||
const startRecording = async () => {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
|
||
// 检查支持的音频格式,优先使用 webm
|
||
const mimeType = MediaRecorder.isTypeSupported("audio/webm")
|
||
? "audio/webm"
|
||
: "audio/mp4";
|
||
|
||
const mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||
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: mimeType });
|
||
|
||
// 先停止计时器,然后获取最终时长
|
||
if (recordingTimerRef.current) {
|
||
clearInterval(recordingTimerRef.current);
|
||
recordingTimerRef.current = null;
|
||
}
|
||
|
||
// 使用引用值获取准确的录制时长
|
||
const duration = actualRecordingTimeRef.current;
|
||
console.log("录制时长:", duration, "秒");
|
||
|
||
// 无论时长是否符合要求,都要释放麦克风权限
|
||
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;
|
||
}
|
||
|
||
try {
|
||
// 将 Blob 转换为 File 对象,以便上传
|
||
const audioFile = new File(
|
||
[audioBlob],
|
||
`recording_${Date.now()}.webm`,
|
||
{ type: mimeType }
|
||
);
|
||
|
||
// 设置上传状态
|
||
setIsUploadingRecording(true);
|
||
|
||
// 使用 uploadFile 上传到服务器
|
||
console.log("开始上传录制的音频文件...");
|
||
const uploadedUrl = await uploadFile(audioFile);
|
||
console.log("音频上传成功,服务器URL:", uploadedUrl);
|
||
|
||
// 使用服务器返回的真实URL地址
|
||
onAudioRecorded(audioBlob, uploadedUrl);
|
||
} catch (error) {
|
||
console.error("音频上传失败:", error);
|
||
message.error("Audio upload failed, please try again");
|
||
return;
|
||
} finally {
|
||
setIsUploadingRecording(false);
|
||
}
|
||
|
||
// 重置计时器状态
|
||
setRecordingTime(0);
|
||
actualRecordingTimeRef.current = 0; // 重置引用值
|
||
setIsRecording(false);
|
||
};
|
||
|
||
mediaRecorder.start();
|
||
setIsRecording(true);
|
||
setRecordingTime(0); // 重置计时器
|
||
actualRecordingTimeRef.current = 0; // 重置引用值
|
||
|
||
// 开始计时
|
||
recordingTimerRef.current = setInterval(() => {
|
||
setRecordingTime((prev) => prev + 1);
|
||
actualRecordingTimeRef.current += 1; // 同时更新引用值
|
||
}, 1000);
|
||
} catch (error) {
|
||
console.error("录制失败:", error);
|
||
}
|
||
};
|
||
|
||
// 停止录制
|
||
const stopRecording = () => {
|
||
if (mediaRecorderRef.current && isRecording) {
|
||
mediaRecorderRef.current.stop();
|
||
setIsRecording(false);
|
||
// 注意:计时器清理现在在 onstop 回调中处理
|
||
}
|
||
};
|
||
|
||
// 播放/暂停控制
|
||
const togglePlay = () => {
|
||
setIsPlaying(!isPlaying);
|
||
};
|
||
|
||
// 音量调节
|
||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const newVolume = parseFloat(e.target.value);
|
||
// 确保音量值在 0-1 范围内
|
||
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
||
console.log("newVolume", newVolume, "clampedVolume", clampedVolume);
|
||
setVolume(clampedVolume);
|
||
if (isMuted && clampedVolume > 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 flex flex-col justify-center h-32 ">
|
||
<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);
|
||
console.log("duration", duration);
|
||
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 flex flex-col justify-center h-32">
|
||
{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-3 text-white/60">
|
||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
||
<span className="text-xs">Recording...</span>
|
||
<span className="text-sm font-mono text-red-400">
|
||
{Math.floor(recordingTime / 60)
|
||
.toString()
|
||
.padStart(2, "0")}
|
||
:{(recordingTime % 60).toString().padStart(2, "0")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : isUploadingRecording ? (
|
||
// 录制音频上传中状态
|
||
<div className="text-center">
|
||
<div className="text-xs text-white/60 mb-3">
|
||
Uploading recorded audio...
|
||
</div>
|
||
<div className="flex items-center justify-center">
|
||
<div className="w-8 h-8 border-2 border-white/20 border-t-white/60 rounded-full animate-spin"></div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
// 录制准备状态
|
||
<div className="text-center">
|
||
<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-[rgb(106,244,249)]"
|
||
: "text-white/40 hover:text-[rgb(106,244,249)]/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-[rgb(199,59,255)]"
|
||
: "text-white/40 hover:text-[rgb(199,59,255)]/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 h-52 group"
|
||
data-alt="audio-player-container"
|
||
>
|
||
{/* 大旋转的播放圆盘 */}
|
||
<div className=" absolute z-10 right-10 top-[45%] translate-y-[-50%] scale-[1.4]">
|
||
<div className="relative">
|
||
{/* 外层边框 - 唱片边缘 */}
|
||
<div
|
||
className={`w-28 h-28 rounded-full border-4 border-gray-400/30 ${
|
||
isPlaying ? "animate-spin" : ""
|
||
}`}
|
||
style={{
|
||
animationDuration: "6s",
|
||
}}
|
||
></div>
|
||
|
||
{/* 唱片主体 - 纯黑色唱片面 */}
|
||
<div
|
||
className={`absolute inset-2 w-24 h-24 rounded-full bg-[0,0,0,.4] shadow-[inset_0_2px_8px_rgba(0,0,0,0.8)] ${
|
||
isPlaying ? "animate-spin" : ""
|
||
}`}
|
||
style={{ animationDuration: "6s" }}
|
||
>
|
||
{/* 唱片纹理 - 细密的同心圆 */}
|
||
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_center,transparent_0%,rgba(255,255,255,0.02)_1px,transparent_2px)] bg-[length:4px_4px]"></div>
|
||
|
||
{/* 中心孔洞 - 透明圆环,显示背景色 */}
|
||
<div className="absolute top-1/2 left-1/2 w-8 h-8 rounded-full -translate-x-1/2 -translate-y-1/2 bg-transparent border-2 border-white/20"></div>
|
||
|
||
{/* MovieFlow 字母 - 环形文本效果 */}
|
||
<div className="absolute inset-0">
|
||
<svg
|
||
viewBox="0 0 96 96"
|
||
className="w-full h-full"
|
||
style={{
|
||
filter: "drop-shadow(0 0 8px rgba(106, 244, 249, 0.3))",
|
||
}}
|
||
>
|
||
{/* 定义渐变 */}
|
||
<defs>
|
||
<linearGradient
|
||
id="movieFlowGradient"
|
||
x1="0%"
|
||
y1="0%"
|
||
x2="100%"
|
||
y2="0%"
|
||
>
|
||
<stop offset="30%" stopColor="rgb(106, 244, 249)" />
|
||
<stop offset="70%" stopColor="rgb(199, 59, 255)" />
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
{/* 圆形路径 - 减小半径让文字更靠近中心 */}
|
||
<path
|
||
id="movieFlowCircle"
|
||
d="M 48,16 a 32,32 0 1,1 0,64 a 32,32 0 1,1 0,-64"
|
||
fill="none"
|
||
stroke="none"
|
||
/>
|
||
|
||
{/* 环形文本 - 调整起始位置让文字更居中 */}
|
||
<text fontSize="12" fontWeight="bold" letterSpacing="1">
|
||
<textPath
|
||
href="#movieFlowCircle"
|
||
startOffset="25%"
|
||
textAnchor="middle"
|
||
fill="url(#movieFlowGradient)"
|
||
>
|
||
MovieFlow
|
||
</textPath>
|
||
</text>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 唱片光泽效果 */}
|
||
<div
|
||
className={`absolute inset-2 w-24 h-24 rounded-full bg-gradient-to-br from-transparent via-white/5 to-transparent ${
|
||
isPlaying ? "animate-spin" : ""
|
||
}`}
|
||
style={{ animationDuration: "10s" }}
|
||
></div>
|
||
</div>
|
||
</div>
|
||
{/* 播放控制 */}
|
||
<button
|
||
onClick={togglePlay}
|
||
className="absolute z-10 right-[4.75rem] top-[45%] translate-y-[-50%] flex items-center justify-center w-10 h-10 bg-gradient-to-br from-[rgb(106,244,249)] to-[rgb(199,59,255)] text-white rounded-full shadow-[inset_0_1px_0_rgba(255,255,255,0.3),0_4px_12px_rgba(0,0,0,0.3)] hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.4),0_6px_20px_rgba(106,244,249,0.4)] transition-all duration-300 hover:scale-105 active:scale-95"
|
||
>
|
||
{isPlaying ? (
|
||
<Pause className="w-5 h-5 drop-shadow-sm" />
|
||
) : (
|
||
<Play className="w-5 h-5 drop-shadow-sm ml-0.5" />
|
||
)}
|
||
</button>
|
||
{/* 头部 - 只显示操作按钮 */}
|
||
<div className="relative -right-3 z-10 flex justify-end gap-2 mb-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||
<button
|
||
onClick={handleDelete}
|
||
className="text-white/60 hover:text-red-400 transition-colors"
|
||
title="Delete audio"
|
||
data-alt="delete-audio-button"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
{showCloseButton && (
|
||
<button
|
||
onClick={onClose}
|
||
className="text-white/60 hover:text-white/80 transition-colors"
|
||
data-alt="close-audio-button"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{/* 音频设置 */}
|
||
<div className="relative z-1 flex items-center justify-between px-2 mt-16 mb-2">
|
||
<VolumeSlider volume={volume} onVolumeChange={handleVolumeChange} />
|
||
</div>
|
||
{/* WaveSurfer 波形图区域 */}
|
||
<div className=" relative z-5 h-16 flex-1 bg-white/[0.05] rounded-lg overflow-hidden">
|
||
<WaveformPlayer
|
||
audioUrl={audioUrl}
|
||
isPlaying={isPlaying}
|
||
onPlayStateChange={setIsPlaying}
|
||
volume={volume}
|
||
isMuted={isMuted}
|
||
/>
|
||
</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;
|
||
|
||
// 创建Canvas渐变
|
||
const canvas = document.createElement("canvas");
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
|
||
const gradient = ctx.createLinearGradient(0, 0, 0, 64); // 64是波形高度
|
||
gradient.addColorStop(0, "rgb(106, 244, 249)"); // 顶部:青色
|
||
gradient.addColorStop(0.5, "rgb(152, 151, 254)"); // 中间:蓝紫色
|
||
gradient.addColorStop(1, "rgb(199, 59, 255)"); // 底部:紫色
|
||
|
||
// 创建WaveSurfer实例并配置样式
|
||
const ws = WaveSurfer.create({
|
||
container: containerRef.current,
|
||
waveColor: gradient, // 使用渐变作为波形颜色
|
||
progressColor: "rgb(152, 151, 254)", // 播放进度颜色 - 半透明白色,更优雅
|
||
cursorColor: "rgb(152, 151, 254)", // 播放光标颜色 - 紫色
|
||
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>
|
||
);
|
||
}
|
||
|
||
function VolumeSlider({
|
||
volume,
|
||
onVolumeChange,
|
||
}: {
|
||
volume: number;
|
||
onVolumeChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||
}) {
|
||
const [isHovered, setIsHovered] = useState(false);
|
||
|
||
return (
|
||
<div className="w-full h-full">
|
||
<style jsx>{`
|
||
/* From Uiverse.io by javierBarroso */
|
||
/* level settings 👇 */
|
||
|
||
.slider {
|
||
/* slider */
|
||
--slider-width: 60%;
|
||
--slider-height: 12px;
|
||
--slider-bg: rgba(82, 82, 82, 0.322);
|
||
--slider-border-radius: 4px;
|
||
/* level */
|
||
--level-color: rgb(106,244,249);
|
||
--level-transition-duration: 5s;
|
||
/* icon */
|
||
--icon-margin: 10px;
|
||
--icon-color: var(--slider-bg);
|
||
--icon-size: 20px;
|
||
position: relative;
|
||
left: -40px;
|
||
}
|
||
|
||
.slider {
|
||
position: relative;
|
||
cursor: pointer;
|
||
display: -webkit-inline-box;
|
||
display: -ms-inline-flexbox;
|
||
display: inline-flex;
|
||
-webkit-box-orient: horizontal;
|
||
-webkit-box-direction: reverse;
|
||
-ms-flex-direction: row-reverse;
|
||
flex-direction: row-reverse;
|
||
-webkit-box-align: center;
|
||
-ms-flex-align: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.slider .volume {
|
||
display: inline-block;
|
||
vertical-align: top;
|
||
margin-right: var(--icon-margin);
|
||
color: var(--icon-color);
|
||
width: var(--icon-size);
|
||
height: auto;
|
||
position: absolute;
|
||
left: 12px;
|
||
pointer-events: none;
|
||
transition-duration: 0.5s;
|
||
}
|
||
|
||
.slider .level {
|
||
position: relative;
|
||
left: -30px;
|
||
top: -40px;
|
||
-webkit-appearance: none;
|
||
-moz-appearance: none;
|
||
appearance: none;
|
||
width: var(--slider-width);
|
||
height: var(--slider-height);
|
||
background: var(--slider-bg);
|
||
overflow: hidden;
|
||
border-radius: var(--slider-border-radius);
|
||
-webkit-transition: height var(--level-transition-duration);
|
||
-o-transition: height var(--level-transition-duration);
|
||
transition: height var(--level-transition-duration);
|
||
cursor: inherit;
|
||
transform: rotate(270deg);
|
||
opacity: 0;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.slider:hover .level {
|
||
opacity: 1;
|
||
}
|
||
|
||
.slider .level::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 0px;
|
||
height: 0px;
|
||
-webkit-box-shadow: -200px 0 0 200px var(--level-color);
|
||
box-shadow: -100px 0 5px 100px var(--level-color),
|
||
-100px 0px 20px 100px var(--level-color);
|
||
}
|
||
.slider .level:hover ~ .volume {
|
||
color: var(--level-color);
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.slider .level::-moz-range-thumb {
|
||
width: 0;
|
||
height: 0;
|
||
border-radius: 0;
|
||
border: none;
|
||
box-shadow: -100px 0 5px 100px var(--level-color),
|
||
-100px 0px 20px 100px var(--level-color);
|
||
}
|
||
`}</style>
|
||
<label
|
||
className="slider"
|
||
onMouseEnter={() => setIsHovered(true)}
|
||
onMouseLeave={() => setIsHovered(false)}
|
||
>
|
||
<input
|
||
type="range"
|
||
className="level"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
value={volume}
|
||
onChange={onVolumeChange}
|
||
/>
|
||
<Volume2 className="text-[#777876]" />
|
||
</label>
|
||
</div>
|
||
);
|
||
}
|