diff --git a/CHANGELOG.md b/CHANGELOG.md
index 454f0230..0f2fb050 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,10 @@ and this project adheres to
## [Unreleased]
+### Added
+
+- 🚸(frontend) explain camera-in-use failures on the join screen
+
## [1.27.0] - 2026-08-14
### 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..be5e67fa 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,6 +20,7 @@ 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 { useDeviceIcons } from '../../../hooks/useDeviceIcons'
@@ -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) {
@@ -185,10 +188,18 @@ export const ToggleDevice = ({
setShowDeviceNotFound(true) : undefined
+ deviceMissing
+ ? () => setAlertError(MediaDeviceFailure.NotFound)
+ : undefined
}
/>
)}
+ {deviceInUse && (
+ setAlertError(MediaDeviceFailure.DeviceInUse)}
+ />
+ )}
{silentMicWarning && (
({
tooltip={
deviceMissing
? t(`deviceNotFound.${kind}`)
- : cannotUseDevice
- ? t('tooltip', { keyPrefix: 'permissionsButton' })
- : toggleLabel
+ : deviceInUse
+ ? t(`deviceInUse.${kind}`)
+ : cannotUseDevice
+ ? t('tooltip', { keyPrefix: 'permissionsButton' })
+ : toggleLabel
}
{...computedToggleButtonProps}
{...overrideToggleButtonProps}
@@ -217,9 +230,9 @@ export const ToggleDevice = ({
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..8cca93e1
--- /dev/null
+++ b/src/frontend/src/features/rooms/livekit/hooks/useDeviceInUse.ts
@@ -0,0 +1,15 @@
+import { useSnapshot } from 'valtio'
+import { deviceInUseStore } from '@/stores/deviceInUse'
+import { PERMISSION_BY_DEVICE_KIND } from '@/stores/permissions'
+import { useCannotUseDevice } from './useCannotUseDevice'
+import { useDeviceMissing } from './useDeviceMissing'
+
+export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
+ const inUse = useSnapshot(deviceInUseStore)
+ const cannotUseDevice = useCannotUseDevice(kind)
+ const deviceMissing = useDeviceMissing(kind)
+
+ const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
+ if (!permissionKind || cannotUseDevice || deviceMissing) return false
+ return inUse[permissionKind]
+}
diff --git a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts
index 4192c837..d7dd2943 100644
--- a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts
+++ b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts
@@ -18,6 +18,7 @@ import {
noteSystemPermissionDenied,
type PermissionKind,
} from '@/stores/permissions'
+import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceInUse'
import { getOS } from '@/utils/os'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import {
@@ -89,7 +90,15 @@ const onMediaPermissionError = (
return
}
- // "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
+ if (
+ MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.DeviceInUse &&
+ path === 'join_preview'
+ ) {
+ noteDeviceInUse(kind)
+ void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
+ return
+ }
+
reportError(
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
e,
@@ -97,6 +106,11 @@ const onMediaPermissionError = (
)
}
+const noteDeviceReady = (kind?: PermissionKind) => {
+ noteGumSuccess(kind)
+ clearDeviceInUse(kind)
+}
+
// Module-level: effect dependencies, must be referentially stable.
const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false)
@@ -114,7 +128,7 @@ export const requestDevicePermission = async (
? await createLocalAudioTrack()
: await createLocalVideoTrack()
track.stop()
- noteGumSuccess(PERMISSION_KIND[kind])
+ noteDeviceReady(PERMISSION_KIND[kind])
return true
} catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
@@ -158,7 +172,7 @@ function useWarmupPermissions(): WarmupState {
video: true,
})
)
- noteGumSuccess()
+ noteDeviceReady()
bothReady()
} catch (error) {
if (
@@ -180,7 +194,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 +204,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 +241,7 @@ function useLocalTrack({
let cancelled = false
create()
.then((newTrack) => {
- noteGumSuccess(permissionKind)
+ noteDeviceReady(permissionKind)
if (cancelled) {
newTrack.stop()
return
@@ -291,6 +305,8 @@ export function useJoinTracks(): {
const { audioReady, videoReady } = useWarmupPermissions()
+ useEffect(() => () => clearDeviceInUse(), [])
+
const createAudio = useCallback(
() =>
createLocalAudioTrack({
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/deviceInUse.ts b/src/frontend/src/stores/deviceInUse.ts
new file mode 100644
index 00000000..db610a42
--- /dev/null
+++ b/src/frontend/src/stores/deviceInUse.ts
@@ -0,0 +1,21 @@
+import { proxy } from 'valtio'
+import type { PermissionKind } from './permissions'
+
+export const deviceInUseStore = proxy>({
+ camera: false,
+ microphone: false,
+})
+
+const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
+
+export const noteDeviceInUse = (kind?: PermissionKind) => {
+ for (const k of kind ? [kind] : ALL_KINDS) {
+ deviceInUseStore[k] = true
+ }
+}
+
+export const clearDeviceInUse = (kind?: PermissionKind) => {
+ for (const k of kind ? [kind] : ALL_KINDS) {
+ deviceInUseStore[k] = false
+ }
+}