// 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 }}>
) : (
Sélectionner un patient
);
return (
(patient ? onWithPatient : onTap)?.()}
onMouseDown={() => setPressed(true)} onMouseUp={() => setPressed(false)}
onMouseLeave={() => setPressed(false)} onTouchStart={() => setPressed(true)}
onTouchEnd={() => setPressed(false)} style={{
position: 'relative', width: 180, height: 180, borderRadius: '50%',
border: `2px solid ${recording ? accent : DT_INK}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transform: `scale(${pressed || recording ? 1.04 : 1})`,
transition: `transform ${DV6_MOTION.selectEnter}ms ${DV6_MOTION.standard}, border-color ${DV6_MOTION.normal}ms ease`,
cursor: 'pointer', background: recording ? `${accent}10` : 'transparent', padding: 0,
}}>
{recording &&
}
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)}
{mm}:{ss}
{!ready ? 'Ouverture du micro' : paused ? 'En pause' : 'Enregistrement'}
Annuler
setPaused((value) => !value)}>
{paused ? 'Reprendre' : 'Pause'}
Terminer
>
);
}
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;