diff --git a/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts b/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts index 2b8c084a1..357e5fe22 100644 --- a/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts +++ b/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts @@ -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'; @@ -57,6 +67,10 @@ export class ReactNativeDeviceManager implements IDeviceManager void): () => void { const listener = () => callback(); diff --git a/packages/react-client/src/FishjamProvider.tsx b/packages/react-client/src/FishjamProvider.tsx index 69537ad31..0add2fdf0 100644 --- a/packages/react-client/src/FishjamProvider.tsx +++ b/packages/react-client/src/FishjamProvider.tsx @@ -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, @@ -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; + /** + * Platform reported to Fishjam. Defaults to `"web"`. Read once on first + * render. + */ + clientType?: ClientType; } const asLegacyDeviceError = (error: CoreDeviceError | null): DeviceError | null => @@ -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. * @@ -132,20 +156,12 @@ const toDevicePersistence = (handlers: PersistLastDeviceHandlers): IDevicePersis export function FishjamProvider(props: FishjamProviderProps) { const fishjamClientRef = useRef(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, @@ -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(), diff --git a/packages/react-client/src/hooks/devices/useCamera.ts b/packages/react-client/src/hooks/devices/useCamera.ts index c06c64fb5..a87701083 100644 --- a/packages/react-client/src/hooks/devices/useCamera.ts +++ b/packages/react-client/src/hooks/devices/useCamera.ts @@ -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 { /** diff --git a/packages/react-client/src/hooks/devices/useMicrophone.ts b/packages/react-client/src/hooks/devices/useMicrophone.ts index a64bcfffc..8331d1055 100644 --- a/packages/react-client/src/hooks/devices/useMicrophone.ts +++ b/packages/react-client/src/hooks/devices/useMicrophone.ts @@ -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 */ diff --git a/packages/react-client/src/hooks/useLivestreamStreamer.ts b/packages/react-client/src/hooks/useLivestreamStreamer.ts index 4945641ec..b4d20ea19 100644 --- a/packages/react-client/src/hooks/useLivestreamStreamer.ts +++ b/packages/react-client/src/hooks/useLivestreamStreamer.ts @@ -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 */ @@ -53,6 +54,8 @@ export const useLivestreamStreamer = (): UseLivestreamStreamerResult => { const [error, setError] = useState(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(null); const disconnect = useCallback(() => { @@ -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, { @@ -87,7 +91,7 @@ export const useLivestreamStreamer = (): UseLivestreamStreamerResult => { else console.error(e); } }, - [disconnect, onConnectionStateChange, fishjamId], + [disconnect, onConnectionStateChange, fishjamId, fishjamClientRef], ); return { connect, disconnect, error, isConnected }; diff --git a/packages/react-client/src/index.ts b/packages/react-client/src/index.ts index c30077099..2d0558eeb 100644 --- a/packages/react-client/src/index.ts +++ b/packages/react-client/src/index.ts @@ -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"; diff --git a/packages/react-client/src/tests/deviceManagerInjection.spec.ts b/packages/react-client/src/tests/deviceManagerInjection.spec.ts new file mode 100644 index 000000000..c640c7eb3 --- /dev/null +++ b/packages/react-client/src/tests/deviceManagerInjection.spec.ts @@ -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; +}; + +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(); + }); +}); diff --git a/packages/react-client/src/types/internal.ts b/packages/react-client/src/types/internal.ts index 01788be90..1f2f0a1ce 100644 --- a/packages/react-client/src/types/internal.ts +++ b/packages/react-client/src/types/internal.ts @@ -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; diff --git a/packages/tsunami/src/FishjamClient.ts b/packages/tsunami/src/FishjamClient.ts index aebe48d00..b11703f47 100644 --- a/packages/tsunami/src/FishjamClient.ts +++ b/packages/tsunami/src/FishjamClient.ts @@ -243,6 +243,14 @@ export class FishjamClient { this.resources.assertActive(); if (!this.deviceOrchestrator) throw new DeviceManagerMissingError(); diff --git a/packages/tsunami/src/controllers/DeviceOrchestrator.ts b/packages/tsunami/src/controllers/DeviceOrchestrator.ts index bb668eff9..5713e99fa 100644 --- a/packages/tsunami/src/controllers/DeviceOrchestrator.ts +++ b/packages/tsunami/src/controllers/DeviceOrchestrator.ts @@ -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"; @@ -167,6 +173,10 @@ export class DeviceOrchestrator { return initializationPromise; } + public createMediaStream(tracks: PlatformMediaStreamTrack[]): PlatformMediaStream { + return this.deps.deviceManager.createMediaStream(tracks); + } + public dispose(): void { this.deviceChangeCleanup(); this.camera.dispose(); diff --git a/packages/tsunami/src/controllers/TrackDeviceController.ts b/packages/tsunami/src/controllers/TrackDeviceController.ts index 04e11437f..cf3eda22c 100644 --- a/packages/tsunami/src/controllers/TrackDeviceController.ts +++ b/packages/tsunami/src/controllers/TrackDeviceController.ts @@ -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; @@ -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, @@ -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(); } diff --git a/packages/tsunami/src/devices/WebDeviceManager.ts b/packages/tsunami/src/devices/WebDeviceManager.ts index 131f49259..1e72d9e78 100644 --- a/packages/tsunami/src/devices/WebDeviceManager.ts +++ b/packages/tsunami/src/devices/WebDeviceManager.ts @@ -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 = { @@ -46,6 +52,10 @@ export class WebDeviceManager implements IDeviceManager { } } + public createMediaStream(tracks: PlatformMediaStreamTrack[]): MediaStream { + return new MediaStream(tracks as MediaStreamTrack[]); + } + public onDeviceChange(callback: () => void): () => void { const mediaDevices = this.getMediaDevices(); diff --git a/packages/tsunami/src/devices/deviceManager.ts b/packages/tsunami/src/devices/deviceManager.ts index adea3b83a..5807c2943 100644 --- a/packages/tsunami/src/devices/deviceManager.ts +++ b/packages/tsunami/src/devices/deviceManager.ts @@ -68,4 +68,10 @@ export interface IDeviceManager; getDisplayMedia(options?: DisplayMediaStreamOptions): Promise; 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; } diff --git a/packages/tsunami/src/state/clientState.ts b/packages/tsunami/src/state/clientState.ts index 69cafb009..cbfa3d9fd 100644 --- a/packages/tsunami/src/state/clientState.ts +++ b/packages/tsunami/src/state/clientState.ts @@ -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; diff --git a/packages/tsunami/src/testing/FakeDeviceManager.ts b/packages/tsunami/src/testing/FakeDeviceManager.ts index 9fe1d2bfe..0f2479c7d 100644 --- a/packages/tsunami/src/testing/FakeDeviceManager.ts +++ b/packages/tsunami/src/testing/FakeDeviceManager.ts @@ -1,6 +1,11 @@ import { vi } from "vitest"; -import type { DeviceItem, IDeviceManager, IDevicePersistence } from "../devices/deviceManager"; +import type { + DeviceItem, + IDeviceManager, + IDevicePersistence, + PlatformMediaStreamTrack, +} from "../devices/deviceManager"; import { createFakeTrack, FakeMediaStream } from "./FakeMediaStream"; type TrackKind = "audio" | "video"; @@ -84,6 +89,10 @@ export class FakeDeviceManager implements IDeviceManager { async (_options?: DisplayMediaStreamOptions): Promise => this.displayMediaFactory(), ); + createMediaStream = vi.fn( + (tracks: PlatformMediaStreamTrack[]): MediaStream => new FakeMediaStream(tracks as MediaStreamTrack[]), + ); + onDeviceChange = vi.fn((callback: () => void): (() => void) => { this.deviceChangeListeners.add(callback); let active = true;