OmniVoice-Studio / frontend /src /components /CaptureWidget.jsx
Lê Phi Nam
Definitive fix: patch all 3 API URL sources for Docker same-origin serving
f88dfcd
Raw
History Blame Contribute Delete
13.3 kB
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { X, Loader } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { useAppStore } from '../store';
import './CaptureWidget.css';
import { API as API_BASE } from '../api/client';
import { addTranscription } from '../pages/Transcriptions';
// Flip the system tray icon between default and red-dot. No-op when not
// running inside the Tauri shell (e.g. browser webui, Docker).
async function setTrayRecording(recording) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('set_tray_recording', { recording });
} catch { /* not in Tauri */ }
}
const LS_CAPTURE_MODE = 'omni_capture_mode';
function formatElapsed(ms) {
const secs = Math.floor(ms / 1000);
const mins = Math.floor(secs / 60);
const s = secs % 60;
if (mins > 0) return `${mins}:${String(s).padStart(2, '0')}`;
return `${s}s`;
}
/**
* CaptureWidget — floating pill for dictation.
*
* Minimal status-only UI: pulsing dot + label + timer.
* All interaction via global hotkey (hold-to-talk).
* Records → transcribes → auto-pastes → auto-dismisses.
*/
export default function CaptureWidget({ onDismiss }) {
const [state, setState] = useState('idle'); // idle | recording | transcribing | done | error
const [transcript, setTranscript] = useState('');
const [duration, setDuration] = useState(0);
const [captureMode] = useState(() =>
localStorage.getItem(LS_CAPTURE_MODE) || 'fast'
);
const [lastEngine, setLastEngine] = useState('');
const [lastTime, setLastTime] = useState(0);
const [partialText, setPartialText] = useState('');
const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
const streamRef = useRef(null);
const timerRef = useRef(null);
const wsRef = useRef(null);
const wsPendingRef = useRef([]);
const wsHadFinalRef = useRef(false);
const fallbackTimerRef = useRef(null);
const startTimeRef = useRef(0);
// ── Hold-to-talk: listen for tray-dictate (start) and tray-dictate-stop (release) ──
useEffect(() => {
let unlistenStart, unlistenStop;
(async () => {
try {
const { listen } = await import('@tauri-apps/api/event');
unlistenStart = await listen('tray-dictate', () => {
if (state === 'idle' || state === 'done' || state === 'error') {
startRecording();
}
});
unlistenStop = await listen('tray-dictate-stop', () => {
if (state === 'recording') {
stopRecording();
}
});
} catch { /* not in Tauri */ }
})();
return () => {
if (unlistenStart) unlistenStart();
if (unlistenStop) unlistenStop();
};
}, [state]);
// Keyboard fallback: ⌘+Shift+Space toggles in web mode
useEffect(() => {
const handler = (e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'Space') {
e.preventDefault();
if (state === 'idle' || state === 'done' || state === 'error') {
startRecording();
} else if (state === 'recording') {
stopRecording();
}
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [state]);
// Timer while recording
useEffect(() => {
if (state === 'recording') {
const t0 = Date.now();
timerRef.current = setInterval(() => setDuration(Date.now() - t0), 100);
return () => clearInterval(timerRef.current);
}
clearInterval(timerRef.current);
}, [state]);
// Apply transcription result → auto-paste → auto-dismiss
const applyResult = useCallback(async (data) => {
setTranscript(data.text || '');
setLastEngine(data.engine || '');
setLastTime(data.transcription_time_s || 0);
setState('done');
if (data.text) {
addTranscription(data);
}
if (data.text) {
try {
await navigator.clipboard.writeText(data.text);
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('simulate_paste');
} catch { /* not in Tauri */ }
} catch { /* clipboard API may fail */ }
// Auto-dismiss after 1.5s
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
if (onDismiss) onDismiss();
}, 1500);
} else {
// No speech — auto-dismiss after 2.5s
setTimeout(async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
if (onDismiss) onDismiss();
}, 2500);
}
}, [onDismiss]);
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 }
});
streamRef.current = stream;
chunksRef.current = [];
wsPendingRef.current = [];
wsHadFinalRef.current = false;
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
// Open WebSocket BEFORE starting recorder
try {
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsHost = window.location.host;
const wsUrl = `${wsProto}://${wsHost}/ws/transcribe`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
for (const buf of wsPendingRef.current) {
try { ws.send(buf); } catch {}
}
wsPendingRef.current = [];
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.type === 'partial') {
setPartialText(msg.text || '');
} else if (msg.type === 'final') {
wsHadFinalRef.current = true;
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
applyResult(msg);
try { ws.close(); } catch {}
} else if (msg.type === 'error') {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
try { ws.close(); } catch {}
wsRef.current = null;
if (!wsHadFinalRef.current) sendForTranscription();
}
} catch {}
};
ws.onerror = () => { wsRef.current = null; };
ws.onclose = () => {
wsRef.current = null;
if (
!wsHadFinalRef.current
&& mediaRecorderRef.current
&& mediaRecorderRef.current.state === 'inactive'
) {
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
sendForTranscription();
}
};
wsRef.current = ws;
} catch {
wsRef.current = null;
}
const recorder = new MediaRecorder(stream, { mimeType });
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
e.data.arrayBuffer().then(buf => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(buf);
} else {
wsPendingRef.current.push(buf);
}
});
}
};
recorder.onstop = () => {};
recorder.start(250);
mediaRecorderRef.current = recorder;
startTimeRef.current = Date.now();
setTrayRecording(true);
setState('recording');
setTranscript('');
setPartialText('');
setDuration(0);
} catch {
const isMac = navigator.platform?.includes('Mac');
const isWindows = navigator.platform?.includes('Win');
const hint = isMac
? 'macOS: open System Settings → Privacy & Security → Microphone and enable OmniVoice.'
: isWindows
? 'Windows: open Settings → Privacy & security → Microphone and allow OmniVoice.'
: 'Linux: check that your user is in the audio group and the WebView has mic access.';
toast.error(`Microphone access denied. ${hint}`, { duration: 6000 });
setTrayRecording(false);
setState('error');
}
}, [applyResult]);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
// Signal EOF to WebSocket
const ws = wsRef.current;
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
const sendEof = () => { try { ws.send('EOF'); } catch {} };
if (ws.readyState === WebSocket.OPEN) {
sendEof();
} else {
ws.addEventListener('open', sendEof, { once: true });
}
// Fallback timer
const recorded = startTimeRef.current ? Date.now() - startTimeRef.current : 0;
const ms = Math.max(15000, recorded + 10000);
if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = setTimeout(() => {
fallbackTimerRef.current = null;
if (!wsHadFinalRef.current) {
try { wsRef.current?.close(); } catch {}
wsRef.current = null;
sendForTranscription();
}
}, ms);
}
setTrayRecording(false);
setState('transcribing');
}, []);
const sendForTranscription = useCallback(async () => {
if (wsHadFinalRef.current) return;
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
const formData = new FormData();
formData.append('audio', blob, 'capture.webm');
formData.append('mode', captureMode);
try {
const res = await fetch(`${API_BASE}/transcribe`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.detail || `HTTP ${res.status}`);
}
const data = await res.json();
if (wsHadFinalRef.current) return;
await applyResult(data);
} catch (err) {
if (wsHadFinalRef.current) return;
toast.error(`Transcription failed: ${err.message}`);
setState('error');
setTranscript('');
}
}, [captureMode, applyResult]);
const dismiss = async () => {
setState('idle');
setTranscript('');
setDuration(0);
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().hide();
} catch { /* not in Tauri */ }
if (onDismiss) onDismiss();
};
// Idle: render nothing — pill is hold-to-talk only (Whisper-Flow / Ghost-Pepper
// style). The tray-dictate listener above stays mounted, so the shortcut still
// triggers startRecording() which flips state out of 'idle' and remounts the
// pill DOM with the slide-in animation.
if (state === 'idle') return null;
// ── Pill label ──
let label = '';
let emoji = '';
if (state === 'recording') {
emoji = '🎙️';
label = partialText || 'Listening…';
} else if (state === 'transcribing') {
emoji = '📝';
label = partialText || 'Transcribing…';
} else if (state === 'done' && transcript) {
emoji = '✅';
label = 'Pasted';
} else if (state === 'done' && !transcript) {
emoji = '⚠️';
label = 'No speech detected';
} else if (state === 'error') {
emoji = '❌';
label = 'Mic access denied';
}
return (
<div className={`capture-pill capture-pill--${state}`} role="status" aria-live="polite">
{/* Pulsing status dot */}
<span className="capture-pill__dot" />
{/* Content */}
<div className="capture-pill__content">
<span className="capture-pill__label">
{emoji} {label}
</span>
</div>
{/* Timer */}
{(state === 'recording' || state === 'transcribing') && (
<span className="capture-pill__timer">
{formatElapsed(duration)}
</span>
)}
{/* Transcribing spinner */}
{state === 'transcribing' && (
<Loader size={14} className="capture-pill__spinner" />
)}
{/* Dismiss — only on done/error */}
{(state === 'done' || state === 'error') && (
<button className="capture-pill__dismiss" onClick={dismiss} aria-label="Dismiss">
<X size={12} />
</button>
)}
</div>
);
}