2025-08-29 01:41:13 +08:00

859 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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,
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>
);
}