feat: BluePlayButton 內建 TTS 邏輯重構 + TypeScript 錯誤修復

重構亮點:
• BluePlayButton 內建完整 TTS 播放邏輯
• 移除 8 個組件中 97 行重複代碼
• 組件使用極度簡化:複雜配置 → 一行代碼

技術優化:
• 修復 TypeScript "Type 'never'" 錯誤
• 重新設計邏輯流程,清晰的條件分支
• 支援標準 TTS + 自定義播放兩種模式

使用簡化:
• 從: <BluePlayButton isPlaying={state} onToggle={handler} />
• 到: <BluePlayButton text="hello" />

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-10-02 16:51:45 +08:00
parent 97704a7dfa
commit d742cf52f9
8 changed files with 52 additions and 154 deletions

View File

@ -146,11 +146,8 @@ export const FlashcardContentBlocks: React.FC<FlashcardContentBlocksProps> = ({
<BluePlayButton
text={flashcard.example}
lang="en-US"
isPlaying={isPlayingExample}
onToggle={onToggleExampleTTS}
disabled={isPlayingWord}
size="md"
title={isPlayingExample ? "點擊停止播放" : "點擊聽例句發音"}
title="點擊聽例句發音"
/>
</div>
</div>

View File

@ -30,11 +30,9 @@ export const FlashcardDetailHeader: React.FC<FlashcardDetailHeaderProps> = ({
<BluePlayButton
text={flashcard.word}
lang="en-US"
isPlaying={isPlayingWord}
onToggle={onToggleWordTTS}
disabled={isPlayingExample}
size="md"
title={isPlayingWord ? "點擊停止播放" : "點擊聽詞彙發音"}
title="點擊聽詞彙發音"
/>
</div>
</div>

View File

@ -13,26 +13,6 @@ interface FlashcardFormProps {
}
export function FlashcardForm({ initialData, isEdit = false, onSuccess, onCancel }: FlashcardFormProps) {
const [isPlayingWord, setIsPlayingWord] = useState(false)
// TTS 播放邏輯
const handleToggleWordTTS = (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)
}
const [formData, setFormData] = useState<CreateFlashcardRequest>({
word: initialData?.word || '',
@ -151,8 +131,6 @@ export function FlashcardForm({ initialData, isEdit = false, onSuccess, onCancel
{formData.pronunciation && (
<BluePlayButton
text={formData.word}
isPlaying={isPlayingWord}
onToggle={handleToggleWordTTS}
size="sm"
title="播放單詞"
/>

View File

@ -34,32 +34,12 @@ export const IdiomDetailModal: React.FC<IdiomDetailModalProps> = ({
onSaveIdiom,
className = ''
}) => {
const [isPlaying, setIsPlaying] = useState(false)
if (!idiomPopup) {
return null
}
const { analysis } = idiomPopup
const handlePlayPronunciation = (text: string, lang?: string) => {
if (isPlaying) {
speechSynthesis.cancel()
setIsPlaying(false)
return
}
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang || 'en-US'
utterance.rate = 0.8
utterance.onstart = () => setIsPlaying(true)
utterance.onend = () => setIsPlaying(false)
utterance.onerror = () => setIsPlaying(false)
speechSynthesis.speak(utterance)
}
const handleSave = async () => {
if (onSaveIdiom) {
await onSaveIdiom(idiomPopup.idiom, analysis)
@ -81,8 +61,6 @@ export const IdiomDetailModal: React.FC<IdiomDetailModalProps> = ({
<BluePlayButton
text={analysis.idiom}
lang="en-US"
isPlaying={isPlaying}
onToggle={handlePlayPronunciation}
size="sm"
title="播放發音"
/>

View File

@ -16,51 +16,6 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
disabled = false
}) => {
const [isFlipped, setIsFlipped] = useState(false)
const [isPlayingWord, setIsPlayingWord] = useState(false)
const [isPlayingExample, setIsPlayingExample] = useState(false)
// TTS 播放邏輯
const handleToggleWordTTS = useCallback((text: string, lang?: string) => {
if (isPlayingWord) {
speechSynthesis.cancel()
setIsPlayingWord(false)
return
}
setIsPlayingExample(false)
speechSynthesis.cancel()
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 handleToggleExampleTTS = useCallback((text: string, lang?: string) => {
if (isPlayingExample) {
speechSynthesis.cancel()
setIsPlayingExample(false)
return
}
setIsPlayingWord(false)
speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang || 'en-US'
utterance.rate = 0.8
utterance.onstart = () => setIsPlayingExample(true)
utterance.onend = () => setIsPlayingExample(false)
utterance.onerror = () => setIsPlayingExample(false)
speechSynthesis.speak(utterance)
}, [isPlayingExample])
const [selectedConfidence, setSelectedConfidence] = useState<number | null>(null)
const [cardHeight, setCardHeight] = useState<number>(400)
const frontRef = useRef<HTMLDivElement>(null)
@ -158,8 +113,6 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
<div onClick={(e) => e.stopPropagation()}>
<BluePlayButton
text={cardData.word}
isPlaying={isPlayingWord}
onToggle={handleToggleWordTTS}
size="sm"
title="播放單詞"
/>
@ -201,8 +154,6 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
<div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}>
<BluePlayButton
text={cardData.example}
isPlaying={isPlayingExample}
onToggle={handleToggleExampleTTS}
size="sm"
title="播放例句"
/>

View File

@ -23,8 +23,6 @@ const SentenceListeningTestComponent: React.FC<SentenceListeningTestProps> = ({
}) => {
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
const [showResult, setShowResult] = useState(false)
const [isPlayingExample, setIsPlayingExample] = useState(false)
// 判斷是否已答題(選擇了答案)
const hasAnswered = selectedAnswer !== null
@ -37,32 +35,11 @@ const SentenceListeningTestComponent: React.FC<SentenceListeningTestProps> = ({
const isCorrect = useMemo(() => selectedAnswer === cardData.example, [selectedAnswer, cardData.example])
// TTS 播放邏輯
const handleToggleTTS = useCallback((text: string, lang?: string) => {
if (isPlayingExample) {
speechSynthesis.cancel()
setIsPlayingExample(false)
return
}
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang || 'en-US'
utterance.rate = 0.8
utterance.onstart = () => setIsPlayingExample(true)
utterance.onend = () => setIsPlayingExample(false)
utterance.onerror = () => setIsPlayingExample(false)
speechSynthesis.speak(utterance)
}, [isPlayingExample])
// 音頻播放區域
const audioArea = (
<div className="text-center">
<BluePlayButton
text={cardData.example}
isPlaying={isPlayingExample}
onToggle={handleToggleTTS}
size="md"
title="播放例句"
/>

View File

@ -1,26 +1,28 @@
import React from 'react'
import React, { useState } from 'react'
interface BluePlayButtonProps {
text?: string
lang?: string
isPlaying: boolean
onToggle: (text: string, lang?: string) => void
disabled?: boolean
className?: string
size?: 'sm' | 'md' | 'lg'
title?: string
onPlayStart?: () => void
onPlayEnd?: () => void
}
export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
text,
lang = 'en-US',
isPlaying,
onToggle,
disabled = false,
className = '',
size = 'md',
title
title,
onPlayStart,
onPlayEnd
}) => {
const [isPlaying, setIsPlaying] = useState(false)
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-10 h-10',
@ -33,13 +35,52 @@ export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
lg: 'w-6 h-6'
}
const handleClick = () => {
onToggle(text || '', lang)
// 內建 TTS 邏輯
const handleToggle = () => {
// 停止播放邏輯
if (isPlaying) {
speechSynthesis.cancel()
setIsPlaying(false)
if (onPlayEnd) onPlayEnd()
return
}
// 開始播放邏輯
if (onPlayStart) {
// 自定義播放場景(如錄音回放)
setIsPlaying(true)
onPlayStart()
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)
}
}
return (
<button
onClick={handleClick}
onClick={handleToggle}
disabled={disabled}
title={title || (isPlaying ? "點擊停止播放" : "點擊播放發音")}
className={`group relative ${sizeClasses[size]} rounded-full shadow-lg transform transition-all duration-200

View File

@ -24,32 +24,12 @@ export const WordPopup: React.FC<WordPopupProps> = ({
isSaving = false
}) => {
const { getWordProperty } = useWordAnalysis()
const [isPlaying, setIsPlaying] = useState(false)
if (!selectedWord || !analysis?.[selectedWord]) {
return null
}
const wordAnalysis = analysis[selectedWord]
const handlePlayPronunciation = (text: string, lang?: string) => {
if (isPlaying) {
speechSynthesis.cancel()
setIsPlaying(false)
return
}
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang || 'en-US'
utterance.rate = 0.8
utterance.onstart = () => setIsPlaying(true)
utterance.onend = () => setIsPlaying(false)
utterance.onerror = () => setIsPlaying(false)
speechSynthesis.speak(utterance)
}
const handleSaveWord = async () => {
if (onSaveWord) {
await onSaveWord(selectedWord, wordAnalysis)
@ -78,8 +58,6 @@ export const WordPopup: React.FC<WordPopupProps> = ({
<BluePlayButton
text={getWordProperty(wordAnalysis, 'word') || selectedWord}
lang="en-US"
isPlaying={isPlaying}
onToggle={handlePlayPronunciation}
size="sm"
title="播放發音"
/>