From 8757a8952c6fd3fc43ff4b6ec1c7097feb1179ef Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Thu, 20 Aug 2026 19:47:10 +0330 Subject: [PATCH] feat(frontend): split Add detail into a segmented control with voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The microphone becomes the second segment of the Add detail button, built like the detail chip's trash affordance in the same file — an overflow-hidden rounded wrapper holding two raw + {voice ? ( + + ) : ( + + )} + {voice ? : null} +
{details.map((d, idx) => { const detailLocked = isDetailLocked(d); @@ -310,3 +332,82 @@ function NotesField({ ); } + +/** + * "Add detail", split into two segments with the microphone at the logical end. + * + * Built like the detail chip's trash affordance in this same file — an + * `inline-flex items-stretch overflow-hidden rounded` wrapper holding two raw ` + +
+ ); +} diff --git a/frontend/src/components/ui/treatment/VoiceRecordingBar.tsx b/frontend/src/components/ui/treatment/VoiceRecordingBar.tsx new file mode 100644 index 0000000..30ad007 --- /dev/null +++ b/frontend/src/components/ui/treatment/VoiceRecordingBar.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Loader2, X } from 'lucide-react'; +import type { VoiceCaptureState } from '@/lib/voice/useVoiceCapture'; + +const METER_BARS = 9; + +function formatElapsed(ms: number): string { + const totalSeconds = Math.floor(Math.max(0, ms) / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +/** + * Live recording / processing strip. + * + * Sits between the header row and the chip strip rather than inside the segmented + * control: the header is `sm:justify-between`, so growing the button mid-recording would + * shove the row on every start and every stop. + */ +export function VoiceRecordingBar({ voice }: { voice: VoiceCaptureState }) { + const t = useTranslations('treatment'); + + if (voice.phase === 'idle') return null; + + const isRecording = voice.phase === 'recording'; + + return ( +
+ {isRecording ? ( + <> + + + {formatElapsed(voice.elapsedMs)} + {voice.maxMs != null ? ( + / {formatElapsed(voice.maxMs)} + ) : null} + + + + ) : ( + <> + + {t('voiceProcessing')} + + )} + + +
+ ); +} + +/** Proves the microphone is actually hearing something — silence looks identical otherwise. */ +function LevelMeter({ level }: { level: number }) { + return ( + + {Array.from({ length: METER_BARS }, (_, index) => { + // Bars light up left to right as the level rises, with a floor so the meter never + // looks dead while a quiet voice is still being captured. + const threshold = (index + 1) / METER_BARS; + const active = level >= threshold * 0.9; + const height = active ? 30 + threshold * 70 : 20; + return ( + + ); + })} + + ); +} diff --git a/frontend/src/lib/voice/useVoiceCapture.ts b/frontend/src/lib/voice/useVoiceCapture.ts index 37574d7..40983e0 100644 --- a/frontend/src/lib/voice/useVoiceCapture.ts +++ b/frontend/src/lib/voice/useVoiceCapture.ts @@ -65,6 +65,12 @@ export function useVoiceCapture({ const cancelledRef = useRef(false); /** getUserMedia is async; without this a permission granted after unmount leaks the mic. */ const mountedRef = useRef(true); + /** + * Set synchronously on click. `phase` does not become 'recording' until getUserMedia + * resolves, so without this a second click during the permission prompt would start a + * second stream and orphan the first — mic indicator lit, interval leaked. + */ + const startingRef = useRef(false); const teardown = useCallback(() => { if (timerRef.current) { @@ -81,16 +87,21 @@ export function useVoiceCapture({ // Releasing the microphone on unmount matters: the browser shows a recording indicator // for as long as the track is live, and an orphaned one looks like the app is listening. - useEffect(() => () => { - mountedRef.current = false; - cancelledRef.current = true; - abortRef.current?.abort(); - try { - recorderRef.current?.stop(); - } catch { - // already stopped - } - teardown(); + useEffect(() => { + // Re-armed on every mount: React StrictMode runs mount → unmount → mount in dev, and + // a ref that is only ever set false would leave the hook permanently "unmounted". + mountedRef.current = true; + return () => { + mountedRef.current = false; + cancelledRef.current = true; + abortRef.current?.abort(); + try { + recorderRef.current?.stop(); + } catch { + // already stopped + } + teardown(); + }; }, [teardown]); const send = useCallback( @@ -133,71 +144,80 @@ export function useVoiceCapture({ }, [teardown]); const onStart = useCallback(() => { - if (phase !== 'idle') return; + if (phase !== 'idle' || startingRef.current) return; if (!isMediaRecorderSupported()) { onError(clientError('VOICE_MIC_DENIED')); return; } cancelledRef.current = false; + startingRef.current = true; + void (async () => { - let stream: MediaStream; try { - stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - } catch { - // Permission refused, or no input device. Never a server round-trip. - onError(clientError('VOICE_MIC_DENIED')); - return; - } - - if (!mountedRef.current) { - // Permission resolved after the component went away — release it immediately - // rather than leaving the browser's recording indicator lit. - stream.getTracks().forEach((track) => track.stop()); - return; - } - - const mimeType = pickRecordingMimeType(); - if (mimeType === null) { - stream.getTracks().forEach((track) => track.stop()); - onError(clientError('VOICE_MIC_DENIED')); - return; - } - - streamRef.current = stream; - chunksRef.current = []; - const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); - recorderRef.current = recorder; - - recorder.ondataavailable = (event) => { - if (event.data.size > 0) chunksRef.current.push(event.data); - }; - recorder.onstop = () => { - const durationMs = Date.now() - startedAtRef.current; - const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType }); - teardown(); - if (cancelledRef.current || blob.size === 0) { - setPhase('idle'); - setElapsedMs(0); + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch { + // Permission refused, or no input device. Never a server round-trip. + onError(clientError('VOICE_MIC_DENIED')); return; } - void send(blob, recorder.mimeType || mimeType || 'audio/webm', durationMs); - }; - attachLevelMeter(stream, audioContextRef, setLevel); + if (!mountedRef.current) { + // Permission resolved after the component went away — release it immediately + // rather than leaving the browser's recording indicator lit. + stream.getTracks().forEach((track) => track.stop()); + return; + } - startedAtRef.current = Date.now(); - recorder.start(); - setPhase('recording'); - setElapsedMs(0); + const mimeType = pickRecordingMimeType(); + if (mimeType === null) { + stream.getTracks().forEach((track) => track.stop()); + onError(clientError('VOICE_MIC_DENIED')); + return; + } - timerRef.current = setInterval(() => { - const elapsed = Date.now() - startedAtRef.current; - setElapsedMs(elapsed); - // Auto-stop proceeds to processing with what was captured; discarding two minutes - // of dictation because a timer expired would be the worst possible failure. - if (maxMs != null && elapsed >= maxMs) stop(); - }, LEVEL_POLL_MS); + streamRef.current = stream; + chunksRef.current = []; + const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + recorderRef.current = recorder; + + recorder.ondataavailable = (event) => { + if (event.data.size > 0) chunksRef.current.push(event.data); + }; + recorder.onstop = () => { + const durationMs = Date.now() - startedAtRef.current; + const blob = new Blob(chunksRef.current, { type: recorder.mimeType || mimeType }); + teardown(); + if (cancelledRef.current || blob.size === 0) { + setPhase('idle'); + setElapsedMs(0); + return; + } + // Prefer what the recorder actually produced, then the blob's own type. Old + // Safari accepts no mimeType hint, and defaulting to webm would mislabel its + // mp4/aac clips as something they are not. + void send(blob, recorder.mimeType || blob.type || mimeType || 'audio/webm', durationMs); + }; + + attachLevelMeter(stream, audioContextRef, setLevel); + + startedAtRef.current = Date.now(); + recorder.start(); + setPhase('recording'); + setElapsedMs(0); + + timerRef.current = setInterval(() => { + const elapsed = Date.now() - startedAtRef.current; + setElapsedMs(elapsed); + // Auto-stop proceeds to processing with what was captured; discarding two + // minutes of dictation because a timer expired would be the worst failure. + if (maxMs != null && elapsed >= maxMs) stop(); + }, LEVEL_POLL_MS); + } finally { + startingRef.current = false; + } })(); }, [maxMs, onError, phase, send, stop, teardown]);