Skip to content
Open
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
213 changes: 163 additions & 50 deletions packages/react-client/src/FishjamProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { type FishjamClient, getLogger, type ReconnectConfig } from "@fishjam-cloud/ts-client";
import { FishjamClient as TsunamiClient } from "@fishjam-cloud/tsunami";
import { type PropsWithChildren, type RefObject, useMemo, useRef } from "react";
import {
type DeviceError as CoreDeviceError,
type DeviceItem,
FishjamClient as TsunamiClient,
type IDevicePersistence,
type InitializeDevicesResult as CoreInitializeDevicesResult,
type LocalDeviceState,
type PlatformMediaStreamTrack,
type TrackDeviceController,
type TrackMiddleware as CoreTrackMiddleware,
WebDeviceManager,
} from "@fishjam-cloud/tsunami";
import { type PropsWithChildren, type RefObject, useCallback, useMemo, useRef, useSyncExternalStore } from "react";

import { CameraContext } from "./contexts/camera";
import { CustomSourceContext } from "./contexts/customSource";
Expand All @@ -11,15 +22,19 @@ import { InitDevicesContext } from "./contexts/initDevices";
import { MicrophoneContext } from "./contexts/microphone";
import { PeerStatusContext } from "./contexts/peerStatus";
import { ScreenshareContext } from "./contexts/screenshare";
import { VIDEO_TRACK_CONSTRAINTS } from "./devices/constraints";
import { useMediaDevices } from "./hooks/internal/devices/useMediaDevices";
import { useCustomSourceManager } from "./hooks/internal/useCustomSourceManager";
import { useFishjamClientState } from "./hooks/internal/useFishjamClientState";
import { usePeerStatus } from "./hooks/internal/usePeerStatus";
import { useScreenShareManager } from "./hooks/internal/useScreenshareManager";
import { useTrackManager } from "./hooks/internal/useTrackManager";
import type { BandwidthLimits, PersistLastDeviceHandlers, StreamConfig } from "./types/public";
import { mergeWithDefaultBandwitdthLimits } from "./utils/bandwidth";
import type { DeviceManager, TrackManager } from "./types/internal";
import type {
BandwidthLimits,
DeviceError,
InitializeDevicesResult,
PersistLastDeviceHandlers,
StreamConfig,
TrackMiddleware,
} from "./types/public";
import { getLastDevice, saveLastDevice } from "./utils/localStorage";

