mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-12 19:56:53 +00:00
🐛(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:
committed by
aleb_the_flash
parent
089db20a2e
commit
199c0297d4
@@ -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
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
+13
-4
@@ -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 (
|
||||
|
||||
+29
-4
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user