diff --git a/frontend/app/flashcards/[id]/page.tsx b/frontend/app/flashcards/[id]/page.tsx index f94fe15..a1673f1 100644 --- a/frontend/app/flashcards/[id]/page.tsx +++ b/frontend/app/flashcards/[id]/page.tsx @@ -36,6 +36,76 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) { // 圖片生成狀態 const [isGeneratingImage, setIsGeneratingImage] = useState(false) const [generationProgress, setGenerationProgress] = useState('') + const [isPlayingWord, setIsPlayingWord] = useState(false) + const [isPlayingExample, setIsPlayingExample] = useState(false) + + // TTS播放控制 - 詞彙發音 + const toggleWordTTS = (text: string, lang: string = 'en-US') => { + if (!('speechSynthesis' in window)) { + toast.error('您的瀏覽器不支援語音播放'); + return; + } + + // 如果正在播放詞彙,則停止 + if (isPlayingWord) { + speechSynthesis.cancel(); + setIsPlayingWord(false); + return; + } + + // 停止所有播放並開始新播放 + speechSynthesis.cancel(); + setIsPlayingWord(true); + setIsPlayingExample(false); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = lang; + utterance.rate = 0.8; // 詞彙播放稍慢 + utterance.pitch = 1.0; + utterance.volume = 1.0; + + utterance.onend = () => setIsPlayingWord(false); + utterance.onerror = () => { + setIsPlayingWord(false); + toast.error('語音播放失敗'); + }; + + speechSynthesis.speak(utterance); + } + + // TTS播放控制 - 例句發音 + const toggleExampleTTS = (text: string, lang: string = 'en-US') => { + if (!('speechSynthesis' in window)) { + toast.error('您的瀏覽器不支援語音播放'); + return; + } + + // 如果正在播放例句,則停止 + if (isPlayingExample) { + speechSynthesis.cancel(); + setIsPlayingExample(false); + return; + } + + // 停止所有播放並開始新播放 + speechSynthesis.cancel(); + setIsPlayingExample(true); + setIsPlayingWord(false); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = lang; + utterance.rate = 0.9; // 例句播放正常語速 + utterance.pitch = 1.0; + utterance.volume = 1.0; + + utterance.onend = () => setIsPlayingExample(false); + utterance.onerror = () => { + setIsPlayingExample(false); + toast.error('語音播放失敗'); + }; + + speechSynthesis.speak(utterance); + } // 假資料 - 用於展示效果 const mockCards: {[key: string]: any} = { @@ -369,10 +439,40 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) { {getPartOfSpeechDisplay(flashcard.partOfSpeech)} {flashcard.pronunciation} - @@ -507,10 +607,40 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) { "{flashcard.example}"

-
diff --git a/frontend/components/AudioPlayer.tsx b/frontend/components/AudioPlayer.tsx index d1ffbbc..b933570 100644 --- a/frontend/components/AudioPlayer.tsx +++ b/frontend/components/AudioPlayer.tsx @@ -1,191 +1,102 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; -import { Play, Pause, Volume2, VolumeX, Settings } from 'lucide-react'; +import { useState } from 'react'; export interface AudioPlayerProps { text: string; - audioUrl?: string; - autoPlay?: boolean; + lang?: string; onPlayStart?: () => void; onPlayEnd?: () => void; onError?: (error: string) => void; className?: string; -} - -export interface TTSResponse { - audioUrl: string; - duration: number; - cacheHit: boolean; - error?: string; + disabled?: boolean; } export default function AudioPlayer({ text, - audioUrl: providedAudioUrl, - autoPlay = false, + lang = 'en-US', onPlayStart, onPlayEnd, onError, - className = '' + className = '', + disabled = false }: AudioPlayerProps) { const [isPlaying, setIsPlaying] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [audioUrl, setAudioUrl] = useState(providedAudioUrl || null); - const [error, setError] = useState(null); - const audioRef = useRef(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'); + // TTS播放控制功能 + const toggleTTS = () => { + if (!('speechSynthesis' in window)) { + onError?.('您的瀏覽器不支援語音播放'); 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(); + speechSynthesis.cancel(); + setIsPlaying(false); + onPlayEnd?.(); + return; } - }; - // 處理音頻事件 - const handleAudioEnd = () => { - setIsPlaying(false); - onPlayEnd?.(); - }; + // 開始播放 + speechSynthesis.cancel(); + setIsPlaying(true); + onPlayStart?.(); - const handleAudioError = () => { - setIsPlaying(false); - const errorMessage = 'Audio playback error'; - setError(errorMessage); - onError?.(errorMessage); - }; + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = lang; + utterance.rate = 0.8; // 稍慢語速 + utterance.pitch = 1.0; + utterance.volume = 1.0; - // 自動播放 - useEffect(() => { - if (autoPlay && text && !audioUrl) { - generateAudio(text); - } - }, [autoPlay, text]); + utterance.onend = () => { + setIsPlaying(false); + onPlayEnd?.(); + }; + + utterance.onerror = () => { + setIsPlaying(false); + onError?.('語音播放失敗'); + }; + + speechSynthesis.speak(utterance); + }; return ( -
- {/* 隱藏的音頻元素 */} -
+ + {/* 按鈕圖標 */} +
+ {isPlaying ? ( + + + + ) : ( + + + + )} +
+ + {/* 懸停提示光環 */} + {!disabled && ( +
+ )} + ); } \ No newline at end of file