Files
record-app-next/components/AudioPlayer.tsx
2025-07-31 17:05:07 +08:00

432 lines
15 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 { 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>
);
}