diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index ff2e90cb..81ff7db3 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -22,7 +22,6 @@ "@tanstack/react-query": "5.101.1", "@timephy/rnnoise-wasm": "1.0.0", "crisp-sdk-web": "1.1.2", - "derive-valtio": "0.2.0", "hoofd": "1.7.3", "humanize-duration": "3.33.2", "i18next": "26.3.1", @@ -4710,15 +4709,6 @@ "node": ">= 0.8" } }, - "node_modules/derive-valtio": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz", - "integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==", - "license": "MIT", - "peerDependencies": { - "valtio": ">=2.0.0-rc.0" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index cc85786e..88a927bf 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -29,7 +29,6 @@ "@tanstack/react-query": "5.101.1", "@timephy/rnnoise-wasm": "1.0.0", "crisp-sdk-web": "1.1.2", - "derive-valtio": "0.2.0", "hoofd": "1.7.3", "humanize-duration": "3.33.2", "i18next": "26.3.1", diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 63762bb7..766a6836 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -41,6 +41,10 @@ import { notifyAutoMutedOnJoin } from '@/features/notifications/utils' import { useSnapshot } from 'valtio' import { userPreferencesStore } from '@/stores/userPreferences' import { userStore } from '@/stores/user' +import { + PERMISSION_BY_DEVICE_KIND, + notePermissionDeniedFromGum, +} from '@/stores/permissions' export const Conference = ({ roomId, @@ -313,6 +317,9 @@ export const Conference = ({ case MediaDeviceFailure.DeviceInUse: setMediaDeviceError({ error: e, kind }) break + case MediaDeviceFailure.PermissionDenied: + notePermissionDeniedFromGum(PERMISSION_BY_DEVICE_KIND[kind]) + break default: break } diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 1d93fa60..5bc75070 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -8,6 +8,7 @@ import { createLocalVideoTrack, type LocalAudioTrack, type LocalVideoTrack, + MediaDeviceFailure, Track, } from 'livekit-client' import { H } from '@/primitives/H' @@ -33,7 +34,11 @@ import { ApiLobbyStatus, type ApiRequestEntry } from '../api/requestEntry' import { Spinner } from '@/primitives/Spinner' import { ApiAccessLevel } from '../api/ApiRoom' import { useLoginHint } from '@/hooks/useLoginHint' -import { openPermissionsDialog } from '@/stores/permissions' +import { + notePermissionDeniedFromGum, + openPermissionsDialog, + PermissionKind, +} from '@/stores/permissions' import { isSafari } from '@/utils/livekit' import { reportError } from '@/features/analytics/telemetry' @@ -54,8 +59,14 @@ import { useSnapshot } from 'valtio' import { useUser } from '@/features/auth/api/useUser' import { useConfig } from '@/api/useConfig' -const onError = (e: Error) => +const onError = (e: Error, kind?: PermissionKind) => { reportError('join_preview_failure', e, { path: 'join_preview' }) + if ( + MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied + ) { + notePermissionDeniedFromGum(kind) + } +} const Effects = ({ videoTrack, @@ -198,7 +209,7 @@ export const Join = ({ }) setDynamicVideoTrack(track) } catch (error) { - onError(error as Error) + onError(error as Error, 'camera') } } @@ -234,7 +245,7 @@ export const Join = ({ }) setDynamicAudioTrack(track) } catch (error) { - onError(error as Error) + onError(error as Error, 'microphone') } } if ( diff --git a/src/frontend/src/features/rooms/hooks/useWatchPermissions.ts b/src/frontend/src/features/rooms/hooks/useWatchPermissions.ts index fce355e9..02a993bd 100644 --- a/src/frontend/src/features/rooms/hooks/useWatchPermissions.ts +++ b/src/frontend/src/features/rooms/hooks/useWatchPermissions.ts @@ -1,165 +1,36 @@ import { useEffect } from 'react' -import { permissionsStore } from '@/stores/permissions' -import { isSafari } from '@/utils/livekit' -import { reportError } from '@/features/analytics/telemetry' - -const POLLING_TIME = 500 +import { syncPermissions } from '@/stores/permissions' export const useWatchPermissions = () => { useEffect(() => { - let cleanup: (() => void) | undefined - let intervalId: ReturnType | undefined - let isCancelled = false + const sync = () => void syncPermissions() + sync() - const checkPermissions = async () => { - try { - if (!navigator.permissions) { - if (!isCancelled) { - permissionsStore.cameraPermission = 'unavailable' - permissionsStore.microphonePermission = 'unavailable' - } - return - } + navigator.mediaDevices?.addEventListener?.('devicechange', sync) + window.addEventListener('focus', sync) - const [cameraPermission, microphonePermission] = await Promise.all([ - navigator.permissions.query({ name: 'camera' }), - navigator.permissions.query({ name: 'microphone' }), - ]) - - if (isCancelled) return - - /** - * Safari Permission API Limitation Workaround - * - * Safari has a known issue where permission change events are not reliably fired - * when users interact with permission prompts. This is documented in Apple's forums: - * https://developer.apple.com/forums/thread/757353 - * - * The problem: - * - When permissions are in 'prompt' state, Safari may not trigger 'change' events - * - Users can grant/deny permissions through system prompts, but our listeners won't detect it - * - This leaves the UI in an inconsistent state showing outdated permission status - * - * The solution: - * - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state - * - Continue polling until both permissions are no longer in 'prompt' state - * - This ensures we catch permission changes even when Safari fails to fire events - * - * This polling is Safari-specific and only activates when needed to minimize performance impact. - */ - if ( - isSafari() && - (cameraPermission.state === 'prompt' || - microphonePermission.state === 'prompt') - ) { - // Start polling every 1 second if either permission is in 'prompt' state - if (!intervalId) { - intervalId = setInterval(async () => { - try { - const [updatedCamera, updatedMicrophone] = await Promise.all([ - navigator.permissions.query({ name: 'camera' }), - navigator.permissions.query({ name: 'microphone' }), - ]) - - if (isCancelled) return - - const cameraChanged = - permissionsStore.cameraPermission !== updatedCamera.state - const microphoneChanged = - permissionsStore.microphonePermission !== - updatedMicrophone.state - - if (cameraChanged) { - permissionsStore.cameraPermission = updatedCamera.state - } - - if (microphoneChanged) { - permissionsStore.microphonePermission = - updatedMicrophone.state - } - - if ( - updatedCamera.state !== 'prompt' && - updatedMicrophone.state !== 'prompt' - ) { - if (intervalId) { - clearInterval(intervalId) - intervalId = undefined - } - } - } catch (error) { - if (!isCancelled) { - reportError('permission_poll_failure', error, { - context: 'Error polling permissions:', - }) - } - } - }, POLLING_TIME) - } - } - - permissionsStore.cameraPermission = cameraPermission.state - permissionsStore.microphonePermission = microphonePermission.state - - const handleCameraChange = (e: Event) => { - const target = e.target as PermissionStatus - permissionsStore.cameraPermission = target.state - - if ( - intervalId && - target.state !== 'prompt' && - microphonePermission.state !== 'prompt' - ) { - clearInterval(intervalId) - intervalId = undefined - } - } - - const handleMicrophoneChange = (e: Event) => { - const target = e.target as PermissionStatus - permissionsStore.microphonePermission = target.state - - if ( - intervalId && - target.state !== 'prompt' && - microphonePermission.state !== 'prompt' - ) { - clearInterval(intervalId) - intervalId = undefined - } - } - - cameraPermission.addEventListener('change', handleCameraChange) - microphonePermission.addEventListener('change', handleMicrophoneChange) - - cleanup = () => { - cameraPermission.removeEventListener('change', handleCameraChange) - microphonePermission.removeEventListener( - 'change', - handleMicrophoneChange - ) - if (intervalId) { - clearInterval(intervalId) - intervalId = undefined - } - } - } catch (error) { - if (!isCancelled) { - reportError('permission_poll_failure', error, { - context: 'Error checking permissions:', - }) - } - } finally { - if (!isCancelled) { - permissionsStore.isLoading = false - } - } + let statuses: PermissionStatus[] = [] + let cancelled = false + if (navigator.permissions) { + Promise.all([ + navigator.permissions.query({ name: 'camera' as PermissionName }), + navigator.permissions.query({ name: 'microphone' as PermissionName }), + ]) + .then((results) => { + if (cancelled) return + statuses = results + statuses.forEach((s) => s.addEventListener('change', sync)) + }) + .catch(() => { + // Query unsupported: devicechange/focus + gUM outcomes cover it. + }) } - checkPermissions() return () => { - isCancelled = true - cleanup?.() + cancelled = true + navigator.mediaDevices?.removeEventListener?.('devicechange', sync) + window.removeEventListener('focus', sync) + statuses.forEach((s) => s.removeEventListener('change', sync)) } }, []) } diff --git a/src/frontend/src/stores/permissions.ts b/src/frontend/src/stores/permissions.ts index a6737f77..a5561cd6 100644 --- a/src/frontend/src/stores/permissions.ts +++ b/src/frontend/src/stores/permissions.ts @@ -1,22 +1,17 @@ import { proxy } from 'valtio' -import { derive } from 'derive-valtio' type PermissionState = | undefined | 'granted' | 'prompt' | 'denied' - | 'unavailable' -type BaseState = { +type State = { cameraPermission: PermissionState microphonePermission: PermissionState isLoading: boolean isPermissionDialogOpen: boolean requestOrigin?: 'audioinput' | 'videoinput' -} - -type DerivedState = { isCameraGranted: boolean isMicrophoneGranted: boolean isCameraDenied: boolean @@ -25,34 +20,31 @@ type DerivedState = { isMicrophonePrompted: boolean } -type State = BaseState & DerivedState - -export const permissionsStore = proxy({ +export const permissionsStore = proxy({ cameraPermission: undefined, microphonePermission: undefined, isLoading: true, isPermissionDialogOpen: false, requestOrigin: undefined, -}) as State - -derive( - { - isCameraGranted: (get) => - get(permissionsStore).cameraPermission == 'granted', - isMicrophoneGranted: (get) => - get(permissionsStore).microphonePermission == 'granted', - isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied', - isMicrophoneDenied: (get) => - get(permissionsStore).microphonePermission == 'denied', - isCameraPrompted: (get) => - get(permissionsStore).cameraPermission == 'prompt', - isMicrophonePrompted: (get) => - get(permissionsStore).microphonePermission == 'prompt', + get isCameraGranted() { + return this.cameraPermission === 'granted' }, - { - proxy: permissionsStore, - } -) + get isMicrophoneGranted() { + return this.microphonePermission === 'granted' + }, + get isCameraDenied() { + return this.cameraPermission === 'denied' + }, + get isMicrophoneDenied() { + return this.microphonePermission === 'denied' + }, + get isCameraPrompted() { + return this.cameraPermission === 'prompt' + }, + get isMicrophonePrompted() { + return this.microphonePermission === 'prompt' + }, +}) export const openPermissionsDialog = ( requestOrigin?: 'audioinput' | 'videoinput' @@ -64,3 +56,84 @@ export const openPermissionsDialog = ( export const closePermissionsDialog = () => { permissionsStore.isPermissionDialogOpen = false } + +export type PermissionKind = 'camera' | 'microphone' + +const KIND_MAP = { + camera: 'videoinput', + microphone: 'audioinput', +} as const + +export const PERMISSION_BY_DEVICE_KIND: Partial< + Record +> = { + videoinput: 'camera', + audioinput: 'microphone', +} + +export const setPermissions = ( + p: Partial> +) => { + if (p.camera && p.camera !== permissionsStore.cameraPermission) { + permissionsStore.cameraPermission = p.camera + } + if (p.microphone && p.microphone !== permissionsStore.microphonePermission) { + permissionsStore.microphonePermission = p.microphone + } + permissionsStore.isLoading = false +} + +const queryPermission = async (name: PermissionKind) => { + try { + const status = await navigator.permissions.query({ + name: name as PermissionName, + }) + return status.state + } catch { + return undefined + } +} + +const labelsVisible = async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices() + return (kind: PermissionKind) => + devices.some((d) => d.kind === KIND_MAP[kind] && !!d.label) + } catch { + return (_kind: PermissionKind) => false + } +} + +export const syncPermissions = async () => { + const [camera, microphone] = await Promise.all([ + queryPermission('camera'), + queryPermission('microphone'), + ]) + if (camera && microphone) { + setPermissions({ camera, microphone }) + return + } + const granted = await labelsVisible() + const resolve = (kind: PermissionKind, queried?: PermissionState) => { + if (queried) return queried + const current = + kind === 'camera' + ? permissionsStore.cameraPermission + : permissionsStore.microphonePermission + if (current === 'denied') return 'denied' + return granted(kind) ? 'granted' : 'prompt' + } + setPermissions({ + camera: resolve('camera', camera), + microphone: resolve('microphone', microphone), + }) +} + +export const notePermissionDeniedFromGum = (kind?: PermissionKind) => { + if (kind) { + setPermissions({ [kind]: 'denied' }) + void syncPermissions() + return + } + void syncPermissions() +}