Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/heavy-pugs-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@whereby.com/browser-sdk": minor
"@whereby.com/core": minor
---

Add a headless pre-call network test: `PreCallTestClient` (via `client.getPreCallTest()`) in core, and the `usePreCallTest` hook in browser-sdk. It measures available bitrate and packet loss against the Whereby media servers before joining a room, without needing a room or local media.
68 changes: 68 additions & 0 deletions packages/browser-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,74 @@ function MyPreCallUX() {
}
```

#### usePreCallTest

The `usePreCallTest` hook measures the connection between the end user and the
Whereby media servers, so you can warn people about a poor network before they
join a room. It needs no room and no local media, and runs on its own
connection.

Because the test saturates the connection to measure it, run it *before*
joining — running it during a call takes bandwidth away from the call.

```js
import { usePreCallTest } from "@whereby.com/browser-sdk/react";

function MyNetworkCheck() {
const { state, actions } = usePreCallTest();
const { status, result, error } = state;

return (
<div className="networkCheck">
<button onClick={() => actions.startTest()} disabled={status === "running"}>
{status === "running" ? "Testing…" : "Test my connection"}
</button>

{status === "completed" && result.success && <p>Your connection looks good.</p>}
{status === "completed" && result.warning && (
<p>
Your connection may struggle: {result.details.recvAvailableBitrate.toFixed(1)} Mbps down,{" "}
{(result.details.recvLoss * 100).toFixed(1)}% packet loss.
</p>
)}
{status === "failed" && <p>Could not test your connection: {error.message}</p>}
</div>
);
}
```

The test takes 15 seconds by default; pass `{ durationSeconds }` to
`startTest()` to change that (minimum 10 — below that there is not enough media
to report on). `startTest()` also resolves with the same result object, if you
prefer to await it rather than read `state`. Call `actions.stopTest()` to abort
a run.

`result.success` means no problems were found. `result.warning` means the test
completed but the connection is degraded — the flags in `result.details` say
which check tripped: `lowRecvAvailableBitrate` (below 1.5 Mbps), `highSendLoss`
or `highRecvLoss` (above 3% packet loss). A test that could not produce a
verdict at all sets `status` to `"failed"` and fills in `error`.

##### Checking camera, microphone and speakers

Unlike the network test, whether a camera or microphone *works* is a judgement
only the end user can make — they have to see themselves and hear themselves.
The SDK gives you the pieces to build that; the verdict comes from your UI:

- **Camera** — render `localMedia.state.localStream` in a `<VideoView>` and let
the user switch between `cameraDevices`, then ask "can you see yourself?".
- **Speakers** — play a sound and route it to the chosen device with
[`HTMLMediaElement.setSinkId()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId)
using `localMedia.state.currentSpeakerDeviceId`, then ask "can you hear it?".
- **Microphone** — feed the local stream's audio track into a Web Audio
[`AnalyserNode`](https://developer.mozilla.org/en-US/docs/Web/API/AnalyserNode)
to draw a level meter, or record a few seconds with `MediaRecorder` and play
it back, then ask "can you hear yourself?".

Device errors that *are* machine-detectable — blocked permissions, a device
that fails to open — surface on `useLocalMedia` as `cameraDeviceError`,
`microphoneDeviceError` and `startError`.

#### useRoomConnection

The `useRoomConnection` hook provides a way to connect participants in a given
Expand Down
13 changes: 13 additions & 0 deletions packages/browser-sdk/src/lib/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { Provider as WherebyProvider } from "./Provider";
export { VideoView } from "./VideoView";
export { useRoomConnection } from "./useRoomConnection";
export { useLocalMedia } from "./useLocalMedia";
export { usePreCallTest } from "./usePreCallTest";
export { Grid as VideoGrid, GridCell, GridVideoView } from "./Grid";
export { MAX_FILES_PER_UPLOAD, MAX_FILE_SIZE, ACCEPTED_FILE_TYPES } from "@whereby.com/core";
export {
Expand All @@ -15,8 +16,20 @@ export { getUsableCameraEffectPresets, isAudioDenoiserSupported } from "@whereby

export type { UseLocalMediaResult } from "./useLocalMedia/types";

export type { UsePreCallTestOptions, UsePreCallTestResult } from "./usePreCallTest/types";

export type { RoomConnectionActions, RoomConnectionOptions } from "./useRoomConnection/types";

export type {
PreCallTestDetails,
PreCallTestError,
PreCallTestErrorReason,
PreCallTestOptions,
PreCallTestResult,
PreCallTestState,
PreCallTestStatus,
} from "@whereby.com/core";

export type {
ChatMessageState as ChatMessage,
ChatFileShare,
Expand Down
55 changes: 55 additions & 0 deletions packages/browser-sdk/src/lib/react/usePreCallTest/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as React from "react";
import { PreCallTestOptions, PreCallTestState } from "@whereby.com/core";
import { UsePreCallTestResult } from "./types";
import { WherebyContext } from "../Provider";
import { initialState } from "./initialState";

/**
* Measures the connection between this client and the Whereby media servers, so
* you can warn people about a bad network before they join a room.
*
* The test is never started for you - call `actions.startTest()`. It needs no
* room and no local media, but it does compete for bandwidth with any call
* already in progress, so run it before joining.
*/
export function usePreCallTest(): UsePreCallTestResult {
const client = React.useContext(WherebyContext)?.getPreCallTest();
const [preCallTestState, setPreCallTestState] = React.useState<PreCallTestState>(() => initialState);

if (!client) {
throw new Error("WherebyClient is not initialized. Please wrap your component with WherebyProvider.");
}

const isRunning = preCallTestState.status === "running";
const isRunningRef = React.useRef(isRunning);
isRunningRef.current = isRunning;

React.useEffect(() => {
// A test may already be running - started elsewhere, or before this
// component mounted.
setPreCallTestState(client.getState());

const unsubscribe = client.subscribe(setPreCallTestState);

return () => {
unsubscribe();

// Leave a finished test's result in place for anyone else reading it;
// only tear down a test that is still consuming bandwidth.
if (isRunningRef.current) {
client.stopTest();
}
};
}, [client]);

const startTest = React.useCallback((options?: PreCallTestOptions) => client.startTest(options), [client]);
const stopTest = React.useCallback(() => client.stopTest(), [client]);

return {
state: preCallTestState,
actions: {
startTest,
stopTest,
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PreCallTestState } from "@whereby.com/core";

export const initialState: PreCallTestState = {
status: "idle",
result: null,
error: null,
};
16 changes: 16 additions & 0 deletions packages/browser-sdk/src/lib/react/usePreCallTest/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { PreCallTestOptions, PreCallTestResult, PreCallTestState } from "@whereby.com/core";

interface PreCallTestActions {
/**
* Runs a test and resolves with its result, or with `null` if it could not
* complete - `state.error` says why. A call made while a test is running
* resolves with `null` and leaves the running test alone.
*/
startTest: (options?: PreCallTestOptions) => Promise<PreCallTestResult | null>;
/** Aborts a running test and returns to the idle state. */
stopTest: () => void;
}

export type UsePreCallTestResult = { state: PreCallTestState; actions: PreCallTestActions };

export type UsePreCallTestOptions = PreCallTestOptions;
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as React from "react";
import { usePreCallTest } from "../../lib/react";
import type { PreCallTestDetails, PreCallTestResult } from "../../lib/react";

function Verdict({ result }: { result: PreCallTestResult }) {
if (result.success) {
return <strong className="preCallTestVerdictSuccess">Connection looks good</strong>;
}

if (result.warning) {
return <strong className="preCallTestVerdictWarning">Connection is degraded</strong>;
}

return <strong className="preCallTestVerdictFailure">Connection is too poor for a call</strong>;
}

function Details({ details }: { details: PreCallTestDetails }) {
const rows: Array<[string, string, boolean]> = [
["Test time", `${(details.testTime / 1000).toFixed(1)} s`, false],
[
"Available downstream bitrate",
`${details.recvAvailableBitrate.toFixed(2)} Mbps`,
details.lowRecvAvailableBitrate,
],
["Upstream packet loss", `${(details.sendLoss * 100).toFixed(1)} %`, details.highSendLoss],
["Downstream packet loss", `${(details.recvLoss * 100).toFixed(1)} %`, details.highRecvLoss],
];

return (
<table className="preCallTestDetails">
<tbody>
{rows.map(([label, value, isBad]) => (
<tr key={label}>
<td>{label}</td>
<td className={isBad ? "preCallTestVerdictWarning" : undefined}>{value}</td>
</tr>
))}
</tbody>
</table>
);
}

export default function PreCallTestExperience({
durationSeconds,
sfuServerOverrideHost,
}: {
durationSeconds?: number;
sfuServerOverrideHost?: string;
}) {
const {
state: { status, result, error },
actions: { startTest, stopTest },
} = usePreCallTest();

const isRunning = status === "running";

return (
<div>
<h3>Pre-call test</h3>
<p>
Measures the connection between this client and the Whereby media servers. It needs no room and no local
media, but it competes for bandwidth with any call in progress - run it before joining.
</p>
<div className="controls">
<button onClick={() => startTest({ durationSeconds, sfuServerOverrideHost })} disabled={isRunning}>
Start test
</button>
<button onClick={() => stopTest()} disabled={!isRunning}>
Stop test
</button>
</div>
<p>
Status: <code>{status}</code>
{isRunning && ` (running for up to ${durationSeconds ?? 15} s)`}
</p>
{error && (
<p className="preCallTestVerdictFailure">
Test failed ({error.reason}): {error.message}
</p>
)}
{result && (
<div>
<Verdict result={result} />
<Details details={result.details} />
</div>
)}
</div>
);
}
Loading
Loading