feat: 實現 TTS 播放功能 + 改進詞彙選擇 UX 流程

## TTS 播放功能 (BluePlayButton)
-  實現瀏覽器內建 TTS 語音播放
-  添加瀏覽器支援檢測和錯誤處理
-  支援語速、音調、音量調整參數
-  改進播放/停止狀態管理
-  優化視覺回饋和無障礙體驗

## FlipMemory 組件整合
-  在單詞展示區添加播放按鈕
-  在例句區塊添加播放按鈕
-  防止播放觸發翻卡動作

## VocabChoiceQuiz UX 改進
-  移除自動跳頁邏輯,改為手動「下一題」
-  答題後顯示「下一題」按鈕取代「跳過」
-  在答案解析中添加單詞和例句播放功能
-  提供更好的學習體驗,讓用戶有時間查看解析

## 技術改進
- 🎵 使用 Web Speech API 實現 TTS
- 📱 響應式設計,支援多種按鈕尺寸
- 🛡️ 完善的錯誤處理和記憶體管理
-  即時回應,無網路延遲

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-10-05 05:06:12 +08:00
parent 3ff3b7f0a1
commit fde7d1209b
3 changed files with 198 additions and 53 deletions

View File

@ -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 && (
<span className="text-lg text-gray-500">{card.pronunciation}</span>
)}
<div onClick={(e) => e.stopPropagation()}>
<BluePlayButton
text={card.word}
size="sm"
title="播放單詞發音"
rate={0.8}
lang="en-US"
/>
</div>
</div>
</div>
</div>
@ -154,6 +164,15 @@ export function FlipMemory({ card, onAnswer, onSkip }: SimpleFlipCardProps) {
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<div className="relative">
<p className="text-gray-700 italic mb-2 text-left pr-12">"{card.example}"</p>
<div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}>
<BluePlayButton
text={card.example}
size="sm"
title="播放例句"
rate={0.7}
lang="en-US"
/>
</div>
</div>
<p className="text-gray-600 text-sm text-left">{card.exampleTranslation}</p>
</div>

View File

@ -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<string | null>(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
<button
key={index}
onClick={() => handleAnswerSelect(option)}
disabled={showResult}
disabled={hasAnswered}
className={buttonClass}
>
{option}
@ -119,10 +126,28 @@ export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoice
<strong></strong> {card.word}
</p>
<p className="text-gray-700">
<strong></strong> {card.pronunciation}
<strong></strong> {card.pronunciation}
<span className='ml-2' onClick={(e) => e.stopPropagation()}>
<BluePlayButton
text={card.word}
size="sm"
title="播放單詞發音"
rate={0.8}
lang="en-US"
/>
</span>
</p>
<p className="text-gray-700">
<strong></strong> "{card.example}"
<span className='ml-2' onClick={(e) => e.stopPropagation()}>
<BluePlayButton
text={card.example}
size="sm"
title="播放單詞發音"
rate={0.8}
lang="en-US"
/>
</span>
</p>
<p className="text-gray-600 text-sm">
{card.exampleTranslation}
@ -133,17 +158,26 @@ export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoice
)}
</div>
{/* 跳過按鈕 - 移到卡片外面 */}
{!showResult && (
<div className="mt-6">
{/* 按鈕區域 - 根據答題狀態顯示不同按鈕 */}
<div className="mt-6">
{!hasAnswered ? (
// 未答題時顯示跳過按鈕
<button
onClick={handleSkipClick}
className="w-full border border-gray-300 text-gray-700 py-3 px-4 rounded-lg font-medium hover:bg-gray-50 transition-colors"
>
</button>
</div>
)}
) : (
// 已答題時顯示下一題按鈕
<button
onClick={handleNext}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
</button>
)}
</div>
</div>
)
}

View File

@ -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<BluePlayButtonProps> = ({
@ -18,10 +22,16 @@ export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
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<SpeechSynthesisUtterance | null>(null)
const sizeClasses = {
sm: 'w-8 h-8',
@ -35,59 +45,134 @@ export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
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 (
<button
onClick={handleToggle}
disabled={disabled}
title={title || (isPlaying ? "點擊停止播放" : "點擊播放發音")}
disabled={isDisabled}
title={buttonTitle}
className={`group relative ${sizeClasses[size]} rounded-full shadow-lg transform transition-all duration-200
${isPlaying
${!isSupported
? 'bg-gradient-to-br from-gray-400 to-gray-500 shadow-gray-200 cursor-not-allowed opacity-75'
: isPlaying
? 'bg-gradient-to-br from-green-500 to-green-600 shadow-green-200 scale-105'
: 'bg-gradient-to-br from-blue-500 to-blue-600 hover:shadow-xl hover:scale-105 shadow-blue-200'
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${className}
} ${isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${className}
`}
>
{/* 播放中波紋效果 */}
@ -97,11 +182,18 @@ export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
{/* 按鈕圖標 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{isPlaying ? (
{!isSupported ? (
// 不支援圖標 - 禁用音頻
<svg className={`${iconSizes[size]} text-white`} fill="currentColor" viewBox="0 0 24 24">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
) : isPlaying ? (
// 暫停圖標
<svg className={`${iconSizes[size]} text-white`} fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
) : (
// 播放圖標
<svg className={`${iconSizes[size]} text-white group-hover:scale-110 transition-transform`} fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
@ -109,7 +201,7 @@ export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
</div>
{/* 懸停提示光環 */}
{!isPlaying && !disabled && (
{!isPlaying && !isDisabled && isSupported && (
<div className="absolute inset-0 rounded-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-200"></div>
)}
</button>