dramaling-vocab-learning/frontend/components/AudioPlayer.tsx

191 lines
4.7 KiB
TypeScript
Raw 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';
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>
);
}