// Parcours Web V6 : extension chargée après la maquette figée. Le dashboard // continue de posséder son flux et ses permissions ; seules ses deux liaisons // runtime (micro et dictée) sont remplacées avant le montage de la coque. const DV6_STORE = window.VascoDictationV6.createArmedPatientStore( () => window.VascoDictationV6.sessionScope(window.VascoApi?.currentUser?.()), ); const DV6_MOTION = window.VascoUI?.tokens?.motion || Object.freeze({ normal: 200, selectEnter: 180, ambientSlow: 2800, standard: 'cubic-bezier(.2,.7,.2,1)', }); function dv6PatientLabel(patient) { return patient ? `${patient.last} ${patient.first}` : 'Aucun patient associé'; } function DictationV6PatientSheet({ runtime, onPick, onClose, title = 'Sélectionner un patient' }) { return (
); } function DictationV6MicHero({ accent, recording, onTap, onWithPatient }) { const { Button, IconButton, InlineAction } = window.VascoUI; const [pressed, setPressed] = React.useState(false); const [patient, setPatient] = React.useState(() => DV6_STORE.current()); const [picking, setPicking] = React.useState(false); React.useEffect(() => DV6_STORE.subscribe(setPatient), []); const runtime = window.VascoApi?.dictation; const openPicker = () => { // Le composant de la maquette appelle bien `runtime.patients(query)` : on // lui fournit la recherche serveur existante, jamais les fixtures locales. if (!runtime?.patients) { showToast('La recherche patient est indisponible', 'error'); return; } setPicking(true); }; // Le contrôle est construit avant le micro dans le code, puis placé sous lui // comme dans la V6. Il arme un dossier ; il ne lance jamais un enregistrement. const patientControl = patient ? (
{dv6PatientLabel(patient)} DV6_STORE.disarm()} label="Retirer le patient" variant="ghost" size={28} style={{ marginRight: 4, color: DT_MED }}>
) : ( ); return (
Dicter →
{patient ? 'Tapez pour enregistrer sur ce dossier' : 'Tapez pour enregistrer'} {patientControl}
{picking && ( { DV6_STORE.arm(picked); setPicking(false); }} onClose={() => setPicking(false)} /> )}
); } function DictationV6Recorder({ patient, onStop, onCancel }) { const { Button } = window.VascoUI; const [elapsed, setElapsed] = React.useState(0); const [paused, setPaused] = React.useState(false); const [ready, setReady] = React.useState(false); const captureRef = React.useRef(null); const cancelRef = React.useRef(onCancel); cancelRef.current = onCancel; const release = React.useCallback(() => { const capture = captureRef.current; captureRef.current = null; if (!capture) return null; capture.processor.onaudioprocess = null; capture.source.disconnect(); capture.processor.disconnect(); capture.silentOutput.disconnect(); capture.stream.getTracks().forEach((track) => track.stop()); capture.context.close().catch(() => {}); return capture; }, []); React.useEffect(() => { let cancelled = false; const open = async () => { try { if (!navigator.mediaDevices?.getUserMedia) throw new Error('Ce navigateur ne permet pas l’enregistrement.'); const AudioContextClass = window.AudioContext || window.webkitAudioContext; if (!AudioContextClass) throw new Error('L’enregistrement n’est pas disponible.'); const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); if (cancelled) { stream.getTracks().forEach((track) => track.stop()); return; } const context = new AudioContextClass(); const source = context.createMediaStreamSource(stream); const processor = context.createScriptProcessor(4096, 1, 1); const silentOutput = context.createGain(); const chunks = []; silentOutput.gain.value = 0; processor.onaudioprocess = (event) => { if (!captureRef.current?.paused) chunks.push(new Float32Array(event.inputBuffer.getChannelData(0))); }; source.connect(processor); processor.connect(silentOutput); silentOutput.connect(context.destination); captureRef.current = { context, source, processor, silentOutput, stream, chunks, sampleRate: context.sampleRate, startedAt: performance.now(), paused: false, }; setReady(true); } catch (failure) { showToast(failure?.message || 'Le micro n’a pas pu démarrer', 'error'); cancelRef.current(); } }; void open(); return () => { cancelled = true; release(); }; }, [release]); React.useEffect(() => { if (captureRef.current) captureRef.current.paused = paused; }, [paused]); React.useEffect(() => { if (!ready || paused) return undefined; const id = setInterval(() => setElapsed((value) => value + 1), 1000); return () => clearInterval(id); }, [ready, paused]); const finish = () => { const capture = release(); if (!capture) { showToast('Le micro n’a pas démarré : rien n’a été enregistré', 'error'); onCancel(); return; } const blob = window.encodePcmWav(capture.chunks, capture.sampleRate); if (blob.size <= 44) { showToast('Aucun son n’a été enregistré', 'error'); onCancel(); return; } onStop(elapsed, { blob, durationMs: Math.max(1, performance.now() - capture.startedAt) }); }; const mm = String(Math.floor(elapsed / 60)).padStart(2, '0'); const ss = String(elapsed % 60).padStart(2, '0'); return ( <>
{dv6PatientLabel(patient)}
{!paused && <>}
{mm}:{ss} {!ready ? 'Ouverture du micro' : paused ? 'En pause' : 'Enregistrement'}
); } function DictationV6Flow({ onClose, onSent, runtime = null }) { const openedPatientRef = React.useRef(DV6_STORE.current()); const openedPatientId = openedPatientRef.current?.id || null; const patient = openedPatientRef.current; const [step, setStep] = React.useState('recording'); const [phase, setPhase] = React.useState(null); const handoffAttempt = React.useRef(runtime?.newAttempt?.() || {}); const stop = async (_elapsed, capture) => { if (!runtime) { showToast('La dictée nécessite une connexion au serveur', 'error'); onClose(); return; } setStep('processing'); setPhase('uploading'); try { const draft = window.VascoDictationV6.directDraft(patient, capture); const result = await runtime.start(draft, (progress) => setPhase(progress.stage)); setPhase('handoff'); const handoff = await runtime.handoff(result, handoffAttempt.current); DV6_STORE.consume(openedPatientId); onClose(); onSent?.(handoff.conversation); } catch (failure) { showToast(failure?.message || 'La dictée n’a pas pu être remise à l’Assistant', 'error'); // Le choix reste armé. Un nouveau tap du dashboard est nécessaire : on // ne rouvre jamais le micro de lui-même après une panne réseau. onClose(); } finally { setPhase(null); } }; return (
{step === 'recording' && ( )} {step === 'processing' && }
); } MicHero = DictationV6MicHero; window.MicHero = DictationV6MicHero; DictationFlow = DictationV6Flow; window.DictationFlow = DictationV6Flow;