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

322 lines
8.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;
accent?: 'us' | 'uk';
speed?: number;
autoPlay?: boolean;
showAccentSelector?: boolean;
showSpeedControl?: 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,
accent = 'us',
speed = 1.0,
autoPlay = false,
showAccentSelector = true,
showSpeedControl = true,
onPlayStart,
onPlayEnd,
onError,
className = ''
}: AudioPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [currentAccent, setCurrentAccent] = useState<'us' | 'uk'>(accent);
const [currentSpeed, setCurrentSpeed] = useState(speed);
const [audioUrl, setAudioUrl] = useState<string | null>(providedAudioUrl || null);
const [showSettings, setShowSettings] = useState(false);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement>(null);
// 生成音頻
const generateAudio = async (textToSpeak: string, accent: 'us' | 'uk', speed: number) => {
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: accent,
speed: speed,
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, currentAccent, currentSpeed);
if (!urlToPlay) return;
}
const audio = audioRef.current;
if (!audio) return;
audio.src = urlToPlay;
audio.playbackRate = currentSpeed;
audio.volume = isMuted ? 0 : volume;
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 = () => {
if (isPlaying) {
pauseAudio();
} else {
playAudio();
}
};
// 處理音頻事件
const handleAudioEnd = () => {
setIsPlaying(false);
onPlayEnd?.();
};
const handleAudioError = () => {
setIsPlaying(false);
const errorMessage = 'Audio playback error';
setError(errorMessage);
onError?.(errorMessage);
};
// 切換口音
const handleAccentChange = async (newAccent: 'us' | 'uk') => {
if (newAccent === currentAccent) return;
setCurrentAccent(newAccent);
setAudioUrl(null); // 清除現有音頻,強制重新生成
// 如果正在播放,停止並重新生成
if (isPlaying) {
pauseAudio();
await generateAudio(text, newAccent, currentSpeed);
}
};
// 切換速度
const handleSpeedChange = async (newSpeed: number) => {
if (newSpeed === currentSpeed) return;
setCurrentSpeed(newSpeed);
// 如果音頻正在播放,直接調整播放速度
const audio = audioRef.current;
if (audio && isPlaying) {
audio.playbackRate = newSpeed;
} else {
// 否則清除音頻,重新生成
setAudioUrl(null);
}
};
// 音量控制
const handleVolumeChange = (newVolume: number) => {
setVolume(newVolume);
const audio = audioRef.current;
if (audio) {
audio.volume = isMuted ? 0 : newVolume;
}
};
const toggleMute = () => {
const newMuted = !isMuted;
setIsMuted(newMuted);
const audio = audioRef.current;
if (audio) {
audio.volume = newMuted ? 0 : volume;
}
};
// 自動播放
useEffect(() => {
if (autoPlay && text && !audioUrl) {
generateAudio(text, currentAccent, currentSpeed);
}
}, [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>
{/* 口音選擇器 */}
{showAccentSelector && (
<div className="flex gap-1">
<button
onClick={() => handleAccentChange('us')}
className={`
px-2 py-1 text-xs rounded transition-colors
${currentAccent === 'us'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
`}
>
US
</button>
<button
onClick={() => handleAccentChange('uk')}
className={`
px-2 py-1 text-xs rounded transition-colors
${currentAccent === 'uk'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
`}
>
UK
</button>
</div>
)}
{/* 速度控制 */}
{showSpeedControl && (
<div className="flex items-center gap-1">
<span className="text-xs text-gray-600">Speed:</span>
<select
value={currentSpeed}
onChange={(e) => handleSpeedChange(parseFloat(e.target.value))}
className="text-xs border border-gray-300 rounded px-1 py-0.5"
>
<option value={0.5}>0.5x</option>
<option value={0.75}>0.75x</option>
<option value={1.0}>1x</option>
<option value={1.25}>1.25x</option>
<option value={1.5}>1.5x</option>
<option value={2.0}>2x</option>
</select>
</div>
)}
{/* 音量控制 */}
<div className="flex items-center gap-1">
<button
onClick={toggleMute}
className="p-1 text-gray-600 hover:text-gray-800"
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
type="range"
min={0}
max={1}
step={0.1}
value={isMuted ? 0 : volume}
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
className="w-16 h-1"
/>
</div>
{/* 錯誤顯示 */}
{error && (
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
{error}
</div>
)}
</div>
);
}