-
Notifications
You must be signed in to change notification settings - Fork 4
feat(stream): connection states, receive-side video quality, blur and noise cancellation (#1134) #1143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(stream): connection states, receive-side video quality, blur and noise cancellation (#1134) #1143
Changes from all commits
e0f5f93
6b21c20
57d3693
56b021f
6c6fd43
cc02577
c1e574a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| /** | ||
| * @jest-environment node | ||
| */ | ||
|
|
||
| /** | ||
| * #1134 — the meeting room collapsed every non-JOINED state into one spinner, | ||
| * and offered no control over the video it subscribed to. Both are covered | ||
| * here, because both are decisions rather than rendering: which connection | ||
| * states are recoverable by the SDK and which need the person to act, and which | ||
| * resolution a menu choice actually asks the SFU for. | ||
| */ | ||
|
|
||
| import { CallingState } from "@stream-io/video-client"; | ||
|
|
||
| import { describeCallingState } from "../../lib/stream/connection-state"; | ||
| import { | ||
| applyIncomingVideoSetting, | ||
| incomingVideoSettingToResolution, | ||
| resolveIncomingVideoSetting, | ||
| } from "../../lib/stream/incoming-video"; | ||
|
|
||
| describe("connection states are told apart", () => { | ||
| it("says nothing at all once the call is joined", () => { | ||
| expect(describeCallingState(CallingState.JOINED)).toBeNull(); | ||
| }); | ||
|
|
||
| it.each([CallingState.RECONNECTING, CallingState.MIGRATING])( | ||
| "treats %s as transient, with no rejoin action", | ||
| (state) => { | ||
| const advice = describeCallingState(state); | ||
| expect(advice?.tone).toBe("warning"); | ||
| expect(advice?.canRejoin).toBe(false); | ||
| }, | ||
| ); | ||
|
|
||
| it("distinguishes being offline from being reconnected", () => { | ||
| const offline = describeCallingState(CallingState.OFFLINE); | ||
| const reconnecting = describeCallingState(CallingState.RECONNECTING); | ||
|
|
||
| expect(offline?.title).not.toBe(reconnecting?.title); | ||
| // The SDK recovers to RECONNECTING by itself once the network returns, so | ||
| // there is nothing for the person to press. | ||
| expect(offline?.canRejoin).toBe(false); | ||
| }); | ||
|
|
||
| it.each([CallingState.RECONNECTING_FAILED, CallingState.LEFT])( | ||
| "offers a manual rejoin from %s, the states a Call instance cannot recover from", | ||
| (state) => { | ||
| const advice = describeCallingState(state); | ||
| expect(advice?.tone).toBe("terminal"); | ||
| expect(advice?.canRejoin).toBe(true); | ||
| }, | ||
| ); | ||
|
|
||
| it("falls back to the ordinary connecting screen for the cold path", () => { | ||
| for (const state of [ | ||
| CallingState.IDLE, | ||
| CallingState.JOINING, | ||
| CallingState.RINGING, | ||
| CallingState.UNKNOWN, | ||
| ]) { | ||
| const advice = describeCallingState(state); | ||
| expect(advice?.tone).toBe("loading"); | ||
| expect(advice?.canRejoin).toBe(false); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe("incoming video quality maps to what the SFU is asked for", () => { | ||
| it("resolves each named setting to its dimensions", () => { | ||
| expect(incomingVideoSettingToResolution("1080p")).toEqual({ | ||
| width: 1920, | ||
| height: 1080, | ||
| }); | ||
| expect(incomingVideoSettingToResolution("720p")).toEqual({ | ||
| width: 1280, | ||
| height: 720, | ||
| }); | ||
| expect(incomingVideoSettingToResolution("480p")).toEqual({ | ||
| width: 640, | ||
| height: 480, | ||
| }); | ||
| expect(incomingVideoSettingToResolution("auto")).toBeUndefined(); | ||
| expect(incomingVideoSettingToResolution("off")).toBeUndefined(); | ||
| }); | ||
|
|
||
| const makeCall = () => ({ | ||
| setIncomingVideoEnabled: jest.fn(), | ||
| setPreferredIncomingVideoResolution: jest.fn(), | ||
| }); | ||
|
|
||
| it("turns incoming video off for audio only", () => { | ||
| const call = makeCall(); | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- only the two setters are exercised | ||
| applyIncomingVideoSetting(call as any, "off"); | ||
|
|
||
| expect(call.setIncomingVideoEnabled).toHaveBeenCalledWith(false); | ||
| expect(call.setPreferredIncomingVideoResolution).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("goes back to auto by re-enabling, which drops the cap with it", () => { | ||
| const call = makeCall(); | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- only the two setters are exercised | ||
| applyIncomingVideoSetting(call as any, "auto"); | ||
|
|
||
| // Re-enabling is what undoes both a previous "audio only" and a previous | ||
| // manual cap — the SDK clears the subscription overrides on enable. | ||
| expect(call.setIncomingVideoEnabled).toHaveBeenCalledWith(true); | ||
| expect(call.setPreferredIncomingVideoResolution).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("enables BEFORE capping, so the cap is not wiped by the enable", () => { | ||
| const call = makeCall(); | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- only the two setters are exercised | ||
| applyIncomingVideoSetting(call as any, "480p"); | ||
|
|
||
| expect(call.setIncomingVideoEnabled).toHaveBeenCalledWith(true); | ||
| expect(call.setPreferredIncomingVideoResolution).toHaveBeenCalledWith({ | ||
| width: 640, | ||
| height: 480, | ||
| }); | ||
| expect( | ||
| call.setIncomingVideoEnabled.mock.invocationCallOrder[0], | ||
| ).toBeLessThan( | ||
| call.setPreferredIncomingVideoResolution.mock.invocationCallOrder[0], | ||
| ); | ||
| }); | ||
|
|
||
| it("reads the call's own state back into the menu selection", () => { | ||
| expect(resolveIncomingVideoSetting({ enabled: false })).toBe("off"); | ||
| expect(resolveIncomingVideoSetting({ enabled: true })).toBe("auto"); | ||
| expect( | ||
| resolveIncomingVideoSetting({ | ||
| enabled: true, | ||
| preferredResolution: { width: 1280, height: 720 }, | ||
| }), | ||
| ).toBe("720p"); | ||
| expect( | ||
| resolveIncomingVideoSetting({ | ||
| enabled: true, | ||
| preferredResolution: { width: 640, height: 480 }, | ||
| }), | ||
| ).toBe("480p"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| /** | ||
| * @jest-environment node | ||
| */ | ||
|
|
||
| /** | ||
| * #1134 — the meeting page could sit in a skeleton forever. | ||
| * | ||
| * `useGetCallById` returns early while the video client is undefined, and | ||
| * deliberately so: the provider mounts it lazily, so `undefined` is the normal | ||
| * cold-load state and raising an error there produced a "Video client not | ||
| * available" flash on every open. | ||
| * | ||
| * But the effect re-runs only on `[client, callId, rejoinKey]`. If the client | ||
| * never arrives — Stream unconfigured, a token fetch that keeps failing, a | ||
| * provider that errored out — none of those change, `isCallLoading` stays true, | ||
| * and `app/meetings/[id]/page.tsx` renders `MeetingRoomSkeleton` with no error, | ||
| * no message and no exit. Someone waiting to be let into a session they paid | ||
| * for watches a placeholder animate. | ||
| * | ||
| * The fix bounds the wait rather than removing it. The bound is the part worth | ||
| * testing: it has to outlast the provider's own retry ladder, or it fires while | ||
| * the provider is still retrying and would have succeeded — turning a slow | ||
| * connect into a reported failure. | ||
| */ | ||
|
|
||
| import { readFileSync } from "fs"; | ||
| import { join } from "path"; | ||
|
|
||
| const hook = readFileSync( | ||
| join(process.cwd(), "app/meetings/[id]/hooks/useGetCallById.ts"), | ||
| "utf8", | ||
| ); | ||
| const provider = readFileSync( | ||
| join(process.cwd(), "providers/StreamProviderImpl.tsx"), | ||
| "utf8", | ||
| ); | ||
|
|
||
| /** The ladder StreamProviderImpl actually walks, derived from its own source. */ | ||
| function providerBackoffMs(): number { | ||
| const maxAttempts = Number( | ||
| /currentAttempts < (\d+)/.exec(provider)?.[1] ?? NaN, | ||
| ); | ||
| const capMs = Number( | ||
| /Math\.min\(1000 \* Math\.pow\(2, attempt\), (\d+)\)/.exec( | ||
| provider, | ||
| )?.[1] ?? NaN, | ||
| ); | ||
| expect(Number.isFinite(maxAttempts)).toBe(true); | ||
| expect(Number.isFinite(capMs)).toBe(true); | ||
|
|
||
| // Attempt n sleeps min(1000 * 2^n, cap) before retry n+1, for n = 1..max-1. | ||
| let total = 0; | ||
| for (let n = 1; n < maxAttempts; n++) { | ||
| total += Math.min(1000 * 2 ** n, capMs); | ||
| } | ||
| return total; | ||
| } | ||
|
|
||
| describe("the bounded wait for the video client", () => { | ||
| it("exists at all", () => { | ||
| expect(hook).toContain("CLIENT_WAIT_TIMEOUT_MS"); | ||
| // The early return must survive — an immediate error is the flash this | ||
| // deliberately avoids. | ||
| expect(hook).toContain("if (!client) return;"); | ||
| }); | ||
|
|
||
| it("clears loading and sets an error when it elapses", () => { | ||
| // Both, not just the error: `page.tsx` gates on `isCallLoading` first, so | ||
| // an error left underneath a true loading flag renders the same skeleton. | ||
| const block = /CLIENT_WAIT_TIMEOUT_MS\);/.exec(hook); | ||
| expect(block).not.toBeNull(); | ||
| const timeoutBody = hook.slice( | ||
| hook.indexOf("const timer = setTimeout"), | ||
| hook.indexOf("CLIENT_WAIT_TIMEOUT_MS);"), | ||
| ); | ||
| expect(timeoutBody).toContain("setError("); | ||
| expect(timeoutBody).toContain("setIsCallLoading(false)"); | ||
| }); | ||
|
|
||
| it("outlasts the provider's full retry ladder", () => { | ||
| const timeout = Number( | ||
| /CLIENT_WAIT_TIMEOUT_MS = ([\d_]+)/ | ||
| .exec(hook)?.[1] | ||
| .replace(/_/g, "") ?? NaN, | ||
| ); | ||
| expect(Number.isFinite(timeout)).toBe(true); | ||
|
|
||
| const backoff = providerBackoffMs(); | ||
| // 30_000ms today. A bound at or under it would report failure while the | ||
| // provider was still working — the connect attempts themselves take time | ||
| // on top of this, so the margin is the point. | ||
| expect(backoff).toBeGreaterThan(0); | ||
| expect(timeout).toBeGreaterThan(backoff); | ||
| }); | ||
|
|
||
| it("cancels the timer when the client arrives", () => { | ||
| // Otherwise a client that lands at 44s still gets an error at 45s, on top | ||
| // of a call that resolved fine. | ||
| expect(hook).toContain("return () => clearTimeout(timer)"); | ||
| expect(hook).toContain("if (client || !callId) return;"); | ||
| }); | ||
|
|
||
| it("does not fire once a client is present", () => { | ||
| // The guard is the first statement, so a mounted-with-client render never | ||
| // schedules anything. | ||
| const effectStart = hook.indexOf("if (client || !callId) return;"); | ||
| const timerStart = hook.indexOf("const timer = setTimeout"); | ||
| expect(effectStart).toBeGreaterThan(-1); | ||
| expect(timerStart).toBeGreaterThan(effectStart); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| "use client"; | ||
|
|
||
| import { useBackgroundFilters } from "@stream-io/video-react-sdk"; | ||
| import { Aperture, MicVocal, Wand2 } from "lucide-react"; | ||
|
|
||
| import { | ||
| DropdownMenu, | ||
| DropdownMenuContent, | ||
| DropdownMenuLabel, | ||
| DropdownMenuSeparator, | ||
| DropdownMenuTrigger, | ||
| } from "@/components/ui/dropdown-menu"; | ||
| import { EffectRow } from "./EffectRow"; | ||
| import { useNoiseCancellationGate } from "./NoiseCancellationGate"; | ||
|
|
||
| /** | ||
| * Background blur and noise cancellation, in one menu (#1134). | ||
| * | ||
| * Both are privacy controls before they are polish: a consultation is often | ||
| * taken from a kitchen or a shared flat, and until now the only way to keep a | ||
| * room out of a session was to turn the camera off altogether. | ||
| */ | ||
| export function CallEffectsMenu() { | ||
| const { | ||
| isSupported, | ||
| isReady, | ||
| isLoading, | ||
| backgroundFilter, | ||
| applyBackgroundBlurFilter, | ||
| disableBackgroundFilter, | ||
| } = useBackgroundFilters(); | ||
|
|
||
| const noiseCancellation = useNoiseCancellationGate(); | ||
|
|
||
| const blurOn = backgroundFilter === "blur"; | ||
|
|
||
| return ( | ||
| <DropdownMenu> | ||
| <DropdownMenuTrigger asChild> | ||
| <button | ||
| className="rounded-xl bg-zinc-800 p-3 transition-colors hover:bg-zinc-700" | ||
| title="Effects" | ||
| aria-label="Background and audio effects" | ||
| > | ||
| <Wand2 className="h-5 w-5 text-white" aria-hidden="true" /> | ||
| </button> | ||
|
Check warning on line 46 in app/meetings/[id]/components/CallEffectsMenu.tsx
|
||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent | ||
| align="center" | ||
| className="min-w-[260px] rounded-xl border-zinc-800 bg-zinc-900 p-2" | ||
| sideOffset={12} | ||
| > | ||
| <DropdownMenuLabel className="px-3 py-1.5 text-xs font-normal text-zinc-500"> | ||
| Effects | ||
| </DropdownMenuLabel> | ||
| <DropdownMenuSeparator className="bg-zinc-800" /> | ||
|
|
||
| {isSupported ? ( | ||
| <EffectRow | ||
| icon={Aperture} | ||
| label="Blur my background" | ||
| hint={ | ||
| isLoading ? "Applying…" : isReady ? "Hides your room" : "Loading…" | ||
|
Check warning on line 63 in app/meetings/[id]/components/CallEffectsMenu.tsx
|
||
| } | ||
| active={blurOn} | ||
| disabled={!isReady || isLoading} | ||
| loading={isLoading || !isReady} | ||
| onToggle={() => { | ||
| if (blurOn) disableBackgroundFilter(); | ||
| else applyBackgroundBlurFilter("high"); | ||
| }} | ||
| /> | ||
| ) : ( | ||
| // Safari is excluded by the SDK on purpose — see CallFiltersProvider. | ||
| <p className="px-3 py-2.5 text-xs text-zinc-500"> | ||
| Background blur is not available in this browser. Chrome, Edge and | ||
| Firefox support it. | ||
| </p> | ||
| )} | ||
|
|
||
| {noiseCancellation.available && ( | ||
| <EffectRow | ||
| icon={MicVocal} | ||
| label="Reduce background noise" | ||
| hint={ | ||
| !noiseCancellation.isOn | ||
| ? "Filters keyboards, traffic and fans out of your microphone" | ||
| : noiseCancellation.isReady | ||
| ? "On" | ||
| : "Starting…" | ||
|
Check warning on line 90 in app/meetings/[id]/components/CallEffectsMenu.tsx
|
||
| } | ||
| active={noiseCancellation.isOn && noiseCancellation.isReady} | ||
| disabled={noiseCancellation.isChecking} | ||
| loading={noiseCancellation.isOn && !noiseCancellation.isReady} | ||
| onToggle={noiseCancellation.toggle} | ||
| /> | ||
| )} | ||
| </DropdownMenuContent> | ||
| </DropdownMenu> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.