Files
llink/js/desktop/src/hooks/use-media-devices.ts
T
Arjun PatelandGitHub a8a0b7db1b infra: add linting and formatting for js projects (#230)
* wip

* wip

* wip

* format

* wip(mobile): lint and format

* cleanup

* nits

* nits

* idiomatic react

* nit

* format all root files
2026-06-02 07:44:24 -07:00

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,
};
}