From d723c14d844b304c1ec50fb5271d56b1520769e7 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 21 Aug 2026 18:11:49 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20handle=20device-in-use?= =?UTF-8?q?=20errors=20on=20Chrome=20/=20Windows=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Chrome and Windows 10, when the camera or microphone is already in use by another application, the browser rejects `getUserMedia`. Without dedicated handling, users just saw their camera or microphone not turning on, without any explanation. Detect this specific failure and surface a clear message to the user, so they understand another application is holding the device and know what to do about it. --- CHANGELOG.md | 2 + .../src/features/analytics/telemetry.ts | 1 + .../src/features/rooms/components/Join.tsx | 10 +- .../rooms/hooks/useWatchDeviceReleased.ts | 30 ++++ .../Device/PermissionNeededButton.tsx | 18 ++- .../controls/Device/ToggleDevice.tsx | 62 +++++--- .../rooms/livekit/hooks/useDeviceInUse.ts | 21 +++ .../rooms/livekit/hooks/useJoinTracks.ts | 95 ++---------- .../hooks/useWatchMediaDeviceErrors.ts | 26 +++- .../rooms/livekit/prefabs/VideoConference.tsx | 7 +- .../rooms/livekit/utils/mediaPermissions.ts | 135 ++++++++++++++++++ .../src/features/rooms/routes/Room.tsx | 2 + src/frontend/src/locales/de/rooms.json | 5 + src/frontend/src/locales/en/rooms.json | 5 + src/frontend/src/locales/fr/rooms.json | 5 + src/frontend/src/locales/nl/rooms.json | 5 + src/frontend/src/stores/deviceAvailability.ts | 29 +++- 17 files changed, 338 insertions(+), 120 deletions(-) create mode 100644 src/frontend/src/features/rooms/hooks/useWatchDeviceReleased.ts create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useDeviceInUse.ts create mode 100644 src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 59338f3c..c7fc59e9 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 @@ -31,6 +32,7 @@ and this project adheres to - 🐛(frontend) stop init_virtual_background from firing on blur updates - 🐛(frontend) hoist mute confirmation dialog to VideoConference level - 🐛(frontend) fix joined notification tile no longer rendering properly +- 🐛(frontend) handle device-in-use errors on Chrome / Windows 10 ## [1.27.0] - 2026-08-14 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/hooks/useWatchDeviceReleased.ts b/src/frontend/src/features/rooms/hooks/useWatchDeviceReleased.ts new file mode 100644 index 00000000..0244d922 --- /dev/null +++ b/src/frontend/src/features/rooms/hooks/useWatchDeviceReleased.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react' +import { useSnapshot } from 'valtio' +import { deviceAvailabilityStore } from '@/stores/deviceAvailability' +import { probeDeviceReleased } from '../livekit/utils/mediaPermissions' +import type { PermissionKind } from '@/stores/permissions' + +const RETRY_INTERVAL_MS = 30_000 + +/** + * There is no browser event for "another app released the device", so + * while a device is flagged in use, re-probe it periodically. Only clears + * the flag (the toggle becomes usable again); it never re-enables the + * device on the user's behalf. + */ +export function useWatchDeviceReleased() { + const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore) + useWatchKind('camera', cameraInUse) + useWatchKind('microphone', microphoneInUse) +} + +function useWatchKind(kind: PermissionKind, inUse: boolean) { + useEffect(() => { + if (!inUse) return + const id = setInterval( + () => void probeDeviceReleased(kind), + RETRY_INTERVAL_MS + ) + return () => clearInterval(id) + }, [kind, inUse]) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/PermissionNeededButton.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/PermissionNeededButton.tsx index 0641ae80..388af375 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/PermissionNeededButton.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/PermissionNeededButton.tsx @@ -5,16 +5,18 @@ import { css } from '@/styled-system/css' import { useTranslation } from 'react-i18next' type PermissionNeededButtonProps = { - tooltip?: string - onPress?: () => void + tooltip?: string | null + onPress?: (() => void) | null + ariaLabel?: string } export const PermissionNeededButton = ({ tooltip, onPress, + ariaLabel, }: PermissionNeededButtonProps) => { const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' }) - const label = tooltip ?? t('tooltip') + const label = tooltip === undefined ? t('tooltip') : tooltip return (