From 6c833164672e6702fc09d94a8b5bc07819be9a49 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=84=AD=E6=B2=9B=E8=BB=92?=
Date: Thu, 25 Sep 2025 23:51:41 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E7=B5=B1=E4=B8=80=E5=85=A8=E5=89=8D?=
=?UTF-8?q?=E7=AB=AF=E6=92=AD=E6=94=BE=E6=8C=89=E9=88=95=E7=82=BA=E7=B2=BE?=
=?UTF-8?q?=E7=BE=8E=E5=9C=93=E5=BD=A2TTS=E8=A8=AD=E8=A8=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 完全重構AudioPlayer組件,移除後端依賴,改用純TTS
- 統一播放按鈕設計:圓形漸變、播放中波紋動畫、懸停特效
- 實現獨立播放狀態:詞彙和例句播放按鈕各自管理狀態
- 添加完整無障礙支援:aria-label、title提示、鍵盤支援
- 優化播放控制:點擊播放/暫停、互斥播放、錯誤處理
- 現在所有頁面的播放按鈕都使用統一的精美圓形設計
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
---
frontend/app/flashcards/[id]/page.tsx | 146 +++++++++++++++-
frontend/components/AudioPlayer.tsx | 229 ++++++++------------------
2 files changed, 208 insertions(+), 167 deletions(-)
diff --git a/frontend/app/flashcards/[id]/page.tsx b/frontend/app/flashcards/[id]/page.tsx
index f94fe15..a1673f1 100644
--- a/frontend/app/flashcards/[id]/page.tsx
+++ b/frontend/app/flashcards/[id]/page.tsx
@@ -36,6 +36,76 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
// 圖片生成狀態
const [isGeneratingImage, setIsGeneratingImage] = useState(false)
const [generationProgress, setGenerationProgress] = useState('')
+ 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} = {
@@ -369,10 +439,40 @@ function FlashcardDetailContent({ cardId }: { cardId: string }) {
{getPartOfSpeechDisplay(flashcard.partOfSpeech)}
{flashcard.pronunciation}
-
-
-
+ toggleExampleTTS(flashcard.example, 'en-US')}
+ 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 && (
+
+ )}
+
+ {/* 按鈕圖標 */}
+
+ {isPlayingExample ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 懸停提示光環 */}
+ {!isPlayingWord && !isPlayingExample && (
+
+ )}
diff --git a/frontend/components/AudioPlayer.tsx b/frontend/components/AudioPlayer.tsx
index d1ffbbc..b933570 100644
--- a/frontend/components/AudioPlayer.tsx
+++ b/frontend/components/AudioPlayer.tsx
@@ -1,191 +1,102 @@
'use client';
-import { useState, useRef, useEffect } from 'react';
-import { Play, Pause, Volume2, VolumeX, Settings } from 'lucide-react';
+import { useState } from 'react';
export interface AudioPlayerProps {
text: string;
- audioUrl?: string;
- autoPlay?: boolean;
+ lang?: string;
onPlayStart?: () => void;
onPlayEnd?: () => void;
onError?: (error: string) => void;
className?: string;
-}
-
-export interface TTSResponse {
- audioUrl: string;
- duration: number;
- cacheHit: boolean;
- error?: string;
+ disabled?: boolean;
}
export default function AudioPlayer({
text,
- audioUrl: providedAudioUrl,
- autoPlay = false,
+ lang = 'en-US',
onPlayStart,
onPlayEnd,
onError,
- className = ''
+ className = '',
+ disabled = false
}: AudioPlayerProps) {
const [isPlaying, setIsPlaying] = useState(false);
- const [isLoading, setIsLoading] = useState(false);
- const [audioUrl, setAudioUrl] = useState(providedAudioUrl || null);
- const [error, setError] = useState(null);
- const audioRef = useRef(null);
-
- // 生成音頻
- const generateAudio = async (textToSpeak: string) => {
- 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');
+ // TTS播放控制功能
+ const toggleTTS = () => {
+ if (!('speechSynthesis' in window)) {
+ onError?.('您的瀏覽器不支援語音播放');
return;
}
- try {
- let urlToPlay = audioUrl;
-
- // 如果沒有音頻 URL,先生成
- if (!urlToPlay) {
- urlToPlay = await generateAudio(text);
- if (!urlToPlay) return;
- }
-
- const audio = audioRef.current;
- if (!audio) return;
-
- audio.src = urlToPlay;
-
- await audio.play();
- setIsPlaying(true);
- onPlayStart?.();
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'Failed to play audio';
- setError(errorMessage);
- onError?.(errorMessage);
- }
- };
-
- // 暫停音頻
- const pauseAudio = () => {
- const audio = audioRef.current;
- if (audio) {
- audio.pause();
- setIsPlaying(false);
- }
- };
-
- // 切換播放/暫停
- const togglePlayPause = (e?: React.MouseEvent) => {
- e?.stopPropagation(); // 阻止事件冒泡
+ // 如果正在播放,則停止
if (isPlaying) {
- pauseAudio();
- } else {
- playAudio();
+ speechSynthesis.cancel();
+ setIsPlaying(false);
+ onPlayEnd?.();
+ return;
}
- };
- // 處理音頻事件
- const handleAudioEnd = () => {
- setIsPlaying(false);
- onPlayEnd?.();
- };
+ // 開始播放
+ speechSynthesis.cancel();
+ setIsPlaying(true);
+ onPlayStart?.();
- const handleAudioError = () => {
- setIsPlaying(false);
- const errorMessage = 'Audio playback error';
- setError(errorMessage);
- onError?.(errorMessage);
- };
+ const utterance = new SpeechSynthesisUtterance(text);
+ utterance.lang = lang;
+ utterance.rate = 0.8; // 稍慢語速
+ utterance.pitch = 1.0;
+ utterance.volume = 1.0;
- // 自動播放
- useEffect(() => {
- if (autoPlay && text && !audioUrl) {
- generateAudio(text);
- }
- }, [autoPlay, text]);
+ utterance.onend = () => {
+ setIsPlaying(false);
+ onPlayEnd?.();
+ };
+
+ utterance.onerror = () => {
+ setIsPlaying(false);
+ onError?.('語音播放失敗');
+ };
+
+ speechSynthesis.speak(utterance);
+ };
return (
-
- {/* 隱藏的音頻元素 */}
-
-
- {/* 播放/暫停按鈕 */}
-
- {isLoading ? (
-
- ) : isPlaying ? (
-
- ) : (
-
- )}
-
-
- {/* 錯誤顯示 */}
- {error && (
-
- {error}
-
+
+ {/* 播放中波紋效果 */}
+ {isPlaying && (
+
)}
-
+
+ {/* 按鈕圖標 */}
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 懸停提示光環 */}
+ {!disabled && (
+
+ )}
+
);
}
\ No newline at end of file