Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/react-client/src/FishjamProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -102,7 +102,7 @@ export function FishjamProvider(props: FishjamProviderProps) {

const audioTrackManager = useTrackManager({
tsClient: fishjamClientRef.current,
peerStatus,
getLatestPeerStatus,
deviceManager: microphoneManager,
bandwidthLimits: mergedBandwidthLimits,
streamConfig: props.audioConfig,
Expand All @@ -112,7 +112,7 @@ export function FishjamProvider(props: FishjamProviderProps) {

const videoTrackManager = useTrackManager({
tsClient: fishjamClientRef.current,
peerStatus,
getLatestPeerStatus,
deviceManager: cameraManager,
bandwidthLimits: mergedBandwidthLimits,
streamConfig: props.videoConfig,
Expand Down
20 changes: 13 additions & 7 deletions packages/react-client/src/hooks/internal/usePeerStatus.ts
Original file line number Diff line number Diff line change
@@ -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<PeerStatus>("idle");
const peerStatusRef = useRef<PeerStatus>("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);
Expand All @@ -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 };
};
15 changes: 8 additions & 7 deletions packages/react-client/src/hooks/internal/useTrackManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -20,7 +20,7 @@ interface TrackManagerConfig {
export const useTrackManager = ({
deviceManager,
tsClient,
peerStatus,
getLatestPeerStatus,
bandwidthLimits,
streamConfig,
type,
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
}
};
Expand Down
121 changes: 121 additions & 0 deletions packages/react-client/src/tests/toggleDuringJoin.spec.ts
Original file line number Diff line number Diff line change
@@ -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<MediaStream>();
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<unknown>;
Comment thread
Magmusacy marked this conversation as resolved.
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<unknown>;
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"]);
});
});
Loading