Files
suaveplan/packages/ui/react/react-audio-stream/src/provider/stream-provider.tsx
2026-05-27 23:47:53 +01:00

81 lines
2.8 KiB
TypeScript

import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import type { StreamSessionFactory } from "../session/session-shape/session-shape.types.js";
import {
type StreamContextValue,
StreamReactContext,
} from "./stream-context.js";
export interface StreamProviderProps {
/**
* Factory that constructs the `StreamSession` lazily on first mount.
* Consumers supply this so the package stays transport-agnostic — the
* factory wires in the WebRTC / WebSocket / Trystero transport, the
* `audio-engine-browser` instance, telemetry sinks, etc.
*/
readonly sessionFactory: StreamSessionFactory;
readonly children?: ReactNode;
}
/**
* StrictMode-safe singleton provider. Calls `sessionFactory()` exactly
* once per React tree (guarded by an `inFlight` ref), subscribes to every
* session event, and increments a `version` counter on each tick so hooks
* re-render. On unmount the session is disconnected and event listeners
* are detached.
*/
export function StreamProvider(props: StreamProviderProps): ReactNode {
const factoryRef = useRef(props.sessionFactory);
factoryRef.current = props.sessionFactory;
const sessionRef = useRef<ReturnType<StreamSessionFactory> | null>(null);
const createdRef = useRef(false);
if (!createdRef.current) {
sessionRef.current = factoryRef.current();
createdRef.current = true;
}
const [version, setVersion] = useState(0);
useEffect(() => {
// `sessionRef.current` is non-null here because the throw in the
// memo below runs first when the factory returned null.
const session = sessionRef.current as NonNullable<
typeof sessionRef.current
>;
const bump = (): void => {
setVersion((v) => v + 1);
};
const offs = [
session.on("state-changed", bump),
session.on("interruption", bump),
session.on("peers-changed", bump),
session.on("mixer-changed", bump),
session.on("levels-changed", bump),
session.on("recording-changed", bump),
session.on("permission-changed", bump),
];
return () => {
for (const off of offs) {
off();
}
void Promise.resolve(session.disconnect()).catch(() => {
/* disconnect best-effort — error surfaces via session events */
});
};
}, []);
const value = useMemo<StreamContextValue>(() => {
const session = sessionRef.current;
if (session === null) {
throw new Error("StreamProvider: session factory returned null");
}
return { session, version };
}, [version]);
return (
<StreamReactContext.Provider value={value}>
{props.children}
</StreamReactContext.Provider>
);
}