From 2ec3f54532f579522cd935287b6a2afbc00bc9eb Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sat, 22 Aug 2026 22:02:37 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20handle=20Firefox/Windo?= =?UTF-8?q?ws=20AbortError=20on=20device=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some versions of Firefox on Windows do not raise the `NotReadableError` that LiveKit expects when the camera or microphone fails to start. Instead, they surface an `AbortError` with a message explaining the browser could not start the video or microphone input. This case was observed in PostHog error tracking and was not handled, so users hit an unhelpful failure state. Detect that specific `AbortError` and route it through the same device-in-use / not-readable handling as the standard error, so users get a clear explanation. --- CHANGELOG.md | 1 + .../features/rooms/components/Conference.tsx | 3 ++- .../rooms/livekit/hooks/useJoinTracks.ts | 3 ++- .../hooks/useWatchMediaDeviceErrors.ts | 3 ++- .../rooms/livekit/prefabs/VideoConference.tsx | 3 ++- .../rooms/livekit/utils/mediaPermissions.ts | 23 ++++++++++++++++++- 6 files changed, 31 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7fc59e9..3ea55cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to - 🐛(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 +- 🐛(frontend) handle Firefox/Windows AbortError on device start ## [1.27.0] - 2026-08-14 diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index bb9e2a11..866ce41f 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -14,6 +14,7 @@ import { type RoomOptions, VideoPresets, } from 'livekit-client' +import { getMediaDeviceFailure } from '@/features/rooms/livekit/utils/mediaPermissions' import { keys } from '@/api/queryKeys' import { queryClient } from '@/api/queryClient' import { Screen } from '@/layout/Screen' @@ -231,7 +232,7 @@ export const Conference = ({ backgroundColor: 'primaryDark.50 !important', })} onError={(e) => { - const failure = MediaDeviceFailure.getFailure(e) + const failure = getMediaDeviceFailure(e) if (failure && failure !== MediaDeviceFailure.Other) return // connect() was aborted by a disconnect() before the join completed diff --git a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts index 8522dbfd..91ff8e01 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts @@ -14,6 +14,7 @@ import { type PermissionKind, } from '@/stores/permissions' import { + getMediaDeviceFailure, noteDeviceReady, onMediaPermissionError, } from '../utils/mediaPermissions' @@ -83,7 +84,7 @@ function useWarmupPermissions(): WarmupState { bothReady() } catch (error) { if ( - MediaDeviceFailure.getFailure(error as Error) === + getMediaDeviceFailure(error as Error) === MediaDeviceFailure.PermissionDenied && !isSystemPermissionError(error) ) { diff --git a/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts b/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts index 1307349b..740c4453 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useWatchMediaDeviceErrors.ts @@ -21,6 +21,7 @@ import { syncDeviceAvailability, } from '@/stores/deviceAvailability' import { captureMediaEvent } from '@/features/analytics/telemetry' +import { getMediaDeviceFailure } from '../utils/mediaPermissions' import { getOS } from '@/utils/os' type MediaDeviceAlert = { @@ -63,7 +64,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & { useEffect(() => { const onDeviceError = (error: Error, kind?: MediaDeviceKind) => { - const failure = MediaDeviceFailure.getFailure(error) + const failure = getMediaDeviceFailure(error) if (!failure) return if (failure != MediaDeviceFailure.Other) { void captureMediaEvent('media-device-error', { diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 0c9f96ca..9fd112f7 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -1,5 +1,6 @@ import { isWeb } from '@livekit/components-core' import { MediaDeviceFailure, Track } from 'livekit-client' +import { getMediaDeviceFailure } from '../utils/mediaPermissions' import React, { useState } from 'react' import { ConnectionStateToast, @@ -101,7 +102,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) { } } - if (MediaDeviceFailure.getFailure(error) != MediaDeviceFailure.Other) { + if (getMediaDeviceFailure(error) !== MediaDeviceFailure.Other) { return } diff --git a/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts b/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts index a7cbc333..3d307b2c 100644 --- a/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts +++ b/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts @@ -22,6 +22,27 @@ import { getOS } from '@/utils/os' */ export type MediaPath = 'join_preview' | 'room' +/** + * LiveKit only maps NotReadableError/TrackStartError to DeviceInUse. + * Firefox reports a device held by another app as + * `AbortError: Starting videoinput failed` (or audioinput), which LiveKit + * classifies as Other. Normalise it here; every classification in the app + * should go through this instead of MediaDeviceFailure.getFailure. + */ +export const getMediaDeviceFailure = ( + error: Error +): MediaDeviceFailure | undefined => { + const failure = MediaDeviceFailure.getFailure(error) + if ( + failure === MediaDeviceFailure.Other && + error.name === 'AbortError' && + /^Starting (video|audio)input failed/i.test(error.message) + ) { + return MediaDeviceFailure.DeviceInUse + } + return failure +} + export const PERMISSION_KIND: Record< 'audioinput' | 'videoinput', PermissionKind @@ -40,7 +61,7 @@ export const onMediaPermissionError = ( kind?: PermissionKind, path: MediaPath = 'join_preview' ) => { - const failure = MediaDeviceFailure.getFailure(e) + const failure = getMediaDeviceFailure(e) if (failure === MediaDeviceFailure.PermissionDenied) { void classifyPermissionError(e, kind).then((scope) => {