import React, { useEffect, useRef, useState } from 'react'; import { Mic, Pause, Play, Square, Loader2, TriangleAlert, EarOff, Trash2 } from 'lucide-react'; import { MAX_SEND_ATTEMPTS, type ScribeStopOutcome } from '../../../services/scribe/useScribeRecorder'; import { ScribeRecordingOwner, useAnyScribeRecordingSession, useScribeRecordingSession, type ScribeRecordingSession, } from '../../../services/scribe/ScribeRecordingOwner'; import { useViewerEpoch } from '../../../services/scribe/recordingDisposition'; import { useOptionalVideoServiceContext } from '../../../services/VideoService'; import { encounterContext } from '../../../services/video/videoContract'; import { TelehealthRecordingAssertion } from './TelehealthRecordingAssertion'; /** * S4 - the recorder, as a practitioner sees it. * * IT CANNOT START WITHOUT CONSENT, AND THAT IS ENFORCED IN THREE PLACES * * `permitted` gates this control - the presign endpoint re-checks the Consent server-side - the * completion handler re-checks it again before drafting. The UI gate is the courteous one - it stops a * practitioner pressing a button that would only 403 - and it is the ONLY one of the three that is not * load-bearing. A client-side gate is not a gate. * * WHAT THE PRACTITIONER MUST BE ABLE TO TELL APART * * `auto-paused` and `hard-paused` are DIFFERENT STATES and the difference matters in the room: * - **auto-paused** - the room went quiet, or the encounter left `in-progress`. Recording resumes by * itself when someone speaks again. Nothing is wrong. * - **hard-paused** - a human pressed pause, for a reason the software does not know: a sensitive * aside, a phone call, a third party in the room. It resumes ONLY when a human says so, and the * pre-roll will not reach back across it. * Rendering these the same would either make a deliberate privacy pause look like a glitch, or make a * silence pause look like something needing intervention. * * The timer counts SPEECH-ONLY milliseconds - what the practice is actually billed for - not wall * clock. A recorder showing 40 minutes for a visit that will be billed as 14 teaches people the number * is meaningless. */ interface Props { patientId?: string; encounterId?: string; encounterStatus?: string; /** Whether the server currently permits recording this encounter. */ permitted: boolean; /** * Fired when audio has gone to the server - including when the upload could not be CONFIRMED, which * is exactly the case where a draft may still be on its way. * * It exists because a draft arrives 60-90 seconds after the recorder finishes, and the draft panel * fetched once on mount. watched a clinician wait **151 seconds** at an empty panel while the * finished note sat on the server; one reload showed it in full. Silence for a minute and a half after * a visit reads as failure, and the recovery a clinician reaches for - record it again - **replaces * the draft that was about to appear.** */ onRecordingSent?: () => void; } /** * How long the one-sided condition must persist before it is asserted. * * Shortened from 10s to 3s for E10. The grace still has to exist - a participant lands on the roster * before Chime binds their audio, so the condition is briefly and harmlessly true on every join - but E10 * is an ASSERTION OF FACT rather than a warning, and a fact stated ten seconds late is ten seconds of a * clinician believing the patient is on the tape when they are not. 3s clears the bind without letting the * false state stand. */ const ONE_SIDED_GRACE_MS = 3_000; const formatMs = (ms: number): string => { const total = Math.floor(ms / 1000); return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; }; /** * EACH OF THESE CALLS FOR A DIFFERENT ACTION, WHICH IS THE WHOLE REASON THEY ARE SEPARATE. * * They all used to read *"the recording could not be uploaded"*, because the service collapsed every * server answer into `null`. A spend ceiling then looked like a network fault, and a patient's withdrawal * looked like our software breaking - a decision presented as a failure *. * Every one of them also has to say what happened to the AUDIO. Where it is kept for a resend the panel * says so below; a practitioner who is not told will wait for a note that is never coming. */ const ERROR_TEXT: Record = { 'no-microphone': 'Microphone unavailable. Check the device and site permission, then try again.', 'permission-denied': 'Microphone unavailable. Check the device and site permission, then try again.', 'unsupported-browser': 'This browser cannot record audio. Chrome, Edge or Firefox will work.', 'upload-failed': 'The recording could not be uploaded. Nothing was transcribed, and no note was drafted.', 'too-short': 'Too little speech was recorded to transcribe, so nothing was sent and no note was drafted.', // SAYS "WE DO NOT KNOW", because we do not - and the last clause is the load-bearing one. A new // recording REPLACES the draft for this encounter ( lost a good two-voice take that way), so // telling someone to "try again" here is telling them to destroy the note that may be on its way. 'upload-unconfirmed': 'Upload was not confirmed. A draft may still arrive. Check before recording again; ' + 'a new recording replaces this visit\'s scribe draft.', // Deliberately does NOT say whether they declined or were never asked - the server keeps those // together on purpose, and the action is the same either way: ask, and record the answer above. 'consent-refused': 'This visit is not consented to be recorded, so the recording was not sent. Ask the patient ' + 'and record their answer above.', 'consent-unverifiable': 'We couldn\'t check this patient\'s recording consent, so the recording was not ' + 'sent. Nothing about their consent has changed — try again in a moment.', // A business fact, not a fault. Naming the ceiling is what stops it being read as a bug. 'ceiling-reached': 'This clinician has reached the monthly recorded-minute limit for the scribe ' + 'add-on, so the recording was not sent. Nothing was transcribed.', 'visit-finishing': 'This visit is being finished, so recording did not start.', }; /** The errors a second ask for the microphone can clear. */ const MICROPHONE_ERRORS = new Set(['no-microphone', 'permission-denied']); /** What the hour's auto-stop says, by what became of the send; a failure is told by the error line. */ const CAP_TEXT: Record = { sending: 'Recording stopped at the one-hour limit for a session. Sending it for its note…', uploaded: 'Recording stopped at the one-hour limit for a session and was sent for its note. Start a new recording to keep going.', unconfirmed: 'Recording stopped at the one-hour limit for a session.', 'not-sent': 'Recording stopped at the one-hour limit for a session.', }; /** * The recorder for this encounter. Where a host above holds the recording (the meeting window, whose narrow * rungs unmount this panel), the panel is a view of that one and reconnects to it on every mount: resizing * never starts a second recorder or offers Start over one still capturing. Anywhere else it brings its own * owner, keyed to the visit, which is the lifetime it always had. */ export const ScribeRecorderPanel: React.FC = (props) => { const session = useScribeRecordingSession(props.encounterId, props.patientId); // Another viewer, or the same one signing in again, gets a new owner; the old one's teardown releases its audio. const epoch = useViewerEpoch(); if (session) return ; if (!props.patientId || !props.encounterId) return null; return ( ); }; const OwnedRecorderView: React.FC = (props) => { const session = useScribeRecordingSession(props.encounterId, props.patientId); return session ? : null; }; const RecorderView: React.FC = ({ patientId, encounterId, permitted, onRecordingSent, session, }) => { const video = useOptionalVideoServiceContext(); const { recorder, running, sendState, stoppedAtCap, finish, sendAgain, sentAt, attachView } = session; const { phase } = recorder; // Counted while on screen, so a host can show the recording itself whenever no view of it is. useEffect(() => attachView(), [attachView]); // The editor arms its wait for a draft when audio goes. Told on a change only: a view that mounts after the // send finds the time on the session instead, so it is never told twice. const sentRef = useRef(onRecordingSent); sentRef.current = onRecordingSent; const toldSentAt = useRef(sentAt); useEffect(() => { if (sentAt === toldSentAt.current) return; toldSentAt.current = sentAt; if (sentAt !== null) sentRef.current?.(); }, [sentAt]); /** * IS THE LIVE MEETING THIS ENCOUNTER'S? Every use of the call below is scoped through this, because * another encounter's or a case conference's participants are not this visit's. */ const callIsThisEncounter = Boolean( video && encounterId && video.call.phase === 'active' && video.call.context === encounterContext(encounterId), ); // The far side of THIS encounter's call. Names are resolved inside the assertion component, not here // - see its header for why the identity hooks must not run on an in-person visit. const remoteParticipants = callIsThisEncounter ? (video?.remoteParticipants ?? []) : []; // Somebody is in this encounter's call, we are recording, and their audio is not on the tape. const oneSidedNow = running && callIsThisEncounter && (video?.remoteAttendees.length ?? 0) > 0 && !recorder.remoteMixed; const [confirmingDiscard, setConfirmingDiscard] = useState(false); const [oneSided, setOneSided] = useState(false); useEffect(() => { if (!oneSidedNow) { setOneSided(false); return undefined; } const timer = setTimeout(() => setOneSided(true), ONE_SIDED_GRACE_MS); return () => clearTimeout(timer); }, [oneSidedNow]); if (!patientId || !encounterId) return null; // Nothing to offer: the consent control above is where this becomes available. Held audio stays // visible so it can still be discarded; it is never sent without the server's consent check. if (!permitted && !running && !recorder.held) return null; return (
{phase === 'capturing' && ( Recording )} {phase === 'auto-paused' && ( // Not a fault, and it must not read as one - it resumes by itself. {recorder.autoPauseCause === 'encounter-status' ? 'Paused — the visit is not in progress' : 'Listening — the room is quiet'} )} {phase === 'hard-paused' && ( Paused by you )} {(phase === 'idle' || phase === 'stopped') && ( Record this visit )}
{running && ( {formatMs(recorder.capturedMs)} speech recorded )}
{stoppedAtCap && !running && (

{CAP_TEXT[sendState ?? 'sending']}

)} {phase === 'hard-paused' && (

Nothing is captured until you resume.

)} {phase === 'auto-paused' && recorder.autoPauseCause !== 'encounter-status' && (

Recording resumes when speech returns. A manual pause never resumes automatically.

)} {running && !recorder.calibrated && ( // Capture runs throughout calibration - this is a status line, not a wait.

Measuring the room…

)} {/* ⏳ NOT AN ERROR, AND IT MUST NOT LOOK LIKE ONE. The consent record has not caught up with a grant made seconds ago. Saying "this patient has not consented" here would be a statement about a real person that is momentarily false — made in front of them, moments after they agreed. Nothing is lost while this shows: the audio is already recorded and held. */} {recorder.awaitingConsent && (

Consent was just recorded — waiting for it to take effect, one moment.

)} {recorder.askingForMicrophone && (

Asking your browser for the microphone.

)} {recorder.error && (
{ERROR_TEXT[recorder.error] ?? 'The recording could not be completed.'} {MICROPHONE_ERRORS.has(recorder.error) && ( )}
)}
{!running && !recorder.held && !recorder.uploading && ( )} {running && phase !== 'hard-paused' && ( )} {phase === 'hard-paused' && ( )} {running && ( )} {/* 🚨 THE EXIT THAT DOES NOT SPEND. Until this existed the only button that stopped the recorder was the one that paid to transcribe it — so a mistaken start, or a patient withdrawing consent mid-visit, had no correct action available. ⚠️ Two clicks, because it is destructive and unrecoverable: the audio is dropped on the device and there is no copy anywhere ⇒ [[feedback-confirm-on-delete]]. */} {!running && recorder.held && ( )} {(running || recorder.held) && !recorder.uploading && ( confirmingDiscard ? ( ) : ( ) )}
{!running && recorder.held && (

{!recorder.held.canRetry ? 'It could not be sent in ' + MAX_SEND_ATTEMPTS + ' tries, so it can only be discarded.' : !permitted ? 'The recording is kept on this page, but it cannot be sent until recording consent is confirmed.' : 'The recording is kept on this page until you send it again or discard it. Leaving the page discards it.'}

)} {confirmingDiscard && (

{/* An unanswered send may have arrived: discarding removes this copy, not that one. */} {!running && recorder.held?.mayHaveArrived ? 'Discard this device\'s copy of the recording? An earlier upload may already have arrived, and discarding cannot undo it.' : 'Discard this recording? It cannot be recovered.'}

)} {/* 🚨 E10 — ASSERT THE FACT, NEVER WARN ABOUT THE RISK (card 263, Spencer-approved). A one-sided recording transcribes and drafts PERFECTLY: clean note, `unsupported: 0`, nothing on screen telling it apart from a good one. A banner saying "transcription may not capture other attendees" is dismissed by day two and protects nobody after that. The app KNOWS which case it is in, so it says which — every time, as a statement of fact. E11: with multiple attendees a count and a ROSTER, because "some attendees" is not actionable and a silently one-sided multi-party call drops a specialist's clinical opinion from the record, not just the patient's answers. */} {running && remoteParticipants.length > 0 && (recorder.remoteMixed || oneSided) && ( )} {running && (

Only speech is captured. Pause for anything that should not be recorded.

)}
); }; /** * THE RECORDING, WHEN ITS PANEL IS NOT ON SCREEN. The meeting window's narrow rungs show one panel at a time, * and on a phone none until one is opened, so a running recording would otherwise be invisible while it * captures. Shown by the host only while no recorder view is mounted, so there is never a second set of * controls beside the panel's own. Discarding asks for confirmation, so it stays in the note. */ export const ScribeRecordingBar: React.FC<{ onOpenNote?: () => void }> = ({ onOpenNote }) => { const session = useAnyScribeRecordingSession(); if (!session || session.views > 0) return null; const { recorder, running, sendState, finish } = session; const sending = sendState === 'sending' || recorder.uploading; if (!running && !recorder.held && !sending) return null; const status = sending ? 'Sending the recording…' : !running ? 'Recording not sent' : recorder.phase === 'capturing' ? 'Recording' : recorder.phase === 'hard-paused' ? 'Paused by you' : recorder.autoPauseCause === 'encounter-status' ? 'Paused — the visit is not in progress' : 'Listening'; return (
{running && recorder.phase === 'capturing' ? : null} {status} {running && {formatMs(recorder.capturedMs)}} {running && recorder.phase !== 'hard-paused' && ( )} {running && recorder.phase === 'hard-paused' && ( )} {running && ( )} {onOpenNote && ( )}
); }; const clsxStatus = (live: boolean) => (live ? 'ak-status-line ak-status-line--error' : 'ak-status-line');