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>
156 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|