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
18 changes: 16 additions & 2 deletions packages/mobile-client/src/devices/ReactNativeDeviceManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { mediaDevices, type MediaStream as ReactNativeMediaStream } from '@fishjam-cloud/react-native-webrtc';
import type { DeviceItem, DeviceType, IDeviceManager, IDevicePersistence } from '@fishjam-cloud/tsunami';
import {
mediaDevices,
MediaStream as ReactNativeMediaStream,
type MediaStreamTrack as ReactNativeMediaStreamTrack,
} from '@fishjam-cloud/react-native-webrtc';
import type {
DeviceItem,
DeviceType,
IDeviceManager,
IDevicePersistence,
PlatformMediaStreamTrack,
} from '@fishjam-cloud/tsunami';

import { InMemoryDevicePersistence } from './InMemoryDevicePersistence';

Expand Down Expand Up @@ -57,6 +67,10 @@ export class ReactNativeDeviceManager implements IDeviceManager<ReactNativeMedia
return nativeMediaDevices.getDisplayMedia();
}

public createMediaStream(tracks: PlatformMediaStreamTrack[]): ReactNativeMediaStream {
return new ReactNativeMediaStream(tracks as ReactNativeMediaStreamTrack[]);
}

// react-native-webrtc does not currently emit devicechange; this method exists to satisfy IDeviceManager.
public onDeviceChange(callback: () => void): () => void {
const listener = () => callback();
Expand Down
39 changes: 28 additions & 11 deletions packages/react-client/src/FishjamProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { FishjamClient, ReconnectConfig } from "@fishjam-cloud/ts-client";
import type { ClientType, FishjamClient, ReconnectConfig } from "@fishjam-cloud/ts-client";
import {
type DeviceError as CoreDeviceError,
type DeviceItem,
FishjamClient as TsunamiClient,
type IDeviceManager,
type IDevicePersistence,
type InitializeDevicesResult as CoreInitializeDevicesResult,
type LocalDeviceState,
Expand Down Expand Up @@ -88,6 +89,18 @@ export interface FishjamProviderProps extends PropsWithChildren {
* Allows to provide your own FishjamClient instance from ts-client.
*/
fishjamClient?: FishjamClient;
/**
* Advanced: platform device layer used for media acquisition. Defaults to
* the browser device manager wired to `persistLastDevice`. When provided,
* the manager owns persistence and `persistLastDevice` is ignored. Read
* once on first render.
*/
deviceManager?: IDeviceManager<PlatformMediaStream>;
/**
* Platform reported to Fishjam. Defaults to `"web"`. Read once on first
* render.
*/
clientType?: ClientType;
}

const asLegacyDeviceError = (error: CoreDeviceError | null): DeviceError | null =>
Expand Down Expand Up @@ -120,6 +133,17 @@ const toDevicePersistence = (handlers: PersistLastDeviceHandlers): IDevicePersis
handlers.saveLastDevice({ deviceId: device.deviceId, label: device.label } as MediaDeviceInfo, deviceType),
});

const createWebDeviceManager = (persistLastDevice: FishjamProviderProps["persistLastDevice"]): WebDeviceManager => {
const persistHandlers =
persistLastDevice === false
? undefined
: typeof persistLastDevice === "object"
? persistLastDevice
: { getLastDevice, saveLastDevice };

return new WebDeviceManager({ persistence: persistHandlers && toDevicePersistence(persistHandlers) });
};

