Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 70 additions & 35 deletions components/roundtable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,41 +322,6 @@
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');

Expand Down Expand Up @@ -429,6 +394,76 @@
}
};

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

Check warning on line 453 in components/roundtable/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Unit Tests

React Hook useEffect has missing dependencies: 'cancelRecording', 'handleToggleInput', and 'handleToggleVoice'. Either include them or remove the dependency array
isInLiveFlow,
isDiscussionPaused,
thinkingState,
currentSpeech,
onDiscussionPause,
onDiscussionResume,
asrEnabled,
isInputOpen,
isVoiceOpen,
isRecording,
isProcessing,
]);

const isPresentationInteractionActive = isInputOpen || isVoiceOpen || isRecording || isProcessing;

useEffect(() => {
Expand Down
58 changes: 56 additions & 2 deletions components/stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EngineMode>('idle');
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
}
Expand Down Expand Up @@ -798,8 +810,6 @@ export function Stage({
}, []);

useEffect(() => {
if (!isPresenting) return;

const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
if (
Expand All @@ -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();
Expand All @@ -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;
}
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions lib/hooks/use-audio-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
const timerRef = useRef<NodeJS.Timeout | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Web Speech API not typed
const speechRecognitionRef = useRef<any>(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(
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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;
Expand All @@ -146,6 +161,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
}

onError?.(errorMessage);
busyRef.current = false;
setIsRecording(false);
setRecordingTime(0);
if (timerRef.current) {
Expand All @@ -155,6 +171,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
};

recognition.onend = () => {
busyRef.current = false;
setIsRecording(false);
setRecordingTime(0);
if (timerRef.current) {
Expand Down Expand Up @@ -198,6 +215,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {

// Send to server for transcription
await transcribeAudio(audioBlob);
busyRef.current = false;
};

// Start recording
Expand All @@ -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?.('无法访问麦克风,请检查权限设置');
}
Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -270,6 +292,7 @@ export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop());
}

busyRef.current = false;
setIsRecording(false);
setRecordingTime(0);

Expand Down
Loading