Files
zclaw_openfang/desktop/src/components/classroom_player/TtsPlayer.tsx
iven 28299807b6 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>
2026-04-02 19:24:44 +08:00

156 lines
4.3 KiB
TypeScript

/**
* 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>
);
}