jbot: fix voice button stuck state + add global fallback handlers

Fixes:
1. Race condition: after await startRec(), check isSpaceDownRef again —
   fast-tap released before mic ready was leaving phase stuck at 'listening'
2. Global window mouseup/touchend/touchcancel listeners as fallback so
   releasing outside the button boundary always triggers handleSpaceUp
3. Show ✕ cancel button during ALL non-idle phases (was only thinking/speaking)
4. handleCancel now stops the recorder if called during listening/acquiring
5. Safety auto-cancel timeout: if stuck in 'listening' > 60s, auto-stop
6. Add stopRec to handleSpaceDown dep array

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-17 14:13:38 +10:00
co-authored by Copilot
parent 2c41b46da0
commit 21fd058d46
2 changed files with 52 additions and 7 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+49 -4
View File
@@ -47,7 +47,9 @@ function speakText(text) {
// ── Hold-to-talk voice button (defined outside App to keep stable identity) ──
function VoiceButton({ phase, onStart, onEnd, onCancel }) {
const busy = phase === 'thinking' || phase === 'speaking';
const busy = phase === 'thinking' || phase === 'speaking';
// Show cancel any time we're not fully idle
const showCancel = phase !== 'idle';
let label = '🎙 按住说话';
let mod = '';
if (phase === 'acquiring') { label = '⏳ 准备中…'; mod = ' vbtn--acquiring'; }
@@ -56,6 +58,7 @@ function VoiceButton({ phase, onStart, onEnd, onCancel }) {
else if (phase === 'speaking') { label = '🔊 播放中…'; mod = ' vbtn--busy'; }
const startEvt = (e) => { e.preventDefault(); if (!busy) onStart(); };
// Button-level end — global window listener is the primary fallback
const endEvt = (e) => { e.preventDefault(); onEnd(); };
return (
@@ -72,7 +75,7 @@ function VoiceButton({ phase, onStart, onEnd, onCancel }) {
>
{label}
</button>
{busy && (
{showCancel && (
<button className="vbtn-cancel" onClick={onCancel} title="取消"></button>
)}
</div>
@@ -147,13 +150,19 @@ export default function App() {
const abortRef = useRef(null);
const handleCancel = useCallback(() => {
isSpaceDownRef.current = false;
// Stop recorder if actively recording
const phase = voicePhaseRef.current;
if (phase === 'listening' || phase === 'acquiring') {
stopRec().catch(() => {});
}
abortRef.current?.abort();
abortRef.current = null;
setVoicePhase('idle');
setVoiceStatus(null);
setExprId('idle');
scheduleNext();
}, [scheduleNext]);
}, [scheduleNext, stopRec]);
// Load backend mode from server config on mount
useEffect(() => {
@@ -192,11 +201,20 @@ export default function App() {
return;
}
// Race condition guard: user released before mic was ready (fast tap)
if (!isSpaceDownRef.current) {
stopRec().catch(() => {}); // discard the just-acquired recording
setVoicePhase('idle');
setVoiceStatus(null);
scheduleNext();
return;
}
// Only show "listening" once the mic is actually recording
setExprId('listening');
setVoicePhase('listening');
setVoiceStatus({ type: 'listening', text: '🎙 请讲话 · 松开结束' });
}, [clearAuto, startRec, scheduleNext]);
}, [clearAuto, startRec, stopRec, scheduleNext]);
const handleSpaceUp = useCallback(async () => {
if (!isSpaceDownRef.current) return;
@@ -364,6 +382,33 @@ export default function App() {
};
}, [handleSpaceDown, handleSpaceUp, pick]);
// ── Global mouse/touch fallback — catches release outside button boundary ──
useEffect(() => {
const onUp = () => handleSpaceUp();
window.addEventListener('mouseup', onUp);
window.addEventListener('touchend', onUp, { passive: true });
window.addEventListener('touchcancel', onUp, { passive: true });
return () => {
window.removeEventListener('mouseup', onUp);
window.removeEventListener('touchend', onUp);
window.removeEventListener('touchcancel', onUp);
};
}, [handleSpaceUp]);
// ── Safety timeout: auto-cancel if stuck in listening > 60 s ──
const recTimerRef = useRef(null);
useEffect(() => {
if (voicePhase === 'listening') {
recTimerRef.current = setTimeout(() => {
console.warn('[PTT] Auto-stop: max recording time reached');
handleSpaceUp();
}, 60_000);
} else {
clearTimeout(recTimerRef.current);
}
return () => clearTimeout(recTimerRef.current);
}, [voicePhase, handleSpaceUp]);
// ── Render ─────────────────────────────────────────────────
return (