diff --git a/CHANGELOG.md b/CHANGELOG.md index 59338f3c..f3cebecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - 📈(frontend) track errors when starting or stopping a recording +- 🚾(frontend) explain camera-in-use failures on the join screen ### Changed diff --git a/src/frontend/src/features/analytics/telemetry.ts b/src/frontend/src/features/analytics/telemetry.ts index e208490d..79e454ae 100644 --- a/src/frontend/src/features/analytics/telemetry.ts +++ b/src/frontend/src/features/analytics/telemetry.ts @@ -137,6 +137,7 @@ export const captureMediaEvent = async ( | 'media-device-topology' | 'media-device-success' | 'device-not-found' + | 'device-in-use' | 'permissions-denied' | 'screen-share-permission-denied' | 'silent-mic-detected' diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index caaffd39..663d7ff5 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -28,6 +28,7 @@ import { userChoicesStore, } from '@/stores/userChoices' import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice' +import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse' import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing' import { useJoinTracks } from '../livekit/hooks/useJoinTracks' import { SilentMicDetector } from './SilentMicDetector' @@ -218,12 +219,14 @@ const switchTrackDevice = function getPreviewMessages({ cameraFound, cameraDenied, + cameraInUse, micDenied, videoEnabled, videoStarted, }: { cameraFound: boolean cameraDenied: boolean + cameraInUse: boolean micDenied: boolean videoEnabled: boolean videoStarted: boolean @@ -235,6 +238,9 @@ function getPreviewMessages({ const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted' return { hint: key, permissionsButtonLabel: key } } + if (cameraInUse) { + return { hint: 'cameraInUse', permissionsButtonLabel: null } + } if (!videoEnabled) { return { hint: 'cameraDisabled', permissionsButtonLabel: null } } @@ -328,18 +334,20 @@ const VideoPreview = ({ const cameraDenied = useCannotUseDevice('videoinput') const micDenied = useCannotUseDevice('audioinput') const cameraMissing = useDeviceMissing('videoinput') + const cameraInUse = useDeviceInUse('videoinput') const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled) const { hint, permissionsButtonLabel } = getPreviewMessages({ cameraFound: !cameraMissing, cameraDenied, + cameraInUse, micDenied, videoEnabled, videoStarted, }) - const isError = cameraMissing || cameraDenied + const isError = cameraMissing || cameraDenied || cameraInUse return (
diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx index 23b9f201..0361f649 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx @@ -20,8 +20,9 @@ import { openPermissionsDialog } from '@/stores/permissions' import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic' import { useSnapshot } from 'valtio' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' +import { useDeviceInUse } from '../../../hooks/useDeviceInUse' import { useDeviceMissing } from '../../../hooks/useDeviceMissing' -import { requestDevicePermission } from '../../../hooks/useJoinTracks' +import { requestDevicePermission } from '../../../utils/mediaPermissions' import { useDeviceIcons } from '../../../hooks/useDeviceIcons' import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut' import type { @@ -97,21 +98,23 @@ export const ToggleDevice = ({ const deviceIcons = useDeviceIcons(kind) const cannotUseDevice = useCannotUseDevice(kind) const deviceMissing = useDeviceMissing(kind) + const deviceInUse = useDeviceInUse(kind) const { status: silentMicStatus } = useSnapshot(silentMicStore) const silentMicWarning = kind === 'audioinput' && silentMicStatus === 'silent' && !cannotUseDevice && - !deviceMissing + !deviceMissing && + !deviceInUse const deviceShortcut = useDeviceShortcut(kind) const announce = useScreenReaderAnnounce() const isRequestingPermission = useRef(false) - const [showDeviceNotFound, setShowDeviceNotFound] = useState(false) + const [alertError, setAlertError] = useState(null) const onPress = async () => { if (!enabled && deviceMissing) { - setShowDeviceNotFound(true) + setAlertError(MediaDeviceFailure.NotFound) return } if (!cannotUseDevice) { @@ -179,16 +182,32 @@ export const ToggleDevice = ({ return } + const getToggleTooltip = () => { + if (deviceMissing) return t(`deviceNotFound.${kind}`) + if (deviceInUse) return t(`deviceInUse.${kind}`) + if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' }) + return toggleLabel + } + const toggleTooltip = getToggleTooltip() + return (
{(cannotUseDevice || deviceMissing) && ( setShowDeviceNotFound(true) : undefined + deviceMissing + ? () => setAlertError(MediaDeviceFailure.NotFound) + : undefined } /> )} + {deviceInUse && ( + setAlertError(MediaDeviceFailure.DeviceInUse)} + /> + )} {silentMicWarning && ( ({ shySelected onPress={onPress} aria-label={toggleLabel} - tooltip={ - deviceMissing - ? t(`deviceNotFound.${kind}`) - : cannotUseDevice - ? t('tooltip', { keyPrefix: 'permissionsButton' }) - : toggleLabel - } + tooltip={toggleTooltip} {...computedToggleButtonProps} {...overrideToggleButtonProps} > setShowDeviceNotFound(false)} + onClose={() => setAlertError(null)} />
) diff --git a/src/frontend/src/features/rooms/livekit/hooks/useDeviceInUse.ts b/src/frontend/src/features/rooms/livekit/hooks/useDeviceInUse.ts new file mode 100644 index 00000000..2f4c0067 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useDeviceInUse.ts @@ -0,0 +1,21 @@ +import { useSnapshot } from 'valtio' +import { deviceAvailabilityStore } from '@/stores/deviceAvailability' +import { useCannotUseDevice } from './useCannotUseDevice' +import { useDeviceMissing } from './useDeviceMissing' + +export const useDeviceInUse = (kind: MediaDeviceKind): boolean => { + const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore) + const cannotUseDevice = useCannotUseDevice(kind) + const deviceMissing = useDeviceMissing(kind) + + if (cannotUseDevice || deviceMissing) return false + + switch (kind) { + case 'videoinput': + return cameraInUse + case 'audioinput': + return microphoneInUse + default: + return false + } +} diff --git a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts index 4192c837..8522dbfd 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts @@ -10,16 +10,13 @@ import { } from 'livekit-client' import { BackgroundProcessorFactory } from '../components/blur' import { - classifyPermissionError, - isLikelySystemNotFound, isSystemPermissionError, - noteGumSuccess, - notePermissionDeniedFromGum, - noteSystemPermissionDenied, type PermissionKind, } from '@/stores/permissions' -import { getOS } from '@/utils/os' -import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' +import { + noteDeviceReady, + onMediaPermissionError, +} from '../utils/mediaPermissions' import { saveAudioInputDeviceId, saveAudioInputEnabled, @@ -39,64 +36,6 @@ const VOICE_AUDIO_CONSTRAINTS = { sampleSize: 16, } as const -const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = { - audioinput: 'microphone', - videoinput: 'camera', -} - -type MediaPath = 'join_preview' | 'room' - -const onMediaPermissionError = ( - e: Error, - kind?: PermissionKind, - path: MediaPath = 'join_preview' -) => { - if ( - MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied - ) { - void classifyPermissionError(e, kind).then((scope) => { - if (scope === 'system') { - noteSystemPermissionDenied(kind) - } else { - notePermissionDeniedFromGum(kind) - } - captureMediaEvent('permissions-denied', { - path, - kind, - denied_scope: scope, - os: getOS(), - }) - }) - return - } - - if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) { - // Firefox reports OS-level blocks as NotFoundError (macOS privacy - // settings, missing Android app permissions). - void isLikelySystemNotFound(e, kind).then((system) => { - if (system) { - noteSystemPermissionDenied(kind) - captureMediaEvent('permissions-denied', { - path, - kind, - denied_scope: 'system', - os: getOS(), - }) - return - } - captureMediaEvent('device-not-found', { path, kind }) - }) - return - } - - // "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen. - reportError( - path === 'room' ? 'room_media_failure' : 'join_preview_failure', - e, - { path, kind } - ) -} - // Module-level: effect dependencies, must be referentially stable. const disableAudio = () => saveAudioInputEnabled(false) const disableVideo = () => saveVideoInputEnabled(false) @@ -104,24 +43,6 @@ const disableVideo = () => saveVideoInputEnabled(false) const stopAll = (stream: MediaStream) => stream.getTracks().forEach((track) => track.stop()) -export const requestDevicePermission = async ( - kind: 'audioinput' | 'videoinput', - path: MediaPath = 'join_preview' -): Promise => { - try { - const track = - kind === 'audioinput' - ? await createLocalAudioTrack() - : await createLocalVideoTrack() - track.stop() - noteGumSuccess(PERMISSION_KIND[kind]) - return true - } catch (error) { - onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path) - return false - } -} - type WarmupState = { audioReady: boolean videoReady: boolean @@ -158,7 +79,7 @@ function useWarmupPermissions(): WarmupState { video: true, }) ) - noteGumSuccess() + noteDeviceReady() bothReady() } catch (error) { if ( @@ -180,7 +101,7 @@ function useWarmupPermissions(): WarmupState { .getUserMedia({ audio: true }) .then((stream) => { stopAll(stream) - noteGumSuccess('microphone') + noteDeviceReady('microphone') }) .catch((e) => onMediaPermissionError(e as Error, 'microphone')) .finally(() => @@ -190,7 +111,7 @@ function useWarmupPermissions(): WarmupState { .getUserMedia({ video: true }) .then((stream) => { stopAll(stream) - noteGumSuccess('camera') + noteDeviceReady('camera') }) .catch((e) => onMediaPermissionError(e as Error, 'camera')) .finally(() => @@ -227,7 +148,7 @@ function useLocalTrack({ let cancelled = false create() .then((newTrack) => { - noteGumSuccess(permissionKind) + noteDeviceReady(permissionKind) if (cancelled) { newTrack.stop() return diff --git a/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts b/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts index 47803444..ac8edd8d 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useState } from 'react' import { useRoomContext } from '@livekit/components-react' -import { MediaDeviceFailure, RoomEvent } from 'livekit-client' +import { + type LocalTrackPublication, + MediaDeviceFailure, + RoomEvent, + Track, +} from 'livekit-client' import { PERMISSION_BY_DEVICE_KIND, type PermissionDeniedScope, @@ -10,7 +15,11 @@ import { notePermissionDeniedFromGum, noteSystemPermissionDenied, } from '@/stores/permissions' -import { syncDeviceAvailability } from '@/stores/deviceAvailability' +import { + clearDeviceInUse, + noteDeviceInUse, + syncDeviceAvailability, +} from '@/stores/deviceAvailability' import { captureMediaEvent } from '@/features/analytics/telemetry' import { getOS } from '@/utils/os' @@ -21,6 +30,11 @@ type MediaDeviceAlert = { const NO_ALERT: MediaDeviceAlert = { error: null, kind: null } +const PERMISSION_BY_SOURCE: Partial> = { + [Track.Source.Camera]: 'camera', + [Track.Source.Microphone]: 'microphone', +} + const capturePermissionsDenied = ( scope: PermissionDeniedScope, kind?: PermissionKind @@ -63,6 +77,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & { const permissionKind = PERMISSION_BY_DEVICE_KIND[kind] switch (failure) { case MediaDeviceFailure.DeviceInUse: + noteDeviceInUse(permissionKind) setAlert({ error: failure, kind }) break case MediaDeviceFailure.NotFound: @@ -92,9 +107,16 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & { break } } + const onTrackPublished = (publication: LocalTrackPublication) => { + const permissionKind = PERMISSION_BY_SOURCE[publication.source] + if (permissionKind) clearDeviceInUse(permissionKind) + } room.on(RoomEvent.MediaDevicesError, onDeviceError) + room.on(RoomEvent.LocalTrackPublished, onTrackPublished) return () => { room.off(RoomEvent.MediaDevicesError, onDeviceError) + room.off(RoomEvent.LocalTrackPublished, onTrackPublished) + clearDeviceInUse() } }, [room]) diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 16084e99..0c9f96ca 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -1,5 +1,5 @@ import { isWeb } from '@livekit/components-core' -import { Track } from 'livekit-client' +import { MediaDeviceFailure, Track } from 'livekit-client' import React, { useState } from 'react' import { ConnectionStateToast, @@ -100,6 +100,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) { return } } + + if (MediaDeviceFailure.getFailure(error) != MediaDeviceFailure.Other) { + return + } + reportError('device_switch_failure', error, { at: 'ControlBar.onDeviceError', source, diff --git a/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts b/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts new file mode 100644 index 00000000..2bbfc9b2 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts @@ -0,0 +1,115 @@ +import { + createLocalAudioTrack, + createLocalVideoTrack, + MediaDeviceFailure, +} from 'livekit-client' +import { + classifyPermissionError, + isLikelySystemNotFound, + noteGumSuccess, + notePermissionDeniedFromGum, + noteSystemPermissionDenied, + type PermissionKind, +} from '@/stores/permissions' +import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceAvailability' +import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' +import { getOS } from '@/utils/os' + +/** + * Shared handling of getUserMedia() outcomes, used by both: + * - the join preview (`useJoinTracks`: warmup + local track acquisition) + * - the in-room device toggle (`ToggleDevice`, when permission is missing) + */ +export type MediaPath = 'join_preview' | 'room' + +export const PERMISSION_KIND: Record< + 'audioinput' | 'videoinput', + PermissionKind +> = { + audioinput: 'microphone', + videoinput: 'camera', +} + +export const noteDeviceReady = (kind?: PermissionKind) => { + noteGumSuccess(kind) + clearDeviceInUse(kind) +} + +export const onMediaPermissionError = ( + e: Error, + kind?: PermissionKind, + path: MediaPath = 'join_preview' +) => { + const failure = MediaDeviceFailure.getFailure(e) + + if (failure === MediaDeviceFailure.PermissionDenied) { + void classifyPermissionError(e, kind).then((scope) => { + if (scope === 'system') { + noteSystemPermissionDenied(kind) + } else { + notePermissionDeniedFromGum(kind) + } + captureMediaEvent('permissions-denied', { + path, + kind, + denied_scope: scope, + os: getOS(), + }) + }) + return + } + + if (failure === MediaDeviceFailure.NotFound) { + // Firefox reports OS-level blocks as NotFoundError (macOS privacy + // settings, missing Android app permissions). + void isLikelySystemNotFound(e, kind).then((system) => { + if (system) { + noteSystemPermissionDenied(kind) + captureMediaEvent('permissions-denied', { + path, + kind, + denied_scope: 'system', + os: getOS(), + }) + return + } + captureMediaEvent('device-not-found', { path, kind }) + }) + return + } + + if (failure === MediaDeviceFailure.DeviceInUse) { + noteDeviceInUse(kind) + void captureMediaEvent('device-in-use', { path, kind, os: getOS() }) + return + } + + // "Other" is still reported as an error. + reportError( + path === 'room' ? 'room_media_failure' : 'join_preview_failure', + e, + { path, kind } + ) +} + +/** + * Triggers the browser permission prompt for one device kind by acquiring + * and immediately releasing a track. Resolves to whether access was granted. + */ +export const requestDevicePermission = async ( + kind: 'audioinput' | 'videoinput', + path: MediaPath = 'join_preview' +): Promise => { + try { + const track = + kind === 'audioinput' + ? await createLocalAudioTrack() + : await createLocalVideoTrack() + track.stop() + noteDeviceReady(PERMISSION_KIND[kind]) + return true + } catch (error) { + onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path) + return false + } +} diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index bdacc707..f376255e 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -16,6 +16,10 @@ "videoinput": "Keine Kamera erkannt. PrĂŒfe, ob sie richtig angeschlossen ist.", "audioinput": "Kein Mikrofon erkannt. PrĂŒfe, ob es richtig angeschlossen ist." }, + "deviceInUse": { + "videoinput": "Kamera nicht verfĂŒgbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.", + "audioinput": "Mikrofon nicht verfĂŒgbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet." + }, "settings": { "audio": "Audioeinstellungen", "video": "Videoeinstellungen" @@ -67,6 +71,7 @@ }, "cameraDisabled": "Kamera ist deaktiviert.", "cameraNotFound": "Keine Kamera erkannt. PrĂŒfe, ob sie richtig angeschlossen ist.", + "cameraInUse": "Deine Kamera ist nicht verfĂŒgbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.", "cameraStarting": "Kamera wird gestartet
", "cameraNotGranted": "Möchtest du, dass andere dich wĂ€hrend des Meetings sehen können?", "cameraAndMicNotGranted": "Möchtest du, dass andere dich wĂ€hrend des Meetings sehen und hören können?", diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 1c48fd80..22735163 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -16,6 +16,10 @@ "videoinput": "No camera detected. Check that it is properly wired.", "audioinput": "No microphone detected. Check that it is properly wired." }, + "deviceInUse": { + "videoinput": "Camera unavailable: it is probably in use by another application or browser tab.", + "audioinput": "Microphone unavailable: it is probably in use by another application or browser tab." + }, "settings": { "audio": "Audio settings", "video": "Video settings" @@ -67,6 +71,7 @@ }, "cameraDisabled": "Camera is disabled.", "cameraNotFound": "No camera detected. Check that it is properly plugged in.", + "cameraInUse": "Your camera is unavailable. It is probably being used by another application or browser tab.", "cameraStarting": "Camera is starting
", "cameraNotGranted": "Would you like others to be able to see you during the meeting?", "cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 9091d5a5..4de935b3 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -16,6 +16,10 @@ "videoinput": "Aucune camĂ©ra dĂ©tectĂ©e. VĂ©rifiez qu'elle est bien branchĂ©e.", "audioinput": "Aucun microphone dĂ©tectĂ©. VĂ©rifiez qu'il est bien branchĂ©." }, + "deviceInUse": { + "videoinput": "CamĂ©ra indisponible : elle est probablement utilisĂ©e par une autre application ou un autre onglet.", + "audioinput": "Microphone indisponible : il est probablement utilisĂ© par une autre application ou un autre onglet." + }, "settings": { "audio": "ParamĂštres audio", "video": "ParamĂštres video" @@ -67,6 +71,7 @@ }, "cameraDisabled": "La camĂ©ra est dĂ©sactivĂ©e.", "cameraNotFound": "Aucune camĂ©ra dĂ©tectĂ©e. VĂ©rifiez qu'elle est bien branchĂ©e.", + "cameraInUse": "Votre camĂ©ra n'est pas disponible. Elle est probablement utilisĂ©e par une autre application ou un autre onglet.", "cameraStarting": "La camĂ©ra va dĂ©marrer
", "cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la rĂ©union ?", "cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la rĂ©union ?", diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 36fff7dc..239f3641 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -16,6 +16,10 @@ "videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.", "audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten." }, + "deviceInUse": { + "videoinput": "Camera niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.", + "audioinput": "Microfoon niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad." + }, "settings": { "audio": "Audio-instellingen", "video": "Video-instellingen" @@ -67,6 +71,7 @@ }, "cameraDisabled": "Camera is uitgeschakeld.", "cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.", + "cameraInUse": "Je camera is niet beschikbaar. Deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.", "cameraStarting": "Camera wordt ingeschakeld
", "cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?", "cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?", diff --git a/src/frontend/src/stores/deviceAvailability.ts b/src/frontend/src/stores/deviceAvailability.ts index 3edafdda..a70f80b6 100644 --- a/src/frontend/src/stores/deviceAvailability.ts +++ b/src/frontend/src/stores/deviceAvailability.ts @@ -1,14 +1,39 @@ import { proxy } from 'valtio' import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' +import type { PermissionKind } from './permissions' -// Device presence (not permission): enumerateDevices() exposes kinds -// before any grant. Optimistic defaults until the first sync. +// Device availability (not permission): +// - 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({ hasCamera: true, hasMicrophone: true, + cameraInUse: false, + microphoneInUse: false, synced: false, }) +const IN_USE_KEY: Record = { + camera: 'cameraInUse', + microphone: 'microphoneInUse', +} + +const ALL_KINDS: PermissionKind[] = ['camera', 'microphone'] + +const setDeviceInUse = (inUse: boolean, kind?: PermissionKind) => { + for (const k of kind ? [kind] : ALL_KINDS) { + deviceAvailabilityStore[IN_USE_KEY[k]] = inUse + } +} + +export const noteDeviceInUse = (kind?: PermissionKind) => + setDeviceInUse(true, kind) + +export const clearDeviceInUse = (kind?: PermissionKind) => + setDeviceInUse(false, kind) + export const syncDeviceAvailability = async (): Promise => { try { const devices = await navigator.mediaDevices.enumerateDevices()