191 lines
4.7 KiB
TypeScript
191 lines
4.7 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useRef, useEffect } from 'react';
|
||
import { Play, Pause, Volume2, VolumeX, Settings } from 'lucide-react';
|
||
|
||
export interface AudioPlayerProps {
|
||
text: string;
|
||
audioUrl?: string;
|
||
autoPlay?: boolean;
|
||
onPlayStart?: () => void;
|
||
onPlayEnd?: () => void;
|
||
onError?: (error: string) => void;
|
||
className?: string;
|
||
}
|
||
|
||
export interface TTSResponse {
|
||
audioUrl: string;
|
||
duration: number;
|
||
cacheHit: boolean;
|
||
error?: string;
|
||
}
|
||
|
||
export default function AudioPlayer({
|
||
text,
|
||
audioUrl: providedAudioUrl,
|
||
autoPlay = false,
|
||
onPlayStart,
|
||
onPlayEnd,
|
||
onError,
|
||
className = ''
|
||
}: AudioPlayerProps) {
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [audioUrl, setAudioUrl] = useState<string | null>(providedAudioUrl || null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const audioRef = useRef<HTMLAudioElement>(null);
|
||
|
||
// 生成音頻
|
||
const generateAudio = async (textToSpeak: string) => {
|
||
try {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
const response = await fetch('/api/audio/tts', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`
|
||
},
|
||
body: JSON.stringify({
|
||
text: textToSpeak,
|
||
accent: 'us',
|
||
speed: 1.0,
|
||
voice: ''
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const data: TTSResponse = await response.json();
|
||
|
||
if (data.error) {
|
||
throw new Error(data.error);
|
||
}
|
||
|
||
setAudioUrl(data.audioUrl);
|
||
return data.audioUrl;
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : 'Failed to generate audio';
|
||
setError(errorMessage);
|
||
onError?.(errorMessage);
|
||
return null;
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// 播放音頻
|
||
const playAudio = async () => {
|
||
if (!text) {
|
||
setError('No text to play');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
let urlToPlay = audioUrl;
|
||
|
||
// 如果沒有音頻 URL,先生成
|
||
if (!urlToPlay) {
|
||
urlToPlay = await generateAudio(text);
|
||
if (!urlToPlay) return;
|
||
}
|
||
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
|
||
audio.src = urlToPlay;
|
||
|
||
await audio.play();
|
||
setIsPlaying(true);
|
||
onPlayStart?.();
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : 'Failed to play audio';
|
||
setError(errorMessage);
|
||
onError?.(errorMessage);
|
||
}
|
||
};
|
||
|
||
// 暫停音頻
|
||
const pauseAudio = () => {
|
||
const audio = audioRef.current;
|
||
if (audio) {
|
||
audio.pause();
|
||
setIsPlaying(false);
|
||
}
|
||
};
|
||
|
||
// 切換播放/暫停
|
||
const togglePlayPause = (e?: React.MouseEvent) => {
|
||
e?.stopPropagation(); // 阻止事件冒泡
|
||
if (isPlaying) {
|
||
pauseAudio();
|
||
} else {
|
||
playAudio();
|
||
}
|
||
};
|
||
|
||
// 處理音頻事件
|
||
const handleAudioEnd = () => {
|
||
setIsPlaying(false);
|
||
onPlayEnd?.();
|
||
};
|
||
|
||
const handleAudioError = () => {
|
||
setIsPlaying(false);
|
||
const errorMessage = 'Audio playback error';
|
||
setError(errorMessage);
|
||
onError?.(errorMessage);
|
||
};
|
||
|
||
// 自動播放
|
||
useEffect(() => {
|
||
if (autoPlay && text && !audioUrl) {
|
||
generateAudio(text);
|
||
}
|
||
}, [autoPlay, text]);
|
||
|
||
return (
|
||
<div className={`audio-player flex items-center gap-2 ${className}`}>
|
||
{/* 隱藏的音頻元素 */}
|
||
<audio
|
||
ref={audioRef}
|
||
onEnded={handleAudioEnd}
|
||
onError={handleAudioError}
|
||
preload="none"
|
||
/>
|
||
|
||
{/* 播放/暫停按鈕 */}
|
||
<button
|
||
onClick={togglePlayPause}
|
||
disabled={isLoading || !text}
|
||
className={`
|
||
flex items-center justify-center w-10 h-10 rounded-full transition-colors
|
||
${isLoading || !text
|
||
? 'bg-gray-300 cursor-not-allowed'
|
||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||
}
|
||
`}
|
||
title={isPlaying ? 'Pause' : 'Play'}
|
||
>
|
||
{isLoading ? (
|
||
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||
) : isPlaying ? (
|
||
<Pause size={20} />
|
||
) : (
|
||
<Play size={20} />
|
||
)}
|
||
</button>
|
||
|
||
{/* 錯誤顯示 */}
|
||
{error && (
|
||
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
|
||
{error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} |