🐛(frontend) handle missing device errors gracefully

Handle the "requested device not found" error surfaced in production
when users arrive without a microphone or camera available on their
computer. Some devices also have a hardware button that physically
locks the microphone and makes it invisible to the browser.

Instead of failing loudly, surface a clearer state to the user so
they can still proceed with whatever device is actually available.
This commit is contained in:
lebaudantoine
2026-08-10 18:59:24 +02:00
committed by aleb_the_flash
parent 089db20a2e
commit 199c0297d4
14 changed files with 204 additions and 9 deletions
+1
View File
@@ -27,6 +27,7 @@ and this project adheres to
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
- 🐛(frontend) fix permission store regression
- 🐛(frontend) handle missing device errors gracefully
## [1.25.2] - 2026-08-06
@@ -133,7 +133,8 @@ export const captureMediaEvent = async (
| 'media-device-error'
| 'media-acquisition'
| 'media-device-topology'
| 'media-device-success',
| 'media-device-success'
| 'device-not-found',
props: Record<string, unknown>
) => {
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
@@ -45,6 +45,7 @@ import {
PERMISSION_BY_DEVICE_KIND,
notePermissionDeniedFromGum,
} from '@/stores/permissions'
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
export const Conference = ({
roomId,
@@ -317,6 +318,10 @@ export const Conference = ({
case MediaDeviceFailure.DeviceInUse:
setMediaDeviceError({ error: e, kind })
break
case MediaDeviceFailure.NotFound:
setMediaDeviceError({ error: e, kind })
void syncDeviceAvailability()
break
case MediaDeviceFailure.PermissionDenied:
notePermissionDeniedFromGum(PERMISSION_BY_DEVICE_KIND[kind])
break
@@ -29,6 +29,7 @@ import {
} from '@/stores/userChoices'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
const styles = {
page: css({
@@ -214,16 +215,21 @@ const switchTrackDevice =
}
function getPreviewMessages({
cameraFound,
cameraDenied,
micDenied,
videoEnabled,
videoStarted,
}: {
cameraFound: boolean
cameraDenied: boolean
micDenied: boolean
videoEnabled: boolean
videoStarted: boolean
}): { hint: string | null; permissionsButtonLabel: string | null } {
if (!cameraFound) {
return { hint: 'cameraNotFound', permissionsButtonLabel: null }
}
if (cameraDenied) {
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
return { hint: key, permissionsButtonLabel: key }
@@ -320,10 +326,12 @@ const VideoPreview = ({
const cameraDenied = useCannotUseDevice('videoinput')
const micDenied = useCannotUseDevice('audioinput')
const { hasCamera } = useSnapshot(deviceAvailabilityStore)
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
const { hint, permissionsButtonLabel } = getPreviewMessages({
cameraFound: hasCamera,
cameraDenied,
micDenied,
videoEnabled,
@@ -0,0 +1,19 @@
import { useEffect } from 'react'
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
export function useWatchDeviceAvailability() {
useEffect(() => {
if (!navigator.mediaDevices) return
syncDeviceAvailability()
navigator.mediaDevices.addEventListener(
'devicechange',
syncDeviceAvailability
)
return () => {
navigator.mediaDevices.removeEventListener(
'devicechange',
syncDeviceAvailability
)
}
}, [])
}
@@ -4,8 +4,17 @@ import { openPermissionsDialog } from '@/stores/permissions'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
export const PermissionNeededButton = () => {
type PermissionNeededButtonProps = {
tooltip?: string
onPress?: () => void
}
export const PermissionNeededButton = ({
tooltip,
onPress,
}: PermissionNeededButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
const label = tooltip ?? t('tooltip')
return (
<div
className={css({
@@ -17,9 +26,9 @@ export const PermissionNeededButton = () => {
})}
>
<Button
aria-label={t('ariaLabel')}
tooltip={t('tooltip')}
onPress={() => openPermissionsDialog()}
aria-label={tooltip ? label : t('ariaLabel')}
tooltip={label}
onPress={onPress ?? (() => openPermissionsDialog())}
variant="permission"
>
<div
@@ -4,6 +4,8 @@ import { useEffect, useMemo } from 'react'
import { Select, SelectProps } from '@/primitives/Select'
import type { Placement } from '@react-types/overlays'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useSnapshot } from 'valtio'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
import type { LocalAudioTrack } from 'livekit-client'
import { AudioLevelGauge } from './AudioLevelGauge'
@@ -113,6 +115,31 @@ export const SelectDevice = ({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const { hasCamera, hasMicrophone } = useSnapshot(deviceAvailabilityStore)
const deviceMissing =
kind === 'videoinput'
? !hasCamera
: kind === 'audioinput'
? !hasMicrophone
: false
if (deviceMissing) {
return (
<Select
aria-label={t(`NotFound.title.${kind}`, {
keyPrefix: 'mediaErrorDialog',
})}
label=""
isDisabled={true}
items={[]}
placeholder={t(`NotFound.title.${kind}`, {
keyPrefix: 'mediaErrorDialog',
})}
iconComponent={deviceIcons.select}
{...contextProps}
/>
)
}
if (cannotUseDevice) {
return (
@@ -12,9 +12,13 @@ import {
useMaybeRoomContext,
useRoomContext,
} from '@livekit/components-react'
import { MediaDeviceFailure } from 'livekit-client'
import { MediaDeviceErrorAlert } from '@/features/rooms/components/MediaDeviceErrorAlert'
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { useSnapshot } from 'valtio'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
@@ -91,12 +95,19 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const { hasCamera, hasMicrophone } = useSnapshot(deviceAvailabilityStore)
const deviceMissing = kind === 'videoinput' ? !hasCamera : !hasMicrophone
const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
const isRequestingPermission = useRef(false)
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
const onPress = async () => {
if (!enabled && deviceMissing) {
setShowDeviceNotFound(true)
return
}
if (!cannotUseDevice) {
toggle()
return
@@ -161,7 +172,14 @@ export const ToggleDevice = <T extends ToggleSource>({
return (
<div style={{ position: 'relative' }}>
{cannotUseDevice && <PermissionNeededButton />}
{(cannotUseDevice || deviceMissing) && (
<PermissionNeededButton
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
onPress={
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
}
/>
)}
<ToggleButton
isSelected={!enabled}
isDisabled={isDisabled}
@@ -172,15 +190,22 @@ export const ToggleDevice = <T extends ToggleSource>({
onPress={onPress}
aria-label={toggleLabel}
tooltip={
cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
deviceMissing
? t(`deviceNotFound.${kind}`)
: cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
{...computedToggleButtonProps}
{...overrideToggleButtonProps}
>
<Icon />
</ToggleButton>
<MediaDeviceErrorAlert
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
kind={kind}
onClose={() => setShowDeviceNotFound(false)}
/>
</div>
)
}
@@ -14,6 +14,7 @@ import {
} from '@/features/rooms/utils/isRoomValid'
import { useConfig } from '@/api/useConfig.ts'
import { LogLevel, setLogLevel } from 'livekit-client'
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
const BaseRoom = ({ children }: { children: ReactNode }) => {
return (
@@ -42,6 +43,7 @@ const Room = () => {
}, [data?.silence_livekit_debug_logs])
useKeyboardShortcuts()
useWatchDeviceAvailability()
const clearRouterState = () => {
if (window?.history?.state) {
+15
View File
@@ -12,6 +12,10 @@
"loading": "Laden…",
"select": "Wähle einen Wert",
"permissionsNeeded": "Berechtigung erforderlich",
"deviceNotFound": {
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
},
"settings": {
"audio": "Audioeinstellungen",
"video": "Videoeinstellungen"
@@ -62,6 +66,7 @@
"usernameEmpty": "Dein Name darf nicht leer sein"
},
"cameraDisabled": "Kamera ist deaktiviert.",
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
"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?",
@@ -117,6 +122,16 @@
"videoinput": "Deine Kamera wird bereits in einem anderen Tab oder von einer anderen App verwendet. Dies verhindert die Aktivierung in diesem Meeting. Wenn du deine Kamera hier verwenden möchtest, schließe den anderen Tab oder die App."
}
},
"NotFound": {
"title": {
"audioinput": "Kein Mikrofon erkannt",
"videoinput": "Keine Kamera erkannt"
},
"body": {
"audioinput": "Auf deinem Gerät wurde kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist und nicht durch einen Schalter stummgeschaltet wird, und versuche es dann erneut.",
"videoinput": "Auf deinem Gerät wurde keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist und nicht durch eine Abdeckung oder einen Schalter blockiert wird, und versuche es dann erneut."
}
},
"close": "OK"
},
"permissionErrorDialog": {
+15
View File
@@ -12,6 +12,10 @@
"loading": "Loading…",
"select": "Select a value",
"permissionsNeeded": "Permission needed",
"deviceNotFound": {
"videoinput": "No camera detected. Check that it is properly wired.",
"audioinput": "No microphone detected. Check that it is properly wired."
},
"settings": {
"audio": "Audio settings",
"video": "Video settings"
@@ -62,6 +66,7 @@
"usernameEmpty": "Your name cannot be empty"
},
"cameraDisabled": "Camera is disabled.",
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
"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?",
@@ -117,6 +122,16 @@
"videoinput": "Your camera is already in use by another tab or application, which prevents it from being activated in this video call. If you wish, close the other tab or application to use it here."
}
},
"NotFound": {
"title": {
"audioinput": "No microphone detected",
"videoinput": "No camera detected"
},
"body": {
"audioinput": "No microphone was detected on your device. Check that it is properly plugged in and not disabled by a mute switch, then try again.",
"videoinput": "No camera was detected on your device. Check that it is properly plugged in and not blocked by a cover or switch, then try again."
}
},
"close": "OK"
},
"permissionErrorDialog": {
+15
View File
@@ -12,6 +12,10 @@
"loading": "Chargement…",
"select": "Sélectionnez une valeur",
"permissionsNeeded": "Autorisations nécessaires",
"deviceNotFound": {
"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é."
},
"settings": {
"audio": "Paramètres audio",
"video": "Paramètres video"
@@ -62,6 +66,7 @@
"usernameEmpty": "Votre nom ne peut pas être vide"
},
"cameraDisabled": "La caméra est désactivée.",
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
"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 ?",
@@ -117,6 +122,16 @@
"videoinput": "Votre caméra est déjà utilisée dans un autre onglet ou une autre application, ce qui empêche son activation dans cette visioconférence. Si vous le souhaitez, fermez l'autre onglet ou application pour l'utiliser ici."
}
},
"NotFound": {
"title": {
"audioinput": "Aucun microphone détecté",
"videoinput": "Aucune caméra détectée"
},
"body": {
"audioinput": "Aucun microphone n'a été détecté sur votre appareil. Vérifiez qu'il est bien branché et qu'aucun bouton ne le coupe, puis réessayez.",
"videoinput": "Aucune caméra n'a été détectée sur votre appareil. Vérifiez qu'elle est bien branchée et qu'aucun cache ou bouton ne la bloque, puis réessayez."
}
},
"close": "OK"
},
"permissionErrorDialog": {
+15
View File
@@ -12,6 +12,10 @@
"loading": "Bezig met laden…",
"select": "Selecteer een waarde",
"permissionsNeeded": "Toestemming vereist",
"deviceNotFound": {
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
},
"settings": {
"audio": "Audio-instellingen",
"video": "Video-instellingen"
@@ -62,6 +66,7 @@
"usernameEmpty": "Uw naam kan niet leeg zijn"
},
"cameraDisabled": "Camera is uitgeschakeld.",
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
"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?",
@@ -117,6 +122,16 @@
"videoinput": "Je camera wordt al gebruikt door een ander tabblad of een andere toepassing, waardoor deze niet geactiveerd kan worden in dit videogesprek. Sluit indien gewenst het andere tabblad of de toepassing om je camera hier te gebruiken."
}
},
"NotFound": {
"title": {
"audioinput": "Geen microfoon gedetecteerd",
"videoinput": "Geen camera gedetecteerd"
},
"body": {
"audioinput": "Er is geen microfoon gedetecteerd op je apparaat. Controleer of deze goed is aangesloten en niet door een schakelaar is gedempt, en probeer het opnieuw.",
"videoinput": "Er is geen camera gedetecteerd op je apparaat. Controleer of deze goed is aangesloten en niet door een afdekking of schakelaar wordt geblokkeerd, en probeer het opnieuw."
}
},
"close": "OK"
},
"permissionErrorDialog": {
@@ -0,0 +1,38 @@
import { proxy } from 'valtio'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
// Device presence (not permission): enumerateDevices() exposes kinds
// before any grant. Optimistic defaults until the first sync.
export const deviceAvailabilityStore = proxy({
hasCamera: true,
hasMicrophone: true,
synced: false,
})
export const syncDeviceAvailability = async (): Promise<void> => {
try {
const devices = await navigator.mediaDevices.enumerateDevices()
const hasCamera = devices.some((d) => d.kind === 'videoinput')
const hasMicrophone = devices.some((d) => d.kind === 'audioinput')
// Report each absence once, not on every devicechange.
const firstSync = !deviceAvailabilityStore.synced
if (!hasCamera && (firstSync || deviceAvailabilityStore.hasCamera)) {
void captureMediaEvent('device-not-found', { kind: 'videoinput' })
}
if (
!hasMicrophone &&
(firstSync || deviceAvailabilityStore.hasMicrophone)
) {
void captureMediaEvent('device-not-found', { kind: 'audioinput' })
}
deviceAvailabilityStore.hasCamera = hasCamera
deviceAvailabilityStore.hasMicrophone = hasMicrophone
deviceAvailabilityStore.synced = true
} catch (error) {
reportError('permissions_api_failure', error as Error, {
context: 'enumerateDevices for device availability',
})
}
}