import { useCallback, useEffect, useRef, useState } from "react";
import {
CallingState,
ToggleAudioPreviewButton,
ToggleVideoPreviewButton,
VideoPreview,
useCall,
useCallStateHooks,
useLoopbackRecording,
} from "@stream-io/video-react-sdk";
export const PreCallTest = () => {
const call = useCall();
const { useCallCallingState, useCameraState, useMicrophoneState } =
useCallStateHooks();
const callingState = useCallCallingState();
const { isEnabled: isCameraEnabled } = useCameraState();
const { isEnabled: isMicrophoneEnabled } = useMicrophoneState();
const {
startRecording,
stopRecording,
recordingState,
loopbackAudioStream,
loopbackVideoStream,
} = useLoopbackRecording();
const [error, setError] = useState<Error>();
const [recordingUrl, setRecordingUrl] = useState<string>();
const recordingUrlRef = useRef<string>();
// the hook revokes nothing on your behalf
const showRecording = useCallback((recording?: Blob) => {
if (recordingUrlRef.current) URL.revokeObjectURL(recordingUrlRef.current);
const url = recording ? URL.createObjectURL(recording) : undefined;
recordingUrlRef.current = url;
setRecordingUrl(url);
}, []);
useEffect(
() => () => {
if (recordingUrlRef.current) URL.revokeObjectURL(recordingUrlRef.current);
},
[],
);
const runTest = useCallback(async () => {
if (!call) return;
setError(undefined);
showRecording(undefined); // back to preview while the next test runs
try {
await call.join({ create: true, allowOwnTracksLoopback: true });
const recording = await startRecording({ includeVideo: isCameraEnabled });
if (recording) showRecording(recording.blob);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
call.leave().catch(console.error);
}
}, [call, startRecording, showRecording, isCameraEnabled]);
const isRecording = recordingState === "recording";
const isConnecting =
(callingState === CallingState.JOINING ||
callingState === CallingState.JOINED) &&
!isRecording;
const onClick = () => (isRecording ? stopRecording() : runTest());
const label = isRecording
? "Stop recording"
: isConnecting
? "Connecting…"
: "Record loopback";
return (
<div className="pre-call-test">
<h1>Test your camera and microphone</h1>
{error && <p role="alert">{error.message}</p>}
{recordingUrl ? (
<video src={recordingUrl} controls playsInline />
) : (
<>
<VideoPreview />
<div>
<ToggleAudioPreviewButton Menu={null} />
<ToggleVideoPreviewButton Menu={null} />
</div>
<div role="status">
{loopbackAudioStream ? "Audio echo live" : "Waiting for audio echo"}
{loopbackVideoStream ? "Video echo live" : "Waiting for video echo"}
</div>
</>
)}
{!isMicrophoneEnabled && !isRecording && (
<p role="status">
Enable your microphone to run the test — a loopback recording always
includes audio. Your camera is optional.
</p>
)}
<button
type="button"
onClick={onClick}
disabled={isConnecting || !isMicrophoneEnabled}
>
{label}
</button>
</div>
);
};Pre-call Self-Test
Introduction
Build a pre-call self-test screen where users record a short loopback of their own microphone, camera, and network path before joining a real call. Once recorded, they can watch the result back - what they see and hear is exactly what the round trip through Stream's SFU delivers to other participants.
This recipe uses the loopback recording API. The result is a single screen with three states: device preview, recording, and playback.
Implementation
Consider using a dedicated call type (for example pre-call-test)
and a recognisable call ID (for example prefixed with pre_call_test_) for the loopback call. That keeps self-tests
cleanly separated from real call sessions in logs, analytics, and the Stream dashboard, and lets you scope permissions
and defaults independently.
The screen renders inside <StreamCall> on a throwaway call and owns the join → record → leave sequence itself. Do not
join when the screen mounts: allowOwnTracksLoopback is fixed at join time, and joining a call has billing and
call-analytics consequences, so it should follow a deliberate button press rather than a page load. The
loopback recording guide shows how to create that call.
useLoopbackRecording gives you recordingState to drive the button, and loopbackAudioStream /
loopbackVideoStream as live "the echo is flowing" indicators.
Surfacing stats during the test
useCallStatsReport() works inside the loopback call too. Render latency, jitter, and bitrate - with the SDK's
<StatCard />, or your own markup - while the user is recording, so connectivity issues surface even when the playback
looks fine.
See the Call Stats Report guide for the shape of the report.