@fishjam-cloud/react-client
Version:
React client library for Fishjam
127 lines (126 loc) • 6.68 kB
JavaScript
import { TrackTypeError } from "@fishjam-cloud/ts-client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
export function useCustomSourceManager({ fishjamClient, peerStatus, logger, }) {
const [sources, setSources] = useState({});
// setStream reads the current sources synchronously (to diff against the old stream and to
// remove stale tracks), but must stay referentially stable so consumers can safely depend on
// it in effects. Reading through a ref keeps it out of the useCallback deps.
const sourcesRef = useRef(sources);
sourcesRef.current = sources;
// Replacing a source's stream issues two setStream calls in the same tick (unpublish the old,
// publish the new). Run concurrently they would read the same snapshot and race to remove the
// same track IDs — and when the publish call loses that race, the new stream is never
// published. This chain serializes the calls instead.
const setStreamQueue = useRef(Promise.resolve());
const pendingSources = useMemo(() => Object.entries(sources).filter(([_, source]) => source.trackIds === undefined), [sources]);
const getDisplayName = useCallback(() => {
const name = fishjamClient.getLocalPeer()?.metadata?.peer?.displayName;
if (typeof name == "string")
return name;
}, [fishjamClient]);
const addTrackToFishjamClient = useCallback(async (track, trackMetadata) => {
try {
return fishjamClient.addTrack(track, trackMetadata);
}
catch (err) {
if (err instanceof TrackTypeError) {
logger.warn(err.message);
return undefined;
}
throw err;
}
}, [fishjamClient, logger]);
const startStreaming = useCallback(async (source) => {
const stream = source?.stream;
const video = stream.getVideoTracks().at(0);
const audio = stream.getAudioTracks().at(0);
const promises = [];
const displayName = getDisplayName();
if (video) {
const videoMetadata = { type: "customVideo", displayName, paused: false };
promises.push(addTrackToFishjamClient(video, videoMetadata));
}
if (audio) {
const audioMetadata = { type: "customAudio", displayName, paused: false };
promises.push(addTrackToFishjamClient(audio, audioMetadata));
}
if (promises.length === 0) {
logger.warn("Attempted to add empty MediaStream as custom source.");
return source;
}
const [videoId, audioId] = await Promise.all(promises);
return { ...source, trackIds: { videoId, audioId } };
}, [addTrackToFishjamClient, getDisplayName, logger]);
const removeTracks = useCallback(async ({ videoId, audioId }) => {
const promises = [];
if (videoId)
promises.push(fishjamClient.removeTrack(videoId));
if (audioId)
promises.push(fishjamClient.removeTrack(audioId));
await Promise.all(promises);
}, [fishjamClient]);
const getSource = useCallback((sourceId) => sources[sourceId], [sources]);
// Updates both the state (what consumers render) and the ref (what queued setStream calls
// read synchronously, before React re-renders).
const updateSources = useCallback((update) => {
sourcesRef.current = update(sourcesRef.current);
setSources(update);
}, []);
const applySetStream = useCallback(async (sourceId, stream) => {
const oldSource = sourcesRef.current[sourceId];
if (stream === oldSource?.stream)
return;
if (oldSource?.trackIds)
await removeTracks(oldSource.trackIds);
if (stream !== null) {
updateSources((old) => ({ ...old, [sourceId]: { stream } }));
}
else if (oldSource) {
updateSources((old) => Object.fromEntries(Object.entries(old).filter(([id]) => id !== sourceId)));
}
}, [removeTracks, updateSources]);
const setStream = useCallback((sourceId, stream) => {
const run = setStreamQueue.current.then(() => applySetStream(sourceId, stream));
// Chain a never-rejecting link so one failed call cannot poison the queue; the caller
// still observes failures through the returned promise.
setStreamQueue.current = run.catch(() => undefined);
return run;
}, [applySetStream]);
useEffect(() => {
const onConnected = async () => {
if (pendingSources.length === 0)
return;
const results = await Promise.all(pendingSources.map(async ([id, source]) => [id, await startStreaming(source)]));
// While the tracks were being added, setStream may have unpublished the source or replaced
// its stream. Record track IDs only where the entry is still the one we started streaming
// (same stream, still no track IDs) — anything else must not be patched (it would resurrect
// an unpublished source or attach the IDs to a different stream), and the tracks we just
// added for it are orphans to unpublish again.
const isStillCurrent = ([id, started]) => {
const current = sourcesRef.current[id];
return current !== undefined && current.stream === started.stream && current.trackIds === undefined;
};
const patch = results.filter(isStillCurrent);
const orphans = results.filter((result) => !isStillCurrent(result));
if (patch.length > 0) {
updateSources((old) => ({ ...old, ...Object.fromEntries(patch) }));
}
for (const [, started] of orphans) {
if (started.trackIds)
await removeTracks(started.trackIds);
}
};
const onDisconnected = () => {
updateSources((old) => Object.fromEntries(Object.entries(old).map(([id, source]) => [id, { ...source, trackIds: undefined }])));
};
// addTrack can reject for reasons other than TrackTypeError (e.g. a disconnect mid-add);
// surface it instead of leaving an unhandled rejection.
if (peerStatus === "connected")
onConnected().catch((error) => logger.error("Failed to publish custom sources", error));
fishjamClient.on("disconnected", onDisconnected);
return () => {
fishjamClient.off("disconnected", onDisconnected);
};
}, [pendingSources, fishjamClient, peerStatus, startStreaming, removeTracks, updateSources, logger]);
return { setStream, getSource };
}