/**
* Provides the Fishjam Context.
*
Expand All @@ -132,20 +156,12 @@ const toDevicePersistence = (handlers: PersistLastDeviceHandlers): IDevicePersis
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,
clientType: props.clientType,
signallingClient: props.fishjamClient,
deviceManager: new WebDeviceManager({
persistence: persistHandlers && toDevicePersistence(persistHandlers),
}),
deviceManager: props.deviceManager ?? createWebDeviceManager(props.persistLastDevice),
videoConstraints: props.constraints?.video,
audioConstraints: props.constraints?.audio,
bandwidthLimits: props.bandwidthLimits,
Expand All @@ -172,6 +188,7 @@ export function FishjamProvider(props: FishjamProviderProps) {
selectDevice: (deviceId) => asStartDeviceResult(controller.startDevice(deviceId)),
activeDevice: deviceState.activeDevice,
deviceTrack: asDomTrack(deviceState.track),
deviceStream: asDomStream(deviceState.stream),
deviceList,
deviceEnabled: deviceState.isEnabled,
enableDevice: () => controller.enableDevice(),
Expand Down
6 changes: 1 addition & 5 deletions packages/react-client/src/hooks/devices/useCamera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ export function useCamera() {

const { videoTrackManager, cameraManager } = cameraCtx;

const cameraStream = useMemo(() => {
const track = videoTrackManager.deviceTrack;
if (!track) return null;
return new MediaStream([track]);
}, [videoTrackManager.deviceTrack]);
const cameraStream = cameraManager.deviceStream;

return {
/**
Expand Down
6 changes: 1 addition & 5 deletions packages/react-client/src/hooks/devices/useMicrophone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,7 @@ export function useMicrophone() {

const { audioTrackManager, microphoneManager } = microphoneCtx;

const microphoneStream = useMemo(() => {
const track = audioTrackManager.deviceTrack;
if (!track) return null;
return new MediaStream([track]);
}, [audioTrackManager.deviceTrack]);
const microphoneStream = microphoneManager.deviceStream;

return {
/** Toggles current microphone on/off */
Expand Down
10 changes: 7 additions & 3 deletions packages/react-client/src/hooks/useLivestreamStreamer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { LivestreamError, publishLivestream, type PublishLivestreamResult } from "@fishjam-cloud/ts-client";
import { buildLivestreamWhipUrl } from "@fishjam-cloud/tsunami";
import { useCallback, useRef, useState } from "react";
import { useCallback, useContext, useRef, useState } from "react";

import { FishjamClientContext } from "../contexts/fishjamClient";
import { useFishjamId } from "../contexts/fishjamId";

