From 06494371cc8f457fb90293691e647dce25f8aedc Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 21 Aug 2026 23:09:38 +0200 Subject: [PATCH] fixup! wip handle device in use --- .../rooms/hooks/useWatchDeviceReleased.ts | 30 +++++++++++++++++++ .../controls/Device/ToggleDevice.tsx | 26 +++++++++------- .../rooms/livekit/utils/mediaPermissions.ts | 20 +++++++++++++ .../src/features/rooms/routes/Room.tsx | 2 ++ 4 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 src/frontend/src/features/rooms/hooks/useWatchDeviceReleased.ts 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/ToggleDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx index 0361f649..98679135 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 @@ -99,6 +99,7 @@ export const ToggleDevice = ({ const cannotUseDevice = useCannotUseDevice(kind) const deviceMissing = useDeviceMissing(kind) const deviceInUse = useDeviceInUse(kind) + const explainDeviceInUse = deviceInUse && context === 'room' const { status: silentMicStatus } = useSnapshot(silentMicStore) const silentMicWarning = kind === 'audioinput' && @@ -112,26 +113,27 @@ export const ToggleDevice = ({ const isRequestingPermission = useRef(false) const [alertError, setAlertError] = useState(null) + const mediaPath = context === 'join' ? 'join_preview' : 'room' + const onPress = async () => { if (!enabled && deviceMissing) { setAlertError(MediaDeviceFailure.NotFound) return } - if (!cannotUseDevice) { + if (!cannotUseDevice && !deviceInUse) { toggle() return } if (isRequestingPermission.current) return isRequestingPermission.current = true try { - const granted = await requestDevicePermission( - kind, - context === 'join' ? 'join_preview' : 'room' - ) - if (granted) { + const acquired = await requestDevicePermission(kind, mediaPath) + if (acquired) { toggle() - } else { + } else if (cannotUseDevice) { openPermissionsDialog(kind) + } else if (explainDeviceInUse) { + setAlertError(MediaDeviceFailure.DeviceInUse) } } finally { isRequestingPermission.current = false @@ -184,7 +186,7 @@ export const ToggleDevice = ({ const getToggleTooltip = () => { if (deviceMissing) return t(`deviceNotFound.${kind}`) - if (deviceInUse) return t(`deviceInUse.${kind}`) + if (explainDeviceInUse) return t(`deviceInUse.${kind}`) if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' }) return toggleLabel } @@ -204,8 +206,12 @@ export const ToggleDevice = ({ )} {deviceInUse && ( setAlertError(MediaDeviceFailure.DeviceInUse)} + tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : undefined} + onPress={ + explainDeviceInUse + ? () => setAlertError(MediaDeviceFailure.DeviceInUse) + : undefined + } /> )} {silentMicWarning && ( diff --git a/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts b/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts index 2bbfc9b2..a7cbc333 100644 --- a/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts +++ b/src/frontend/src/features/rooms/livekit/utils/mediaPermissions.ts @@ -92,6 +92,26 @@ export const onMediaPermissionError = ( ) } +/** + * Silent availability check for a device that was reported "in use": + * acquires and releases it without any error reporting, so it can be + * polled. Clears the in-use flag on success. + */ +export const probeDeviceReleased = async ( + kind: PermissionKind +): Promise => { + try { + const stream = await navigator.mediaDevices.getUserMedia( + kind === 'camera' ? { video: true } : { audio: true } + ) + stream.getTracks().forEach((track) => track.stop()) + noteDeviceReady(kind) + return true + } catch { + return false + } +} + /** * Triggers the browser permission prompt for one device kind by acquiring * and immediately releasing a track. Resolves to whether access was granted. diff --git a/src/frontend/src/features/rooms/routes/Room.tsx b/src/frontend/src/features/rooms/routes/Room.tsx index 8dad5b58..ae8ba164 100644 --- a/src/frontend/src/features/rooms/routes/Room.tsx +++ b/src/frontend/src/features/rooms/routes/Room.tsx @@ -16,6 +16,7 @@ import { import { useConfig } from '@/api/useConfig.ts' import { LogLevel, setLogLevel } from 'livekit-client' import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability' +import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased' import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle' const BaseRoom = ({ children }: { children: ReactNode }) => { @@ -49,6 +50,7 @@ const Room = () => { useKeyboardShortcuts() useWatchDeviceAvailability() + useWatchDeviceReleased() const clearRouterState = () => { if (window?.history?.state) {