From 6c833164672e6702fc09d94a8b5bc07819be9a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=84=AD=E6=B2=9B=E8=BB=92?= Date: Thu, 25 Sep 2025 23:51:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=B5=B1=E4=B8=80=E5=85=A8=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=92=AD=E6=94=BE=E6=8C=89=E9=88=95=E7=82=BA=E7=B2=BE?= =?UTF-8?q?=E7=BE=8E=E5=9C=93=E5=BD=A2TTS=E8=A8=AD=E8=A8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完全重構AudioPlayer組件,移除後端依賴,改用純TTS - 統一播放按鈕設計:圓形漸變、播放中波紋動畫、懸停特效 - 實現獨立播放狀態:詞彙和例句播放按鈕各自管理狀態 - 添加完整無障礙支援:aria-label、title提示、鍵盤支援 - 優化播放控制:點擊播放/暫停、互斥播放、錯誤處理 - 現在所有頁面的播放按鈕都使用統一的精美圓形設計 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/app/flashcards/[id]/page.tsx | 146 +++++++++++++++- frontend/components/AudioPlayer.tsx | 229 ++++++++------------------ 2 files changed, 208 insertions(+), 167 deletions(-) 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