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:
parent
5167d91090
commit
df1c2b92ef
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import type { Flashcard } from '@/lib/services/flashcards'
|
import type { Flashcard } from '@/lib/services/flashcards'
|
||||||
import { getFlashcardImageUrl } from '@/lib/utils/flashcardUtils'
|
import { getFlashcardImageUrl } from '@/lib/utils/flashcardUtils'
|
||||||
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
|
|
||||||
interface FlashcardContentBlocksProps {
|
interface FlashcardContentBlocksProps {
|
||||||
flashcard: Flashcard
|
flashcard: Flashcard
|
||||||
|
|
@ -142,40 +143,15 @@ export const FlashcardContentBlocks: React.FC<FlashcardContentBlocksProps> = ({
|
||||||
"{flashcard.example}"
|
"{flashcard.example}"
|
||||||
</p>
|
</p>
|
||||||
<div className="absolute bottom-0 right-0">
|
<div className="absolute bottom-0 right-0">
|
||||||
<button
|
<BluePlayButton
|
||||||
onClick={() => onToggleExampleTTS(flashcard.example, 'en-US')}
|
text={flashcard.example}
|
||||||
|
lang="en-US"
|
||||||
|
isPlaying={isPlayingExample}
|
||||||
|
onToggle={onToggleExampleTTS}
|
||||||
disabled={isPlayingWord}
|
disabled={isPlayingWord}
|
||||||
|
size="md"
|
||||||
title={isPlayingExample ? "點擊停止播放" : "點擊聽例句發音"}
|
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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-blue-700 text-left text-base">
|
<p className="text-blue-700 text-left text-base">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import type { Flashcard } from '@/lib/services/flashcards'
|
import type { Flashcard } from '@/lib/services/flashcards'
|
||||||
import { getPartOfSpeechDisplay, getCEFRColor } from '@/lib/utils/flashcardUtils'
|
import { getPartOfSpeechDisplay, getCEFRColor } from '@/lib/utils/flashcardUtils'
|
||||||
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
|
|
||||||
interface FlashcardDetailHeaderProps {
|
interface FlashcardDetailHeaderProps {
|
||||||
flashcard: Flashcard
|
flashcard: Flashcard
|
||||||
|
|
@ -25,42 +26,16 @@ export const FlashcardDetailHeader: React.FC<FlashcardDetailHeaderProps> = ({
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg text-gray-600">{flashcard.pronunciation}</span>
|
<span className="text-lg text-gray-600">{flashcard.pronunciation}</span>
|
||||||
|
|
||||||
{/* TTS播放按鈕 - 藍底漸層設計 */}
|
{/* TTS播放按鈕 - 使用統一的藍底漸層組件 */}
|
||||||
<button
|
<BluePlayButton
|
||||||
onClick={() => onToggleWordTTS(flashcard.word, 'en-US')}
|
text={flashcard.word}
|
||||||
|
lang="en-US"
|
||||||
|
isPlaying={isPlayingWord}
|
||||||
|
onToggle={onToggleWordTTS}
|
||||||
disabled={isPlayingExample}
|
disabled={isPlayingExample}
|
||||||
|
size="md"
|
||||||
title={isPlayingWord ? "點擊停止播放" : "點擊聽詞彙發音"}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { flashcardsService, type CreateFlashcardRequest, type Flashcard } from '@/lib/services/flashcards'
|
import { flashcardsService, type CreateFlashcardRequest, type Flashcard } from '@/lib/services/flashcards'
|
||||||
import AudioPlayer from '@/components/media/AudioPlayer'
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
|
|
||||||
interface FlashcardFormProps {
|
interface FlashcardFormProps {
|
||||||
cardSets?: any[] // 保持相容性
|
cardSets?: any[] // 保持相容性
|
||||||
|
|
@ -13,6 +13,27 @@ 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 || '',
|
||||||
translation: initialData?.translation || '',
|
translation: initialData?.translation || '',
|
||||||
|
|
@ -128,7 +149,13 @@ export function FlashcardForm({ initialData, isEdit = false, onSuccess, onCancel
|
||||||
placeholder="例如: /wɜːrd/"
|
placeholder="例如: /wɜːrd/"
|
||||||
/>
|
/>
|
||||||
{formData.pronunciation && (
|
{formData.pronunciation && (
|
||||||
<AudioPlayer text={formData.word} />
|
<BluePlayButton
|
||||||
|
text={formData.word}
|
||||||
|
isPlaying={isPlayingWord}
|
||||||
|
onToggle={handleToggleWordTTS}
|
||||||
|
size="sm"
|
||||||
|
title="播放單詞"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Play } from 'lucide-react'
|
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { ContentBlock } from '@/components/shared/ContentBlock'
|
import { ContentBlock } from '@/components/shared/ContentBlock'
|
||||||
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
|
|
||||||
interface IdiomAnalysis {
|
interface IdiomAnalysis {
|
||||||
idiom: string
|
idiom: string
|
||||||
|
|
@ -34,16 +34,29 @@ 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 = () => {
|
const handlePlayPronunciation = (text: string, lang?: string) => {
|
||||||
const utterance = new SpeechSynthesisUtterance(analysis.idiom)
|
if (isPlaying) {
|
||||||
utterance.lang = 'en-US'
|
speechSynthesis.cancel()
|
||||||
|
setIsPlaying(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text)
|
||||||
|
utterance.lang = lang || 'en-US'
|
||||||
utterance.rate = 0.8
|
utterance.rate = 0.8
|
||||||
|
|
||||||
|
utterance.onstart = () => setIsPlaying(true)
|
||||||
|
utterance.onend = () => setIsPlaying(false)
|
||||||
|
utterance.onerror = () => setIsPlaying(false)
|
||||||
|
|
||||||
speechSynthesis.speak(utterance)
|
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-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-base text-gray-600">{analysis.pronunciation}</span>
|
<span className="text-base text-gray-600">{analysis.pronunciation}</span>
|
||||||
<button
|
<BluePlayButton
|
||||||
onClick={handlePlayPronunciation}
|
text={analysis.idiom}
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
lang="en-US"
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onToggle={handlePlayPronunciation}
|
||||||
|
size="sm"
|
||||||
title="播放發音"
|
title="播放發音"
|
||||||
>
|
/>
|
||||||
<Play size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useRef, useEffect, memo, useCallback } from 'react'
|
import { useState, useRef, useEffect, memo, useCallback } from 'react'
|
||||||
import AudioPlayer from '@/components/media/AudioPlayer'
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
import {
|
import {
|
||||||
ErrorReportButton,
|
ErrorReportButton,
|
||||||
TestHeader,
|
TestHeader,
|
||||||
|
|
@ -16,6 +16,51 @@ 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)
|
||||||
|
|
@ -111,7 +156,13 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
<span className="text-lg text-gray-500">{cardData.pronunciation}</span>
|
<span className="text-lg text-gray-500">{cardData.pronunciation}</span>
|
||||||
)}
|
)}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<AudioPlayer text={cardData.word} />
|
<BluePlayButton
|
||||||
|
text={cardData.word}
|
||||||
|
isPlaying={isPlayingWord}
|
||||||
|
onToggle={handleToggleWordTTS}
|
||||||
|
size="sm"
|
||||||
|
title="播放單詞"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -148,7 +199,13 @@ const FlipMemoryTestComponent: React.FC<FlipMemoryTestProps> = ({
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<p className="text-gray-700 italic mb-2 text-left pr-12">{cardData.example}</p>
|
<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()}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 text-sm text-left">{cardData.exampleTranslation}</p>
|
<p className="text-gray-600 text-sm text-left">{cardData.exampleTranslation}</p>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useCallback, useMemo, memo } from 'react'
|
import React, { useState, useCallback, useMemo, memo } from 'react'
|
||||||
import AudioPlayer from '@/components/media/AudioPlayer'
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
import {
|
import {
|
||||||
TestResultDisplay,
|
TestResultDisplay,
|
||||||
ListeningTestContainer,
|
ListeningTestContainer,
|
||||||
|
|
@ -23,6 +23,7 @@ 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
|
||||||
|
|
@ -36,10 +37,35 @@ 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">
|
||||||
<AudioPlayer text={cardData.example} />
|
<BluePlayButton
|
||||||
|
text={cardData.example}
|
||||||
|
isPlaying={isPlayingExample}
|
||||||
|
onToggle={handleToggleTTS}
|
||||||
|
size="md"
|
||||||
|
title="播放例句"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useCallback, useMemo, memo } from 'react'
|
import React, { useState, useCallback, useMemo, memo } from 'react'
|
||||||
import AudioPlayer from '@/components/media/AudioPlayer'
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
import {
|
import {
|
||||||
TestResultDisplay,
|
TestResultDisplay,
|
||||||
ListeningTestContainer,
|
ListeningTestContainer,
|
||||||
|
|
@ -18,6 +18,7 @@ const VocabListeningTestComponent: React.FC<VocabListeningTestProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
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 [isPlayingWord, setIsPlayingWord] = useState(false)
|
||||||
|
|
||||||
// 判斷是否已答題(選擇了答案)
|
// 判斷是否已答題(選擇了答案)
|
||||||
const hasAnswered = selectedAnswer !== null
|
const hasAnswered = selectedAnswer !== null
|
||||||
|
|
@ -31,13 +32,38 @@ const VocabListeningTestComponent: React.FC<VocabListeningTestProps> = ({
|
||||||
|
|
||||||
const isCorrect = useMemo(() => selectedAnswer === cardData.word, [selectedAnswer, cardData.word])
|
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 = (
|
const audioArea = (
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<h3 className="font-semibold text-gray-900 mb-2 text-left">發音</h3>
|
<h3 className="font-semibold text-gray-900 mb-2 text-left">發音</h3>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{cardData.pronunciation && <span className="text-gray-700">{cardData.pronunciation}</span>}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
onSubmit,
|
||||||
disabled = false
|
disabled = false
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isPlayingRecording, setIsPlayingRecording] = useState(false)
|
||||||
|
|
||||||
|
const handlePlaybackToggle = () => {
|
||||||
|
if (isPlayingRecording) {
|
||||||
|
setIsPlayingRecording(false)
|
||||||
|
} else {
|
||||||
|
setIsPlayingRecording(true)
|
||||||
|
onPlayback()
|
||||||
|
// 模擬播放結束
|
||||||
|
setTimeout(() => setIsPlayingRecording(false), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
{/* 錄音按鈕 */}
|
{/* 錄音按鈕 */}
|
||||||
|
|
@ -273,13 +286,16 @@ export const RecordingControl: React.FC<RecordingControlProps> = memo(({
|
||||||
{/* 控制按鈕 */}
|
{/* 控制按鈕 */}
|
||||||
{hasRecording && !isRecording && (
|
{hasRecording && !isRecording && (
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={onPlayback}
|
<BluePlayButton
|
||||||
disabled={disabled}
|
isPlaying={isPlayingRecording}
|
||||||
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 disabled:opacity-50"
|
onToggle={handlePlaybackToggle}
|
||||||
>
|
disabled={disabled}
|
||||||
播放
|
size="sm"
|
||||||
</button>
|
title="播放錄音"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700 text-sm">播放錄音</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { memo } from 'react'
|
import React, { memo, useState, useCallback } from 'react'
|
||||||
import AudioPlayer from '@/components/media/AudioPlayer'
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
|
|
||||||
interface TestResultDisplayProps {
|
interface TestResultDisplayProps {
|
||||||
isCorrect: boolean
|
isCorrect: boolean
|
||||||
|
|
@ -22,6 +22,35 @@ export const TestResultDisplay = memo<TestResultDisplayProps>(({
|
||||||
exampleTranslation,
|
exampleTranslation,
|
||||||
showResult
|
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
|
if (!showResult) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -49,14 +78,26 @@ export const TestResultDisplay = memo<TestResultDisplayProps>(({
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{word && <span className="font-semibold text-left text-xl">{word}</span>}
|
{word && <span className="font-semibold text-left text-xl">{word}</span>}
|
||||||
{pronunciation && <span className="mx-2">{pronunciation}</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{example}
|
{example}
|
||||||
<AudioPlayer text={example} />
|
<BluePlayButton
|
||||||
|
text={example}
|
||||||
|
isPlaying={isPlayingExample}
|
||||||
|
onToggle={(text, lang) => handleToggleTTS(text, 'example', lang)}
|
||||||
|
size="sm"
|
||||||
|
title="播放例句"
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
{exampleTranslation}
|
{exampleTranslation}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Play } from 'lucide-react'
|
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { ContentBlock } from '@/components/shared/ContentBlock'
|
import { ContentBlock } from '@/components/shared/ContentBlock'
|
||||||
import { getCEFRColor } from '@/lib/utils/flashcardUtils'
|
import { getCEFRColor } from '@/lib/utils/flashcardUtils'
|
||||||
|
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||||
import { useWordAnalysis } from './hooks/useWordAnalysis'
|
import { useWordAnalysis } from './hooks/useWordAnalysis'
|
||||||
import type { WordAnalysis } from './types'
|
import type { WordAnalysis } from './types'
|
||||||
|
|
||||||
|
|
@ -24,6 +24,7 @@ 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
|
||||||
|
|
@ -31,11 +32,21 @@ export const WordPopup: React.FC<WordPopupProps> = ({
|
||||||
|
|
||||||
const wordAnalysis = analysis[selectedWord]
|
const wordAnalysis = analysis[selectedWord]
|
||||||
|
|
||||||
const handlePlayPronunciation = () => {
|
const handlePlayPronunciation = (text: string, lang?: string) => {
|
||||||
const word = getWordProperty(wordAnalysis, 'word') || selectedWord
|
if (isPlaying) {
|
||||||
const utterance = new SpeechSynthesisUtterance(word)
|
speechSynthesis.cancel()
|
||||||
utterance.lang = 'en-US'
|
setIsPlaying(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text)
|
||||||
|
utterance.lang = lang || 'en-US'
|
||||||
utterance.rate = 0.8
|
utterance.rate = 0.8
|
||||||
|
|
||||||
|
utterance.onstart = () => setIsPlaying(true)
|
||||||
|
utterance.onend = () => setIsPlaying(false)
|
||||||
|
utterance.onerror = () => setIsPlaying(false)
|
||||||
|
|
||||||
speechSynthesis.speak(utterance)
|
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">
|
<span className="text-sm sm:text-base text-gray-600 break-all">
|
||||||
{getWordProperty(wordAnalysis, 'pronunciation')}
|
{getWordProperty(wordAnalysis, 'pronunciation')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<BluePlayButton
|
||||||
onClick={handlePlayPronunciation}
|
text={getWordProperty(wordAnalysis, 'word') || selectedWord}
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-600 hover:bg-blue-700 text-white transition-colors"
|
lang="en-US"
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onToggle={handlePlayPronunciation}
|
||||||
|
size="sm"
|
||||||
title="播放發音"
|
title="播放發音"
|
||||||
>
|
/>
|
||||||
<Play size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue