♻️(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 { useEffect } from 'react'
import { useSnapshot } from 'valtio' 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 { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
import type { PermissionKind } from '@/stores/permissions' 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 * 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 * while a device is flagged in use, re-probe it periodically. The probe
* the flag (the toggle becomes usable again); it never re-enables the * targets the currently selected device (the flag is about what the app
* device on the user's behalf. * 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() { export function useWatchDeviceReleased() {
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore) const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
useWatchKind('camera', cameraInUse) const { audioDeviceId, videoDeviceId } = useSnapshot(userChoicesStore)
useWatchKind('microphone', microphoneInUse) 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(() => { useEffect(() => {
if (!inUse) return if (!inUse) return
const id = setInterval( const id = setInterval(
() => void probeDeviceReleased(kind), () => void probeDeviceReleased(kind, selectedDeviceId || undefined),
RETRY_INTERVAL_MS RETRY_INTERVAL_MS
) )
return () => clearInterval(id) return () => clearInterval(id)
}, [kind, inUse]) }, [kind, inUse, selectedDeviceId])
} }
@@ -23,6 +23,7 @@ import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceInUse } from '../../../hooks/useDeviceInUse' import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing' import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { requestDevicePermission } from '../../../utils/mediaPermissions' import { requestDevicePermission } from '../../../utils/mediaPermissions'
import { userChoicesStore } from '@/stores/userChoices'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons' import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut' import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import type { import type {
@@ -127,7 +128,16 @@ export const ToggleDevice = <T extends ToggleSource>({
if (isRequestingPermission.current) return if (isRequestingPermission.current) return
isRequestingPermission.current = true isRequestingPermission.current = true
try { 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) { if (acquired) {
toggle() toggle()
} else if (cannotUseDevice) { } else if (cannotUseDevice) {
@@ -131,12 +131,14 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
enabled, enabled,
create, create,
permissionKind, permissionKind,
deviceId,
onFailure, onFailure,
}: { }: {
ready: boolean ready: boolean
enabled: boolean enabled: boolean
create: () => Promise<T> create: () => Promise<T>
permissionKind: PermissionKind permissionKind: PermissionKind
deviceId?: string
onFailure: () => void onFailure: () => void
}): T | null { }): T | null {
const [track, setTrack] = useState<T | null>(null) const [track, setTrack] = useState<T | null>(null)
@@ -157,13 +159,18 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
setTrack(newTrack) setTrack(newTrack)
}) })
.catch((error) => { .catch((error) => {
onMediaPermissionError(error as Error, permissionKind) onMediaPermissionError(
error as Error,
permissionKind,
'join_preview',
deviceId
)
onFailure() onFailure()
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, [ready, enabled, track, create, permissionKind, onFailure]) }, [ready, enabled, track, create, permissionKind, deviceId, onFailure])
// Release on toggle-off so the LED turns off. // Release on toggle-off so the LED turns off.
useEffect(() => { useEffect(() => {
@@ -237,6 +244,7 @@ export function useJoinTracks(): {
enabled: audioEnabled, enabled: audioEnabled,
create: createAudio, create: createAudio,
permissionKind: 'microphone', permissionKind: 'microphone',
deviceId: audioDeviceId,
onFailure: disableAudio, onFailure: disableAudio,
}) })
@@ -245,6 +253,7 @@ export function useJoinTracks(): {
enabled: videoEnabled, enabled: videoEnabled,
create: createVideo, create: createVideo,
permissionKind: 'camera', permissionKind: 'camera',
deviceId: videoDeviceId,
onFailure: disableVideo, onFailure: disableVideo,
}) })
@@ -62,7 +62,8 @@ export const noteDeviceReady = (kind?: PermissionKind) => {
export const onMediaPermissionError = ( export const onMediaPermissionError = (
e: Error, e: Error,
kind?: PermissionKind, kind?: PermissionKind,
path: MediaPath = 'join_preview' path: MediaPath = 'join_preview',
deviceId?: string
) => { ) => {
const failure = getMediaDeviceFailure(e) const failure = getMediaDeviceFailure(e)
@@ -103,7 +104,7 @@ export const onMediaPermissionError = (
} }
if (failure === MediaDeviceFailure.DeviceInUse) { if (failure === MediaDeviceFailure.DeviceInUse) {
noteDeviceInUse(kind) noteDeviceInUse(kind, deviceId)
void captureMediaEvent('device-in-use', { path, kind, os: getOS() }) void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
return return
} }
@@ -122,11 +123,13 @@ export const onMediaPermissionError = (
* polled. Clears the in-use flag on success. * polled. Clears the in-use flag on success.
*/ */
export const probeDeviceReleased = async ( export const probeDeviceReleased = async (
kind: PermissionKind kind: PermissionKind,
deviceId?: string
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
const constraint = deviceId ? { deviceId: { exact: deviceId } } : true
const stream = await navigator.mediaDevices.getUserMedia( const stream = await navigator.mediaDevices.getUserMedia(
kind === 'camera' ? { video: true } : { audio: true } kind === 'camera' ? { video: constraint } : { audio: constraint }
) )
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track) => track.stop())
noteDeviceReady(kind) noteDeviceReady(kind)
@@ -142,18 +145,24 @@ export const probeDeviceReleased = async (
*/ */
export const requestDevicePermission = async ( export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput', kind: 'audioinput' | 'videoinput',
path: MediaPath = 'join_preview' path: MediaPath = 'join_preview',
deviceId?: string
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
const track = const track =
kind === 'audioinput' kind === 'audioinput'
? await createLocalAudioTrack() ? await createLocalAudioTrack({ deviceId })
: await createLocalVideoTrack() : await createLocalVideoTrack({ deviceId })
track.stop() track.stop()
noteDeviceReady(PERMISSION_KIND[kind]) noteDeviceReady(PERMISSION_KIND[kind])
return true return true
} catch (error) { } catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path) onMediaPermissionError(
error as Error,
PERMISSION_KIND[kind],
path,
deviceId
)
return false return false
} }
} }
+37 -8
View File
@@ -6,8 +6,18 @@ import type { PermissionKind } from './permissions'
// - presence: enumerateDevices() exposes kinds before any grant. // - presence: enumerateDevices() exposes kinds before any grant.
// Optimistic defaults until the first sync. // Optimistic defaults until the first sync.
// - in use: the device exists and is allowed, but getUserMedia() failed // - in use: the device exists and is allowed, but getUserMedia() failed
// because another application or tab is holding it. // because another application or tab is holding it. The id of the
export const deviceAvailabilityStore = proxy({ // 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, hasCamera: true,
hasMicrophone: true, hasMicrophone: true,
cameraInUse: false, cameraInUse: false,
@@ -20,19 +30,38 @@ const IN_USE_KEY: Record<PermissionKind, 'cameraInUse' | 'microphoneInUse'> = {
microphone: 'microphoneInUse', microphone: 'microphoneInUse',
} }
const IN_USE_DEVICE_KEY: Record<
PermissionKind,
'cameraInUseDeviceId' | 'microphoneInUseDeviceId'
> = {
camera: 'cameraInUseDeviceId',
microphone: 'microphoneInUseDeviceId',
}
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone'] 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) { 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) => export const clearDeviceInUse = (kind?: PermissionKind) => {
setDeviceInUse(true, kind) 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) => export const clearDeviceInUseForOtherDevice = (
setDeviceInUse(false, kind) 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> => { export const syncDeviceAvailability = async (): Promise<void> => {
try { try {