diff --git a/packages/react-client/src/hooks/useConnection.ts b/packages/react-client/src/hooks/useConnection.ts index 922aca4a..e2668545 100644 --- a/packages/react-client/src/hooks/useConnection.ts +++ b/packages/react-client/src/hooks/useConnection.ts @@ -1,10 +1,10 @@ import type { GenericMetadata } from "@fishjam-cloud/ts-client"; +import { httpToWebsocketUrl, resolveFishjamUrl } from "@fishjam-cloud/tsunami"; import { useCallback, useContext } from "react"; import { FishjamClientContext } from "../contexts/fishjamClient"; import { useFishjamId } from "../contexts/fishjamId"; import { PeerStatusContext } from "../contexts/peerStatus"; -import { httpToWebsocketUrl, resolveFishjamUrl } from "../utils/fishjamUrl"; import { useReconnection } from "./internal/useReconnection"; export interface JoinRoomConfig { diff --git a/packages/react-client/src/hooks/useLivestreamStreamer.ts b/packages/react-client/src/hooks/useLivestreamStreamer.ts index edf2d505..4945641e 100644 --- a/packages/react-client/src/hooks/useLivestreamStreamer.ts +++ b/packages/react-client/src/hooks/useLivestreamStreamer.ts @@ -1,8 +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 { useFishjamId } from "../contexts/fishjamId"; -import { buildLivestreamWhipUrl } from "../utils/fishjamUrl"; /** @category Livestream */ export type StreamerInputs = diff --git a/packages/react-client/src/hooks/useLivestreamViewer.ts b/packages/react-client/src/hooks/useLivestreamViewer.ts index b88dab49..650143df 100644 --- a/packages/react-client/src/hooks/useLivestreamViewer.ts +++ b/packages/react-client/src/hooks/useLivestreamViewer.ts @@ -1,8 +1,8 @@ import { LivestreamError, receiveLivestream, type ReceiveLivestreamResult } from "@fishjam-cloud/ts-client"; +import { buildLivestreamWhepUrl } from "@fishjam-cloud/tsunami"; import { useCallback, useRef, useState } from "react"; import { useFishjamId } from "../contexts/fishjamId"; -import { buildLivestreamWhepUrl } from "../utils/fishjamUrl"; export type ConnectViewerConfig = { token: string; streamId?: never } | { streamId: string; token?: never }; diff --git a/packages/react-client/src/hooks/usePeers.ts b/packages/react-client/src/hooks/usePeers.ts index 292d5329..c58dd7e6 100644 --- a/packages/react-client/src/hooks/usePeers.ts +++ b/packages/react-client/src/hooks/usePeers.ts @@ -1,11 +1,10 @@ -import type { EncodingReason, Metadata, Peer, SimulcastConfig, TrackMetadata, Variant } from "@fishjam-cloud/ts-client"; -import type { FishjamClient } from "@fishjam-cloud/tsunami"; +import type { Metadata, Variant } from "@fishjam-cloud/ts-client"; +import { localPeerWithTracks, remotePeerWithTracks } from "@fishjam-cloud/tsunami"; import { useCallback, useContext } from "react"; import { FishjamClientContext } from "../contexts/fishjamClient"; import { FishjamClientStateContext } from "../contexts/fishjamState"; -import type { BrandedPeer } from "../types/internal"; -import type { PeerId, RemoteTrack, Track, TrackId } from "../types/public"; +import type { PeerId, RemoteTrack, Track } from "../types/public"; /** * @@ -24,79 +23,6 @@ export type PeerWithTracks { - fishjamClient.setTargetTrackEncoding(track.trackId, encoding); - }, - }; -} - -function getLocalPeerWithTracks(peer: BrandedPeer): PeerWithTracks { - const tracks = [...peer.tracks.values()].map(trackContextToTrack); - - return { - id: peer.id, - metadata: peer.metadata as Peer["metadata"], - tracks, - cameraTrack: tracks.find(({ metadata }) => metadata?.type === "camera"), - microphoneTrack: tracks.find(({ metadata }) => metadata?.type === "microphone"), - screenShareVideoTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareVideo"), - screenShareAudioTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareAudio"), - customVideoTracks: tracks.filter(({ metadata }) => metadata?.type === "customVideo"), - customAudioTracks: tracks.filter(({ metadata }) => metadata?.type === "customAudio"), - }; -} - -function getRemotePeerWithTracks( - peer: BrandedPeer, - fishjamClient: FishjamClient, -): PeerWithTracks { - const tracks = [...peer.tracks.values()].map((track) => trackContextToRemoteTrack(track, fishjamClient)); - - return { - id: peer.id, - metadata: peer.metadata as Peer["metadata"], - tracks, - cameraTrack: tracks.find(({ metadata }) => metadata?.type === "camera"), - microphoneTrack: tracks.find(({ metadata }) => metadata?.type === "microphone"), - screenShareVideoTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareVideo"), - screenShareAudioTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareAudio"), - customVideoTracks: tracks.filter(({ metadata }) => metadata?.type === "customVideo"), - customAudioTracks: tracks.filter(({ metadata }) => metadata?.type === "customAudio"), - }; -} - /** * Hook allows to access id, tracks and metadata of the local and remote peers. * @@ -110,18 +36,19 @@ export function usePeers, ServerMetadata const fishjamClient = useContext(FishjamClientContext); if (!clientState || !fishjamClient) throw Error("usePeers must be used within FishjamProvider"); - const localPeer: PeerWithTracks | null = clientState.localPeer - ? getLocalPeerWithTracks( - clientState.localPeer as BrandedPeer, - ) + // The tsunami views are structurally identical to the public shapes; the + // casts reintroduce the branded ids and DOM media types of the public API. + const localPeer = clientState.localPeer + ? (localPeerWithTracks(clientState.localPeer) as unknown as PeerWithTracks) : null; - const remotePeers: PeerWithTracks[] = Object.values(clientState.peers).map( + const remotePeers = Object.values(clientState.peers).map( (peer) => - getRemotePeerWithTracks( - peer as BrandedPeer, - fishjamClient.current, - ), + remotePeerWithTracks(peer, fishjamClient.current) as unknown as PeerWithTracks< + PeerMetadata, + ServerMetadata, + RemoteTrack + >, ); const setReceivedTracksQuality = useCallback( diff --git a/packages/react-client/src/hooks/useSandbox.ts b/packages/react-client/src/hooks/useSandbox.ts index 57d54572..0ae3f7cc 100644 --- a/packages/react-client/src/hooks/useSandbox.ts +++ b/packages/react-client/src/hooks/useSandbox.ts @@ -1,124 +1,44 @@ +import { + getSandboxLivestream, + getSandboxMoqPublisherAccess, + getSandboxMoqSubscriberAccess, + getSandboxPeerToken, + getSandboxViewerToken, + type MoqAccess, + type RoomType, +} from "@fishjam-cloud/tsunami"; import { useCallback } from "react"; -import { MissingSandboxApiUrlError } from "../utils/errors"; - -type BasicInfo = { id: string; name: string }; -type RoomManagerResponse = { - peerToken: string; - url: string; - room: BasicInfo; - peer: BasicInfo; -}; - -type MoqAccessResponse = { - connection_url: string; - token: string; -}; - -export type MoqAccess = { - connectionUrl: string; - token: string; -}; +export type { MoqAccess, RoomType }; export type UseSandboxProps = { sandboxApiUrl: string; }; -export type RoomType = "conference" | "livestream" | "audio_only"; - export const useSandbox = (props: UseSandboxProps) => { const sandboxApiUrl = props?.sandboxApiUrl; - const getSandboxPeerToken = useCallback( - async (roomName: string, peerName: string, roomType: RoomType = "conference") => { - if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); - - const url = new URL(sandboxApiUrl); - url.searchParams.set("roomName", roomName); - url.searchParams.set("peerName", peerName); - url.searchParams.set("roomType", roomType); - - const res = await fetch(url); - - if (!res.ok) { - const message = `Failed to retrieve peer token for peer '${peerName}' in ${roomType} room '${roomName}'.`; - throw new Error(message); - } - - const data: RoomManagerResponse = await res.json(); - return data.peerToken; - }, - [sandboxApiUrl], - ); - - const getSandboxViewerToken = useCallback( - async (roomName: string) => { - if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); - - const url = new URL(`${sandboxApiUrl}/${roomName}/livestream-viewer-token`); - - const res = await fetch(url); - if (!res.ok) { - let message = `Failed to retrieve viewer token for '${roomName}' livestream room.`; - if (res.status === 404) { - message = `A livestream room of name '${roomName}' does not exist.`; - } - throw new Error(message); - } - const data: { token: string } = await res.json(); - - return data.token; - }, - [sandboxApiUrl], - ); - - const getSandboxLivestream = useCallback( - async (roomName: string, isPublic: boolean = false) => { - if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); - - const url = new URL(`${sandboxApiUrl}/livestream`); - url.searchParams.set("roomName", roomName); - url.searchParams.set("public", isPublic.toString()); - - const res = await fetch(url); - if (!res.ok) throw new Error(`Failed to retrieve streamer token for '${roomName}' livestream room.`); - - const data: { streamerToken: string; room: { id: string; name: string } } = await res.json(); - return data; - }, - [sandboxApiUrl], - ); - - const fetchMoqAccess = useCallback( - async (streamName: string, type: "subscriber" | "publisher"): Promise => { - if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); - - const urlEncodedStreamName = encodeURIComponent(streamName); - - const res = await fetch(`${sandboxApiUrl}/moq/${urlEncodedStreamName}/${type}`); - if (!res.ok) throw new Error(`Failed to retrieve MoQ ${type} connection for stream '${streamName}'.`); - - const data: MoqAccessResponse = await res.json(); - return { connectionUrl: data.connection_url, token: data.token }; - }, - [sandboxApiUrl], - ); - - const getSandboxMoqPublisherAccess = useCallback( - async (streamName: string) => fetchMoqAccess(streamName, "publisher"), - [fetchMoqAccess], - ); - - const getSandboxMoqSubscriberAccess = useCallback( - async (streamName: string) => fetchMoqAccess(streamName, "subscriber"), - [fetchMoqAccess], - ); - return { - getSandboxPeerToken, - getSandboxViewerToken, - getSandboxLivestream, - getSandboxMoqPublisherAccess, - getSandboxMoqSubscriberAccess, + getSandboxPeerToken: useCallback( + (roomName: string, peerName: string, roomType: RoomType = "conference") => + getSandboxPeerToken(sandboxApiUrl, roomName, peerName, roomType), + [sandboxApiUrl], + ), + getSandboxViewerToken: useCallback( + (roomName: string) => getSandboxViewerToken(sandboxApiUrl, roomName), + [sandboxApiUrl], + ), + getSandboxLivestream: useCallback( + (roomName: string, isPublic: boolean = false) => getSandboxLivestream(sandboxApiUrl, roomName, isPublic), + [sandboxApiUrl], + ), + getSandboxMoqPublisherAccess: useCallback( + (streamName: string) => getSandboxMoqPublisherAccess(sandboxApiUrl, streamName), + [sandboxApiUrl], + ), + getSandboxMoqSubscriberAccess: useCallback( + (streamName: string) => getSandboxMoqSubscriberAccess(sandboxApiUrl, streamName), + [sandboxApiUrl], + ), }; }; diff --git a/packages/react-client/src/utils/errors.ts b/packages/react-client/src/utils/errors.ts deleted file mode 100644 index e96ce48a..00000000 --- a/packages/react-client/src/utils/errors.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class MissingSandboxApiUrlError extends Error { - constructor() { - super("useSandbox requires a sandboxApiUrl, you can get it at: https://fishjam.io/app/sandbox"); - this.name = "MissingSandboxApiUrlError"; - } -} diff --git a/packages/react-client/src/utils/fishjamUrl.ts b/packages/tsunami/src/fishjamUrl.ts similarity index 100% rename from packages/react-client/src/utils/fishjamUrl.ts rename to packages/tsunami/src/fishjamUrl.ts diff --git a/packages/tsunami/src/index.ts b/packages/tsunami/src/index.ts index 08a7c527..e53c37eb 100644 --- a/packages/tsunami/src/index.ts +++ b/packages/tsunami/src/index.ts @@ -28,6 +28,13 @@ export { WebDeviceManager, type WebDeviceManagerOptions } from "./devices/WebDev export { type ErrorRecoverability, FishjamError } from "./errors/FishjamError"; export { ClientDisposedError, DeviceManagerMissingError } from "./errors/lifecycleErrors"; export { FishjamClient, type FishjamClientConfig } from "./FishjamClient"; +export { + buildLivestreamWhepUrl, + buildLivestreamWhipUrl, + extractDomainFromFishjamId, + httpToWebsocketUrl, + resolveFishjamUrl, +} from "./fishjamUrl"; export type { BandwidthLimits, InitializeDevicesResult, @@ -40,6 +47,16 @@ export type { TracksMiddleware, TracksMiddlewareResult, } from "./mediaTypes"; +export { + getSandboxLivestream, + getSandboxMoqPublisherAccess, + getSandboxMoqSubscriberAccess, + getSandboxPeerToken, + getSandboxViewerToken, + MissingSandboxApiUrlError, + type MoqAccess, + type RoomType, +} from "./sandbox"; export { type ClientState, createInitialClientState, @@ -48,5 +65,13 @@ export { type PeerStatus, type ScreenShareState, } from "./state/clientState"; +export { + localPeerWithTracks, + type PeerTrackView, + type PeerWithTracksView, + type RemotePeerTrackView, + remotePeerWithTracks, + type RemoteTrackQualitySetter, +} from "./state/peerViews"; export { StateStore, type StateStoreOptions, type StoreListener } from "./state/StateStore"; export * from "@fishjam-cloud/ts-client"; diff --git a/packages/tsunami/src/sandbox.test.ts b/packages/tsunami/src/sandbox.test.ts new file mode 100644 index 00000000..fcd151e1 --- /dev/null +++ b/packages/tsunami/src/sandbox.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { buildLivestreamWhepUrl, buildLivestreamWhipUrl, httpToWebsocketUrl, resolveFishjamUrl } from "./fishjamUrl"; +import { getSandboxLivestream, getSandboxPeerToken, getSandboxViewerToken, MissingSandboxApiUrlError } from "./sandbox"; + +const mockFetch = (options: { ok: boolean; status?: number; json?: () => Promise }) => { + const fetchSpy = vi.fn(async (_input: string | URL) => ({ + ok: options.ok, + status: options.status ?? (options.ok ? 200 : 500), + json: options.json ?? (async () => ({})), + })); + vi.stubGlobal("fetch", fetchSpy); + return fetchSpy; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("sandbox helpers", () => { + it("throws a typed error when no sandboxApiUrl is provided", async () => { + await expect(getSandboxPeerToken("", "room", "peer")).rejects.toBeInstanceOf(MissingSandboxApiUrlError); + await expect(getSandboxPeerToken("", "room", "peer")).rejects.toThrow(/sandboxApiUrl/); + }); + + it("getSandboxPeerToken builds the query and returns the peer token", async () => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ peerToken: "pt-1" }) }); + + const peerToken = await getSandboxPeerToken("https://sandbox.example/api", "my-room", "alice", "audio_only"); + + expect(peerToken).toBe("pt-1"); + const requestedUrl = new URL(fetchSpy.mock.calls[0][0]); + expect(requestedUrl.searchParams.get("roomName")).toBe("my-room"); + expect(requestedUrl.searchParams.get("peerName")).toBe("alice"); + expect(requestedUrl.searchParams.get("roomType")).toBe("audio_only"); + }); + + it("defaults roomType to conference", async () => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ peerToken: "pt" }) }); + + await getSandboxPeerToken("https://sandbox.example/api", "room", "bob"); + + expect(new URL(fetchSpy.mock.calls[0][0]).searchParams.get("roomType")).toBe("conference"); + }); + + it("getSandboxViewerToken reports a missing livestream room", async () => { + mockFetch({ ok: false, status: 404 }); + + await expect(getSandboxViewerToken("https://sandbox.example/api", "nope")).rejects.toThrow(/does not exist/); + }); + + it("getSandboxLivestream returns the streamer token payload", async () => { + mockFetch({ ok: true, json: async () => ({ streamerToken: "st", room: { id: "1", name: "room" } }) }); + + const data = await getSandboxLivestream("https://sandbox.example/api", "room", true); + + expect(data.streamerToken).toBe("st"); + }); +}); + +describe("fishjam url helpers", () => { + it("resolves a bare fishjam id to the cloud connect url", () => { + expect(resolveFishjamUrl("my-id")).toBe("https://fishjam.io/api/v1/connect/my-id"); + }); + + it("passes a full url through and converts to websocket", () => { + expect(httpToWebsocketUrl(resolveFishjamUrl("https://cloud.example/api/v1/connect/x"))).toBe( + "wss://cloud.example/api/v1/connect/x", + ); + }); + + it("builds livestream urls from the fishjam id domain", () => { + expect(buildLivestreamWhipUrl("https://cloud.example/api/v1/connect/x")).toBe( + "https://cloud.example/api/v1/live/api/whip", + ); + expect(buildLivestreamWhepUrl("bare-id")).toBe("https://fishjam.io/api/v1/live/api/whep"); + }); + + it("rejects an empty fishjam id for livestream urls", () => { + expect(() => buildLivestreamWhipUrl("")).toThrow(/fishjamId is required/); + }); +}); diff --git a/packages/tsunami/src/sandbox.ts b/packages/tsunami/src/sandbox.ts new file mode 100644 index 00000000..1f3b5919 --- /dev/null +++ b/packages/tsunami/src/sandbox.ts @@ -0,0 +1,108 @@ +import { type ErrorRecoverability, FishjamError } from "./errors/FishjamError"; + +type BasicInfo = { id: string; name: string }; + +type RoomManagerResponse = { + peerToken: string; + url: string; + room: BasicInfo; + peer: BasicInfo; +}; + +type MoqAccessResponse = { + connection_url: string; + token: string; +}; + +export type MoqAccess = { + connectionUrl: string; + token: string; +}; + +export type RoomType = "conference" | "livestream" | "audio_only"; + +/** Thrown by the sandbox helpers when no sandboxApiUrl was provided. */ +export class MissingSandboxApiUrlError extends FishjamError { + public readonly recoverability: ErrorRecoverability = "user_action"; + + public constructor() { + super("A sandboxApiUrl is required, you can get it at: https://fishjam.io/app/sandbox"); + this.name = "MissingSandboxApiUrlError"; + } +} + +const requireSandboxApiUrl = (sandboxApiUrl: string): string => { + if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); + return sandboxApiUrl; +}; + +export const getSandboxPeerToken = async ( + sandboxApiUrl: string, + roomName: string, + peerName: string, + roomType: RoomType = "conference", +): Promise => { + const url = new URL(requireSandboxApiUrl(sandboxApiUrl)); + url.searchParams.set("roomName", roomName); + url.searchParams.set("peerName", peerName); + url.searchParams.set("roomType", roomType); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to retrieve peer token for peer '${peerName}' in ${roomType} room '${roomName}'.`); + } + + const data: RoomManagerResponse = await response.json(); + return data.peerToken; +}; + +export const getSandboxViewerToken = async (sandboxApiUrl: string, roomName: string): Promise => { + const url = new URL(`${requireSandboxApiUrl(sandboxApiUrl)}/${roomName}/livestream-viewer-token`); + + const response = await fetch(url); + if (!response.ok) { + let message = `Failed to retrieve viewer token for '${roomName}' livestream room.`; + if (response.status === 404) { + message = `A livestream room of name '${roomName}' does not exist.`; + } + throw new Error(message); + } + + const data: { token: string } = await response.json(); + return data.token; +}; + +export const getSandboxLivestream = async ( + sandboxApiUrl: string, + roomName: string, + isPublic: boolean = false, +): Promise<{ streamerToken: string; room: BasicInfo }> => { + const url = new URL(`${requireSandboxApiUrl(sandboxApiUrl)}/livestream`); + url.searchParams.set("roomName", roomName); + url.searchParams.set("public", isPublic.toString()); + + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to retrieve streamer token for '${roomName}' livestream room.`); + + return response.json(); +}; + +const fetchMoqAccess = async ( + sandboxApiUrl: string, + streamName: string, + type: "subscriber" | "publisher", +): Promise => { + const urlEncodedStreamName = encodeURIComponent(streamName); + + const response = await fetch(`${requireSandboxApiUrl(sandboxApiUrl)}/moq/${urlEncodedStreamName}/${type}`); + if (!response.ok) throw new Error(`Failed to retrieve MoQ ${type} connection for stream '${streamName}'.`); + + const data: MoqAccessResponse = await response.json(); + return { connectionUrl: data.connection_url, token: data.token }; +}; + +export const getSandboxMoqPublisherAccess = (sandboxApiUrl: string, streamName: string): Promise => + fetchMoqAccess(sandboxApiUrl, streamName, "publisher"); + +export const getSandboxMoqSubscriberAccess = (sandboxApiUrl: string, streamName: string): Promise => + fetchMoqAccess(sandboxApiUrl, streamName, "subscriber"); diff --git a/packages/tsunami/src/state/peerViews.test.ts b/packages/tsunami/src/state/peerViews.test.ts new file mode 100644 index 00000000..e61c33a5 --- /dev/null +++ b/packages/tsunami/src/state/peerViews.test.ts @@ -0,0 +1,50 @@ +import type { FishjamTrackContext, Peer, TrackMetadata } from "@fishjam-cloud/ts-client"; +import { describe, expect, it, vi } from "vitest"; + +import { localPeerWithTracks, remotePeerWithTracks } from "./peerViews"; + +const buildTrackContext = (trackId: string, type: TrackMetadata["type"]): FishjamTrackContext => + ({ + trackId, + metadata: { type, paused: false }, + stream: null, + track: null, + simulcastConfig: null, + }) as unknown as FishjamTrackContext; + +const buildPeer = (trackContexts: FishjamTrackContext[]): Peer => ({ + id: "peer-1", + type: "webrtc", + tracks: new Map(trackContexts.map((context) => [context.trackId, context])), +}); + +describe("peer views", () => { + it("buckets tracks by their metadata type", () => { + const peer = buildPeer([ + buildTrackContext("c", "camera"), + buildTrackContext("m", "microphone"), + buildTrackContext("sv", "screenShareVideo"), + buildTrackContext("cv1", "customVideo"), + buildTrackContext("cv2", "customVideo"), + ]); + + const view = localPeerWithTracks(peer); + + expect(view.cameraTrack?.trackId).toBe("c"); + expect(view.microphoneTrack?.trackId).toBe("m"); + expect(view.screenShareVideoTrack?.trackId).toBe("sv"); + expect(view.screenShareAudioTrack).toBeUndefined(); + expect(view.customVideoTracks.map((track) => track.trackId)).toEqual(["cv1", "cv2"]); + expect(view.tracks).toHaveLength(5); + }); + + it("wires remote track quality changes to the quality setter", () => { + const peer = buildPeer([buildTrackContext("rv", "screenShareVideo")]); + const qualitySetter = { setTargetTrackEncoding: vi.fn() }; + + const view = remotePeerWithTracks(peer, qualitySetter); + view.screenShareVideoTrack?.setReceivedQuality("h" as never); + + expect(qualitySetter.setTargetTrackEncoding).toHaveBeenCalledWith("rv", "h"); + }); +}); diff --git a/packages/tsunami/src/state/peerViews.ts b/packages/tsunami/src/state/peerViews.ts new file mode 100644 index 00000000..02e94c67 --- /dev/null +++ b/packages/tsunami/src/state/peerViews.ts @@ -0,0 +1,89 @@ +import type { EncodingReason, Metadata, Peer, SimulcastConfig, TrackMetadata, Variant } from "@fishjam-cloud/ts-client"; + +import type { PlatformMediaStream, PlatformMediaStreamTrack } from "../devices/deviceManager"; + +export type PeerTrackView = { + metadata?: TrackMetadata; + trackId: string; + stream: PlatformMediaStream | null; + simulcastConfig: SimulcastConfig | null; + track: PlatformMediaStreamTrack | null; +}; + +export type RemotePeerTrackView = PeerTrackView & { + encoding?: Variant; + encodingReason?: EncodingReason; + setReceivedQuality: (quality: Variant) => void; +}; + +/** A peer with its tracks bucketed by their metadata type. */ +export type PeerWithTracksView = { + id: string; + metadata?: Metadata; + tracks: TrackView[]; + cameraTrack?: TrackView; + microphoneTrack?: TrackView; + screenShareVideoTrack?: TrackView; + screenShareAudioTrack?: TrackView; + customVideoTracks: TrackView[]; + customAudioTracks: TrackView[]; +}; + +/** The narrow surface remote track views adjust receive quality through. */ +export type RemoteTrackQualitySetter = { + setTargetTrackEncoding(trackId: string, encoding: Variant): void; +}; + +type PeerTrackContext = { + metadata?: unknown; + trackId: string; + stream: MediaStream | null; + simulcastConfig?: SimulcastConfig | null; + track: MediaStreamTrack | null; + encoding?: Variant; + encodingReason?: EncodingReason; +}; + +const trackContextToView = (context: PeerTrackContext): PeerTrackView => ({ + metadata: context.metadata as TrackMetadata, + trackId: context.trackId, + stream: context.stream, + simulcastConfig: context.simulcastConfig ?? null, + track: context.track, +}); + +const buildPeerView = ( + peer: Peer, + toTrackView: (context: PeerTrackContext) => TrackView, +): PeerWithTracksView => { + const tracks = [...peer.tracks.values()].map(toTrackView); + + return { + id: peer.id, + metadata: peer.metadata, + tracks, + cameraTrack: tracks.find(({ metadata }) => metadata?.type === "camera"), + microphoneTrack: tracks.find(({ metadata }) => metadata?.type === "microphone"), + screenShareVideoTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareVideo"), + screenShareAudioTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareAudio"), + customVideoTracks: tracks.filter(({ metadata }) => metadata?.type === "customVideo"), + customAudioTracks: tracks.filter(({ metadata }) => metadata?.type === "customAudio"), + }; +}; + +export const localPeerWithTracks = ( + peer: Peer, +): PeerWithTracksView => buildPeerView(peer, trackContextToView); + +export const remotePeerWithTracks = ( + peer: Peer, + qualitySetter: RemoteTrackQualitySetter, +): PeerWithTracksView => + buildPeerView(peer, (context) => ({ + ...trackContextToView(context), + encoding: context.encoding, + encodingReason: context.encodingReason, + setReceivedQuality: (quality: Variant) => { + qualitySetter.setTargetTrackEncoding(context.trackId, quality); + }, + }));