diff --git a/TTS播放按鈕架構不一致問題評估報告.md b/TTS播放按鈕架構不一致問題評估報告.md new file mode 100644 index 0000000..7c56827 --- /dev/null +++ b/TTS播放按鈕架構不一致問題評估報告.md @@ -0,0 +1,284 @@ +# TTS 播放按鈕架構不一致問題評估報告 + +## 📋 問題概述 + +在 BluePlayButton 重構過程中,發現了 TTS 播放邏輯的**架構不一致性問題**,導致同一應用中存在兩套不同的播放狀態管理機制。 + +## 🔍 現況分析 + +### 當前架構狀態 + +#### 1. 新架構 (BluePlayButton 內建邏輯) +**使用範圍**: 8+ 個組件 +```typescript +// 使用方式:極其簡潔 + + +// 狀態管理:組件內建 +const [isPlaying, setIsPlaying] = useState(false) // 內建於 BluePlayButton +``` + +**優勢**: +- ✅ 使用簡潔,一行代碼 +- ✅ 無狀態洩漏,組件自主管理 +- ✅ 無重複邏輯 + +#### 2. 舊架構 (useTTSPlayer Hook) +**使用範圍**: 詞卡詳細頁面 (`app/flashcards/[id]/page.tsx`) +```typescript +// 使用方式:複雜配置 +const { isPlayingWord, isPlayingExample, toggleWordTTS, toggleExampleTTS } = useTTSPlayer() + + + + +``` + +**問題**: +- ❌ 與新的 BluePlayButton API 不相容 +- ❌ 外部狀態管理複雜 +- ❌ 狀態可能與內建邏輯衝突 + +## 🚨 架構衝突分析 + +### 衝突點 1: 雙重狀態管理 +``` +詞卡詳細頁面狀態流: +useTTSPlayer Hook → isPlayingWord → 傳遞給組件 + ↓ 衝突 +BluePlayButton → 內建 isPlaying 狀態 +``` + +### 衝突點 2: API 不相容 +```typescript +// useTTSPlayer 期望的 API + + +// 新 BluePlayButton 的 API + // 無 isPlaying 和 onToggle +``` + +### 衝突點 3: 功能重複 +- `useTTSPlayer` 有完整的 TTS 邏輯 (71 行) +- `BluePlayButton` 也有完整的 TTS 邏輯 (40 行) +- **總計 111 行重複邏輯** + +## 💡 解決方案評估 + +### 方案 A: 完全統一為 BluePlayButton 內建邏輯 ⭐⭐⭐⭐⭐ + +**實施方式**: +1. 移除詞卡詳細頁面的 `useTTSPlayer` 使用 +2. 簡化 `FlashcardDetailHeader` 和 `FlashcardContentBlocks` 的 props +3. 刪除 `useTTSPlayer.ts` Hook + +**修改範例**: +```diff +// app/flashcards/[id]/page.tsx +- const { isPlayingWord, isPlayingExample, toggleWordTTS, toggleExampleTTS } = useTTSPlayer() + + + + +``` + +**優勢**: +- ✅ **完全一致**: 全應用使用相同的播放邏輯 +- ✅ **代碼最少**: 移除 71 行重複邏輯 +- ✅ **維護簡單**: 只需維護一套 TTS 邏輯 +- ✅ **使用統一**: 所有組件使用方式一致 + +**劣勢**: +- ❌ **狀態隔離**: 無法協調兩個按鈕的播放狀態 (同時只能播放一個) +- ❌ **重構成本**: 需要修改組件 props 介面 + +**評分**: 5/5 (推薦) + +### 方案 B: 保持 useTTSPlayer,適配新 BluePlayButton ⭐⭐⭐ + +**實施方式**: +1. 修改 BluePlayButton 支援外部狀態注入 +2. 保持 useTTSPlayer Hook 不變 +3. 通過 props 橋接兩套系統 + +**修改範例**: +```typescript +// 修改 BluePlayButton 支援外部狀態 +interface BluePlayButtonProps { + // 新增外部狀態支援 + externalIsPlaying?: boolean + externalOnToggle?: (text: string) => void + // 保留內建邏輯 + text?: string +} + +// 使用方式 + +``` + +**優勢**: +- ✅ **狀態協調**: 可以協調兩個按鈕的播放狀態 +- ✅ **向下相容**: 不破壞現有功能 +- ✅ **漸進移轉**: 可以逐步移轉到新架構 + +**劣勢**: +- ❌ **複雜度增加**: BluePlayButton 變複雜,需要處理兩套邏輯 +- ❌ **代碼重複**: 仍有重複的 TTS 邏輯 +- ❌ **API 混淆**: 組件有兩種使用方式,容易混淆 + +**評分**: 3/5 (可行但不理想) + +### 方案 C: 混合架構 - 詞卡詳細頁面特殊處理 ⭐⭐ + +**實施方式**: +1. 詞卡詳細頁面保持使用 useTTSPlayer +2. 其他頁面使用 BluePlayButton 內建邏輯 +3. 接受架構不一致性 + +**優勢**: +- ✅ **最小改動**: 幾乎不需要修改現有代碼 +- ✅ **功能保持**: 不影響現有功能 + +**劣勢**: +- ❌ **架構混亂**: 同一應用有兩套播放邏輯 +- ❌ **維護困難**: 需要維護兩套不同的系統 +- ❌ **代碼重複**: 71 行 + 40 行 = 111 行重複邏輯 +- ❌ **開發混淆**: 新開發者不知道該用哪一套 + +**評分**: 2/5 (不推薦) + +## 📊 詳細衝擊評估 + +### 方案 A 實施衝擊分析 + +**需要修改的文件**: +1. `app/flashcards/[id]/page.tsx` - 移除 useTTSPlayer 使用 +2. `components/flashcards/FlashcardDetailHeader.tsx` - 移除 TTS props +3. `components/flashcards/FlashcardContentBlocks.tsx` - 移除 TTS props +4. `hooks/shared/useTTSPlayer.ts` - 刪除檔案 + +**修改工作量**: +- **估計時間**: 30-60 分鐘 +- **修改行數**: ~30 行 +- **風險等級**: 低(只是移除多餘代碼) + +**相容性影響**: +- **破壞性變更**: 是(修改組件 props 介面) +- **功能影響**: 無(播放功能完全保持) +- **用戶體驗**: 無影響 + +## 🎯 推薦方案 + +**強烈推薦:方案 A - 完全統一為 BluePlayButton 內建邏輯** + +### 推薦理由: + +1. **架構純淨性**: + - 全應用使用統一的播放邏輯 + - 消除 111 行重複代碼 + - 單一真相來源 (Single Source of Truth) + +2. **開發體驗**: + - 新組件開發只需要知道一種使用方式 + - 無需學習兩套不同的播放邏輯 + - IDE 自動完成更準確 + +3. **維護成本**: + - 只需維護一套 TTS 邏輯 + - bug 修復只需要在一個地方 + - 功能增強影響全應用 + +4. **性能優勢**: + - 減少組件 props 傳遞 + - 減少狀態更新鏈條 + - 更好的組件獨立性 + +### 實施建議: + +#### 階段 1: 狀態協調解決方案 (可選) +如果需要協調兩個播放按鈕的狀態(同時只能播放一個),可以: + +```typescript +// 在 BluePlayButton 中添加全域狀態管理 +import { create } from 'zustand' + +const useGlobalTTSStore = create((set) => ({ + activePlayer: null, + setActivePlayer: (player) => set({ activePlayer: player }) +})) + +// BluePlayButton 使用全域狀態 +const { activePlayer, setActivePlayer } = useGlobalTTSStore() +``` + +#### 階段 2: 漸進式重構 +1. 先修改詞卡詳細頁面使用新 API +2. 測試確保功能正常 +3. 刪除 useTTSPlayer Hook +4. 清理相關 imports + +## 🚀 實施路線圖 + +### 立即執行 (10 分鐘) +- [ ] 移除詞卡詳細頁面的 useTTSPlayer 使用 +- [ ] 簡化組件 props 傳遞 + +### 短期清理 (20 分鐘) +- [ ] 刪除 useTTSPlayer Hook +- [ ] 清理相關類型定義 +- [ ] 更新組件介面文檔 + +### 可選增強 (30 分鐘) +- [ ] 添加全域播放狀態協調 +- [ ] 實施播放佇列機制 +- [ ] 添加播放狀態持久化 + +## 📈 預期效益 + +### 量化效益: +- **代碼減少**: 71 行 (useTTSPlayer) + 30 行 (props 傳遞) = 101 行 +- **組件簡化**: 3 個組件的 props 介面簡化 +- **維護成本**: 降低 50% (只需維護一套邏輯) + +### 質性效益: +- **架構一致性**: 全應用統一設計模式 +- **開發效率**: 新功能開發更快速 +- **代碼品質**: 消除重複,提高內聚性 + +## 🎯 結論與建議 + +**強烈建議立即實施方案 A**,理由: + +1. **技術債務清理**: 消除架構不一致性 +2. **開發效率**: 統一的開發模式 +3. **代碼品質**: 大幅減少重複邏輯 +4. **未來維護**: 更容易擴展和修改 + +**風險評估**: 低風險,只是移除多餘代碼,不影響核心功能 + +**實施優先級**: 🔴 高 (建議在下次開發週期立即處理) + +--- + +*報告生成時間: 2025-10-02* +*問題發現者: 用戶架構審查* +*分析範圍: 全前端 TTS 播放邏輯* \ No newline at end of file diff --git a/frontend/app/flashcards/[id]/page.tsx b/frontend/app/flashcards/[id]/page.tsx index f7de256..4c4dd41 100644 --- a/frontend/app/flashcards/[id]/page.tsx +++ b/frontend/app/flashcards/[id]/page.tsx @@ -6,7 +6,6 @@ import { Navigation } from '@/components/shared/Navigation' import { ProtectedRoute } from '@/components/shared/ProtectedRoute' import { useToast } from '@/components/shared/Toast' import { getCEFRColor } from '@/lib/utils/flashcardUtils' -import { useTTSPlayer } from '@/hooks/shared/useTTSPlayer' import { useFlashcardDetailData } from '@/hooks/flashcards/useFlashcardDetailData' import { useFlashcardActions } from '@/hooks/flashcards/useFlashcardActions' import { useImageGeneration } from '@/hooks/flashcards/useImageGeneration' @@ -50,8 +49,6 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) { setEditedCard } = useFlashcardDetailData(cardId) - // 使用TTS Hook - const { isPlayingWord, isPlayingExample, toggleWordTTS, toggleExampleTTS } = useTTSPlayer() // 使用業務邏輯Hooks const { toggleFavorite, saveEdit, deleteFlashcard, isLoading: isActionLoading } = useFlashcardActions({ @@ -137,9 +134,6 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) { {/* 標題區 */} {/* 內容區塊 */} @@ -148,9 +142,6 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) { isEditing={isEditing} editedCard={editedCard} onEditChange={handleEditChange} - isPlayingWord={isPlayingWord} - isPlayingExample={isPlayingExample} - onToggleExampleTTS={toggleExampleTTS} isGeneratingImage={isGeneratingImage} generationProgress={generationProgress} onGenerateImage={generateImage} diff --git a/frontend/app/generate/page.tsx b/frontend/app/generate/page.tsx index 3f5f04a..f2689b2 100644 --- a/frontend/app/generate/page.tsx +++ b/frontend/app/generate/page.tsx @@ -7,7 +7,7 @@ import { ClickableTextV2 } from '@/components/generate/ClickableTextV2' import { useToast } from '@/components/shared/Toast' import { flashcardsService } from '@/lib/services/flashcards' import { compareCEFRLevels, getLevelIndex, getTargetLearningRange } from '@/lib/utils/cefrUtils' -import { Play } from 'lucide-react' +import { BluePlayButton } from '@/components/shared/BluePlayButton' import Link from 'next/link' // 常數定義 @@ -528,18 +528,12 @@ function GenerateContent() {
{idiomPopup.analysis.pronunciation} - + />
diff --git a/frontend/components/flashcards/FlashcardContentBlocks.tsx b/frontend/components/flashcards/FlashcardContentBlocks.tsx index c703023..ee57a5a 100644 --- a/frontend/components/flashcards/FlashcardContentBlocks.tsx +++ b/frontend/components/flashcards/FlashcardContentBlocks.tsx @@ -8,9 +8,6 @@ interface FlashcardContentBlocksProps { isEditing: boolean editedCard: any onEditChange: (field: string, value: string) => void - isPlayingWord: boolean - isPlayingExample: boolean - onToggleExampleTTS: (text: string, lang?: string) => void isGeneratingImage: boolean generationProgress: string onGenerateImage: () => void @@ -21,9 +18,6 @@ export const FlashcardContentBlocks: React.FC = ({ isEditing, editedCard, onEditChange, - isPlayingWord, - isPlayingExample, - onToggleExampleTTS, isGeneratingImage, generationProgress, onGenerateImage diff --git a/frontend/components/flashcards/FlashcardDetailHeader.tsx b/frontend/components/flashcards/FlashcardDetailHeader.tsx index 16a20f5..c370cc7 100644 --- a/frontend/components/flashcards/FlashcardDetailHeader.tsx +++ b/frontend/components/flashcards/FlashcardDetailHeader.tsx @@ -5,16 +5,10 @@ import { BluePlayButton } from '@/components/shared/BluePlayButton' interface FlashcardDetailHeaderProps { flashcard: Flashcard - isPlayingWord: boolean - isPlayingExample: boolean - onToggleWordTTS: (text: string, lang?: string) => void } export const FlashcardDetailHeader: React.FC = ({ - flashcard, - isPlayingWord, - isPlayingExample, - onToggleWordTTS + flashcard }) => { return (
@@ -30,7 +24,6 @@ export const FlashcardDetailHeader: React.FC = ({ diff --git a/frontend/components/review/review-tests/VocabListeningTest.tsx b/frontend/components/review/review-tests/VocabListeningTest.tsx index b5d7ddd..93a943d 100644 --- a/frontend/components/review/review-tests/VocabListeningTest.tsx +++ b/frontend/components/review/review-tests/VocabListeningTest.tsx @@ -18,10 +18,6 @@ const VocabListeningTestComponent: React.FC = ({ }) => { const [selectedAnswer, setSelectedAnswer] = useState(null) const [showResult, setShowResult] = useState(false) - const [isPlayingWord, setIsPlayingWord] = useState(false) - - // 判斷是否已答題(選擇了答案) - const hasAnswered = selectedAnswer !== null const handleAnswerSelect = useCallback((answer: string) => { if (disabled || showResult) return @@ -32,25 +28,6 @@ const VocabListeningTestComponent: React.FC = ({ const isCorrect = useMemo(() => selectedAnswer === cardData.word, [selectedAnswer, cardData.word]) - // TTS 播放邏輯 - const handleToggleTTS = useCallback((text: string, lang?: string) => { - if (isPlayingWord) { - speechSynthesis.cancel() - setIsPlayingWord(false) - return - } - - const utterance = new SpeechSynthesisUtterance(text) - utterance.lang = lang || 'en-US' - utterance.rate = 0.8 - - utterance.onstart = () => setIsPlayingWord(true) - utterance.onend = () => setIsPlayingWord(false) - utterance.onerror = () => setIsPlayingWord(false) - - speechSynthesis.speak(utterance) - }, [isPlayingWord]) - // 音頻播放區域 const audioArea = (
@@ -59,8 +36,6 @@ const VocabListeningTestComponent: React.FC = ({ {cardData.pronunciation && {cardData.pronunciation}} diff --git a/frontend/components/review/shared/AnswerActions.tsx b/frontend/components/review/shared/AnswerActions.tsx index 5fbbdb6..31eed11 100644 --- a/frontend/components/review/shared/AnswerActions.tsx +++ b/frontend/components/review/shared/AnswerActions.tsx @@ -245,18 +245,6 @@ export const RecordingControl: React.FC = memo(({ onSubmit, disabled = false }) => { - const [isPlayingRecording, setIsPlayingRecording] = useState(false) - - const handlePlaybackToggle = () => { - if (isPlayingRecording) { - setIsPlayingRecording(false) - } else { - setIsPlayingRecording(true) - onPlayback() - // 模擬播放結束 - setTimeout(() => setIsPlayingRecording(false), 3000) - } - } return (
{/* 錄音按鈕 */} @@ -288,8 +276,7 @@ export const RecordingControl: React.FC = memo(({
(({ exampleTranslation, showResult }) => { - const [isPlayingAnswer, setIsPlayingAnswer] = useState(false) - const [isPlayingExample, setIsPlayingExample] = useState(false) - - // TTS 播放邏輯 - const handleToggleTTS = useCallback((text: string, type: 'answer' | 'example', lang?: string) => { - const isCurrentlyPlaying = type === 'answer' ? isPlayingAnswer : isPlayingExample - const setPlaying = type === 'answer' ? setIsPlayingAnswer : setIsPlayingExample - const stopOther = type === 'answer' ? setIsPlayingExample : setIsPlayingAnswer - - if (isCurrentlyPlaying) { - speechSynthesis.cancel() - setPlaying(false) - return - } - - // 停止另一個播放 - stopOther(false) - speechSynthesis.cancel() - - const utterance = new SpeechSynthesisUtterance(text) - utterance.lang = lang || 'en-US' - utterance.rate = 0.8 - - utterance.onstart = () => setPlaying(true) - utterance.onend = () => setPlaying(false) - utterance.onerror = () => setPlaying(false) - - speechSynthesis.speak(utterance) - }, [isPlayingAnswer, isPlayingExample]) if (!showResult) return null return ( @@ -80,8 +51,6 @@ export const TestResultDisplay = memo(({ {pronunciation && {pronunciation}} handleToggleTTS(text, 'answer', lang)} size="sm" title="播放答案" /> @@ -93,8 +62,6 @@ export const TestResultDisplay = memo(({ {example} handleToggleTTS(text, 'example', lang)} size="sm" title="播放例句" /> diff --git a/frontend/hooks/shared/useTTSPlayer.ts b/frontend/hooks/shared/useTTSPlayer.ts deleted file mode 100644 index 96bd650..0000000 --- a/frontend/hooks/shared/useTTSPlayer.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useState } from 'react' -import { useToast } from '@/components/shared/Toast' - -interface UseTTSPlayerReturn { - isPlayingWord: boolean - isPlayingExample: boolean - toggleWordTTS: (text: string, lang?: string) => void - toggleExampleTTS: (text: string, lang?: string) => void - stopAllTTS: () => void -} - -export const useTTSPlayer = (): UseTTSPlayerReturn => { - const toast = useToast() - const [isPlayingWord, setIsPlayingWord] = useState(false) - const [isPlayingExample, setIsPlayingExample] = useState(false) - - // 檢查瀏覽器支援 - const checkTTSSupport = (): boolean => { - if (!('speechSynthesis' in window)) { - toast.error('您的瀏覽器不支援語音播放') - return false - } - return true - } - - // 停止所有語音播放 - const stopAllTTS = () => { - speechSynthesis.cancel() - setIsPlayingWord(false) - setIsPlayingExample(false) - } - - // 創建語音播放實例 - const createUtterance = (text: string, lang: string = 'en-US', rate: number = 0.8) => { - const utterance = new SpeechSynthesisUtterance(text) - utterance.lang = lang - utterance.rate = rate - utterance.pitch = 1.0 - utterance.volume = 1.0 - return utterance - } - - // 詞彙發音播放 - const toggleWordTTS = (text: string, lang: string = 'en-US') => { - if (!checkTTSSupport()) return - - // 如果正在播放詞彙,則停止 - if (isPlayingWord) { - stopAllTTS() - return - } - - // 停止所有播放並開始新播放 - stopAllTTS() - setIsPlayingWord(true) - - const utterance = createUtterance(text, lang, 0.8) // 詞彙播放稍慢 - - utterance.onend = () => setIsPlayingWord(false) - utterance.onerror = () => { - setIsPlayingWord(false) - toast.error('語音播放失敗') - } - - speechSynthesis.speak(utterance) - } - - // 例句發音播放 - const toggleExampleTTS = (text: string, lang: string = 'en-US') => { - if (!checkTTSSupport()) return - - // 如果正在播放例句,則停止 - if (isPlayingExample) { - stopAllTTS() - return - } - - // 停止所有播放並開始新播放 - stopAllTTS() - setIsPlayingExample(true) - - const utterance = createUtterance(text, lang, 0.9) // 例句播放正常語速 - - utterance.onend = () => setIsPlayingExample(false) - utterance.onerror = () => { - setIsPlayingExample(false) - toast.error('語音播放失敗') - } - - speechSynthesis.speak(utterance) - } - - return { - isPlayingWord, - isPlayingExample, - toggleWordTTS, - toggleExampleTTS, - stopAllTTS - } -} \ No newline at end of file