/**
Expand Down Expand Up @@ -68,77 +83,175 @@ export interface FishjamProviderProps extends PropsWithChildren {
fishjamClient?: FishjamClient;
}

const asLegacyDeviceError = (error: CoreDeviceError | null): DeviceError | null =>
error === null ? null : { name: error.name };

const asDomTrack = (track: PlatformMediaStreamTrack | null): MediaStreamTrack | null =>
track as MediaStreamTrack | null;

const asStartDeviceResult = async (
result: Promise<[PlatformMediaStreamTrack, null] | [null, CoreDeviceError]>,
): Promise<[MediaStreamTrack, null] | [null, DeviceError]> => {
const [track, error] = await result;
if (error) return [null, { name: error.name }];
return [track as MediaStreamTrack, null];
};

const asSelectDeviceResult = async (result: Promise<CoreDeviceError | undefined>): Promise<DeviceError | undefined> => {
const error = await result;
return error && { name: error.name };
};

const toDevicePersistence = (handlers: PersistLastDeviceHandlers): IDevicePersistence => ({
getLastDevice: (deviceType) => {
const device = handlers.getLastDevice(deviceType);
return device && { deviceId: device.deviceId, label: device.label, kind: deviceType };
},
saveLastDevice: (deviceType, device) =>
handlers.saveLastDevice({ deviceId: device.deviceId, label: device.label } as MediaDeviceInfo, deviceType),
});

/**
* Provides the Fishjam Context
* Provides the Fishjam Context.
*
* Device, track, and session logic lives in `@fishjam-cloud/tsunami`; this
* provider adapts the core client's store snapshots into the context shapes
* the hooks render from.
*
* @category Components
*/
export function FishjamProvider(props: FishjamProviderProps) {
const fishjamClientRef = useRef<TsunamiClient | null>(null);
if (fishjamClientRef.current === null) {
const persistHandlers =
props.persistLastDevice === false
? undefined
: typeof props.persistLastDevice === "object"
? props.persistLastDevice
: { getLastDevice, saveLastDevice };

fishjamClientRef.current = new TsunamiClient({
reconnect: props.reconnect,
debug: props.debug,
signallingClient: props.fishjamClient,
deviceManager: new WebDeviceManager({
persistence: persistHandlers && toDevicePersistence(persistHandlers),
}),
videoConstraints: props.constraints?.video,
audioConstraints: props.constraints?.audio,
bandwidthLimits: props.bandwidthLimits,
videoStreamConfig: props.videoConfig,
audioStreamConfig: props.audioConfig,
});
}
const client = fishjamClientRef.current;
const devices = client.devices;
if (!devices) throw Error("FishjamProvider always injects a device manager");

const persistHandlers = useMemo(() => {
if (props.persistLastDevice === false) return undefined;

if (typeof props.persistLastDevice === "object") return props.persistLastDevice;

return { getLastDevice, saveLastDevice };
}, [props.persistLastDevice]);
const clientState = useSyncExternalStore(client.subscribe, client.getState);
const peerStatus = usePeerStatus(client);

const logger = useMemo(() => getLogger(props.debug ?? false), [props.debug]);
const buildDeviceManager = useCallback(
(
controller: TrackDeviceController,
deviceState: LocalDeviceState,
deviceList: DeviceItem[],
deviceError: CoreDeviceError | null,
): DeviceManager => ({
startDevice: (deviceId) => asStartDeviceResult(controller.startDevice(deviceId ?? undefined)),
stopDevice: () => controller.stopDevice(),
selectDevice: (deviceId) => asStartDeviceResult(controller.startDevice(deviceId)),
activeDevice: deviceState.activeDevice,
deviceTrack: asDomTrack(deviceState.track),
deviceList,
deviceEnabled: deviceState.isEnabled,
enableDevice: () => controller.enableDevice(),
disableDevice: () => controller.disableDevice(),
currentMiddleware: deviceState.middleware as TrackMiddleware,
applyMiddleware: (middleware) => controller.applyMiddleware(middleware as CoreTrackMiddleware).then(asDomTrack),
deviceError: asLegacyDeviceError(deviceError),
selectedDevice: (deviceState.selectedDevice as unknown as MediaDeviceInfo) ?? null,
}),
[],
);

const { cameraManager, microphoneManager, initializeDevices } = useMediaDevices({
videoConstraints: props.constraints?.video ?? VIDEO_TRACK_CONSTRAINTS,
audioConstraints: props.constraints?.audio ?? true,
persistHandlers,
logger,
});
const buildTrackManager = useCallback(
(controller: TrackDeviceController, deviceState: LocalDeviceState): TrackManager => ({
selectDevice: (deviceId) => asSelectDeviceResult(controller.selectDevice(deviceId)),
stopDevice: () => controller.stopDevice(),
startDevice: (deviceId) => asStartDeviceResult(controller.startDevice(deviceId ?? undefined)),
deviceTrack: asDomTrack(deviceState.track),
currentMiddleware: deviceState.middleware as TrackMiddleware,
setTrackMiddleware: (middleware) => controller.setTrackMiddleware(middleware as CoreTrackMiddleware),
toggleMute: () => controller.toggleMute(),
toggleDevice: () => asSelectDeviceResult(controller.toggleDevice()),
}),
[],
);

const peerStatus = usePeerStatus(client);
const cameraContext = useMemo(
() => ({
videoTrackManager: buildTrackManager(devices.camera, clientState.camera),
cameraManager: buildDeviceManager(
devices.camera,
clientState.camera,
clientState.availableCameras,
clientState.cameraError,
),
}),
[
buildTrackManager,
buildDeviceManager,
devices,
clientState.camera,
clientState.availableCameras,
clientState.cameraError,
],
);

const mergedBandwidthLimits = useMemo(
() => mergeWithDefaultBandwitdthLimits(props.bandwidthLimits),
[props.bandwidthLimits],
const microphoneContext = useMemo(
() => ({
audioTrackManager: buildTrackManager(devices.microphone, clientState.microphone),
microphoneManager: buildDeviceManager(
devices.microphone,
clientState.microphone,
clientState.availableMicrophones,
clientState.microphoneError,
),
}),
[
buildTrackManager,
buildDeviceManager,
devices,
clientState.microphone,
clientState.availableMicrophones,
clientState.microphoneError,
],
);

const audioTrackManager = useTrackManager({
tsClient: client,
peerStatus,
deviceManager: microphoneManager,
bandwidthLimits: mergedBandwidthLimits,
streamConfig: props.audioConfig,
type: "microphone",
logger,
});
const initializeDevices = useCallback(
async (settings?: { enableVideo?: boolean; enableAudio?: boolean }): Promise<InitializeDevicesResult> => {
const result: CoreInitializeDevicesResult = await client.initializeDevices(settings);
return {
status: result.status,
stream: result.stream as MediaStream | null,
errors: result.errors && {
audio: asLegacyDeviceError(result.errors.audio),
video: asLegacyDeviceError(result.errors.video),
},
};
},
[client],
);

const videoTrackManager = useTrackManager({
tsClient: client,
peerStatus,
deviceManager: cameraManager,
bandwidthLimits: mergedBandwidthLimits,
streamConfig: props.videoConfig,
type: "camera",
logger,
});
const logger = useMemo(() => getLogger(props.debug ?? false), [props.debug]);

const screenShareManager = useScreenShareManager({
fishjamClient: client,
peerStatus,
logger,
});

const cameraContext = useMemo(() => ({ videoTrackManager, cameraManager }), [videoTrackManager, cameraManager]);
const microphoneContext = useMemo(
() => ({ audioTrackManager, microphoneManager }),
[audioTrackManager, microphoneManager],
);

const customSourceManager = useCustomSourceManager({
fishjamClient: client,
peerStatus,
Expand Down
3 changes: 1 addition & 2 deletions packages/react-client/src/contexts/camera.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createContext } from "react";

import type { DeviceManager } from "../hooks/internal/devices/useDeviceManager";
import type { TrackManager } from "../types/internal";
import type { DeviceManager, TrackManager } from "../types/internal";

export type CameraContextType = {
videoTrackManager: TrackManager;
Expand Down
3 changes: 1 addition & 2 deletions packages/react-client/src/contexts/initDevices.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createContext } from "react";

import type { InitializeDevicesSettings } from "../hooks/internal/devices/useMediaDevices";
import type { InitializeDevicesResult } from "../types/public";
import type { InitializeDevicesResult, InitializeDevicesSettings } from "../types/public";

export const InitDevicesContext = createContext<
((settings?: InitializeDevicesSettings) => Promise<InitializeDevicesResult>) | null
Expand Down
3 changes: 1 addition & 2 deletions packages/react-client/src/contexts/microphone.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createContext } from "react";

import type { DeviceManager } from "../hooks/internal/devices/useDeviceManager";
import type { TrackManager } from "../types/internal";
import type { DeviceManager, TrackManager } from "../types/internal";

export type MicrophoneContextType = {
audioTrackManager: TrackManager;
Expand Down
50 changes: 0 additions & 50 deletions packages/react-client/src/devices/constraints.ts

This file was deleted.

Loading