mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-25 01:37:30 +00:00
♻️(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:
committed by
aleb_the_flash
parent
a9fa3eb4ba
commit
e000377b5c
@@ -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])
|
||||
}
|
||||
|
||||
+11
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user