diff --git a/packages/react-client/src/FishjamProvider.tsx b/packages/react-client/src/FishjamProvider.tsx index f5559afe..d5d22d9e 100644 --- a/packages/react-client/src/FishjamProvider.tsx +++ b/packages/react-client/src/FishjamProvider.tsx @@ -93,7 +93,7 @@ export function FishjamProvider(props: FishjamProviderProps) { logger, }); - const peerStatus = usePeerStatus(fishjamClientRef.current); + const { peerStatus, getLatestPeerStatus } = usePeerStatus(fishjamClientRef.current); const mergedBandwidthLimits = useMemo( () => mergeWithDefaultBandwitdthLimits(props.bandwidthLimits), @@ -102,7 +102,7 @@ export function FishjamProvider(props: FishjamProviderProps) { const audioTrackManager = useTrackManager({ tsClient: fishjamClientRef.current, - peerStatus, + getLatestPeerStatus, deviceManager: microphoneManager, bandwidthLimits: mergedBandwidthLimits, streamConfig: props.audioConfig, @@ -112,7 +112,7 @@ export function FishjamProvider(props: FishjamProviderProps) { const videoTrackManager = useTrackManager({ tsClient: fishjamClientRef.current, - peerStatus, + getLatestPeerStatus, deviceManager: cameraManager, bandwidthLimits: mergedBandwidthLimits, streamConfig: props.videoConfig, diff --git a/packages/react-client/src/hooks/internal/usePeerStatus.ts b/packages/react-client/src/hooks/internal/usePeerStatus.ts index 5244d430..9ee95d86 100644 --- a/packages/react-client/src/hooks/internal/usePeerStatus.ts +++ b/packages/react-client/src/hooks/internal/usePeerStatus.ts @@ -1,23 +1,29 @@ import type { FishjamClient } from "@fishjam-cloud/ts-client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { PeerStatus } from "../../types/public"; export const usePeerStatus = (client: FishjamClient) => { const [peerStatus, setPeerStatus] = useState("idle"); + const peerStatusRef = useRef("idle"); + const getLatestPeerStatus = useCallback(() => peerStatusRef.current, []); useEffect(() => { + const updatePeerStatus = (status: PeerStatus) => { + peerStatusRef.current = status; + setPeerStatus(status); + }; const setConnecting = () => { - setPeerStatus("connecting"); + updatePeerStatus("connecting"); }; const setError = () => { - setPeerStatus("error"); + updatePeerStatus("error"); }; const setJoined = () => { - setPeerStatus("connected"); + updatePeerStatus("connected"); }; const setDisconnected = () => { - setPeerStatus("idle"); + updatePeerStatus("idle"); }; client.on("connectionStarted", setConnecting); @@ -37,7 +43,7 @@ export const usePeerStatus = (client: FishjamClient) => { client.off("connectionError", setError); client.off("disconnected", setDisconnected); }; - }, [client, setPeerStatus]); + }, [client]); - return peerStatus; + return { peerStatus, getLatestPeerStatus }; }; diff --git a/packages/react-client/src/hooks/internal/useTrackManager.ts b/packages/react-client/src/hooks/internal/useTrackManager.ts index 13d39b6e..cf0c592c 100644 --- a/packages/react-client/src/hooks/internal/useTrackManager.ts +++ b/packages/react-client/src/hooks/internal/useTrackManager.ts @@ -10,7 +10,7 @@ import { useCurrentCallback } from "./useCurrentCallback"; interface TrackManagerConfig { deviceManager: DeviceManager; tsClient: FishjamClient; - peerStatus: PeerStatus; + getLatestPeerStatus: () => PeerStatus; bandwidthLimits: BandwidthLimits; streamConfig?: StreamConfig; type: "camera" | "microphone"; @@ -20,7 +20,7 @@ interface TrackManagerConfig { export const useTrackManager = ({ deviceManager, tsClient, - peerStatus, + getLatestPeerStatus, bandwidthLimits, streamConfig, type, @@ -107,13 +107,13 @@ export const useTrackManager = ({ ); const pauseStreaming = useCurrentCallback(async (trackId: string) => { - if (peerStatus !== "connected") return; + if (getLatestPeerStatus() !== "connected") return; await tsClient.replaceTrack(trackId, null); return tsClient.updateTrackMetadata(trackId, { type, paused: true } satisfies TrackMetadata); }); const resumeStreaming = useCurrentCallback(async (trackId: string, track: MediaStreamTrack) => { - if (peerStatus !== "connected") return; + if (getLatestPeerStatus() !== "connected") return; await tsClient.replaceTrack(trackId, track); return tsClient.updateTrackMetadata(trackId, { type, paused: false } satisfies TrackMetadata); }); @@ -154,9 +154,10 @@ export const useTrackManager = ({ const publishStartedTrack = async (track: MediaStreamTrack | null) => { if (!track) return; - if (currentTrackId) { - await resumeStreaming(currentTrackId, track); - } else if (peerStatus === "connected") { + const publishedTrackId = await getCurrentTrackId(); + if (publishedTrackId) { + await resumeStreaming(publishedTrackId, track); + } else if (getLatestPeerStatus() === "connected") { await startStreaming(track, streamConfig); } }; diff --git a/packages/react-client/src/tests/toggleDuringJoin.spec.ts b/packages/react-client/src/tests/toggleDuringJoin.spec.ts new file mode 100644 index 00000000..78235366 --- /dev/null +++ b/packages/react-client/src/tests/toggleDuringJoin.spec.ts @@ -0,0 +1,121 @@ +import { act } from "@testing-library/react"; + +import { useCamera } from "../hooks/devices/useCamera"; +import { useMicrophone } from "../hooks/devices/useMicrophone"; +import { usePeers } from "../hooks/usePeers"; +import { Deferred } from "../utils/deferred"; +import type { MediaDevicesController } from "./support/fakeMediaDevices"; +import { createFakeStream } from "./support/fakeMediaStream"; +import { describe, expect, it } from "./support/fixtures"; + +const holdNextUserMedia = (media: MediaDevicesController, kind: "audio" | "video") => { + const acquisition = new Deferred(); + media.devices.getUserMedia.mockImplementationOnce(() => acquisition.promise); + const stream = createFakeStream([{ kind, deviceId: `${kind}-1` }]); + return { release: () => acquisition.resolve(stream), track: stream.getTracks()[0] }; +}; + +const publishedKinds = (calls: unknown[][]) => calls.map(([track]) => (track as MediaStreamTrack).kind); + +describe("toggling a device while joining", () => { + it("publishes the microphone when joined fires before the device has started", async ({ + media, + client, + renderHook, + }) => { + const { result } = renderHook(() => ({ mic: useMicrophone(), peers: usePeers() })); + const acquisition = holdNextUserMedia(media, "audio"); + + act(() => client.simulateConnectionStarted()); + let toggle: Promise; + act(() => { + toggle = result.current.mic.toggleMicrophone(); + }); + act(() => client.simulateJoined()); + await act(async () => { + acquisition.release(); + await toggle; + }); + + expect(result.current.mic.isMicrophoneOn).toBe(true); + expect(publishedKinds(client.addTrack.mock.calls)).toEqual(["audio"]); + expect(result.current.peers.localPeer?.microphoneTrack?.track).toBe(acquisition.track); + }); + + it("publishes the camera when joined fires before the device has started", async ({ media, client, renderHook }) => { + const { result } = renderHook(() => ({ cam: useCamera(), peers: usePeers() })); + const acquisition = holdNextUserMedia(media, "video"); + + act(() => client.simulateConnectionStarted()); + let toggle: Promise; + act(() => { + toggle = result.current.cam.toggleCamera(); + }); + act(() => client.simulateJoined()); + await act(async () => { + acquisition.release(); + await toggle; + }); + + expect(result.current.cam.isCameraOn).toBe(true); + expect(publishedKinds(client.addTrack.mock.calls)).toEqual(["video"]); + expect(result.current.peers.localPeer?.cameraTrack?.track).toBe(acquisition.track); + }); + + it("publishes the microphone when toggled in the same tick as joined", async ({ media, client, renderHook }) => { + media.setUserMediaStream(createFakeStream([{ kind: "audio", deviceId: "audio-1" }])); + const { result } = renderHook(() => useMicrophone()); + + act(() => client.simulateConnectionStarted()); + await act(async () => { + client.simulateJoined(); + await result.current.toggleMicrophone(); + }); + + expect(publishedKinds(client.addTrack.mock.calls)).toEqual(["audio"]); + }); + + it("publishes the microphone once when the device starts before joined", async ({ media, client, renderHook }) => { + const { result } = renderHook(() => useMicrophone()); + const acquisition = holdNextUserMedia(media, "audio"); + + act(() => client.simulateConnectionStarted()); + await act(async () => { + const toggle = result.current.toggleMicrophone(); + acquisition.release(); + await toggle; + }); + act(() => client.simulateJoined()); + + expect(publishedKinds(client.addTrack.mock.calls)).toEqual(["audio"]); + }); + + it("does not publish a device toggled on before joining when the join never completes", async ({ + media, + client, + renderHook, + }) => { + media.setUserMediaStream(createFakeStream([{ kind: "audio", deviceId: "audio-1" }])); + const { result } = renderHook(() => useMicrophone()); + + act(() => client.simulateConnectionStarted()); + await act(async () => { + await result.current.toggleMicrophone(); + }); + + expect(result.current.isMicrophoneOn).toBe(true); + expect(client.addTrack).not.toHaveBeenCalled(); + }); + + it("publishes the microphone when toggled after joining", async ({ media, client, renderHook }) => { + media.setUserMediaStream(createFakeStream([{ kind: "audio", deviceId: "audio-1" }])); + const { result } = renderHook(() => useMicrophone()); + + act(() => client.simulateJoined()); + await act(async () => { + await result.current.toggleMicrophone(); + }); + + expect(publishedKinds(client.addTrack.mock.calls)).toEqual(["audio"]); + }); +});