diff --git a/CHANGELOG.md b/CHANGELOG.md index 454f0230..0f2fb050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- 🚸(frontend) explain camera-in-use failures on the join screen + ## [1.27.0] - 2026-08-14 ### Changed 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/livekit/components/controls/Device/ToggleDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx index 23b9f201..be5e67fa 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx @@ -20,6 +20,7 @@ import { openPermissionsDialog } from '@/stores/permissions' import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic' import { useSnapshot } from 'valtio' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' +import { useDeviceInUse } from '../../../hooks/useDeviceInUse' import { useDeviceMissing } from '../../../hooks/useDeviceMissing' import { requestDevicePermission } from '../../../hooks/useJoinTracks' import { useDeviceIcons } from '../../../hooks/useDeviceIcons' @@ -97,21 +98,23 @@ export const ToggleDevice = ({ const deviceIcons = useDeviceIcons(kind) const cannotUseDevice = useCannotUseDevice(kind) const deviceMissing = useDeviceMissing(kind) + const deviceInUse = useDeviceInUse(kind) const { status: silentMicStatus } = useSnapshot(silentMicStore) const silentMicWarning = kind === 'audioinput' && silentMicStatus === 'silent' && !cannotUseDevice && - !deviceMissing + !deviceMissing && + !deviceInUse const deviceShortcut = useDeviceShortcut(kind) const announce = useScreenReaderAnnounce() const isRequestingPermission = useRef(false) - const [showDeviceNotFound, setShowDeviceNotFound] = useState(false) + const [alertError, setAlertError] = useState(null) const onPress = async () => { if (!enabled && deviceMissing) { - setShowDeviceNotFound(true) + setAlertError(MediaDeviceFailure.NotFound) return } if (!cannotUseDevice) { @@ -185,10 +188,18 @@ export const ToggleDevice = ({ setShowDeviceNotFound(true) : undefined + deviceMissing + ? () => setAlertError(MediaDeviceFailure.NotFound) + : undefined } /> )} + {deviceInUse && ( + setAlertError(MediaDeviceFailure.DeviceInUse)} + /> + )} {silentMicWarning && ( ({ tooltip={ deviceMissing ? t(`deviceNotFound.${kind}`) - : cannotUseDevice - ? t('tooltip', { keyPrefix: 'permissionsButton' }) - : toggleLabel + : deviceInUse + ? t(`deviceInUse.${kind}`) + : cannotUseDevice + ? t('tooltip', { keyPrefix: 'permissionsButton' }) + : toggleLabel } {...computedToggleButtonProps} {...overrideToggleButtonProps} @@ -217,9 +230,9 @@ export const ToggleDevice = ({ setShowDeviceNotFound(false)} + onClose={() => setAlertError(null)} />
) diff --git a/src/frontend/src/features/rooms/livekit/hooks/useDeviceInUse.ts b/src/frontend/src/features/rooms/livekit/hooks/useDeviceInUse.ts new file mode 100644 index 00000000..8cca93e1 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useDeviceInUse.ts @@ -0,0 +1,15 @@ +import { useSnapshot } from 'valtio' +import { deviceInUseStore } from '@/stores/deviceInUse' +import { PERMISSION_BY_DEVICE_KIND } from '@/stores/permissions' +import { useCannotUseDevice } from './useCannotUseDevice' +import { useDeviceMissing } from './useDeviceMissing' + +export const useDeviceInUse = (kind: MediaDeviceKind): boolean => { + const inUse = useSnapshot(deviceInUseStore) + const cannotUseDevice = useCannotUseDevice(kind) + const deviceMissing = useDeviceMissing(kind) + + const permissionKind = PERMISSION_BY_DEVICE_KIND[kind] + if (!permissionKind || cannotUseDevice || deviceMissing) return false + return inUse[permissionKind] +} diff --git a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts index 4192c837..d7dd2943 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts @@ -18,6 +18,7 @@ import { noteSystemPermissionDenied, type PermissionKind, } from '@/stores/permissions' +import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceInUse' import { getOS } from '@/utils/os' import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' import { @@ -89,7 +90,15 @@ const onMediaPermissionError = ( return } - // "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen. + if ( + MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.DeviceInUse && + path === 'join_preview' + ) { + noteDeviceInUse(kind) + void captureMediaEvent('device-in-use', { path, kind, os: getOS() }) + return + } + reportError( path === 'room' ? 'room_media_failure' : 'join_preview_failure', e, @@ -97,6 +106,11 @@ const onMediaPermissionError = ( ) } +const noteDeviceReady = (kind?: PermissionKind) => { + noteGumSuccess(kind) + clearDeviceInUse(kind) +} + // Module-level: effect dependencies, must be referentially stable. const disableAudio = () => saveAudioInputEnabled(false) const disableVideo = () => saveVideoInputEnabled(false) @@ -114,7 +128,7 @@ export const requestDevicePermission = async ( ? await createLocalAudioTrack() : await createLocalVideoTrack() track.stop() - noteGumSuccess(PERMISSION_KIND[kind]) + noteDeviceReady(PERMISSION_KIND[kind]) return true } catch (error) { onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path) @@ -158,7 +172,7 @@ function useWarmupPermissions(): WarmupState { video: true, }) ) - noteGumSuccess() + noteDeviceReady() bothReady() } catch (error) { if ( @@ -180,7 +194,7 @@ function useWarmupPermissions(): WarmupState { .getUserMedia({ audio: true }) .then((stream) => { stopAll(stream) - noteGumSuccess('microphone') + noteDeviceReady('microphone') }) .catch((e) => onMediaPermissionError(e as Error, 'microphone')) .finally(() => @@ -190,7 +204,7 @@ function useWarmupPermissions(): WarmupState { .getUserMedia({ video: true }) .then((stream) => { stopAll(stream) - noteGumSuccess('camera') + noteDeviceReady('camera') }) .catch((e) => onMediaPermissionError(e as Error, 'camera')) .finally(() => @@ -227,7 +241,7 @@ function useLocalTrack({ let cancelled = false create() .then((newTrack) => { - noteGumSuccess(permissionKind) + noteDeviceReady(permissionKind) if (cancelled) { newTrack.stop() return @@ -291,6 +305,8 @@ export function useJoinTracks(): { const { audioReady, videoReady } = useWarmupPermissions() + useEffect(() => () => clearDeviceInUse(), []) + const createAudio = useCallback( () => createLocalAudioTrack({ diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index bdacc707..f376255e 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -16,6 +16,10 @@ "videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.", "audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist." }, + "deviceInUse": { + "videoinput": "Kamera nicht verfügbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.", + "audioinput": "Mikrofon nicht verfügbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet." + }, "settings": { "audio": "Audioeinstellungen", "video": "Videoeinstellungen" @@ -67,6 +71,7 @@ }, "cameraDisabled": "Kamera ist deaktiviert.", "cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.", + "cameraInUse": "Deine Kamera ist nicht verfügbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.", "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?", diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 1c48fd80..22735163 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -16,6 +16,10 @@ "videoinput": "No camera detected. Check that it is properly wired.", "audioinput": "No microphone detected. Check that it is properly wired." }, + "deviceInUse": { + "videoinput": "Camera unavailable: it is probably in use by another application or browser tab.", + "audioinput": "Microphone unavailable: it is probably in use by another application or browser tab." + }, "settings": { "audio": "Audio settings", "video": "Video settings" @@ -67,6 +71,7 @@ }, "cameraDisabled": "Camera is disabled.", "cameraNotFound": "No camera detected. Check that it is properly plugged in.", + "cameraInUse": "Your camera is unavailable. It is probably being used by another application or browser tab.", "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?", diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 9091d5a5..4de935b3 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -16,6 +16,10 @@ "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é." }, + "deviceInUse": { + "videoinput": "Caméra indisponible : elle est probablement utilisée par une autre application ou un autre onglet.", + "audioinput": "Microphone indisponible : il est probablement utilisé par une autre application ou un autre onglet." + }, "settings": { "audio": "Paramètres audio", "video": "Paramètres video" @@ -67,6 +71,7 @@ }, "cameraDisabled": "La caméra est désactivée.", "cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.", + "cameraInUse": "Votre caméra n'est pas disponible. Elle est probablement utilisée par une autre application ou un autre onglet.", "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 ?", diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 36fff7dc..239f3641 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -16,6 +16,10 @@ "videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.", "audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten." }, + "deviceInUse": { + "videoinput": "Camera niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.", + "audioinput": "Microfoon niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad." + }, "settings": { "audio": "Audio-instellingen", "video": "Video-instellingen" @@ -67,6 +71,7 @@ }, "cameraDisabled": "Camera is uitgeschakeld.", "cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.", + "cameraInUse": "Je camera is niet beschikbaar. Deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.", "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?", diff --git a/src/frontend/src/stores/deviceInUse.ts b/src/frontend/src/stores/deviceInUse.ts new file mode 100644 index 00000000..db610a42 --- /dev/null +++ b/src/frontend/src/stores/deviceInUse.ts @@ -0,0 +1,21 @@ +import { proxy } from 'valtio' +import type { PermissionKind } from './permissions' + +export const deviceInUseStore = proxy>({ + camera: false, + microphone: false, +}) + +const ALL_KINDS: PermissionKind[] = ['camera', 'microphone'] + +export const noteDeviceInUse = (kind?: PermissionKind) => { + for (const k of kind ? [kind] : ALL_KINDS) { + deviceInUseStore[k] = true + } +} + +export const clearDeviceInUse = (kind?: PermissionKind) => { + for (const k of kind ? [kind] : ALL_KINDS) { + deviceInUseStore[k] = false + } +}