From 29116171b12a9c5f0be5a2e64866c2e76a4004bd Mon Sep 17 00:00:00 2001 From: Milosz Filimowski Date: Tue, 4 Aug 2026 13:37:58 +0200 Subject: [PATCH 1/2] mobile overhaul 1/5: react provider grows deviceManager + clientType injection points An injected manager owns persistence (persistLastDevice is skipped entirely), which is what lets mobile drop its localStorage polyfill later. clientType flows through the tsunami config into ts-client, so mobile can stop constructing a signalling client of its own. --- packages/react-client/src/FishjamProvider.tsx | 38 ++++++++--- packages/react-client/src/index.ts | 1 + .../src/tests/deviceManagerInjection.spec.ts | 67 +++++++++++++++++++ 3 files changed, 95 insertions(+), 11 deletions(-) create mode 100644 packages/react-client/src/tests/deviceManagerInjection.spec.ts diff --git a/packages/react-client/src/FishjamProvider.tsx b/packages/react-client/src/FishjamProvider.tsx index 69537ad3..ac527c9d 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, diff --git a/packages/react-client/src/index.ts b/packages/react-client/src/index.ts index c3007709..2d0558ee 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 00000000..5f614d81 --- /dev/null +++ b/packages/react-client/src/tests/deviceManagerInjection.spec.ts @@ -0,0 +1,67 @@ +import type { DeviceItem, IDeviceManager, PlatformMediaStream } from "@fishjam-cloud/tsunami"; +import { act } from "@testing-library/react"; + +import { useCamera } from "../hooks/devices/useCamera"; +import { useInitializeDevices } from "../hooks/devices/useInitializeDevices"; +import { createFakeStream } from "./support/fakeMediaStream"; +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(() => () => {}), + } 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(); + }); +}); From 2721fa4b5097df7a76ee2a3455172b2b4b5de261 Mon Sep 17 00:00:00 2001 From: Milosz Filimowski Date: Tue, 4 Aug 2026 13:46:02 +0200 Subject: [PATCH 2/2] mobile overhaul 2/5: stream construction moves behind the platform boundary IDeviceManager grows createMediaStream; the controller exposes a render-ready stream scoped to the active track (the shared initializeDevices stream never leaks the other kind's track into a preview), and useCamera/useMicrophone/ useLivestreamStreamer stop calling the MediaStream global. The global now survives only for the legacy ts-client/webrtc-client core. --- .../src/devices/ReactNativeDeviceManager.ts | 18 +++++++++++++-- packages/react-client/src/FishjamProvider.tsx | 1 + .../src/hooks/devices/useCamera.ts | 6 +---- .../src/hooks/devices/useMicrophone.ts | 6 +---- .../src/hooks/useLivestreamStreamer.ts | 10 ++++++--- .../src/tests/deviceManagerInjection.spec.ts | 3 ++- packages/react-client/src/types/internal.ts | 2 ++ packages/tsunami/src/FishjamClient.ts | 8 +++++++ .../src/controllers/DeviceOrchestrator.ts | 14 ++++++++++-- .../src/controllers/TrackDeviceController.ts | 22 ++++++++++++++++++- .../tsunami/src/devices/WebDeviceManager.ts | 12 +++++++++- packages/tsunami/src/devices/deviceManager.ts | 6 +++++ packages/tsunami/src/state/clientState.ts | 1 + .../tsunami/src/testing/FakeDeviceManager.ts | 11 +++++++++- 14 files changed, 99 insertions(+), 21 deletions(-) diff --git a/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts b/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts index 2b8c084a..357e5fe2 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 ac527c9d..0add2fdf 100644 --- a/packages/react-client/src/FishjamProvider.tsx +++ b/packages/react-client/src/FishjamProvider.tsx @@ -188,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 c06c64fb..a8770108 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 a64bcfff..8331d105 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 4945641e..b4d20ea1 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/tests/deviceManagerInjection.spec.ts b/packages/react-client/src/tests/deviceManagerInjection.spec.ts index 5f614d81..c640c7eb 100644 --- a/packages/react-client/src/tests/deviceManagerInjection.spec.ts +++ b/packages/react-client/src/tests/deviceManagerInjection.spec.ts @@ -1,9 +1,9 @@ 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 { createFakeStream } from "./support/fakeMediaStream"; import { describe, expect, it, vi } from "./support/fixtures"; const fakeDevices: DeviceItem[] = [ @@ -23,6 +23,7 @@ const createFakeDeviceManager = () => { 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; }; diff --git a/packages/react-client/src/types/internal.ts b/packages/react-client/src/types/internal.ts index 01788be9..1f2f0a1c 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 aebe48d0..b11703f4 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 bb668eff..5713e99f 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 04e11437..cf3eda22 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 131f4925..1e72d9e7 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 adea3b83..5807c294 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 69cafb00..cbfa3d9f 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 9fe1d2bf..0f2479c7 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;