tranth3truong's picture
Deploy public Scribe-only CarePath Space
cc678b9
Raw
History Blame Contribute Delete
5.58 kB
import { useEffect, useRef, useState } from "react";
import { copy, type Language } from "../copy";
import { OnboardingFrame } from "./OnboardingFrame";
export type DeviceCheckResult = { deviceId?: string; voiceReady: boolean };
function MicIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" width="22" height="22" focusable="false">
<rect x="9" y="3" width="6" height="11" rx="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M6 11a6 6 0 0 0 12 0M12 17v4M9 21h6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function DeviceCheck({ language = "vi", onComplete }: {
language?: Language;
onComplete: (result: DeviceCheckResult) => void;
}) {
const text = copy[language].deviceCheck;
const kicker = copy[language].productName;
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [deviceId, setDeviceId] = useState("");
const [level, setLevel] = useState(0);
const [state, setState] = useState<"idle" | "ready" | "unavailable" | "denied">("idle");
const streamRef = useRef<MediaStream | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const frameRef = useRef<number | null>(null);
const testRef = useRef(0);
function stopTest() {
testRef.current += 1;
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
void audioContextRef.current?.close();
audioContextRef.current = null;
}
useEffect(() => {
void navigator.mediaDevices?.enumerateDevices?.().then((all) => {
const microphones = all.filter((device) => device.kind === "audioinput");
setDevices(microphones);
setDeviceId((current) => current || microphones[0]?.deviceId || "");
if (!microphones.length) setState("unavailable");
}).catch(() => setState("unavailable"));
return stopTest;
}, []);
async function testMicrophone() {
stopTest();
const attempt = ++testRef.current;
if (!navigator.mediaDevices?.getUserMedia) {
setState("unavailable");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: deviceId ? { deviceId: { exact: deviceId } } : true,
});
if (testRef.current !== attempt) {
stream.getTracks().forEach((track) => track.stop());
return;
}
streamRef.current = stream;
const context = new AudioContext();
audioContextRef.current = context;
const analyser = context.createAnalyser();
const source = context.createMediaStreamSource(stream);
source.connect(analyser);
const values = new Uint8Array(analyser.fftSize);
const updateLevel = () => {
analyser.getByteTimeDomainData(values);
setLevel(values.reduce((sum, value) => sum + Math.abs(value - 128), 0) / values.length / 128);
frameRef.current = requestAnimationFrame(updateLevel);
};
updateLevel();
setState("ready");
} catch {
stopTest();
setState(devices.length ? "denied" : "unavailable");
}
}
const blocked = state === "unavailable" || state === "denied";
return (
<OnboardingFrame consentSubmitted kicker={kicker} language={language} title={text.title} titleId="device-check-title">
<section className="device-check" aria-labelledby="device-check-title">
<div className={`green-room green-room--${state}`}>
<p className="green-room__intro">
<span className="green-room__icon" aria-hidden="true"><MicIcon /></span>
{text.body}
</p>
{devices.length > 1 ? (
<label className="green-room__picker">
{text.microphone}
<select value={deviceId} onChange={(event) => setDeviceId(event.target.value)}>
{devices.map((device, index) => <option key={device.deviceId} value={device.deviceId}>{device.label || `${text.microphone} ${index + 1}`}</option>)}
</select>
</label>
) : null}
<div className="level-meter">
<span className="meta">{text.level}</span>
<meter min="0" max="1" value={level} />
<div className="level-meter__scale" aria-hidden="true">
<span>{text.scaleLow}</span>
<span>{text.scaleHigh}</span>
</div>
</div>
{state === "ready" ? <p className="green-room__state green-room__state--ready" role="status">{text.ready}</p> : null}
{state === "idle" ? <p className="green-room__state green-room__state--idle">{text.idle}</p> : null}
{blocked ? <p className="green-room__state green-room__state--blocked" role="alert">{state === "unavailable" ? text.unavailable : text.denied}</p> : null}
<div className="green-room__actions">
<button className="secondary-outline" type="button" onClick={() => void testMicrophone()}>{state === "idle" ? text.test : text.retry}</button>
{state === "ready" ? (
<button className="primary" type="button" onClick={() => { stopTest(); onComplete({ deviceId, voiceReady: true }); }}>{text.continueVoice}</button>
) : null}
</div>
</div>
<button className="text-button" type="button" onClick={() => { stopTest(); onComplete({ voiceReady: false }); }}>{text.continueTyped}</button>
</section>
</OnboardingFrame>
);
}