fix(desktop): DeerFlow UI — ChatArea refactor + ai-elements + dead CSS cleanup
ChatArea retry button uses setInput instead of direct sendToGateway, fix bootstrap spinner stuck for non-logged-in users, remove dead CSS (aurora-title/sidebar-open/quick-action-chips), add ai components (ReasoningBlock/StreamingText/ChatMode/ModelSelector/TaskProgress), add ClassroomPlayer + ResizableChatLayout + artifact panel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
155
desktop/src/components/classroom_player/TtsPlayer.tsx
Normal file
155
desktop/src/components/classroom_player/TtsPlayer.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* TtsPlayer — Text-to-Speech playback controls for classroom narration.
|
||||
*
|
||||
* Uses the browser's built-in SpeechSynthesis API.
|
||||
* Provides play/pause, speed, and volume controls.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Volume2, VolumeX, Pause, Play, Gauge } from 'lucide-react';
|
||||
|
||||
interface TtsPlayerProps {
|
||||
text: string;
|
||||
autoPlay?: boolean;
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
export function TtsPlayer({ text, autoPlay = false, onEnd }: TtsPlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [rate, setRate] = useState(1.0);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
||||
|
||||
const speak = useCallback(() => {
|
||||
if (!text || typeof window === 'undefined') return;
|
||||
|
||||
window.speechSynthesis.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = 'zh-CN';
|
||||
utterance.rate = rate;
|
||||
utterance.volume = isMuted ? 0 : 1;
|
||||
|
||||
utterance.onend = () => {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
onEnd?.();
|
||||
};
|
||||
utterance.onerror = () => {
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
};
|
||||
|
||||
utteranceRef.current = utterance;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
}, [text, rate, isMuted, onEnd]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (isPlaying && !isPaused) {
|
||||
window.speechSynthesis.pause();
|
||||
setIsPaused(true);
|
||||
} else if (isPaused) {
|
||||
window.speechSynthesis.resume();
|
||||
setIsPaused(false);
|
||||
} else {
|
||||
speak();
|
||||
}
|
||||
}, [isPlaying, isPaused, speak]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
window.speechSynthesis.cancel();
|
||||
setIsPlaying(false);
|
||||
setIsPaused(false);
|
||||
}, []);
|
||||
|
||||
// Auto-play when text changes
|
||||
useEffect(() => {
|
||||
if (autoPlay && text) {
|
||||
speak();
|
||||
}
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
}, [text, autoPlay, speak]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
{/* Play/Pause button */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full bg-indigo-500 text-white hover:bg-indigo-600 transition-colors"
|
||||
aria-label={isPlaying && !isPaused ? '暂停' : '播放'}
|
||||
>
|
||||
{isPlaying && !isPaused ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Stop button */}
|
||||
{isPlaying && (
|
||||
<button
|
||||
onClick={stop}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
aria-label="停止"
|
||||
>
|
||||
<VolumeX className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Speed control */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Gauge className="w-3.5 h-3.5 text-gray-400" />
|
||||
<select
|
||||
value={rate}
|
||||
onChange={(e) => setRate(Number(e.target.value))}
|
||||
className="text-xs bg-transparent border-none text-gray-600 dark:text-gray-400 cursor-pointer"
|
||||
>
|
||||
<option value={0.5}>0.5x</option>
|
||||
<option value={0.75}>0.75x</option>
|
||||
<option value={1}>1x</option>
|
||||
<option value={1.25}>1.25x</option>
|
||||
<option value={1.5}>1.5x</option>
|
||||
<option value={2}>2x</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Mute toggle */}
|
||||
<button
|
||||
onClick={() => setIsMuted(!isMuted)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
aria-label={isMuted ? '取消静音' : '静音'}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="w-4 h-4" />
|
||||
) : (
|
||||
<Volume2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Status indicator */}
|
||||
{isPlaying && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{isPaused ? '已暂停' : '朗读中...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user