mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-30 12:09:08 +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:
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user