♻️(frontend) track device-in-use state by id instead of by kind

Refactor the device-in-use handling to key state by device id
instead of only by kind (microphone or camera).

On computers with several devices of the same kind, keying by kind
meant that one device being in use marked the whole kind as busy,
even when the user could still use another device. Tracking per id
lets the UI and logic distinguish between devices correctly.

The store is updated accordingly so that per-device state is stored
and read consistently across the app.
This commit is contained in:
lebaudantoine
2026-08-23 16:47:05 +02:00
parent 3ec1627e25
commit 38f3f9f52d
5 changed files with 100 additions and 28 deletions
@@ -1,6 +1,10 @@
import { useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
import {
clearDeviceInUseForOtherDevice,
deviceAvailabilityStore,
} from '@/stores/deviceAvailability'
import { userChoicesStore } from '@/stores/userChoices'
import { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
import type { PermissionKind } from '@/stores/permissions'
@@ -8,23 +12,34 @@ const RETRY_INTERVAL_MS = 30_000
/**
* There is no browser event for "another app released the device", so
* while a device is flagged in use, re-probe it periodically. Only clears
* the flag (the toggle becomes usable again); it never re-enables the
* device on the user's behalf.
* while a device is flagged in use, re-probe it periodically. The probe
* targets the currently selected device (the flag is about what the app
* would acquire, not about any device of the kind). Only clears the flag
* (the toggle becomes usable again); it never re-enables the device on
* the user's behalf.
*/
export function useWatchDeviceReleased() {
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
useWatchKind('camera', cameraInUse)
useWatchKind('microphone', microphoneInUse)
const { audioDeviceId, videoDeviceId } = useSnapshot(userChoicesStore)
useWatchKind('camera', cameraInUse, videoDeviceId)
useWatchKind('microphone', microphoneInUse, audioDeviceId)
}
function useWatchKind(kind: PermissionKind, inUse: boolean) {
function useWatchKind(
kind: PermissionKind,
inUse: boolean,
selectedDeviceId?: string
) {
useEffect(() => {
if (inUse) clearDeviceInUseForOtherDevice(kind, selectedDeviceId)
}, [kind, inUse, selectedDeviceId])
useEffect(() => {
if (!inUse) return
const id = setInterval(
() => void probeDeviceReleased(kind),
() => void probeDeviceReleased(kind, selectedDeviceId || undefined),
RETRY_INTERVAL_MS
)
return () => clearInterval(id)
}, [kind, inUse])
}, [kind, inUse, selectedDeviceId])
}
@@ -23,6 +23,7 @@ import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { requestDevicePermission } from '../../../utils/mediaPermissions'
import { userChoicesStore } from '@/stores/userChoices'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import type {
@@ -127,7 +128,16 @@ export const ToggleDevice = <T extends ToggleSource>({
if (isRequestingPermission.current) return
isRequestingPermission.current = true
try {
const acquired = await requestDevicePermission(kind, mediaPath)
const selectedDeviceId = cannotUseDevice
? undefined
: kind === 'videoinput'
? userChoicesStore.videoDeviceId
: userChoicesStore.audioDeviceId
const acquired = await requestDevicePermission(
kind,
mediaPath,
selectedDeviceId || undefined
)
if (acquired) {
toggle()
} else if (cannotUseDevice) {
@@ -131,12 +131,14 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
enabled,
create,
permissionKind,
deviceId,
onFailure,
}: {
ready: boolean
enabled: boolean
create: () => Promise<T>
permissionKind: PermissionKind
deviceId?: string
onFailure: () => void
}): T | null {
const [track, setTrack] = useState<T | null>(null)
@@ -157,13 +159,18 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
setTrack(newTrack)
})
.catch((error) => {
onMediaPermissionError(error as Error, permissionKind)
onMediaPermissionError(
error as Error,
permissionKind,
'join_preview',
deviceId
)
onFailure()
})
return () => {
cancelled = true
}
}, [ready, enabled, track, create, permissionKind, onFailure])
}, [ready, enabled, track, create, permissionKind, deviceId, onFailure])
// Release on toggle-off so the LED turns off.
useEffect(() => {
@@ -237,6 +244,7 @@ export function useJoinTracks(): {
enabled: audioEnabled,
create: createAudio,
permissionKind: 'microphone',
deviceId: audioDeviceId,
onFailure: disableAudio,
})
@@ -245,6 +253,7 @@ export function useJoinTracks(): {
enabled: videoEnabled,
create: createVideo,
permissionKind: 'camera',
deviceId: videoDeviceId,
onFailure: disableVideo,
})
@@ -62,7 +62,8 @@ export const noteDeviceReady = (kind?: PermissionKind) => {
export const onMediaPermissionError = (
e: Error,
kind?: PermissionKind,
path: MediaPath = 'join_preview'
path: MediaPath = 'join_preview',
deviceId?: string
) => {
const failure = getMediaDeviceFailure(e)
@@ -103,7 +104,7 @@ export const onMediaPermissionError = (
}
if (failure === MediaDeviceFailure.DeviceInUse) {
noteDeviceInUse(kind)
noteDeviceInUse(kind, deviceId)
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
return
}
@@ -122,11 +123,13 @@ export const onMediaPermissionError = (
* polled. Clears the in-use flag on success.
*/
export const probeDeviceReleased = async (
kind: PermissionKind
kind: PermissionKind,
deviceId?: string
): Promise<boolean> => {
try {
const constraint = deviceId ? { deviceId: { exact: deviceId } } : true
const stream = await navigator.mediaDevices.getUserMedia(
kind === 'camera' ? { video: true } : { audio: true }
kind === 'camera' ? { video: constraint } : { audio: constraint }
)
stream.getTracks().forEach((track) => track.stop())
noteDeviceReady(kind)
@@ -142,18 +145,24 @@ export const probeDeviceReleased = async (
*/
export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput',
path: MediaPath = 'join_preview'
path: MediaPath = 'join_preview',
deviceId?: string
): Promise<boolean> => {
try {
const track =
kind === 'audioinput'
? await createLocalAudioTrack()
: await createLocalVideoTrack()
? await createLocalAudioTrack({ deviceId })
: await createLocalVideoTrack({ deviceId })
track.stop()
noteDeviceReady(PERMISSION_KIND[kind])
return true
} catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
onMediaPermissionError(
error as Error,
PERMISSION_KIND[kind],
path,
deviceId
)
return false
}
}
+37 -8
View File
@@ -6,8 +6,18 @@ import type { PermissionKind } from './permissions'
// - presence: enumerateDevices() exposes kinds before any grant.
// Optimistic defaults until the first sync.
// - in use: the device exists and is allowed, but getUserMedia() failed
// because another application or tab is holding it.
export const deviceAvailabilityStore = proxy({
// because another application or tab is holding it. The id of the
// affected device is kept (when known) so that a selection change can
// invalidate a flag that no longer concerns the selected device.
export const deviceAvailabilityStore = proxy<{
hasCamera: boolean
hasMicrophone: boolean
cameraInUse: boolean
microphoneInUse: boolean
cameraInUseDeviceId?: string
microphoneInUseDeviceId?: string
synced: boolean
}>({
hasCamera: true,
hasMicrophone: true,
cameraInUse: false,
@@ -20,19 +30,38 @@ const IN_USE_KEY: Record<PermissionKind, 'cameraInUse' | 'microphoneInUse'> = {
microphone: 'microphoneInUse',
}
const IN_USE_DEVICE_KEY: Record<
PermissionKind,
'cameraInUseDeviceId' | 'microphoneInUseDeviceId'
> = {
camera: 'cameraInUseDeviceId',
microphone: 'microphoneInUseDeviceId',
}
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
const setDeviceInUse = (inUse: boolean, kind?: PermissionKind) => {
export const noteDeviceInUse = (kind?: PermissionKind, deviceId?: string) => {
for (const k of kind ? [kind] : ALL_KINDS) {
deviceAvailabilityStore[IN_USE_KEY[k]] = inUse
deviceAvailabilityStore[IN_USE_KEY[k]] = true
deviceAvailabilityStore[IN_USE_DEVICE_KEY[k]] = deviceId
}
}
export const noteDeviceInUse = (kind?: PermissionKind) =>
setDeviceInUse(true, kind)
export const clearDeviceInUse = (kind?: PermissionKind) => {
for (const k of kind ? [kind] : ALL_KINDS) {
deviceAvailabilityStore[IN_USE_KEY[k]] = false
deviceAvailabilityStore[IN_USE_DEVICE_KEY[k]] = undefined
}
}
export const clearDeviceInUse = (kind?: PermissionKind) =>
setDeviceInUse(false, kind)
export const clearDeviceInUseForOtherDevice = (
kind: PermissionKind,
selectedDeviceId?: string
) => {
const affected = deviceAvailabilityStore[IN_USE_DEVICE_KEY[kind]]
if (!affected || !selectedDeviceId || affected === selectedDeviceId) return
clearDeviceInUse(kind)
}
export const syncDeviceAvailability = async (): Promise<void> => {
try {