432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect } from "react";
|
||
|
||
interface AudioPlayerProps {
|
||
src: string;
|
||
title?: string;
|
||
duration?: number; // 从数据库获取的时长
|
||
recordingId?: string; // 录音ID,用于检查访问权限
|
||
}
|
||
|
||
export default function AudioPlayer({
|
||
src,
|
||
title,
|
||
duration: dbDuration,
|
||
recordingId,
|
||
}: AudioPlayerProps) {
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [duration, setDuration] = useState(dbDuration || 0);
|
||
const [currentTime, setCurrentTime] = useState(0);
|
||
const [volume, setVolume] = useState(1);
|
||
const [showVolumeControl, setShowVolumeControl] = useState(false);
|
||
const [showDownloadMenu, setShowDownloadMenu] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [audioUrl, setAudioUrl] = useState(src);
|
||
|
||
const audioRef = useRef<HTMLAudioElement>(null);
|
||
const progressRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 检查 S3 文件访问权限
|
||
useEffect(() => {
|
||
if (recordingId && src.includes("amazonaws.com")) {
|
||
const checkAccess = async () => {
|
||
try {
|
||
const response = await fetch(
|
||
`/api/recordings/${recordingId}/check-access`
|
||
);
|
||
const data = await response.json();
|
||
|
||
if (data.accessible) {
|
||
console.log("S3 文件可访问:", data.url);
|
||
setError(null);
|
||
|
||
// 使用代理 URL 而不是直接访问 S3
|
||
const proxyUrl = `/api/recordings/${recordingId}/stream`;
|
||
setAudioUrl(proxyUrl);
|
||
} else {
|
||
console.error("S3 文件无法访问:", data.error);
|
||
setError("音频文件无法访问,请检查权限设置");
|
||
}
|
||
} catch (error) {
|
||
console.error("检查文件访问失败:", error);
|
||
setError("检查文件访问失败");
|
||
}
|
||
};
|
||
|
||
checkAccess();
|
||
}
|
||
}, [recordingId, src]);
|
||
|
||
useEffect(() => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
|
||
const handleLoadedMetadata = () => {
|
||
console.log("音频元数据加载完成:", {
|
||
duration: audio.duration,
|
||
src: audio.src,
|
||
readyState: audio.readyState,
|
||
networkState: audio.networkState,
|
||
currentSrc: audio.currentSrc,
|
||
error: audio.error,
|
||
});
|
||
|
||
// 如果数据库中没有时长,则从音频文件获取
|
||
if (!dbDuration) {
|
||
setDuration(audio.duration);
|
||
}
|
||
setIsLoading(false);
|
||
setError(null);
|
||
};
|
||
|
||
const handleTimeUpdate = () => {
|
||
setCurrentTime(audio.currentTime);
|
||
};
|
||
|
||
const handlePlay = () => {
|
||
console.log("音频开始播放");
|
||
setIsPlaying(true);
|
||
};
|
||
|
||
const handlePause = () => {
|
||
console.log("音频暂停");
|
||
setIsPlaying(false);
|
||
};
|
||
|
||
const handleEnded = () => {
|
||
console.log("音频播放结束");
|
||
setIsPlaying(false);
|
||
setCurrentTime(0);
|
||
};
|
||
|
||
const handleError = (e: Event) => {
|
||
const target = e.target as HTMLAudioElement;
|
||
console.error("音频加载失败:", {
|
||
error: target.error,
|
||
errorCode: target.error?.code,
|
||
errorMessage: target.error?.message,
|
||
src: target.src,
|
||
networkState: target.networkState,
|
||
readyState: target.readyState,
|
||
currentSrc: target.currentSrc,
|
||
});
|
||
|
||
setIsLoading(false);
|
||
setError("音频加载失败,请检查文件是否存在");
|
||
};
|
||
|
||
const handleLoadStart = () => {
|
||
console.log("开始加载音频:", src);
|
||
setIsLoading(true);
|
||
setError(null);
|
||
};
|
||
|
||
const handleCanPlay = () => {
|
||
console.log("音频可以播放:", src);
|
||
setIsLoading(false);
|
||
setError(null);
|
||
};
|
||
|
||
const handleCanPlayThrough = () => {
|
||
console.log("音频可以流畅播放:", src);
|
||
setIsLoading(false);
|
||
setError(null);
|
||
};
|
||
|
||
const handleLoad = () => {
|
||
console.log("音频加载完成:", src);
|
||
setIsLoading(false);
|
||
setError(null);
|
||
};
|
||
|
||
const handleAbort = () => {
|
||
console.log("音频加载被中止:", src);
|
||
};
|
||
|
||
const handleSuspend = () => {
|
||
console.log("音频加载被暂停:", src);
|
||
};
|
||
|
||
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||
audio.addEventListener("timeupdate", handleTimeUpdate);
|
||
audio.addEventListener("play", handlePlay);
|
||
audio.addEventListener("pause", handlePause);
|
||
audio.addEventListener("ended", handleEnded);
|
||
audio.addEventListener("error", handleError);
|
||
audio.addEventListener("loadstart", handleLoadStart);
|
||
audio.addEventListener("canplay", handleCanPlay);
|
||
audio.addEventListener("canplaythrough", handleCanPlayThrough);
|
||
audio.addEventListener("load", handleLoad);
|
||
audio.addEventListener("abort", handleAbort);
|
||
audio.addEventListener("suspend", handleSuspend);
|
||
|
||
return () => {
|
||
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||
audio.removeEventListener("timeupdate", handleTimeUpdate);
|
||
audio.removeEventListener("play", handlePlay);
|
||
audio.removeEventListener("pause", handlePause);
|
||
audio.removeEventListener("ended", handleEnded);
|
||
audio.removeEventListener("error", handleError);
|
||
audio.removeEventListener("loadstart", handleLoadStart);
|
||
audio.removeEventListener("canplay", handleCanPlay);
|
||
audio.removeEventListener("canplaythrough", handleCanPlayThrough);
|
||
audio.removeEventListener("load", handleLoad);
|
||
audio.removeEventListener("abort", handleAbort);
|
||
audio.removeEventListener("suspend", handleSuspend);
|
||
};
|
||
}, [dbDuration, src]);
|
||
|
||
// 点击外部区域关闭菜单
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
const target = event.target as Element;
|
||
if (!target.closest(".audio-player-controls")) {
|
||
setShowVolumeControl(false);
|
||
setShowDownloadMenu(false);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => {
|
||
document.removeEventListener("mousedown", handleClickOutside);
|
||
};
|
||
}, []);
|
||
|
||
const togglePlay = () => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
|
||
if (isPlaying) {
|
||
audio.pause();
|
||
} else {
|
||
audio.play().catch((error) => {
|
||
console.error("播放失败:", error);
|
||
setError("播放失败,请重试");
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
const audio = audioRef.current;
|
||
const progress = progressRef.current;
|
||
if (!audio || !progress || duration === 0) return;
|
||
|
||
const rect = progress.getBoundingClientRect();
|
||
const clickX = e.clientX - rect.left;
|
||
const progressWidth = rect.width;
|
||
const clickPercent = clickX / progressWidth;
|
||
|
||
audio.currentTime = clickPercent * duration;
|
||
};
|
||
|
||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
|
||
const newVolume = parseFloat(e.target.value);
|
||
setVolume(newVolume);
|
||
audio.volume = newVolume;
|
||
};
|
||
|
||
const handleDownload = () => {
|
||
const link = document.createElement("a");
|
||
link.href = src;
|
||
link.download = `${title || "recording"}.webm`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
};
|
||
|
||
const formatTime = (time: number) => {
|
||
if (isNaN(time) || time === Infinity) return "0:00";
|
||
const minutes = Math.floor(time / 60);
|
||
const seconds = Math.floor(time % 60);
|
||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||
};
|
||
|
||
return (
|
||
<div className="w-full">
|
||
<audio ref={audioRef} preload="metadata">
|
||
<source src={audioUrl} type="audio/webm" />
|
||
您的浏览器不支持音频播放。
|
||
</audio>
|
||
|
||
<div className="space-y-3">
|
||
{/* 播放控制 */}
|
||
<div className="flex items-center gap-4">
|
||
<button
|
||
onClick={togglePlay}
|
||
disabled={isLoading}
|
||
className="flex items-center justify-center w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 disabled:from-gray-400 disabled:to-gray-400 text-white rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:shadow-none group"
|
||
>
|
||
{isLoading ? (
|
||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||
) : isPlaying ? (
|
||
<svg
|
||
className="w-5 h-5 group-hover:scale-110 transition-transform"
|
||
fill="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||
</svg>
|
||
) : (
|
||
<svg
|
||
className="w-5 h-5 ml-0.5 group-hover:scale-110 transition-transform"
|
||
fill="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path d="M8 5v14l11-7z" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||
{title || "录音"}
|
||
</div>
|
||
<div className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||
<span>{formatTime(currentTime)}</span>
|
||
<span>/</span>
|
||
<span>{formatTime(duration)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 控制按钮组 */}
|
||
<div className="flex items-center gap-1">
|
||
{/* 音量控制 */}
|
||
<div className="relative audio-player-controls">
|
||
<button
|
||
onClick={() => setShowVolumeControl(!showVolumeControl)}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M6.343 6.343a1 1 0 011.414 0l8.486 8.486a1 1 0 01-1.414 1.414L6.343 7.757a1 1 0 010-1.414z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
|
||
{showVolumeControl && (
|
||
<div className="absolute bottom-full right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl p-3 shadow-xl z-10 backdrop-blur-sm">
|
||
<div className="flex items-center gap-3">
|
||
<svg
|
||
className="w-4 h-4 text-gray-500 dark:text-gray-400"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M6.343 6.343a1 1 0 011.414 0l8.486 8.486a1 1 0 01-1.414 1.414L6.343 7.757a1 1 0 010-1.414z"
|
||
/>
|
||
</svg>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
value={volume}
|
||
onChange={handleVolumeChange}
|
||
className="w-24 h-2 bg-gray-200 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 下载按钮 */}
|
||
<div className="relative audio-player-controls">
|
||
<button
|
||
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
|
||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
|
||
title="下载录音"
|
||
>
|
||
<svg
|
||
className="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
|
||
{showDownloadMenu && (
|
||
<div className="absolute bottom-full right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl p-2 shadow-xl z-10 backdrop-blur-sm">
|
||
<button
|
||
onClick={handleDownload}
|
||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors whitespace-nowrap"
|
||
>
|
||
<svg
|
||
className="w-4 h-4 flex-shrink-0"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||
/>
|
||
</svg>
|
||
<span>下载录音</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 进度条 */}
|
||
<div
|
||
ref={progressRef}
|
||
onClick={handleProgressClick}
|
||
className="w-full h-2 bg-gray-200 dark:bg-gray-600 rounded-full cursor-pointer relative group"
|
||
>
|
||
<div
|
||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all duration-100 relative overflow-hidden"
|
||
style={{
|
||
width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%`,
|
||
}}
|
||
>
|
||
{/* 进度条动画效果 */}
|
||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse"></div>
|
||
</div>
|
||
|
||
{/* 进度条悬停效果 */}
|
||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||
<div className="absolute top-0 left-0 w-full h-full bg-blue-200 dark:bg-blue-400/30 rounded-full opacity-30"></div>
|
||
</div>
|
||
|
||
{/* 进度条滑块 */}
|
||
<div
|
||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white dark:bg-gray-200 rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||
style={{
|
||
left: `${duration > 0 ? (currentTime / duration) * 100 : 0}%`,
|
||
transform: "translate(-50%, -50%)",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|