* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
export type PermissionState = 'unknown' | 'granted' | 'denied';
|
|
|
|
interface UseMediaDevicesResult {
|
|
audioInputs: MediaDeviceInfo[];
|
|
videoInputs: MediaDeviceInfo[];
|
|
permissionState: PermissionState;
|
|
refresh: () => Promise<void>;
|
|
requestLabels: () => Promise<void>;
|
|
error: string | null;
|
|
}
|
|
|
|
/**
|
|
* Enumerates input devices and stays subscribed to `devicechange`.
|
|
*
|
|
* Labels are only populated after the user has granted mic/camera
|
|
* permission — `requestLabels` triggers a brief getUserMedia so that
|
|
* subsequent enumerations return human-readable names, matching the
|
|
* pattern most video-conferencing apps use.
|
|
*/
|
|
export function useMediaDevices(): UseMediaDevicesResult {
|
|
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
|
const [permissionState, setPermissionState] =
|
|
useState<PermissionState>('unknown');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const refresh = useCallback(() => {
|
|
return navigator.mediaDevices
|
|
.enumerateDevices()
|
|
.then((list) => {
|
|
setDevices(list);
|
|
// If at least one input device has a non-empty label, permission
|
|
// has been granted at some point for that device kind.
|
|
const hasLabels = list.some(
|
|
(d) =>
|
|
(d.kind === 'audioinput' || d.kind === 'videoinput') &&
|
|
d.label.length > 0,
|
|
);
|
|
if (hasLabels) setPermissionState('granted');
|
|
})
|
|
.catch((err) => {
|
|
setError(err instanceof Error ? err.message : 'Failed to list devices');
|
|
});
|
|
}, []);
|
|
|
|
const requestLabels = useCallback(async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: true,
|
|
video: true,
|
|
});
|
|
// Immediately stop — we only needed the permission grant.
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
setPermissionState('granted');
|
|
setError(null);
|
|
await refresh();
|
|
} catch (err) {
|
|
setPermissionState('denied');
|
|
setError(
|
|
err instanceof Error ? err.message : 'Microphone/camera access denied',
|
|
);
|
|
}
|
|
}, [refresh]);
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
const handle = () => {
|
|
refresh();
|
|
};
|
|
navigator.mediaDevices.addEventListener('devicechange', handle);
|
|
return () => {
|
|
navigator.mediaDevices.removeEventListener('devicechange', handle);
|
|
};
|
|
}, [refresh]);
|
|
|
|
return {
|
|
// Before permission is granted, enumerateDevices returns placeholder
|
|
// entries with an empty deviceId — filter them out so consumers never
|
|
// render an empty-value <SelectItem />, which Radix rejects.
|
|
audioInputs: devices.filter(
|
|
(d) => d.kind === 'audioinput' && d.deviceId !== '',
|
|
),
|
|
videoInputs: devices.filter(
|
|
(d) => d.kind === 'videoinput' && d.deviceId !== '',
|
|
),
|
|
permissionState,
|
|
refresh,
|
|
requestLabels,
|
|
error,
|
|
};
|
|
}
|