/** @category Livestream */
Expand Down Expand Up @@ -53,6 +54,8 @@ export const useLivestreamStreamer = (): UseLivestreamStreamerResult => {
const [error, setError] = useState<LivestreamError | null>(null);
const [isConnected, setIsConnected] = useState(false);
const fishjamId = useFishjamId();
const fishjamClientRef = useContext(FishjamClientContext);
if (!fishjamClientRef) throw Error("useLivestreamStreamer must be used within FishjamProvider");
const resultRef = useRef<PublishLivestreamResult | null>(null);

const disconnect = useCallback(() => {
Expand All @@ -74,7 +77,8 @@ export const useLivestreamStreamer = (): UseLivestreamStreamerResult => {

const videoTrack = video?.getVideoTracks().at(0);
const audioTrack = audio?.getAudioTracks().at(0);
const stream = new MediaStream([videoTrack, audioTrack].filter((v) => v != null));
const tracks = [videoTrack, audioTrack].filter((track) => track != null);
const stream = fishjamClientRef.current.createMediaStream(tracks) as MediaStream;

try {
const result = await publishLivestream(stream, urlOverride ?? buildLivestreamWhipUrl(fishjamId), token, {
Expand All @@ -87,7 +91,7 @@ export const useLivestreamStreamer = (): UseLivestreamStreamerResult => {
else console.error(e);
}
},
[disconnect, onConnectionStateChange, fishjamId],
[disconnect, onConnectionStateChange, fishjamId, fishjamClientRef],
);

return { connect, disconnect, error, isConnected };
Expand Down
1 change: 1 addition & 0 deletions packages/react-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ export type {
TrackBandwidthLimit,
} from "@fishjam-cloud/ts-client";
export { Variant } from "@fishjam-cloud/ts-client";
export type { IDeviceManager, PlatformMediaStream, PlatformMediaStreamTrack } from "@fishjam-cloud/tsunami";
68 changes: 68 additions & 0 deletions packages/react-client/src/tests/deviceManagerInjection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { DeviceItem, IDeviceManager, PlatformMediaStream } from "@fishjam-cloud/tsunami";
import { createFakeStream, FakeMediaStream } from "@fishjam-cloud/tsunami/testing";
import { act } from "@testing-library/react";

import { useCamera } from "../hooks/devices/useCamera";
import { useInitializeDevices } from "../hooks/devices/useInitializeDevices";
import { describe, expect, it, vi } from "./support/fixtures";

const fakeDevices: DeviceItem[] = [
{ deviceId: "native-cam", label: "Native Camera", kind: "video" },
{ deviceId: "native-mic", label: "Native Microphone", kind: "audio" },
];

const createFakeDeviceManager = () => {
const stream = () =>
createFakeStream([
{ kind: "video", deviceId: "native-cam" },
{ kind: "audio", deviceId: "native-mic" },
]);

return {
enumerateDevices: vi.fn(async () => fakeDevices),
getUserMedia: vi.fn(async () => stream() as PlatformMediaStream),
getDisplayMedia: vi.fn(async () => stream() as PlatformMediaStream),
onDeviceChange: vi.fn(() => () => {}),
createMediaStream: vi.fn((tracks) => new FakeMediaStream(tracks as MediaStreamTrack[]) as PlatformMediaStream),
} satisfies IDeviceManager<PlatformMediaStream>;
};

describe("FishjamProvider deviceManager injection", () => {
it("routes device acquisition through the injected manager, not the browser globals", async ({
media,
renderHook,
}) => {
const deviceManager = createFakeDeviceManager();
const { result } = renderHook(() => ({ init: useInitializeDevices(), camera: useCamera() }), {
providerProps: { deviceManager },
});

await act(async () => {
await result.current.init.initializeDevices();
});

expect(deviceManager.getUserMedia).toHaveBeenCalled();
expect(deviceManager.enumerateDevices).toHaveBeenCalled();
expect(media.devices.getUserMedia).not.toHaveBeenCalled();
expect(media.devices.enumerateDevices).not.toHaveBeenCalled();

expect(result.current.camera.isCameraOn).toBe(true);
expect(result.current.camera.cameraDevices).toEqual([fakeDevices[0]]);
});

it("ignores persistLastDevice handlers when a manager is injected", async ({ renderHook }) => {
const deviceManager = createFakeDeviceManager();
const persistHandlers = { getLastDevice: vi.fn(() => null), saveLastDevice: vi.fn() };

const { result } = renderHook(() => useInitializeDevices(), {
providerProps: { deviceManager, persistLastDevice: persistHandlers },
});

await act(async () => {
await result.current.initializeDevices();
});

expect(persistHandlers.getLastDevice).not.toHaveBeenCalled();
expect(persistHandlers.saveLastDevice).not.toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions packages/react-client/src/types/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export type DeviceManager = {
selectDevice: (deviceId: string) => Promise<[MediaStreamTrack, null] | [null, DeviceError]> | undefined;
activeDevice: DeviceItem | null;
deviceTrack: MediaStreamTrack | null;
/** Render-ready stream containing `deviceTrack` — built by the platform device manager. */
deviceStream: MediaStream | null;
deviceList: DeviceItem[];
deviceEnabled: boolean;
enableDevice: () => void;
Expand Down
8 changes: 8 additions & 0 deletions packages/tsunami/src/FishjamClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,14 @@ export class FishjamClient<PeerMetadata = GenericMetadata, ServerMetadata = Gene
return this.requireDevices().customSources.setSource(sourceId, stream);
}

/**
* Wraps tracks in a platform stream via the injected device manager.
* Available only on device-capable clients.
*/
public createMediaStream(tracks: PlatformMediaStreamTrack[]): PlatformMediaStream {
return this.requireDevices().createMediaStream(tracks);
}

private requireDevices(): DeviceOrchestrator<PeerMetadata, ServerMetadata> {
this.resources.assertActive();
if (!this.deviceOrchestrator) throw new DeviceManagerMissingError();
Expand Down
14 changes: 12 additions & 2 deletions packages/tsunami/src/controllers/DeviceOrchestrator.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import type { Logger } from "@fishjam-cloud/ts-client";

import { prepareConstraints } from "../devices/constraints";
import type { DeviceItem, DeviceType, IDeviceManager, PlatformMediaStream } from "../devices/deviceManager";
import { getAvailableMedia,recoverPersistedDevices } from "../devices/mediaInitializer";
import type {
DeviceItem,
DeviceType,
IDeviceManager,
PlatformMediaStream,
PlatformMediaStreamTrack,
} from "../devices/deviceManager";
import { getAvailableMedia, recoverPersistedDevices } from "../devices/mediaInitializer";
import type { BandwidthLimits, InitializeDevicesResult, InitializeDevicesSettings, StreamConfig } from "../mediaTypes";
import type { ClientState } from "../state/clientState";
import type { StateStore } from "../state/StateStore";
Expand Down Expand Up @@ -167,6 +173,10 @@ export class DeviceOrchestrator<PeerMetadata, ServerMetadata> {
return initializationPromise;
}

public createMediaStream(tracks: PlatformMediaStreamTrack[]): PlatformMediaStream {
return this.deps.deviceManager.createMediaStream(tracks);
}

public dispose(): void {
this.deviceChangeCleanup();
this.camera.dispose();
Expand Down
22 changes: 21 additions & 1 deletion packages/tsunami/src/controllers/TrackDeviceController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export class TrackDeviceController {

private stream: PlatformMediaStream | null = null;
private processedTrack: PlatformMediaStreamTrack | null = null;
private displayStream: PlatformMediaStream | null = null;
private displayStreamTrack: PlatformMediaStreamTrack | null = null;
private middleware: TrackMiddleware = null;
private middlewareCleanup: (() => void) | null = null;
private isEnabled = true;
Expand Down Expand Up @@ -74,7 +76,7 @@ export class TrackDeviceController {
const rawDeviceId = this.rawTrack?.getSettings().deviceId;
const next: LocalDeviceState = {
track: this.deviceTrack,
stream: this.stream,
stream: this.getDisplayStream(),
isEnabled: this.isEnabled,
activeDevice: (rawDeviceId && this.deps.getAvailableDevices().find((d) => d.deviceId === rawDeviceId)) || null,
selectedDevice: this.selectedDevice,
Expand Down Expand Up @@ -387,6 +389,24 @@ export class TrackDeviceController {
this.trackEndCleanup = () => rawTrack.removeEventListener?.("ended", handleTrackEnded);
}

// The acquisition stream can carry both kinds (initializeDevices shares one
// stream between camera and microphone), so the state exposes a stream
// scoped to exactly the active track. Rebuilt only when that track changes,
// keeping the snapshot reference stable.
private getDisplayStream(): PlatformMediaStream | null {
const activeTrack = this.deviceTrack;
if (!activeTrack) {
this.displayStream = null;
this.displayStreamTrack = null;
return null;
}
if (this.displayStreamTrack !== activeTrack) {
this.displayStream = this.deps.deviceManager.createMediaStream([activeTrack]);
this.displayStreamTrack = activeTrack;
}
return this.displayStream;
}

private notify(): void {
this.deps.onStateChanged();
}
Expand Down
12 changes: 11 additions & 1 deletion packages/tsunami/src/devices/WebDeviceManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { DeviceItem, DeviceType, IDeviceManager, IDevicePersistence } from "./deviceManager";
import type {
DeviceItem,
DeviceType,
IDeviceManager,
IDevicePersistence,
PlatformMediaStreamTrack,
} from "./deviceManager";
import { classifyDeviceError } from "./errors";

export type WebDeviceManagerOptions = {
Expand Down Expand Up @@ -46,6 +52,10 @@ export class WebDeviceManager implements IDeviceManager<MediaStream> {
}
}

public createMediaStream(tracks: PlatformMediaStreamTrack[]): MediaStream {
return new MediaStream(tracks as MediaStreamTrack[]);
}

public onDeviceChange(callback: () => void): () => void {
const mediaDevices = this.getMediaDevices();

Expand Down
6 changes: 6 additions & 0 deletions packages/tsunami/src/devices/deviceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,10 @@ export interface IDeviceManager<TMediaStream extends PlatformMediaStream = Media
getUserMedia(constraints: MediaStreamConstraints): Promise<TMediaStream>;
getDisplayMedia(options?: DisplayMediaStreamOptions): Promise<TMediaStream>;
onDeviceChange(callback: () => void): () => void;
/**
* Wraps tracks in a platform stream, e.g. to make a middleware-processed
* track renderable. Stream construction is a platform concern — the SDK
* core never touches a `MediaStream` constructor.
*/
createMediaStream(tracks: PlatformMediaStreamTrack[]): TMediaStream;
}
1 change: 1 addition & 0 deletions packages/tsunami/src/state/clientState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type PeerStatus = "connecting" | "connected" | "error" | "idle";
export interface LocalDeviceState {
/** Track ready to be rendered or published (post-middleware when one is set). */
track: PlatformMediaStreamTrack | null;
/** Render-ready stream containing {@link track} (post-middleware when one is set). */
stream: PlatformMediaStream | null;
/** Soft mute flag — `false` while the track is disabled but the device stays on. */
isEnabled: boolean;
Expand Down
Loading