From fde7d1209b705b6fd45f5e1b78319d4f573d07e2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=84=AD=E6=B2=9B=E8=BB=92?=
Date: Sun, 5 Oct 2025 05:06:12 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=A6=E7=8F=BE=20TTS=20=E6=92=AD?=
=?UTF-8?q?=E6=94=BE=E5=8A=9F=E8=83=BD=20+=20=E6=94=B9=E9=80=B2=E8=A9=9E?=
=?UTF-8?q?=E5=BD=99=E9=81=B8=E6=93=87=20UX=20=E6=B5=81=E7=A8=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## TTS 播放功能 (BluePlayButton)
- ✅ 實現瀏覽器內建 TTS 語音播放
- ✅ 添加瀏覽器支援檢測和錯誤處理
- ✅ 支援語速、音調、音量調整參數
- ✅ 改進播放/停止狀態管理
- ✅ 優化視覺回饋和無障礙體驗
## FlipMemory 組件整合
- ✅ 在單詞展示區添加播放按鈕
- ✅ 在例句區塊添加播放按鈕
- ✅ 防止播放觸發翻卡動作
## VocabChoiceQuiz UX 改進
- ✅ 移除自動跳頁邏輯,改為手動「下一題」
- ✅ 答題後顯示「下一題」按鈕取代「跳過」
- ✅ 在答案解析中添加單詞和例句播放功能
- ✅ 提供更好的學習體驗,讓用戶有時間查看解析
## 技術改進
- 🎵 使用 Web Speech API 實現 TTS
- 📱 響應式設計,支援多種按鈕尺寸
- 🛡️ 完善的錯誤處理和記憶體管理
- ⚡ 即時回應,無網路延遲
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
---
.../components/review/quiz/FlipMemory.tsx | 19 +++
.../review/quiz/VocabChoiceQuiz.tsx | 76 ++++++---
frontend/components/shared/BluePlayButton.tsx | 156 ++++++++++++++----
3 files changed, 198 insertions(+), 53 deletions(-)
diff --git a/frontend/components/review/quiz/FlipMemory.tsx b/frontend/components/review/quiz/FlipMemory.tsx
index bc01f33..bafc4de 100644
--- a/frontend/components/review/quiz/FlipMemory.tsx
+++ b/frontend/components/review/quiz/FlipMemory.tsx
@@ -3,6 +3,7 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { CardState } from '@/lib/data/reviewSimpleData'
import { QuizHeader } from '../ui/QuizHeader'
+import { BluePlayButton } from '@/components/shared/BluePlayButton'
interface SimpleFlipCardProps {
card: CardState
@@ -120,6 +121,15 @@ export function FlipMemory({ card, onAnswer, onSkip }: SimpleFlipCardProps) {
{card.pronunciation && (
{card.pronunciation}
)}
+ e.stopPropagation()}>
+
+
@@ -154,6 +164,15 @@ export function FlipMemory({ card, onAnswer, onSkip }: SimpleFlipCardProps) {
例句
"{card.example}"
+
e.stopPropagation()}>
+
+
{card.exampleTranslation}
diff --git a/frontend/components/review/quiz/VocabChoiceQuiz.tsx b/frontend/components/review/quiz/VocabChoiceQuiz.tsx
index d99e419..101ed06 100644
--- a/frontend/components/review/quiz/VocabChoiceQuiz.tsx
+++ b/frontend/components/review/quiz/VocabChoiceQuiz.tsx
@@ -3,6 +3,8 @@
import { useState, useCallback } from 'react'
import { CardState } from '@/lib/data/reviewSimpleData'
import { QuizHeader } from '../ui/QuizHeader'
+import { BluePlayButton } from '@/components/shared/BluePlayButton'
+
interface VocabChoiceTestProps {
card: CardState
@@ -14,30 +16,35 @@ interface VocabChoiceTestProps {
export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoiceTestProps) {
const [selectedAnswer, setSelectedAnswer] = useState(null)
const [showResult, setShowResult] = useState(false)
+ const [hasAnswered, setHasAnswered] = useState(false)
const handleAnswerSelect = useCallback((answer: string) => {
- if (showResult) return
+ if (showResult || hasAnswered) return
setSelectedAnswer(answer)
setShowResult(true)
-
- // 判斷答案是否正確,正確給3分,錯誤給1分
- const isCorrect = answer === card.word
- const confidence = isCorrect ? 2 : 0
-
- // 延遲一點再調用回調,讓用戶看到選擇結果
- setTimeout(() => {
- onAnswer(confidence)
- // 重置狀態為下一題準備
- setSelectedAnswer(null)
- setShowResult(false)
- }, 1500)
- }, [showResult, card.word, onAnswer])
+ setHasAnswered(true)
+ }, [showResult, hasAnswered])
const handleSkipClick = useCallback(() => {
onSkip()
}, [onSkip])
+ const handleNext = useCallback(() => {
+ if (!hasAnswered || !selectedAnswer) return
+
+ // 判斷答案是否正確,正確給2分,錯誤給0分
+ const isCorrect = selectedAnswer === card.word
+ const confidence = isCorrect ? 2 : 0
+
+ onAnswer(confidence)
+
+ // 重置狀態為下一題準備
+ setSelectedAnswer(null)
+ setShowResult(false)
+ setHasAnswered(false)
+ }, [hasAnswered, selectedAnswer, card.word, onAnswer])
+
const isCorrect = selectedAnswer === card.word
return (
@@ -91,7 +98,7 @@ export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoice
- 發音: {card.pronunciation}
+ 發音: {card.pronunciation}
+ e.stopPropagation()}>
+
+
例句: "{card.example}"
+ e.stopPropagation()}>
+
+
{card.exampleTranslation}
@@ -133,17 +158,26 @@ export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoice
)}
- {/* 跳過按鈕 - 移到卡片外面 */}
- {!showResult && (
-
+ {/* 按鈕區域 - 根據答題狀態顯示不同按鈕 */}
+
+ {!hasAnswered ? (
+ // 未答題時顯示跳過按鈕
-
- )}
+ ) : (
+ // 已答題時顯示下一題按鈕
+
+ )}
+
)
}
\ No newline at end of file
diff --git a/frontend/components/shared/BluePlayButton.tsx b/frontend/components/shared/BluePlayButton.tsx
index 0b75f19..b2c6bee 100644
--- a/frontend/components/shared/BluePlayButton.tsx
+++ b/frontend/components/shared/BluePlayButton.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react'
+import React, { useState, useEffect, useRef } from 'react'
interface BluePlayButtonProps {
text?: string
@@ -7,8 +7,12 @@ interface BluePlayButtonProps {
className?: string
size?: 'sm' | 'md' | 'lg'
title?: string
+ rate?: number
+ pitch?: number
+ volume?: number
onPlayStart?: () => void
onPlayEnd?: () => void
+ onError?: (error: string) => void
}
export const BluePlayButton: React.FC = ({
@@ -18,10 +22,16 @@ export const BluePlayButton: React.FC = ({
className = '',
size = 'md',
title,
+ rate = 0.9,
+ pitch = 1.0,
+ volume = 1.0,
onPlayStart,
- onPlayEnd
+ onPlayEnd,
+ onError
}) => {
const [isPlaying, setIsPlaying] = useState(false)
+ const [isSupported, setIsSupported] = useState(true)
+ const utteranceRef = useRef(null)
const sizeClasses = {
sm: 'w-8 h-8',
@@ -35,59 +45,134 @@ export const BluePlayButton: React.FC = ({
lg: 'w-6 h-6'
}
- // 內建 TTS 邏輯
+ // 檢查瀏覽器支援
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ setIsSupported('speechSynthesis' in window)
+ }
+ }, [])
+
+ // 清理未完成的語音
+ useEffect(() => {
+ return () => {
+ if (utteranceRef.current) {
+ speechSynthesis.cancel()
+ }
+ }
+ }, [])
+
+ // 停止當前播放
+ const stopSpeech = () => {
+ if (utteranceRef.current) {
+ speechSynthesis.cancel()
+ utteranceRef.current = null
+ }
+ setIsPlaying(false)
+ if (onPlayEnd) onPlayEnd()
+ }
+
+ // 開始 TTS 播放
+ const startSpeech = (textToSpeak: string) => {
+ try {
+ // 停止任何正在進行的語音
+ speechSynthesis.cancel()
+
+ const utterance = new SpeechSynthesisUtterance(textToSpeak)
+ utteranceRef.current = utterance
+
+ // 設置語音參數
+ utterance.lang = lang
+ utterance.rate = Math.max(0.1, Math.min(2.0, rate)) // 限制範圍 0.1-2.0
+ utterance.pitch = Math.max(0, Math.min(2, pitch)) // 限制範圍 0-2
+ utterance.volume = Math.max(0, Math.min(1, volume)) // 限制範圍 0-1
+
+ // 事件處理
+ utterance.onstart = () => {
+ setIsPlaying(true)
+ if (onPlayStart) onPlayStart()
+ }
+
+ utterance.onend = () => {
+ utteranceRef.current = null
+ setIsPlaying(false)
+ if (onPlayEnd) onPlayEnd()
+ }
+
+ utterance.onerror = (event) => {
+ utteranceRef.current = null
+ setIsPlaying(false)
+ const errorMessage = `Speech synthesis error: ${event.error}`
+ console.warn(errorMessage)
+ if (onError) onError(errorMessage)
+ if (onPlayEnd) onPlayEnd()
+ }
+
+ // 開始播放
+ speechSynthesis.speak(utterance)
+
+ } catch (error) {
+ const errorMessage = `Failed to start speech synthesis: ${error}`
+ console.error(errorMessage)
+ setIsPlaying(false)
+ if (onError) onError(errorMessage)
+ }
+ }
+
+ // 主處理函數
const handleToggle = () => {
+ // 如果不支援 TTS
+ if (!isSupported) {
+ const errorMessage = 'Text-to-speech is not supported in this browser'
+ console.warn(errorMessage)
+ if (onError) onError(errorMessage)
+ return
+ }
+
// 停止播放邏輯
if (isPlaying) {
- speechSynthesis.cancel()
- setIsPlaying(false)
- if (onPlayEnd) onPlayEnd()
+ stopSpeech()
return
}
// 開始播放邏輯
- if (onPlayStart) {
+ if (onPlayStart && !text) {
// 自定義播放場景(如錄音回放)
setIsPlaying(true)
onPlayStart()
+ // 3秒後自動停止(可調整)
setTimeout(() => {
setIsPlaying(false)
if (onPlayEnd) onPlayEnd()
}, 3000)
} else if (text) {
// 標準 TTS 播放
- const utterance = new SpeechSynthesisUtterance(text)
- utterance.lang = lang
- utterance.rate = 0.8
-
- utterance.onstart = () => {
- setIsPlaying(true)
- }
-
- utterance.onend = () => {
- setIsPlaying(false)
- if (onPlayEnd) onPlayEnd()
- }
-
- utterance.onerror = () => {
- setIsPlaying(false)
- if (onPlayEnd) onPlayEnd()
- }
-
- speechSynthesis.speak(utterance)
+ startSpeech(text)
+ } else {
+ const errorMessage = 'No text provided for speech synthesis'
+ console.warn(errorMessage)
+ if (onError) onError(errorMessage)
}
}
+ // 計算按鈕狀態
+ const isDisabled = disabled || !isSupported
+ const buttonTitle = title ||
+ (!isSupported ? "此瀏覽器不支援語音播放" :
+ isPlaying ? "點擊停止播放" :
+ text ? "點擊播放發音" : "點擊播放")
+
return (