feat: 全應用播放按鈕統一為藍底漸層設計 + 架構簡化

組件統一:
• 創建 BluePlayButton 統一組件 - 支援 sm/md/lg 三種尺寸
• 替換 10 個組件中的播放按鈕為統一的藍底漸層設計
• 移除 AudioPlayer 中間層抽象,直接使用 BluePlayButton

清理優化:
• 刪除未使用的 TTSButton 和 AudioPlayer 組件
• 簡化組件架構,每個組件內建 TTS 播放邏輯
• 統一 speechSynthesis API 使用方式

視覺統一:
• 藍底漸層 + 綠色播放中狀態 + 波紋動畫
• 響應式尺寸適配不同使用場景
• 完整的播放/暫停/禁用狀態設計

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-10-02 15:11:02 +08:00
parent 5167d91090
commit df1c2b92ef
13 changed files with 356 additions and 263 deletions

View File

@ -1,6 +1,7 @@
import React from 'react'
import type { Flashcard } from '@/lib/services/flashcards'
import { getFlashcardImageUrl } from '@/lib/utils/flashcardUtils'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
interface FlashcardContentBlocksProps {
flashcard: Flashcard
@ -142,40 +143,15 @@ export const FlashcardContentBlocks: React.FC<FlashcardContentBlocksProps> = ({
"{flashcard.example}"
</p>
<div className="absolute bottom-0 right-0">
<button
onClick={() => onToggleExampleTTS(flashcard.example, 'en-US')}
<BluePlayButton
text={flashcard.example}
lang="en-US"
isPlaying={isPlayingExample}
onToggle={onToggleExampleTTS}
disabled={isPlayingWord}
size="md"
title={isPlayingExample ? "點擊停止播放" : "點擊聽例句發音"}
className={`group relative w-10 h-10 rounded-full shadow-lg transform transition-all duration-200
${isPlayingExample
? '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'
} ${isPlayingWord ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
`}
>
{/* 播放中波紋效果 */}
{isPlayingExample && (
<div className="absolute inset-0 bg-blue-400 rounded-full animate-ping opacity-40"></div>
)}
{/* 按鈕圖標 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{isPlayingExample ? (
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
) : (
<svg className="w-5 h-5 text-white group-hover:scale-110 transition-transform" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</div>
{/* 懸停提示光環 */}
{!isPlayingExample && !isPlayingWord && (
<div className="absolute inset-0 rounded-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-200"></div>
)}
</button>
/>
</div>
</div>
<p className="text-blue-700 text-left text-base">

View File

@ -1,6 +1,7 @@
import React from 'react'
import type { Flashcard } from '@/lib/services/flashcards'
import { getPartOfSpeechDisplay, getCEFRColor } from '@/lib/utils/flashcardUtils'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
interface FlashcardDetailHeaderProps {
flashcard: Flashcard
@ -25,42 +26,16 @@ export const FlashcardDetailHeader: React.FC<FlashcardDetailHeaderProps> = ({
</span>
<span className="text-lg text-gray-600">{flashcard.pronunciation}</span>
{/* TTS播放按鈕 - 藍底漸層設計 */}
<button
onClick={() => onToggleWordTTS(flashcard.word, 'en-US')}
{/* TTS播放按鈕 - 使用統一的藍底漸層組件 */}
<BluePlayButton
text={flashcard.word}
lang="en-US"
isPlaying={isPlayingWord}
onToggle={onToggleWordTTS}
disabled={isPlayingExample}
size="md"
title={isPlayingWord ? "點擊停止播放" : "點擊聽詞彙發音"}
aria-label={isPlayingWord ? `停止播放詞彙:${flashcard.word}` : `播放詞彙發音:${flashcard.word}`}
className={`group relative w-10 h-10 rounded-full shadow-lg transform transition-all duration-200
${isPlayingWord
? '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'
} ${isPlayingExample ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
`}
>
{/* 播放中波紋效果 */}
{isPlayingWord && (
<div className="absolute inset-0 bg-blue-400 rounded-full animate-ping opacity-40"></div>
)}
{/* 按鈕圖標 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{isPlayingWord ? (
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
) : (
<svg className="w-5 h-5 text-white group-hover:scale-110 transition-transform" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</div>
{/* 懸停提示光環 */}
{!isPlayingWord && !isPlayingExample && (
<div className="absolute inset-0 rounded-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-200"></div>
)}
</button>
/>
</div>
</div>

View File

@ -2,7 +2,7 @@
import React, { useState } from 'react'
import { flashcardsService, type CreateFlashcardRequest, type Flashcard } from '@/lib/services/flashcards'
import AudioPlayer from '@/components/media/AudioPlayer'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
interface FlashcardFormProps {
cardSets?: any[] // 保持相容性
@ -13,6 +13,27 @@ 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 || '',
translation: initialData?.translation || '',
@ -128,7 +149,13 @@ export function FlashcardForm({ initialData, isEdit = false, onSuccess, onCancel
placeholder="例如: /wɜːrd/"
/>
{formData.pronunciation && (
<AudioPlayer text={formData.word} />
<BluePlayButton
text={formData.word}
isPlaying={isPlayingWord}
onToggle={handleToggleWordTTS}
size="sm"
title="播放單詞"
/>
)}
</div>
</div>

View File

@ -1,7 +1,7 @@
import React from 'react'
import { Play } from 'lucide-react'
import React, { useState } from 'react'
import { Modal } from '@/components/ui/Modal'
import { ContentBlock } from '@/components/shared/ContentBlock'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
interface IdiomAnalysis {
idiom: string
@ -34,16 +34,29 @@ export const IdiomDetailModal: React.FC<IdiomDetailModalProps> = ({
onSaveIdiom,
className = ''
}) => {
const [isPlaying, setIsPlaying] = useState(false)
if (!idiomPopup) {
return null
}
const { analysis } = idiomPopup
const handlePlayPronunciation = () => {
const utterance = new SpeechSynthesisUtterance(analysis.idiom)
utterance.lang = 'en-US'
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)
}
@ -65,13 +78,14 @@ export const IdiomDetailModal: React.FC<IdiomDetailModalProps> = ({
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-base text-gray-600">{analysis.pronunciation}</span>
<button
onClick={handlePlayPronunciation}
className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-600 hover:bg-blue-700 text-white transition-colors"
<BluePlayButton
text={analysis.idiom}
lang="en-US"
isPlaying={isPlaying}
onToggle={handlePlayPronunciation}
size="sm"
title="播放發音"
>
<Play size={16} />
</button>
/>
</div>
</div>

View File

@ -1,91 +0,0 @@
import React, { useState, useRef } from 'react'
import { Play, Pause, Volume2 } from 'lucide-react'
interface AudioPlayerProps {
text: string
className?: string
autoPlay?: boolean
voice?: 'us' | 'uk'
speed?: number
}
export default function AudioPlayer({
text,
className = '',
autoPlay = false,
voice = 'us',
speed = 1.0
}: AudioPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
const handlePlay = async () => {
if (!text.trim()) return
try {
setIsLoading(true)
// 簡單的TTS模擬 - 實際應該調用TTS API
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = voice === 'us' ? 'en-US' : 'en-GB'
utterance.rate = speed
utterance.onstart = () => {
setIsPlaying(true)
setIsLoading(false)
}
utterance.onend = () => {
setIsPlaying(false)
}
utterance.onerror = () => {
setIsPlaying(false)
setIsLoading(false)
}
window.speechSynthesis.speak(utterance)
} catch (error) {
console.error('TTS Error:', error)
setIsLoading(false)
setIsPlaying(false)
}
}
const handleStop = () => {
window.speechSynthesis.cancel()
setIsPlaying(false)
}
return (
<button
onClick={isPlaying ? handleStop : handlePlay}
disabled={isLoading}
className={`
inline-flex items-center gap-2 px-3 py-1.5
bg-blue-50 hover:bg-blue-100
border border-blue-200 rounded-lg
text-blue-700 text-sm font-medium
transition-colors duration-200
disabled:opacity-50 disabled:cursor-not-allowed
${className}
`}
>
{isLoading ? (
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
) : isPlaying ? (
<Pause size={16} />
) : (
<Play size={16} />
)}
<Volume2 size={14} />
<span>
{isLoading ? '載入中...' : isPlaying ? '播放中' : '播放'}
</span>
</button>
)
}

View File

@ -1,5 +1,5 @@
import { useState, useRef, useEffect, memo, useCallback } from 'react'
import AudioPlayer from '@/components/media/AudioPlayer'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
import {
ErrorReportButton,
TestHeader,
@ -16,6 +16,51 @@ 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)
@ -111,7 +156,13 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
<span className="text-lg text-gray-500">{cardData.pronunciation}</span>
)}
<div onClick={(e) => e.stopPropagation()}>
<AudioPlayer text={cardData.word} />
<BluePlayButton
text={cardData.word}
isPlaying={isPlayingWord}
onToggle={handleToggleWordTTS}
size="sm"
title="播放單詞"
/>
</div>
</div>
</div>
@ -148,7 +199,13 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
<div className="relative">
<p className="text-gray-700 italic mb-2 text-left pr-12">{cardData.example}</p>
<div className="absolute bottom-0 right-0" onClick={(e) => e.stopPropagation()}>
<AudioPlayer text={cardData.example} />
<BluePlayButton
text={cardData.example}
isPlaying={isPlayingExample}
onToggle={handleToggleExampleTTS}
size="sm"
title="播放例句"
/>
</div>
</div>
<p className="text-gray-600 text-sm text-left">{cardData.exampleTranslation}</p>

View File

@ -1,5 +1,5 @@
import React, { useState, useCallback, useMemo, memo } from 'react'
import AudioPlayer from '@/components/media/AudioPlayer'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
import {
TestResultDisplay,
ListeningTestContainer,
@ -23,6 +23,7 @@ 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
@ -36,10 +37,35 @@ 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">
<AudioPlayer text={cardData.example} />
<BluePlayButton
text={cardData.example}
isPlaying={isPlayingExample}
onToggle={handleToggleTTS}
size="md"
title="播放例句"
/>
</div>
)

View File

@ -1,5 +1,5 @@
import React, { useState, useCallback, useMemo, memo } from 'react'
import AudioPlayer from '@/components/media/AudioPlayer'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
import {
TestResultDisplay,
ListeningTestContainer,
@ -18,6 +18,7 @@ const VocabListeningTestComponent: React.FC<VocabListeningTestProps> = ({
}) => {
const [selectedAnswer, setSelectedAnswer] = useState<string | null>(null)
const [showResult, setShowResult] = useState(false)
const [isPlayingWord, setIsPlayingWord] = useState(false)
// 判斷是否已答題(選擇了答案)
const hasAnswered = selectedAnswer !== null
@ -31,13 +32,38 @@ const VocabListeningTestComponent: React.FC<VocabListeningTestProps> = ({
const isCorrect = useMemo(() => selectedAnswer === cardData.word, [selectedAnswer, cardData.word])
// TTS 播放邏輯
const handleToggleTTS = useCallback((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)
}, [isPlayingWord])
// 音頻播放區域
const audioArea = (
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-2 text-left"></h3>
<div className="flex items-center gap-3">
{cardData.pronunciation && <span className="text-gray-700">{cardData.pronunciation}</span>}
<AudioPlayer text={cardData.word} />
<BluePlayButton
text={cardData.word}
isPlaying={isPlayingWord}
onToggle={handleToggleTTS}
size="md"
title="播放單詞"
/>
</div>
</div>
)

View File

@ -1,4 +1,5 @@
import React, { memo } from 'react'
import React, { memo, useState } from 'react'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
/**
*
@ -244,6 +245,18 @@ export const RecordingControl: React.FC<RecordingControlProps> = memo(({
onSubmit,
disabled = false
}) => {
const [isPlayingRecording, setIsPlayingRecording] = useState(false)
const handlePlaybackToggle = () => {
if (isPlayingRecording) {
setIsPlayingRecording(false)
} else {
setIsPlayingRecording(true)
onPlayback()
// 模擬播放結束
setTimeout(() => setIsPlayingRecording(false), 3000)
}
}
return (
<div className="flex flex-col items-center space-y-4">
{/* 錄音按鈕 */}
@ -273,13 +286,16 @@ export const RecordingControl: React.FC<RecordingControlProps> = memo(({
{/* 控制按鈕 */}
{hasRecording && !isRecording && (
<div className="flex space-x-3">
<button
onClick={onPlayback}
<div className="flex items-center gap-2">
<BluePlayButton
isPlaying={isPlayingRecording}
onToggle={handlePlaybackToggle}
disabled={disabled}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50"
>
</button>
size="sm"
title="播放錄音"
/>
<span className="text-gray-700 text-sm"></span>
</div>
<button
onClick={onSubmit}
disabled={disabled}

View File

@ -1,5 +1,5 @@
import React, { memo } from 'react'
import AudioPlayer from '@/components/media/AudioPlayer'
import React, { memo, useState, useCallback } from 'react'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
interface TestResultDisplayProps {
isCorrect: boolean
@ -22,6 +22,35 @@ export const TestResultDisplay = memo<TestResultDisplayProps>(({
exampleTranslation,
showResult
}) => {
const [isPlayingAnswer, setIsPlayingAnswer] = useState(false)
const [isPlayingExample, setIsPlayingExample] = useState(false)
// TTS 播放邏輯
const handleToggleTTS = useCallback((text: string, type: 'answer' | 'example', lang?: string) => {
const isCurrentlyPlaying = type === 'answer' ? isPlayingAnswer : isPlayingExample
const setPlaying = type === 'answer' ? setIsPlayingAnswer : setIsPlayingExample
const stopOther = type === 'answer' ? setIsPlayingExample : setIsPlayingAnswer
if (isCurrentlyPlaying) {
speechSynthesis.cancel()
setPlaying(false)
return
}
// 停止另一個播放
stopOther(false)
speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang || 'en-US'
utterance.rate = 0.8
utterance.onstart = () => setPlaying(true)
utterance.onend = () => setPlaying(false)
utterance.onerror = () => setPlaying(false)
speechSynthesis.speak(utterance)
}, [isPlayingAnswer, isPlayingExample])
if (!showResult) return null
return (
@ -49,14 +78,26 @@ export const TestResultDisplay = memo<TestResultDisplayProps>(({
<p className="text-gray-600">
{word && <span className="font-semibold text-left text-xl">{word}</span>}
{pronunciation && <span className="mx-2">{pronunciation}</span>}
<AudioPlayer text={correctAnswer} />
<BluePlayButton
text={correctAnswer}
isPlaying={isPlayingAnswer}
onToggle={(text, lang) => handleToggleTTS(text, 'answer', lang)}
size="sm"
title="播放答案"
/>
</p>
</div>
<div className="text-left">
<p className="text-gray-600">
{example}
<AudioPlayer text={example} />
<BluePlayButton
text={example}
isPlaying={isPlayingExample}
onToggle={(text, lang) => handleToggleTTS(text, 'example', lang)}
size="sm"
title="播放例句"
/>
</p>
<p className="text-gray-500 text-sm">
{exampleTranslation}

View File

@ -0,0 +1,76 @@
import React 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
}
export const BluePlayButton: React.FC<BluePlayButtonProps> = ({
text,
lang = 'en-US',
isPlaying,
onToggle,
disabled = false,
className = '',
size = 'md',
title
}) => {
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12'
}
const iconSizes = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6'
}
const handleClick = () => {
onToggle(text || '', lang)
}
return (
<button
onClick={handleClick}
disabled={disabled}
title={title || (isPlaying ? "點擊停止播放" : "點擊播放發音")}
className={`group relative ${sizeClasses[size]} rounded-full shadow-lg transform transition-all duration-200
${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}
`}
>
{/* 播放中波紋效果 */}
{isPlaying && (
<div className="absolute inset-0 bg-blue-400 rounded-full animate-ping opacity-40"></div>
)}
{/* 按鈕圖標 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{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>
)}
</div>
{/* 懸停提示光環 */}
{!isPlaying && !disabled && (
<div className="absolute inset-0 rounded-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-200"></div>
)}
</button>
)
}

View File

@ -1,62 +0,0 @@
import React from 'react'
interface TTSButtonProps {
text: string
lang?: string
isPlaying: boolean
onToggle: (text: string, lang?: string) => void
className?: string
size?: 'sm' | 'md' | 'lg'
}
export const TTSButton: React.FC<TTSButtonProps> = ({
text,
lang = 'en-US',
isPlaying,
onToggle,
className = '',
size = 'md'
}) => {
const sizeClasses = {
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm',
lg: 'w-10 h-10 text-base'
}
const baseClasses = `
${sizeClasses[size]}
rounded-full
border-2
transition-all
duration-200
flex
items-center
justify-center
cursor-pointer
hover:scale-110
active:scale-95
`
const stateClasses = isPlaying
? 'bg-blue-500 border-blue-500 text-white animate-pulse'
: 'bg-gray-100 border-gray-300 text-gray-600 hover:bg-blue-50 hover:border-blue-400 hover:text-blue-600'
const handleClick = () => {
onToggle(text, lang)
}
return (
<button
onClick={handleClick}
className={`${baseClasses} ${stateClasses} ${className}`}
title={isPlaying ? '停止播放' : '播放發音'}
disabled={!text}
>
{isPlaying ? (
<span></span>
) : (
<span>🔊</span>
)}
</button>
)
}

View File

@ -1,8 +1,8 @@
import React from 'react'
import { Play } from 'lucide-react'
import React, { useState } from 'react'
import { Modal } from '@/components/ui/Modal'
import { ContentBlock } from '@/components/shared/ContentBlock'
import { getCEFRColor } from '@/lib/utils/flashcardUtils'
import { BluePlayButton } from '@/components/shared/BluePlayButton'
import { useWordAnalysis } from './hooks/useWordAnalysis'
import type { WordAnalysis } from './types'
@ -24,6 +24,7 @@ export const WordPopup: React.FC<WordPopupProps> = ({
isSaving = false
}) => {
const { getWordProperty } = useWordAnalysis()
const [isPlaying, setIsPlaying] = useState(false)
if (!selectedWord || !analysis?.[selectedWord]) {
return null
@ -31,11 +32,21 @@ export const WordPopup: React.FC<WordPopupProps> = ({
const wordAnalysis = analysis[selectedWord]
const handlePlayPronunciation = () => {
const word = getWordProperty(wordAnalysis, 'word') || selectedWord
const utterance = new SpeechSynthesisUtterance(word)
utterance.lang = 'en-US'
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)
}
@ -64,13 +75,14 @@ export const WordPopup: React.FC<WordPopupProps> = ({
<span className="text-sm sm:text-base text-gray-600 break-all">
{getWordProperty(wordAnalysis, 'pronunciation')}
</span>
<button
onClick={handlePlayPronunciation}
className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-600 hover:bg-blue-700 text-white transition-colors"
<BluePlayButton
text={getWordProperty(wordAnalysis, 'word') || selectedWord}
lang="en-US"
isPlaying={isPlaying}
onToggle={handlePlayPronunciation}
size="sm"
title="播放發音"
>
<Play size={16} />
</button>
/>
</div>
</div>