Initial commit

This commit is contained in:
theshy
2025-07-31 17:05:07 +08:00
parent 8fab3b19cc
commit 24f21144ab
91 changed files with 16311 additions and 159 deletions

431
components/AudioPlayer.tsx Normal file
View File

@ -0,0 +1,431 @@
"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>
);
}