diff --git a/components/roundtable/index.tsx b/components/roundtable/index.tsx index 777a46ab..333134ae 100644 --- a/components/roundtable/index.tsx +++ b/components/roundtable/index.tsx @@ -322,41 +322,6 @@ export function Roundtable({ prevStreamingRef.current = !!isStreaming; }, [isStreaming, isSendCooldown]); - // Spacebar shortcut: toggle discussion buffer-level pause/resume - // Much easier than clicking the small bubble during fast text streaming - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Skip when user is typing in an input, textarea, or contentEditable - const tag = (e.target as HTMLElement).tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) { - return; - } - if (e.code !== 'Space') return; - - // Only handle during live flow (QA/Discussion) - if (!isInLiveFlow) return; - - e.preventDefault(); // Prevent page scroll - - if (isDiscussionPaused) { - onDiscussionResume?.(); - } else if (!thinkingState && currentSpeech) { - // Same guard as bubble click: don't pause during thinking or before text arrives - onDiscussionPause?.(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [ - isInLiveFlow, - isDiscussionPaused, - thinkingState, - currentSpeech, - onDiscussionPause, - onDiscussionResume, - ]); - // Separate participants by role (teacherParticipant & studentParticipants declared earlier for effect) const userParticipant = initialParticipants.find((p) => p.role === 'user'); @@ -429,6 +394,76 @@ export function Roundtable({ } }; + // Keyboard shortcuts for roundtable interaction (#255) + // T = toggle text input, V = toggle voice input, Escape = dismiss panels, + // Space = discussion pause/resume (during live flow) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Escape should always work, even when typing in an input + if (e.key === 'Escape') { + if (isInputOpen || isVoiceOpen) { + e.preventDefault(); + e.stopPropagation(); // Prevent fullscreen exit when panels are open + setIsInputOpen(false); + setIsVoiceOpen(false); + if (isRecording || isProcessing) cancelRecording(); + } + return; + } + + // Skip other shortcuts when user is typing in an input, textarea, or contentEditable + const tag = (e.target as HTMLElement).tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) { + return; + } + + switch (e.key) { + case ' ': + case 'Spacebar': + // Only handle during live flow (QA/Discussion) + if (!isInLiveFlow) return; + e.preventDefault(); // Prevent page scroll + if (isDiscussionPaused) { + onDiscussionResume?.(); + } else if (!thinkingState && currentSpeech) { + // Same guard as bubble click: don't pause during thinking or before text arrives + onDiscussionPause?.(); + } + break; + + case 't': + case 'T': + e.preventDefault(); + handleToggleInput(); + break; + + case 'v': + case 'V': + e.preventDefault(); + if (asrEnabled) handleToggleVoice(); + break; + + default: + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + isInLiveFlow, + isDiscussionPaused, + thinkingState, + currentSpeech, + onDiscussionPause, + onDiscussionResume, + asrEnabled, + isInputOpen, + isVoiceOpen, + isRecording, + isProcessing, + ]); + const isPresentationInteractionActive = isInputOpen || isVoiceOpen || isRecording || isProcessing; useEffect(() => { diff --git a/components/stage.tsx b/components/stage.tsx index 785c858b..9b45f359 100644 --- a/components/stage.tsx +++ b/components/stage.tsx @@ -59,6 +59,8 @@ export function Stage({ const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth); const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed); const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed); + const setTTSMuted = useSettingsStore((s) => s.setTTSMuted); + const setTTSVolume = useSettingsStore((s) => s.setTTSVolume); // PlaybackEngine state const [engineMode, setEngineMode] = useState('idle'); @@ -297,12 +299,19 @@ export function Stage({ try { if (document.fullscreenElement === stageElement) { + // Unlock Escape key before exiting fullscreen + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (navigator as any).keyboard?.unlock?.(); await document.exitFullscreen(); return; } setControlsVisible(true); await stageElement.requestFullscreen(); + // Lock Escape key so it doesn't auto-exit fullscreen (#255) + // Escape is handled manually in our keydown handler instead + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {}); setSidebarCollapsed(true); setChatAreaCollapsed(true); } catch { @@ -317,6 +326,9 @@ export function Stage({ setIsPresenting(active); if (!active) { + // Ensure keyboard unlock on any fullscreen exit + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (navigator as any).keyboard?.unlock?.(); setControlsVisible(true); clearPresentationIdleTimer(); } @@ -798,8 +810,6 @@ export function Stage({ }, []); useEffect(() => { - if (!isPresenting) return; - const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; if ( @@ -811,11 +821,13 @@ export function Stage({ switch (event.key) { case 'ArrowLeft': + if (!isPresenting) return; event.preventDefault(); handlePreviousScene(); resetPresentationIdleTimer(); break; case 'ArrowRight': + if (!isPresenting) return; event.preventDefault(); handleNextScene(); resetPresentationIdleTimer(); @@ -828,6 +840,38 @@ export function Stage({ event.preventDefault(); handlePlayPause(); break; + case 'Escape': + // With keyboard.lock(), Escape no longer auto-exits fullscreen. + // If panels are open, roundtable handles Escape (close panels). + // If no panels are open, manually exit fullscreen. + if (isPresenting && !isPresentationInteractionActive) { + event.preventDefault(); + togglePresentation(); + } + break; + case 'ArrowUp': + event.preventDefault(); + setTTSVolume(ttsVolume + 0.1); + break; + case 'ArrowDown': + event.preventDefault(); + setTTSVolume(ttsVolume - 0.1); + break; + case 'm': + case 'M': + event.preventDefault(); + setTTSMuted(!ttsMuted); + break; + case 's': + case 'S': + event.preventDefault(); + setSidebarCollapsed(!sidebarCollapsed); + break; + case 'c': + case 'C': + event.preventDefault(); + setChatAreaCollapsed(!chatAreaCollapsed); + break; default: break; } @@ -837,12 +881,22 @@ export function Stage({ return () => window.removeEventListener('keydown', onKeyDown); }, [ chatSessionType, + chatAreaCollapsed, handleNextScene, handlePlayPause, handlePreviousScene, isPresenting, + isPresentationInteractionActive, isPresentationShortcutTarget, resetPresentationIdleTimer, + setChatAreaCollapsed, + setSidebarCollapsed, + setTTSMuted, + setTTSVolume, + sidebarCollapsed, + togglePresentation, + ttsMuted, + ttsVolume, ]); // Intercept F11 to use our presentation fullscreen instead of browser fullscreen diff --git a/lib/hooks/use-audio-recorder.ts b/lib/hooks/use-audio-recorder.ts index 7e01b368..77c52689 100644 --- a/lib/hooks/use-audio-recorder.ts +++ b/lib/hooks/use-audio-recorder.ts @@ -30,6 +30,8 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { const timerRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Web Speech API not typed const speechRecognitionRef = useRef(null); + // Synchronous lock to prevent rapid re-entry (React state updates are async) + const busyRef = useRef(false); // Send audio to server for transcription const transcribeAudio = useCallback( @@ -84,6 +86,9 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { // Start recording const startRecording = useCallback(async () => { + // Synchronous lock — React state is async so isRecording may be stale + if (busyRef.current) return; + busyRef.current = true; try { // Get current ASR configuration if (typeof window !== 'undefined') { @@ -129,6 +134,16 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { let errorMessage = '语音识别失败'; switch (event.error) { + case 'aborted': + // Non-fatal: caused by our own cancel/stop logic or rapid toggle + busyRef.current = false; + setIsRecording(false); + setRecordingTime(0); + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + return; case 'no-speech': errorMessage = '未检测到语音输入'; break; @@ -146,6 +161,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { } onError?.(errorMessage); + busyRef.current = false; setIsRecording(false); setRecordingTime(0); if (timerRef.current) { @@ -155,6 +171,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { }; recognition.onend = () => { + busyRef.current = false; setIsRecording(false); setRecordingTime(0); if (timerRef.current) { @@ -198,6 +215,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { // Send to server for transcription await transcribeAudio(audioBlob); + busyRef.current = false; }; // Start recording @@ -210,6 +228,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { setRecordingTime((prev) => prev + 1); }, 1000); } catch (error) { + busyRef.current = false; log.error('Failed to start recording:', error); onError?.('无法访问麦克风,请检查权限设置'); } @@ -221,6 +240,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { if (speechRecognitionRef.current) { speechRecognitionRef.current.stop(); speechRecognitionRef.current = null; + busyRef.current = false; setIsRecording(false); if (timerRef.current) { clearInterval(timerRef.current); @@ -232,6 +252,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { // Stop MediaRecorder if active if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); + busyRef.current = false; setIsRecording(false); if (timerRef.current) { @@ -249,6 +270,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { speechRecognitionRef.current.onerror = null; // Suppress browser abort error events speechRecognitionRef.current.stop(); speechRecognitionRef.current = null; + busyRef.current = false; setIsRecording(false); setRecordingTime(0); if (timerRef.current) { @@ -270,6 +292,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) { mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop()); } + busyRef.current = false; setIsRecording(false); setRecordingTime(0);