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:
parent
97704a7dfa
commit
d742cf52f9
|
|
@ -146,11 +146,8 @@ export const FlashcardContentBlocks: React.FC<FlashcardContentBlocksProps> = ({
|
||||||
<BluePlayButton
|
<BluePlayButton
|
||||||
text={flashcard.example}
|
text={flashcard.example}
|
||||||
lang="en-US"
|
lang="en-US"
|
||||||
isPlaying={isPlayingExample}
|
|
||||||
onToggle={onToggleExampleTTS}
|
|
||||||
disabled={isPlayingWord}
|
|
||||||
size="md"
|
size="md"
|
||||||
title={isPlayingExample ? "點擊停止播放" : "點擊聽例句發音"}
|
title="點擊聽例句發音"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,9 @@ export const FlashcardDetailHeader: React.FC<FlashcardDetailHeaderProps> = ({
|
||||||
<BluePlayButton
|
<BluePlayButton
|
||||||
text={flashcard.word}
|
text={flashcard.word}
|
||||||
lang="en-US"
|
lang="en-US"
|
||||||
isPlaying={isPlayingWord}
|
|
||||||
onToggle={onToggleWordTTS}
|
|
||||||
disabled={isPlayingExample}
|
disabled={isPlayingExample}
|
||||||
size="md"
|
size="md"
|
||||||
title={isPlayingWord ? "點擊停止播放" : "點擊聽詞彙發音"}
|
title="點擊聽詞彙發音"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,26 +13,6 @@ interface FlashcardFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlashcardForm({ initialData, isEdit = false, onSuccess, onCancel }: 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>({
|
const [formData, setFormData] = useState<CreateFlashcardRequest>({
|
||||||
word: initialData?.word || '',
|
word: initialData?.word || '',
|
||||||
|
|
@ -151,8 +131,6 @@ export function FlashcardForm({ initialData, isEdit = false, onSuccess, onCancel
|
||||||
{formData.pronunciation && (
|
{formData.pronunciation && (
|
||||||
<BluePlayButton
|
<BluePlayButton
|
||||||
text={formData.word}
|
text={formData.word}
|
||||||
isPlaying={isPlayingWord}
|
|
||||||
onToggle={handleToggleWordTTS}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
title="播放單詞"
|
title="播放單詞"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -34,32 +34,12 @@ export const IdiomDetailModal: React.FC<IdiomDetailModalProps> = ({
|
||||||
onSaveIdiom,
|
onSaveIdiom,
|
||||||
className = ''
|
className = ''
|
||||||
}) => {
|
}) => {
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
|
||||||
|
|
||||||
if (!idiomPopup) {
|
if (!idiomPopup) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const { analysis } = idiomPopup
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (onSaveIdiom) {
|
if (onSaveIdiom) {
|
||||||
await onSaveIdiom(idiomPopup.idiom, analysis)
|
await onSaveIdiom(idiomPopup.idiom, analysis)
|
||||||
|
|
@ -81,8 +61,6 @@ export const IdiomDetailModal: React.FC<IdiomDetailModalProps> = ({
|
||||||
<BluePlayButton
|
<BluePlayButton
|
||||||
text={analysis.idiom}
|
text={analysis.idiom}
|
||||||
lang="en-US"
|
lang="en-US"
|
||||||
isPlaying={isPlaying}
|
|
||||||
onToggle={handlePlayPronunciation}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
title="播放發音"
|
title="播放發音"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -16,51 +16,6 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
disabled = false
|
disabled = false
|
||||||
}) => {
|
}) => {
|
||||||
const [isFlipped, setIsFlipped] = useState(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 [selectedConfidence, setSelectedConfidence] = useState<number | null>(null)
|
||||||
const [cardHeight, setCardHeight] = useState<number>(400)
|
const [cardHeight, setCardHeight] = useState<number>(400)
|
||||||
const frontRef = useRef<HTMLDivElement>(null)
|
const frontRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -158,8 +113,6 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<BluePlayButton
|
<BluePlayButton
|
||||||
text={cardData.word}
|
text={cardData.word}
|
||||||
isPlaying={isPlayingWord}
|
|
||||||
onToggle={handleToggleWordTTS}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
title="播放單詞"
|
title="播放單詞"
|
||||||
/>
|
/>
|
||||||
|
|
@ -201,8 +154,6 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
<div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}>
|
<div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}>
|
||||||
<BluePlayButton
|
<BluePlayButton
|
||||||
text={cardData.example}
|
text={cardData.example}
|
||||||
isPlaying={isPlayingExample}
|
|
||||||
onToggle={handleToggleExampleTTS}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
title="播放例句"
|
title="播放例句"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ const SentenceListeningTestComponent: React.FC<SentenceListeningTestProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
|
||||||
const [showResult, setShowResult] = useState(false)
|
const [showResult, setShowResult] = useState(false)
|
||||||
const [isPlayingExample, setIsPlayingExample] = useState(false)
|
|
||||||
|
|
||||||
// 判斷是否已答題(選擇了答案)
|
// 判斷是否已答題(選擇了答案)
|
||||||
const hasAnswered = selectedAnswer !== null
|
const hasAnswered = selectedAnswer !== null
|
||||||
|
|
||||||
|
|
@ -37,32 +35,11 @@ const SentenceListeningTestComponent: React.FC<SentenceListeningTestProps> = ({
|
||||||
|
|
||||||
const isCorrect = useMemo(() => selectedAnswer === cardData.example, [selectedAnswer, cardData.example])
|
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 = (
|
const audioArea = (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<BluePlayButton
|
<BluePlayButton
|
||||||
text={cardData.example}
|
text={cardData.example}
|
||||||
isPlaying={isPlayingExample}
|
|
||||||
onToggle={handleToggleTTS}
|
|
||||||
size="md"
|
size="md"
|
||||||
title="播放例句"
|
title="播放例句"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,28 @@
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
interface BluePlayButtonProps {
|
interface BluePlayButtonProps {
|
||||||
text?: string
|
text?: string
|
||||||
lang?: string
|
lang?: string
|
||||||
isPlaying: boolean
|
|
||||||
onToggle: (text: string, lang?: string) => void
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
title?: string
|
title?: string
|
||||||
|
onPlayStart?: () => void
|
||||||
|
onPlayEnd?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
|
export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
|
||||||
text,
|
text,
|
||||||
lang = 'en-US',
|
lang = 'en-US',
|
||||||
isPlaying,
|
|
||||||
onToggle,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className = '',
|
className = '',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
title
|
title,
|
||||||
|
onPlayStart,
|
||||||
|
onPlayEnd
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'w-8 h-8',
|
sm: 'w-8 h-8',
|
||||||
md: 'w-10 h-10',
|
md: 'w-10 h-10',
|
||||||
|
|
@ -33,13 +35,52 @@ export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
|
||||||
lg: 'w-6 h-6'
|
lg: 'w-6 h-6'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClick = () => {
|
// 內建 TTS 邏輯
|
||||||
onToggle(text || '', lang)
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleToggle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title={title || (isPlaying ? "點擊停止播放" : "點擊播放發音")}
|
title={title || (isPlaying ? "點擊停止播放" : "點擊播放發音")}
|
||||||
className={`group relative ${sizeClasses[size]} rounded-full shadow-lg transform transition-all duration-200
|
className={`group relative ${sizeClasses[size]} rounded-full shadow-lg transform transition-all duration-200
|
||||||
|
|
|
||||||
|
|
@ -24,32 +24,12 @@ export const WordPopup: React.FC<WordPopupProps> = ({
|
||||||
isSaving = false
|
isSaving = false
|
||||||
}) => {
|
}) => {
|
||||||
const { getWordProperty } = useWordAnalysis()
|
const { getWordProperty } = useWordAnalysis()
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
|
||||||
|
|
||||||
if (!selectedWord || !analysis?.[selectedWord]) {
|
if (!selectedWord || !analysis?.[selectedWord]) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const wordAnalysis = analysis[selectedWord]
|
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 () => {
|
const handleSaveWord = async () => {
|
||||||
if (onSaveWord) {
|
if (onSaveWord) {
|
||||||
await onSaveWord(selectedWord, wordAnalysis)
|
await onSaveWord(selectedWord, wordAnalysis)
|
||||||
|
|
@ -78,8 +58,6 @@ export const WordPopup: React.FC<WordPopupProps> = ({
|
||||||
<BluePlayButton
|
<BluePlayButton
|
||||||
text={getWordProperty(wordAnalysis, 'word') || selectedWord}
|
text={getWordProperty(wordAnalysis, 'word') || selectedWord}
|
||||||
lang="en-US"
|
lang="en-US"
|
||||||
isPlaying={isPlaying}
|
|
||||||
onToggle={handlePlayPronunciation}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
title="播放發音"
|
title="播放發音"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue