Skip to content

Commit 870ee02

Browse files
committed
feat: keyboard shortcuts for roundtable (T/V/Escape/Space)
roundtable/index.tsx: - T: toggle text input box (calls handleToggleInput) - V: toggle voice input (calls handleToggleVoice, gated on asrEnabled) - Escape: dismiss open input/voice panels + cancel recording - Space: discussion pause/resume (existing, now part of unified handler) - All shortcuts guarded against input/textarea/contentEditable focus stage.tsx: - Space (lecture play/pause) now works outside presentation mode - Arrow keys remain presentation-only to avoid scroll interference Closes #255
1 parent f22cdc7 commit 870ee02

3 files changed

Lines changed: 146 additions & 37 deletions

File tree

components/roundtable/index.tsx

Lines changed: 70 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -322,41 +322,6 @@ export function Roundtable({
322322
prevStreamingRef.current = !!isStreaming;
323323
}, [isStreaming, isSendCooldown]);
324324

325-
// Spacebar shortcut: toggle discussion buffer-level pause/resume
326-
// Much easier than clicking the small bubble during fast text streaming
327-
useEffect(() => {
328-
const handleKeyDown = (e: KeyboardEvent) => {
329-
// Skip when user is typing in an input, textarea, or contentEditable
330-
const tag = (e.target as HTMLElement).tagName;
331-
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) {
332-
return;
333-
}
334-
if (e.code !== 'Space') return;
335-
336-
// Only handle during live flow (QA/Discussion)
337-
if (!isInLiveFlow) return;
338-
339-
e.preventDefault(); // Prevent page scroll
340-
341-
if (isDiscussionPaused) {
342-
onDiscussionResume?.();
343-
} else if (!thinkingState && currentSpeech) {
344-
// Same guard as bubble click: don't pause during thinking or before text arrives
345-
onDiscussionPause?.();
346-
}
347-
};
348-
349-
window.addEventListener('keydown', handleKeyDown);
350-
return () => window.removeEventListener('keydown', handleKeyDown);
351-
}, [
352-
isInLiveFlow,
353-
isDiscussionPaused,
354-
thinkingState,
355-
currentSpeech,
356-
onDiscussionPause,
357-
onDiscussionResume,
358-
]);
359-
360325
// Separate participants by role (teacherParticipant & studentParticipants declared earlier for effect)
361326
const userParticipant = initialParticipants.find((p) => p.role === 'user');
362327

@@ -429,6 +394,76 @@ export function Roundtable({
429394
}
430395
};
431396

397+
// Keyboard shortcuts for roundtable interaction (#255)
398+
// T = toggle text input, V = toggle voice input, Escape = dismiss panels,
399+
// Space = discussion pause/resume (during live flow)
400+
useEffect(() => {
401+
const handleKeyDown = (e: KeyboardEvent) => {
402+
// Escape should always work, even when typing in an input
403+
if (e.key === 'Escape') {
404+
if (isInputOpen || isVoiceOpen) {
405+
e.preventDefault();
406+
e.stopPropagation(); // Prevent fullscreen exit when panels are open
407+
setIsInputOpen(false);
408+
setIsVoiceOpen(false);
409+
if (isRecording || isProcessing) cancelRecording();
410+
}
411+
return;
412+
}
413+
414+
// Skip other shortcuts when user is typing in an input, textarea, or contentEditable
415+
const tag = (e.target as HTMLElement).tagName;
416+
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) {
417+
return;
418+
}
419+
420+
switch (e.key) {
421+
case ' ':
422+
case 'Spacebar':
423+
// Only handle during live flow (QA/Discussion)
424+
if (!isInLiveFlow) return;
425+
e.preventDefault(); // Prevent page scroll
426+
if (isDiscussionPaused) {
427+
onDiscussionResume?.();
428+
} else if (!thinkingState && currentSpeech) {
429+
// Same guard as bubble click: don't pause during thinking or before text arrives
430+
onDiscussionPause?.();
431+
}
432+
break;
433+
434+
case 't':
435+
case 'T':
436+
e.preventDefault();
437+
handleToggleInput();
438+
break;
439+
440+
case 'v':
441+
case 'V':
442+
e.preventDefault();
443+
if (asrEnabled) handleToggleVoice();
444+
break;
445+
446+
default:
447+
break;
448+
}
449+
};
450+
451+
window.addEventListener('keydown', handleKeyDown);
452+
return () => window.removeEventListener('keydown', handleKeyDown);
453+
}, [
454+
isInLiveFlow,
455+
isDiscussionPaused,
456+
thinkingState,
457+
currentSpeech,
458+
onDiscussionPause,
459+
onDiscussionResume,
460+
asrEnabled,
461+
isInputOpen,
462+
isVoiceOpen,
463+
isRecording,
464+
isProcessing,
465+
]);
466+
432467
const isPresentationInteractionActive = isInputOpen || isVoiceOpen || isRecording || isProcessing;
433468

