diff --git a/frontend/components/flashcards/FlashcardContentBlocks.tsx b/frontend/components/flashcards/FlashcardContentBlocks.tsx
index 0f3144e..9283842 100644
--- a/frontend/components/flashcards/FlashcardContentBlocks.tsx
+++ b/frontend/components/flashcards/FlashcardContentBlocks.tsx
@@ -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 = ({
"{flashcard.example}"
-
+ />
diff --git a/frontend/components/flashcards/FlashcardDetailHeader.tsx b/frontend/components/flashcards/FlashcardDetailHeader.tsx
index f8f5cbd..eec0b5f 100644
--- a/frontend/components/flashcards/FlashcardDetailHeader.tsx
+++ b/frontend/components/flashcards/FlashcardDetailHeader.tsx
@@ -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 = ({
{flashcard.pronunciation}
- {/* TTS播放按鈕 - 藍底漸層設計 */}
-
+ />
diff --git a/frontend/components/flashcards/FlashcardForm.tsx b/frontend/components/flashcards/FlashcardForm.tsx
index 242e5e5..a36febd 100644
--- a/frontend/components/flashcards/FlashcardForm.tsx
+++ b/frontend/components/flashcards/FlashcardForm.tsx
@@ -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({
word: initialData?.word || '',
translation: initialData?.translation || '',
@@ -128,7 +149,13 @@ export function FlashcardForm({ initialData, isEdit = false, onSuccess, onCancel
placeholder="例如: /wɜːrd/"
/>
{formData.pronunciation && (
-
+
)}
diff --git a/frontend/components/generate/IdiomDetailModal.tsx b/frontend/components/generate/IdiomDetailModal.tsx
index c89d2c8..29f28fc 100644
--- a/frontend/components/generate/IdiomDetailModal.tsx
+++ b/frontend/components/generate/IdiomDetailModal.tsx
@@ -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 = ({
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 = ({
{analysis.pronunciation}
-
+ />
diff --git a/frontend/components/media/AudioPlayer.tsx b/frontend/components/media/AudioPlayer.tsx
deleted file mode 100644
index 4981f19..0000000
--- a/frontend/components/media/AudioPlayer.tsx
+++ /dev/null
@@ -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(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 (
-
- )
-}
\ No newline at end of file
diff --git a/frontend/components/review/review-tests/FlipMemoryTest.tsx b/frontend/components/review/review-tests/FlipMemoryTest.tsx
index a95ce6e..716aec9 100644
--- a/frontend/components/review/review-tests/FlipMemoryTest.tsx
+++ b/frontend/components/review/review-tests/FlipMemoryTest.tsx
@@ -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 = ({
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(null)
const [cardHeight, setCardHeight] = useState(400)
const frontRef = useRef(null)
@@ -111,7 +156,13 @@ const FlipMemoryTestComponent: React.FC = ({
{cardData.pronunciation}
)}
e.stopPropagation()}>
-
+
@@ -148,7 +199,13 @@ const FlipMemoryTestComponent: React.FC = ({
{cardData.example}
e.stopPropagation()}>
-
+
{cardData.exampleTranslation}
diff --git a/frontend/components/review/review-tests/SentenceListeningTest.tsx b/frontend/components/review/review-tests/SentenceListeningTest.tsx
index 530b929..41a052f 100644
--- a/frontend/components/review/review-tests/SentenceListeningTest.tsx
+++ b/frontend/components/review/review-tests/SentenceListeningTest.tsx
@@ -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 = ({
}) => {
const [selectedAnswer, setSelectedAnswer] = useState(null)
const [showResult, setShowResult] = useState(false)
+ const [isPlayingExample, setIsPlayingExample] = useState(false)
// 判斷是否已答題(選擇了答案)
const hasAnswered = selectedAnswer !== null
@@ -36,10 +37,35 @@ const SentenceListeningTestComponent: React.FC = ({
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 = (
)
diff --git a/frontend/components/review/review-tests/VocabListeningTest.tsx b/frontend/components/review/review-tests/VocabListeningTest.tsx
index fab6bfe..b5d7ddd 100644
--- a/frontend/components/review/review-tests/VocabListeningTest.tsx
+++ b/frontend/components/review/review-tests/VocabListeningTest.tsx
@@ -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 = ({
}) => {
const [selectedAnswer, setSelectedAnswer] = useState(null)
const [showResult, setShowResult] = useState(false)
+ const [isPlayingWord, setIsPlayingWord] = useState(false)
// 判斷是否已答題(選擇了答案)
const hasAnswered = selectedAnswer !== null
@@ -31,13 +32,38 @@ const VocabListeningTestComponent: React.FC = ({
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 = (
發音
{cardData.pronunciation &&
{cardData.pronunciation}}
-
+
)
diff --git a/frontend/components/review/shared/AnswerActions.tsx b/frontend/components/review/shared/AnswerActions.tsx
index b0a135b..5fbbdb6 100644
--- a/frontend/components/review/shared/AnswerActions.tsx
+++ b/frontend/components/review/shared/AnswerActions.tsx
@@ -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 = memo(({
onSubmit,
disabled = false
}) => {
+ const [isPlayingRecording, setIsPlayingRecording] = useState(false)
+
+ const handlePlaybackToggle = () => {
+ if (isPlayingRecording) {
+ setIsPlayingRecording(false)
+ } else {
+ setIsPlayingRecording(true)
+ onPlayback()
+ // 模擬播放結束
+ setTimeout(() => setIsPlayingRecording(false), 3000)
+ }
+ }
return (
{/* 錄音按鈕 */}
@@ -273,13 +286,16 @@ export const RecordingControl: React.FC
= memo(({
{/* 控制按鈕 */}
{hasRecording && !isRecording && (
-
+
+
+ 播放錄音
+
{example}
-
+ handleToggleTTS(text, 'example', lang)}
+ size="sm"
+ title="播放例句"
+ />
{exampleTranslation}
diff --git a/frontend/components/shared/BluePlayButton.tsx b/frontend/components/shared/BluePlayButton.tsx
new file mode 100644
index 0000000..575d0b8
--- /dev/null
+++ b/frontend/components/shared/BluePlayButton.tsx
@@ -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 = ({
+ 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 (
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/components/shared/TTSButton.tsx b/frontend/components/shared/TTSButton.tsx
deleted file mode 100644
index 529dd2b..0000000
--- a/frontend/components/shared/TTSButton.tsx
+++ /dev/null
@@ -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 = ({
- 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 (
-
- )
-}
\ No newline at end of file
diff --git a/frontend/components/word/WordPopup.tsx b/frontend/components/word/WordPopup.tsx
index dcf56d1..b848059 100644
--- a/frontend/components/word/WordPopup.tsx
+++ b/frontend/components/word/WordPopup.tsx
@@ -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 = ({
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 = ({
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 = ({
{getWordProperty(wordAnalysis, 'pronunciation')}
-
+ />