diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 88a1005b6..0059d118e 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -123,6 +123,8 @@ export type { ConnectViewerConfig, RoomType, UseSandboxProps, + SandboxOptions, + VideoCodec, BandwidthLimits, Brand, DeviceError, diff --git a/packages/react-client/src/hooks/useSandbox.ts b/packages/react-client/src/hooks/useSandbox.ts index 57d54572d..7a082812f 100644 --- a/packages/react-client/src/hooks/useSandbox.ts +++ b/packages/react-client/src/hooks/useSandbox.ts @@ -1,5 +1,6 @@ import { useCallback } from "react"; +import type { VideoCodec } from "../types/public"; import { MissingSandboxApiUrlError } from "../utils/errors"; type BasicInfo = { id: string; name: string }; @@ -26,22 +27,30 @@ export type UseSandboxProps = { export type RoomType = "conference" | "livestream" | "audio_only"; +export type SandboxOptions = { + videoCodec?: VideoCodec; +}; + export const useSandbox = (props: UseSandboxProps) => { const sandboxApiUrl = props?.sandboxApiUrl; const getSandboxPeerToken = useCallback( - async (roomName: string, peerName: string, roomType: RoomType = "conference") => { + async (roomName: string, peerName: string, roomType: RoomType = "conference", options: SandboxOptions = {}) => { 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); + if (options.videoCodec) url.searchParams.set("videoCodec", options.videoCodec); const res = await fetch(url); if (!res.ok) { - const message = `Failed to retrieve peer token for peer '${peerName}' in ${roomType} room '${roomName}'.`; + let message = `Failed to retrieve peer token for peer '${peerName}' in ${roomType} room '${roomName}'.`; + if (res.status === 409) { + message = `Room '${roomName}' already exists with a different room type or video codec.`; + } throw new Error(message); } @@ -73,15 +82,22 @@ export const useSandbox = (props: UseSandboxProps) => { ); const getSandboxLivestream = useCallback( - async (roomName: string, isPublic: boolean = false) => { + async (roomName: string, isPublic: boolean = false, options: SandboxOptions = {}) => { if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); const url = new URL(`${sandboxApiUrl}/livestream`); url.searchParams.set("roomName", roomName); url.searchParams.set("public", isPublic.toString()); + if (options.videoCodec) url.searchParams.set("videoCodec", options.videoCodec); const res = await fetch(url); - if (!res.ok) throw new Error(`Failed to retrieve streamer token for '${roomName}' livestream room.`); + if (!res.ok) { + let message = `Failed to retrieve streamer token for '${roomName}' livestream room.`; + if (res.status === 409) { + message = `Room '${roomName}' already exists with a different room type or video codec.`; + } + throw new Error(message); + } const data: { streamerToken: string; room: { id: string; name: string } } = await res.json(); return data; diff --git a/packages/react-client/src/index.ts b/packages/react-client/src/index.ts index 5f404ec44..8d953a519 100644 --- a/packages/react-client/src/index.ts +++ b/packages/react-client/src/index.ts @@ -23,7 +23,13 @@ export { type UseLivestreamViewerResult, } from "./hooks/useLivestreamViewer"; export { type PeerWithTracks, usePeers } from "./hooks/usePeers"; -export { type MoqAccess, type RoomType, useSandbox, type UseSandboxProps } from "./hooks/useSandbox"; +export { + type MoqAccess, + type RoomType, + type SandboxOptions, + useSandbox, + type UseSandboxProps, +} from "./hooks/useSandbox"; export { useScreenShare } from "./hooks/useScreenShare"; export { useUpdatePeerMetadata } from "./hooks/useUpdatePeerMetadata"; export { useVAD } from "./hooks/useVAD"; @@ -48,6 +54,7 @@ export type { TracksMiddleware, TracksMiddlewareResult, UseDataChannelResult, + VideoCodec, } from "./types/public"; export type { AuthErrorReason, diff --git a/packages/react-client/src/tests/sandbox.spec.ts b/packages/react-client/src/tests/sandbox.spec.ts index 7b613a851..e8a3c61fd 100644 --- a/packages/react-client/src/tests/sandbox.spec.ts +++ b/packages/react-client/src/tests/sandbox.spec.ts @@ -41,6 +41,24 @@ describe("useSandbox", () => { expect(calledUrl.searchParams.get("roomType")).toBe("conference"); }); + it("getSandboxPeerToken passes the requested video codec", async ({ renderHook }) => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ peerToken: "pt" }) }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + + await result.current.getSandboxPeerToken("room", "bob", "conference", { videoCodec: "h264" }); + const calledUrl = new URL(fetchSpy.mock.calls[0][0]); + expect(calledUrl.searchParams.get("videoCodec")).toBe("h264"); + }); + + it("getSandboxPeerToken omits the video codec by default", async ({ renderHook }) => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ peerToken: "pt" }) }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + + await result.current.getSandboxPeerToken("room", "bob"); + const calledUrl = new URL(fetchSpy.mock.calls[0][0]); + expect(calledUrl.searchParams.has("videoCodec")).toBe(false); + }); + it("getSandboxViewerToken gives a friendly error when the room is missing", async ({ renderHook }) => { mockFetch({ ok: false, status: 404 }); const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); @@ -53,4 +71,47 @@ describe("useSandbox", () => { const data = await result.current.getSandboxLivestream("room", true); expect(data.streamerToken).toBe("st"); }); + + it("getSandboxLivestream passes the visibility", async ({ renderHook }) => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ streamerToken: "st", room: { id: "r", name: "n" } }) }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + + await result.current.getSandboxLivestream("room", true); + const calledUrl = new URL(fetchSpy.mock.calls[0][0]); + expect(calledUrl.searchParams.get("public")).toBe("true"); + }); + + it("getSandboxLivestream passes the requested video codec", async ({ renderHook }) => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ streamerToken: "st", room: { id: "r", name: "n" } }) }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + + await result.current.getSandboxLivestream("room", false, { videoCodec: "h264" }); + const calledUrl = new URL(fetchSpy.mock.calls[0][0]); + expect(calledUrl.searchParams.get("videoCodec")).toBe("h264"); + }); + + it("getSandboxLivestream omits the video codec by default", async ({ renderHook }) => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ streamerToken: "st", room: { id: "r", name: "n" } }) }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + + await result.current.getSandboxLivestream("room"); + const calledUrl = new URL(fetchSpy.mock.calls[0][0]); + expect(calledUrl.searchParams.has("videoCodec")).toBe(false); + }); + + it("getSandboxPeerToken explains a conflicting room configuration", async ({ renderHook }) => { + mockFetch({ ok: false, status: 409 }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + await expect( + result.current.getSandboxPeerToken("room", "bob", "conference", { videoCodec: "h264" }), + ).rejects.toThrow(/already exists with a different/); + }); + + it("getSandboxLivestream explains a conflicting room configuration", async ({ renderHook }) => { + mockFetch({ ok: false, status: 409 }); + const { result } = renderHook(() => useSandbox({ sandboxApiUrl: "https://sandbox.test/api" })); + await expect(result.current.getSandboxLivestream("room", false, { videoCodec: "h264" })).rejects.toThrow( + /already exists with a different/, + ); + }); }); diff --git a/packages/react-client/src/types/public.ts b/packages/react-client/src/types/public.ts index 576558475..5ba0690af 100644 --- a/packages/react-client/src/types/public.ts +++ b/packages/react-client/src/types/public.ts @@ -15,6 +15,8 @@ export type InitializeDevicesResult = { errors: { audio: DeviceError | null; video: DeviceError | null } | null; }; +export type VideoCodec = "vp8" | "h264"; + export type TrackId = Brand; export type PeerId = Brand;