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 - - )} + ) : ( + // 已答題時顯示下一題按鈕 + + )} + ) } \ 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 (