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()