feat: 統一全前端播放按鈕為精美圓形TTS設計

- 完全重構AudioPlayer組件,移除後端依賴,改用純TTS
- 統一播放按鈕設計:圓形漸變、播放中波紋動畫、懸停特效
- 實現獨立播放狀態:詞彙和例句播放按鈕各自管理狀態
- 添加完整無障礙支援:aria-label、title提示、鍵盤支援
- 優化播放控制:點擊播放/暫停、互斥播放、錯誤處理
- 現在所有頁面的播放按鈕都使用統一的精美圓形設計

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-25 23:51:41 +08:00
parent 0f0f1de913
commit 6c83316467
2 changed files with 208 additions and 167 deletions

View File

@ -36,6 +36,76 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
// 圖片生成狀態 // 圖片生成狀態
const [isGeneratingImage, setIsGeneratingImage] = useState(false) const [isGeneratingImage, setIsGeneratingImage] = useState(false)
const [generationProgress, setGenerationProgress] = useState<string>('') const [generationProgress, setGenerationProgress] = useState<string>('')
const [isPlayingWord, setIsPlayingWord] = useState(false)
const [isPlayingExample, setIsPlayingExample] = useState(false)
// TTS播放控制 - 詞彙發音
const toggleWordTTS = (text: string, lang: string = 'en-US') => {
if (!('speechSynthesis' in window)) {
toast.error('您的瀏覽器不支援語音播放');
return;
}
// 如果正在播放詞彙,則停止
if (isPlayingWord) {
speechSynthesis.cancel();
setIsPlayingWord(false);
return;
}
// 停止所有播放並開始新播放
speechSynthesis.cancel();
setIsPlayingWord(true);
setIsPlayingExample(false);
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = lang;
utterance.rate = 0.8; // 詞彙播放稍慢
utterance.pitch = 1.0;
utterance.volume = 1.0;
utterance.onend = () => setIsPlayingWord(false);
utterance.onerror = () => {
setIsPlayingWord(false);
toast.error('語音播放失敗');
};
speechSynthesis.speak(utterance);
}
// TTS播放控制 - 例句發音
const toggleExampleTTS = (text: string, lang: string = 'en-US') => {
if (!('speechSynthesis' in window)) {
toast.error('您的瀏覽器不支援語音播放');
return;
}
// 如果正在播放例句,則停止
if (isPlayingExample) {
speechSynthesis.cancel();
setIsPlayingExample(false);
return;
}
// 停止所有播放並開始新播放
speechSynthesis.cancel();
setIsPlayingExample(true);
setIsPlayingWord(false);
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = lang;
utterance.rate = 0.9; // 例句播放正常語速
utterance.pitch = 1.0;
utterance.volume = 1.0;
utterance.onend = () => setIsPlayingExample(false);
utterance.onerror = () => {
setIsPlayingExample(false);
toast.error('語音播放失敗');
};
speechSynthesis.speak(utterance);
}
// 假資料 - 用於展示效果 // 假資料 - 用於展示效果
const mockCards: {[key: string]: any} = { const mockCards: {[key: string]: any} = {
@ -369,10 +439,40 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
{getPartOfSpeechDisplay(flashcard.partOfSpeech)} {getPartOfSpeechDisplay(flashcard.partOfSpeech)}
</span> </span>
<span className="text-lg text-gray-600">{flashcard.pronunciation}</span> <span className="text-lg text-gray-600">{flashcard.pronunciation}</span>
<button className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors"> <button
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onClick={() => toggleWordTTS(flashcard.word, 'en-US')}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" /> disabled={isPlayingExample}
title={isPlayingWord ? "點擊停止播放" : "點擊聽詞彙發音"}
aria-label={isPlayingWord ? `停止播放詞彙:${flashcard.word}` : `播放詞彙發音:${flashcard.word}`}
className={`group relative w-12 h-12 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-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg> </svg>
) : (
<svg className="w-6 h-6 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> </button>
</div> </div>
</div> </div>
@ -507,10 +607,40 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
"{flashcard.example}" "{flashcard.example}"
</p> </p>
<div className="absolute bottom-0 right-0"> <div className="absolute bottom-0 right-0">
<button className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors"> <button
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onClick={() => toggleExampleTTS(flashcard.example, 'en-US')}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" /> disabled={isPlayingWord}
title={isPlayingExample ? "點擊停止播放" : "點擊聽例句發音"}
aria-label={isPlayingExample ? `停止播放例句:${flashcard.example}` : `播放例句發音:${flashcard.example}`}
className={`group relative w-12 h-12 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-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg> </svg>
) : (
<svg className="w-6 h-6 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> </button>
</div> </div>
</div> </div>

View File

@ -1,191 +1,102 @@
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState } from 'react';
import { Play, Pause, Volume2, VolumeX, Settings } from 'lucide-react';
export interface AudioPlayerProps { export interface AudioPlayerProps {
text: string; text: string;
audioUrl?: string; lang?: string;
autoPlay?: boolean;
onPlayStart?: () => void; onPlayStart?: () => void;
onPlayEnd?: () => void; onPlayEnd?: () => void;
onError?: (error: string) => void; onError?: (error: string) => void;
className?: string; className?: string;
} disabled?: boolean;
export interface TTSResponse {
audioUrl: string;
duration: number;
cacheHit: boolean;
error?: string;
} }
export default function AudioPlayer({ export default function AudioPlayer({
text, text,
audioUrl: providedAudioUrl, lang = 'en-US',
autoPlay = false,
onPlayStart, onPlayStart,
onPlayEnd, onPlayEnd,
onError, onError,
className = '' className = '',
disabled = false
}: AudioPlayerProps) { }: AudioPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(providedAudioUrl || null);
const [error, setError] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement>(null); // TTS播放控制功能
const toggleTTS = () => {
// 生成音頻 if (!('speechSynthesis' in window)) {
const generateAudio = async (textToSpeak: string) => { onError?.('您的瀏覽器不支援語音播放');
try {
setIsLoading(true);
setError(null);
const response = await fetch('/api/audio/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`
},
body: JSON.stringify({
text: textToSpeak,
accent: 'us',
speed: 1.0,
voice: ''
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: TTSResponse = await response.json();
if (data.error) {
throw new Error(data.error);
}
setAudioUrl(data.audioUrl);
return data.audioUrl;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to generate audio';
setError(errorMessage);
onError?.(errorMessage);
return null;
} finally {
setIsLoading(false);
}
};
// 播放音頻
const playAudio = async () => {
if (!text) {
setError('No text to play');
return; return;
} }
try { // 如果正在播放,則停止
let urlToPlay = audioUrl; if (isPlaying) {
speechSynthesis.cancel();
// 如果沒有音頻 URL先生成 setIsPlaying(false);
if (!urlToPlay) { onPlayEnd?.();
urlToPlay = await generateAudio(text); return;
if (!urlToPlay) return;
} }
const audio = audioRef.current; // 開始播放
if (!audio) return; speechSynthesis.cancel();
audio.src = urlToPlay;
await audio.play();
setIsPlaying(true); setIsPlaying(true);
onPlayStart?.(); onPlayStart?.();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to play audio';
setError(errorMessage);
onError?.(errorMessage);
}
};
// 暫停音頻 const utterance = new SpeechSynthesisUtterance(text);
const pauseAudio = () => { utterance.lang = lang;
const audio = audioRef.current; utterance.rate = 0.8; // 稍慢語速
if (audio) { utterance.pitch = 1.0;
audio.pause(); utterance.volume = 1.0;
setIsPlaying(false);
}
};
// 切換播放/暫停 utterance.onend = () => {
const togglePlayPause = (e?: React.MouseEvent) => {
e?.stopPropagation(); // 阻止事件冒泡
if (isPlaying) {
pauseAudio();
} else {
playAudio();
}
};
// 處理音頻事件
const handleAudioEnd = () => {
setIsPlaying(false); setIsPlaying(false);
onPlayEnd?.(); onPlayEnd?.();
}; };
const handleAudioError = () => { utterance.onerror = () => {
setIsPlaying(false); setIsPlaying(false);
const errorMessage = 'Audio playback error'; onError?.('語音播放失敗');
setError(errorMessage);
onError?.(errorMessage);
}; };
// 自動播放 speechSynthesis.speak(utterance);
useEffect(() => { };
if (autoPlay && text && !audioUrl) {
generateAudio(text);
}
}, [autoPlay, text]);
return ( return (
<div className={`audio-player flex items-center gap-2 ${className}`}>
{/* 隱藏的音頻元素 */}
<audio
ref={audioRef}
onEnded={handleAudioEnd}
onError={handleAudioError}
preload="none"
/>
{/* 播放/暫停按鈕 */}
<button <button
onClick={togglePlayPause} onClick={toggleTTS}
disabled={isLoading || !text} disabled={disabled}
className={` title={isPlaying ? "點擊停止播放" : "點擊播放"}
flex items-center justify-center w-10 h-10 rounded-full transition-colors aria-label={isPlaying ? `停止播放:${text}` : `播放:${text}`}
${isLoading || !text className={`group relative w-12 h-12 rounded-full shadow-lg transform transition-all duration-200
? 'bg-gray-300 cursor-not-allowed' ${isPlaying
: 'bg-blue-600 hover:bg-blue-700 text-white' ? '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}
`} `}
title={isPlaying ? 'Pause' : 'Play'}
> >
{isLoading ? ( {/* 播放中波紋效果 */}
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" /> {isPlaying && (
) : isPlaying ? ( <div className="absolute inset-0 bg-blue-400 rounded-full animate-ping opacity-40"></div>
<Pause size={20} /> )}
{/* 按鈕圖標 */}
<div className="relative z-10 flex items-center justify-center w-full h-full">
{isPlaying ? (
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
) : ( ) : (
<Play size={20} /> <svg className="w-6 h-6 text-white group-hover:scale-110 transition-transform" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</div>
{/* 懸停提示光環 */}
{!disabled && (
<div className="absolute inset-0 rounded-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-200"></div>
)} )}
</button> </button>
{/* 錯誤顯示 */}
{error && (
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
{error}
</div>
)}
</div>
); );
} }