import React, { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useScribeServiceAvailable, useScribeServiceContext, type ScribePresignResult } from '../ScribeService'; import { useOptionalVideoServiceContext } from '../VideoService'; import { useIdleActivityHold } from '../IdleTimeoutService'; import { encounterContext } from '../video/videoContract'; import { useScribeRecorder, type ScribeRecorderApi, type ScribeStopOutcome } from './useScribeRecorder'; import { finishReserved, recordingTarget, registerRecording } from './recordingDisposition'; import { viewerEpoch, writeThroughViewer } from '../../utils/writeThroughHold'; /** * ONE RECORDING, OWNED ABOVE WHATEVER A LAYOUT MAY UNMOUNT. * * The recorder used to live in `ScribeRecorderPanel`, inside the note editor. The meeting window mounts only * the active panel at its narrower rungs, so resizing it from 1366 to 390 unmounted the editor, and with it * the recorder: its unmount tore down the microphone and the captured chunks went with the instance. Back at * 1366 the panel offered Start again, with no Finish and nothing uploaded. A layout change is not a Discard. * * So the recording and what became of its send live here. A surface that can drop its panels (the meeting * window) mounts one owner above them for the encounter it shows; the panel reconnects to it on every mount * and is only a view. Anywhere else the panel brings its own owner, which is exactly the old lifetime. * * THE OWNER'S OWN UNMOUNT IS STILL THE SAFE END. Hosts key it by viewer, practice, patient and encounter, so * a change to any of them, the call ending, or the note leaving the window unmounts it, and the recorder's * teardown releases the audio unsent, exactly as before. Nothing here persists audio or sends it on its own. */ export interface ScribeRecordingSession { patientId: string; encounterId: string; recorder: ScribeRecorderApi; /** Capturing, or paused in either way: a visit in progress. */ running: boolean; /** What the last Finish or Send again became, while it is being decided and after. */ sendState: ScribeStopOutcome | 'sending' | null; stoppedAtCap: boolean; /** When audio last went to the server (or may have), so a draft can be waited for. */ sentAt: number | null; finish: () => Promise; sendAgain: () => Promise; /** How many recorder views are on screen; with none, the host shows the recording itself. */ views: number; attachView: () => () => void; } const ScribeRecordingContext = createContext(null); /** This encounter's recording session, if an owner above holds it; another encounter's is never returned. */ export const useScribeRecordingSession = (encounterId?: string, patientId?: string): ScribeRecordingSession | null => { const session = useContext(ScribeRecordingContext); if (!session || !encounterId || session.encounterId !== encounterId) return null; if (patientId && session.patientId !== patientId) return null; return session; }; /** The session above, whichever encounter it is for: for a host that shows the recording when no view does. */ export const useAnyScribeRecordingSession = (): ScribeRecordingSession | null => useContext(ScribeRecordingContext); interface SessionInputs { patientId: string; encounterId: string; encounterStatus?: string; } function useRecordingSession({ patientId, encounterId, encounterStatus }: SessionInputs): ScribeRecordingSession { const { presignRecording } = useScribeServiceContext(); const video = useOptionalVideoServiceContext(); const presign = useCallback( (jobId?: string): Promise => presignRecording(patientId, encounterId, jobId), [presignRecording, patientId, encounterId], ); /** * IS THE LIVE MEETING THIS ENCOUNTER'S? The video provider is mounted once app-wide and one meeting * at a time may belong to ANY context - another patient's encounter, or a case conference about a * different patient entirely. Mixing that audio in would write a second patient's voice into this * patient's recording, transcript and note. */ const callIsThisEncounter = Boolean( video && video.call.phase === 'active' && video.call.context === encounterContext(encounterId), ); // The far side of a telehealth visit, and nothing else; null records the microphone alone. const getRemoteAudioStream = useCallback((): MediaStream | null => ( callIsThisEncounter && video ? video.getRemoteAudioStream() : null ), [callIsThisEncounter, video]); // This visit as the viewer who started the owner saw it. Another viewer, or the same one again, is another target, // and the owner retires at that change or at its layout unmount, before its passive cleanup has run. const [born] = useState(() => ({ epoch: viewerEpoch(), viewer: writeThroughViewer() })); const target = useMemo( () => recordingTarget(patientId, encounterId, born.epoch, born.viewer), [patientId, encounterId, born], ); const targetRef = useRef(target); targetRef.current = target; const mountedRef = useRef(true); useLayoutEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const alive = useCallback(() => mountedRef.current && viewerEpoch() === born.epoch, [born]); const sessionCapRef = useRef<() => void>(() => {}); const recorder = useScribeRecorder({ encounterStatus, presign, getRemoteAudioStream, onSessionCap: () => sessionCapRef.current(), mayStart: () => !finishReserved(targetRef.current), alive, }); // Read through a ref so `finish` stays stable and cannot capture a stale recorder. const recorderRef = useRef(recorder); recorderRef.current = recorder; // Registered before paint and read live, so a Finish never judges this recording from a stale render. useLayoutEffect(() => registerRecording(() => target, () => recorderRef.current.unsettled()), [target]); const { phase } = recorder; const running = phase !== 'idle' && phase !== 'stopped'; // A running recording is activity with no input: the clinician is talking, not typing, and the idle clock // must not sign them out mid-visit. Held here, so it holds while no panel is on screen. useIdleActivityHold(running, 'scribe recording'); /** * Reported on `unconfirmed` as well as `uploaded`, and NOT on `not-sent`: the unconfirmed case is the one * that most needs the draft wait armed, and arming it on `not-sent` would promise a draft nothing is making. */ const [sendState, setSendState] = useState(null); const [sentAt, setSentAt] = useState(null); const report = useCallback(async (send: () => Promise) => { setSendState('sending'); const outcome = await send(); setSendState(outcome); if (outcome !== 'not-sent') setSentAt(Date.now()); }, []); const finish = useCallback(() => report(() => recorderRef.current.stop()), [report]); const sendAgain = useCallback(() => report(() => recorderRef.current.retry()), [report]); // At the hour the session is finished and sent like a manual finish; a new one is only ever started by hand. const [stoppedAtCap, setStoppedAtCap] = useState(false); sessionCapRef.current = () => { setStoppedAtCap(true); void finish(); }; useEffect(() => { if (running) setStoppedAtCap(false); }, [running]); const [views, setViews] = useState(0); const attachView = useCallback(() => { setViews((count) => count + 1); return () => setViews((count) => count - 1); }, []); return useMemo(() => ({ patientId, encounterId, recorder, running, sendState, stoppedAtCap, sentAt, finish, sendAgain, views, attachView, }), [patientId, encounterId, recorder, running, sendState, stoppedAtCap, sentAt, finish, sendAgain, views, attachView]); } /** One recording for one visit, around the views of it. Unmounting it releases the audio unsent. */ export const ScribeRecordingOwner: React.FC = ({ children, ...inputs }) => { const session = useRecordingSession(inputs); return {children}; }; /** * Runs the recording beside the host's children and hands it up; its own unmount is the safe end. Memoised so * the host re-rendering on a handed-up session does not render it again: it renders on its own state changes * only, and hands each of those up once. */ const RecordingHolder = React.memo(({ onSession, ...inputs }: SessionInputs & { onSession: (session: ScribeRecordingSession | null) => void }) => { const session = useRecordingSession(inputs); useEffect(() => { onSession(session); }, [onSession, session]); useEffect(() => () => onSession(null), [onSession]); return null; }); /** * For a host whose children change shape (the meeting window's rungs): the provider around them never moves, * so turning the recording on or off, or replacing it, never remounts what the host shows. The recording runs * in a keyed sibling; `ownerKey` names the viewer, practice, patient and encounter, and a change to any of them * replaces it, which ends the old one through the recorder's own teardown. */ export const ScribeRecordingScope: React.FC<{ enabled: boolean; ownerKey: string; patientId?: string; encounterId?: string; encounterStatus?: string; children: React.ReactNode; }> = ({ enabled, ownerKey, patientId, encounterId, encounterStatus, children }) => { const [session, setSession] = useState(null); // The scribe is an add-on: where it is not mounted there is no recording to hold, and the host is untouched. const scribeMounted = useScribeServiceAvailable(); const on = enabled && scribeMounted && Boolean(patientId && encounterId); return ( <> {on && ( )} {children} ); };