mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
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:
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+49
-4
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user