322 lines
8.7 KiB
TypeScript
322 lines
8.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;
|
||
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>
|
||
);
|
||
} |