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 (