434469
useEffect(() => {

components/stage.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export function Stage({
5959
const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth);
6060
const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed);
6161
const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed);
62+
const setTTSMuted = useSettingsStore((s) => s.setTTSMuted);
63+
const setTTSVolume = useSettingsStore((s) => s.setTTSVolume);
6264

6365
// PlaybackEngine state
6466
const [engineMode, setEngineMode] = useState<EngineMode>('idle');
@@ -297,12 +299,17 @@ export function Stage({
297299

298300
try {
299301
if (document.fullscreenElement === stageElement) {
302+
// Unlock Escape key before exiting fullscreen
303+
(navigator as any).keyboard?.unlock?.();
300304
await document.exitFullscreen();
301305
return;
302306
}
303307

304308
setControlsVisible(true);
305309
await stageElement.requestFullscreen();
310+
// Lock Escape key so it doesn't auto-exit fullscreen (#255)
311+
// Escape is handled manually in our keydown handler instead
312+
await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {});
306313
setSidebarCollapsed(true);
307314
setChatAreaCollapsed(true);
308315
} catch {
@@ -317,6 +324,8 @@ export function Stage({
317324
setIsPresenting(active);
318325

319326
if (!active) {
327+
// Ensure keyboard unlock on any fullscreen exit
328+
(navigator as any).keyboard?.unlock?.();
320329
setControlsVisible(true);
321330
clearPresentationIdleTimer();
322331
}
@@ -798,8 +807,6 @@ export function Stage({
798807
}, []);
799808

800809
useEffect(() => {
801-
if (!isPresenting) return;
802-
803810
const onKeyDown = (event: KeyboardEvent) => {
804811
if (event.defaultPrevented) return;
805812
if (
@@ -811,11 +818,13 @@ export function Stage({
811818

812819
switch (event.key) {
813820
case 'ArrowLeft':
821+
if (!isPresenting) return;
814822
event.preventDefault();
815823
handlePreviousScene();
816824
resetPresentationIdleTimer();
817825
break;
818826
case 'ArrowRight':
827+
if (!isPresenting) return;
819828
event.preventDefault();
820829
handleNextScene();
821830
resetPresentationIdleTimer();
@@ -828,6 +837,38 @@ export function Stage({
828837
event.preventDefault();
829838
handlePlayPause();
830839
break;
840+
case 'Escape':
841+
// With keyboard.lock(), Escape no longer auto-exits fullscreen.
842+
// If panels are open, roundtable handles Escape (close panels).
843+
// If no panels are open, manually exit fullscreen.
844+
if (isPresenting && !isPresentationInteractionActive) {
845+
event.preventDefault();
846+
togglePresentation();
847+
}
848+
break;
849+
case 'ArrowUp':
850+
event.preventDefault();
851+
setTTSVolume(ttsVolume + 0.1);
852+
break;
853+
case 'ArrowDown':
854+
event.preventDefault();
855+
setTTSVolume(ttsVolume - 0.1);
856+
break;
857+
case 'm':
858+
case 'M':
859+
event.preventDefault();
860+
setTTSMuted(!ttsMuted);
861+
break;
862+
case 's':
863+
case 'S':
864+
event.preventDefault();
865+
setSidebarCollapsed(!sidebarCollapsed);
866+
break;
867+
case 'c':
868+
case 'C':
869+
event.preventDefault();
870+
setChatAreaCollapsed(!chatAreaCollapsed);
871+
break;
831872
default:
832873
break;
833874
}
@@ -837,12 +878,22 @@ export function Stage({
837878
return () => window.removeEventListener('keydown', onKeyDown);
838879
}, [
839880
chatSessionType,
881+
chatAreaCollapsed,
840882
handleNextScene,
841883
handlePlayPause,
842884
handlePreviousScene,
843885
isPresenting,
886+
isPresentationInteractionActive,
844887
isPresentationShortcutTarget,
845888
resetPresentationIdleTimer,
889+
setChatAreaCollapsed,
890+
setSidebarCollapsed,
891+
setTTSMuted,
892+
setTTSVolume,
893+
sidebarCollapsed,
894+
togglePresentation,
895+
ttsMuted,
896+
ttsVolume,
846897
]);
847898

848899
// Intercept F11 to use our presentation fullscreen instead of browser fullscreen

lib/hooks/use-audio-recorder.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
3030
const timerRef = useRef<NodeJS.Timeout | null>(null);
3131
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Web Speech API not typed
3232
const speechRecognitionRef = useRef<any>(null);
33+
// Synchronous lock to prevent rapid re-entry (React state updates are async)
34+
const busyRef = useRef(false);
3335

3436
// Send audio to server for transcription
3537
const transcribeAudio = useCallback(
@@ -84,6 +86,9 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
8486

8587
// Start recording
8688
const startRecording = useCallback(async () => {
89+
// Synchronous lock — React state is async so isRecording may be stale
90+
if (busyRef.current) return;
91+
busyRef.current = true;
8792
try {
8893
// Get current ASR configuration
8994
if (typeof window !== 'undefined') {
@@ -129,6 +134,16 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
129134
let errorMessage = '语音识别失败';
130135

131136
switch (event.error) {
137+
case 'aborted':
138+
// Non-fatal: caused by our own cancel/stop logic or rapid toggle
139+
busyRef.current = false;
140+
setIsRecording(false);
141+
setRecordingTime(0);
142+
if (timerRef.current) {
143+
clearInterval(timerRef.current);
144+
timerRef.current = null;
145+
}
146+
return;
132147
case 'no-speech':
133148
errorMessage = '未检测到语音输入';
134149
break;
@@ -146,6 +161,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
146161
}
147162

148163
onError?.(errorMessage);
164+
busyRef.current = false;
149165
setIsRecording(false);
150166
setRecordingTime(0);
151167
if (timerRef.current) {
@@ -155,6 +171,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
155171
};
156172

157173
recognition.onend = () => {
174+
busyRef.current = false;
158175
setIsRecording(false);
159176
setRecordingTime(0);
160177
if (timerRef.current) {
@@ -198,6 +215,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
198215

199216
// Send to server for transcription
200217
await transcribeAudio(audioBlob);
218+
busyRef.current = false;
201219
};
202220

203221
// Start recording
@@ -210,6 +228,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
210228
setRecordingTime((prev) => prev + 1);
211229
}, 1000);
212230
} catch (error) {
231+
busyRef.current = false;
213232
log.error('Failed to start recording:', error);
214233
onError?.('无法访问麦克风,请检查权限设置');
215234
}
@@ -221,6 +240,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
221240
if (speechRecognitionRef.current) {
222241
speechRecognitionRef.current.stop();
223242
speechRecognitionRef.current = null;
243+
busyRef.current = false;
224244
setIsRecording(false);
225245
if (timerRef.current) {
226246
clearInterval(timerRef.current);
@@ -232,6 +252,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
232252
// Stop MediaRecorder if active
233253
if (mediaRecorderRef.current && isRecording) {
234254
mediaRecorderRef.current.stop();
255+
busyRef.current = false;
235256
setIsRecording(false);
236257

237258
if (timerRef.current) {
@@ -249,6 +270,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
249270
speechRecognitionRef.current.onerror = null; // Suppress browser abort error events
250271
speechRecognitionRef.current.stop();
251272
speechRecognitionRef.current = null;
273+
busyRef.current = false;
252274
setIsRecording(false);
253275
setRecordingTime(0);
254276
if (timerRef.current) {
@@ -270,6 +292,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
270292
mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop());
271293
}
272294

295+
busyRef.current = false;
273296
setIsRecording(false);
274297
setRecordingTime(0);
275298

0 commit comments

Comments
 (0)