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:
parent
3ff3b7f0a1
commit
fde7d1209b
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -120,9 +127,27 @@ export function VocabChoiceQuiz({ card, options, onAnswer, onSkip }: VocabChoice
|
|||
</p>
|
||||
<p className="text-gray-700">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue