11import { useEffect } from 'react'
22import { permissionsStore } from '@/stores/permissions'
3- import { isSafari } from '@/utils/livekit'
3+ import { hasUnreliablePermissionsEvents } from '@/utils/livekit'
44
55const POLLING_TIME = 500
66
@@ -20,66 +20,99 @@ export const useWatchPermissions = () => {
2020 return
2121 }
2222
23- const [ cameraPermission , microphonePermission ] = await Promise . all ( [
24- navigator . permissions . query ( { name : 'camera' } ) ,
25- navigator . permissions . query ( { name : 'microphone' } ) ,
26- ] )
23+ let cameraPermission : PermissionStatus | null = null
24+ let microphonePermission : PermissionStatus | null = null
25+
26+ try {
27+ cameraPermission = await navigator . permissions . query ( {
28+ name : 'camera' as PermissionName ,
29+ } )
30+ } catch {
31+ permissionsStore . cameraPermission = 'prompt'
32+ }
33+
34+ try {
35+ microphonePermission = await navigator . permissions . query ( {
36+ name : 'microphone' as PermissionName ,
37+ } )
38+ } catch {
39+ permissionsStore . microphonePermission = 'prompt'
40+ }
2741
2842 if ( isCancelled ) return
2943
3044 /**
31- * Safari Permission API Limitation Workaround
45+ * Browser Permission API Limitation Workaround
3246 *
33- * Safari has a known issue where permission change events are not reliably fired
34- * when users interact with permission prompts. This is documented in Apple's forums:
35- * https://developer.apple.com/forums/thread/757353
47+ * Several browsers have known issues where permission change events are not
48+ * reliably fired when users interact with permission prompts:
49+ * - Safari: https://developer.apple.com/forums/thread/757353
50+ * - All iOS browsers (they use WebKit under Apple's policy)
51+ * - Firefox on Android
3652 *
3753 * The problem:
38- * - When permissions are in 'prompt' state, Safari may not trigger 'change' events
54+ * - When permissions are in 'prompt' state, these browsers may not trigger 'change' events
3955 * - Users can grant/deny permissions through system prompts, but our listeners won't detect it
4056 * - This leaves the UI in an inconsistent state showing outdated permission status
4157 *
4258 * The solution:
4359 * - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
4460 * - Continue polling until both permissions are no longer in 'prompt' state
45- * - This ensures we catch permission changes even when Safari fails to fire events
61+ * - This ensures we catch permission changes even when browsers fail to fire events
4662 *
47- * This polling is Safari-specific and only activates when needed to minimize performance impact.
63+ * This polling only activates on affected browsers and when needed to minimize performance impact.
4864 */
49- if (
50- isSafari ( ) &&
51- ( cameraPermission . state === 'prompt' ||
52- microphonePermission . state === 'prompt' )
53- ) {
54- // Start polling every 1 second if either permission is in 'prompt' state
65+ const needsPolling =
66+ hasUnreliablePermissionsEvents ( ) &&
67+ ( ( cameraPermission ? .state ?? 'prompt' ) === 'prompt' ||
68+ ( microphonePermission ? .state ?? 'prompt' ) === 'prompt' )
69+
70+ if ( needsPolling ) {
5571 if ( ! intervalId ) {
5672 intervalId = setInterval ( async ( ) => {
5773 try {
58- const [ updatedCamera , updatedMicrophone ] = await Promise . all ( [
59- navigator . permissions . query ( { name : 'camera' } ) ,
60- navigator . permissions . query ( { name : 'microphone' } ) ,
61- ] )
74+ let updatedCameraState : PermissionState | null = null
75+ let updatedMicrophoneState : PermissionState | null = null
76+
77+ try {
78+ const updatedCamera = await navigator . permissions . query ( {
79+ name : 'camera' as PermissionName ,
80+ } )
81+ updatedCameraState = updatedCamera . state
82+ } catch {
83+ // Permission query not supported, keep current state
84+ }
6285
63- if ( isCancelled ) return
86+ try {
87+ const updatedMicrophone = await navigator . permissions . query ( {
88+ name : 'microphone' as PermissionName ,
89+ } )
90+ updatedMicrophoneState = updatedMicrophone . state
91+ } catch {
92+ // Permission query not supported, keep current state
93+ }
6494
65- const cameraChanged =
66- permissionsStore . cameraPermission !== updatedCamera . state
67- const microphoneChanged =
68- permissionsStore . microphonePermission !==
69- updatedMicrophone . state
95+ if ( isCancelled ) return
7096
71- if ( cameraChanged ) {
72- permissionsStore . cameraPermission = updatedCamera . state
97+ if (
98+ updatedCameraState &&
99+ permissionsStore . cameraPermission !== updatedCameraState
100+ ) {
101+ permissionsStore . cameraPermission = updatedCameraState
73102 }
74103
75- if ( microphoneChanged ) {
76- permissionsStore . microphonePermission =
77- updatedMicrophone . state
104+ if (
105+ updatedMicrophoneState &&
106+ permissionsStore . microphonePermission !==
107+ updatedMicrophoneState
108+ ) {
109+ permissionsStore . microphonePermission = updatedMicrophoneState
78110 }
79111
112+ // Stop polling when both permissions are resolved
80113 if (
81- updatedCamera . state !== 'prompt' &&
82- updatedMicrophone . state !== 'prompt'
114+ ( updatedCameraState ?? 'prompt' ) !== 'prompt' &&
115+ ( updatedMicrophoneState ?? 'prompt' ) !== 'prompt'
83116 ) {
84117 if ( intervalId ) {
85118 clearInterval ( intervalId )
@@ -95,8 +128,12 @@ export const useWatchPermissions = () => {
95128 }
96129 }
97130
98- permissionsStore . cameraPermission = cameraPermission . state
99- permissionsStore . microphonePermission = microphonePermission . state
131+ if ( cameraPermission ) {
132+ permissionsStore . cameraPermission = cameraPermission . state
133+ }
134+ if ( microphonePermission ) {
135+ permissionsStore . microphonePermission = microphonePermission . state
136+ }
100137
101138 const handleCameraChange = ( e : Event ) => {
102139 const target = e . target as PermissionStatus
@@ -105,7 +142,7 @@ export const useWatchPermissions = () => {
105142 if (
106143 intervalId &&
107144 target . state !== 'prompt' &&
108- microphonePermission . state !== 'prompt'
145+ ( microphonePermission ? .state ?? 'prompt' ) !== 'prompt'
109146 ) {
110147 clearInterval ( intervalId )
111148 intervalId = undefined
@@ -119,22 +156,33 @@ export const useWatchPermissions = () => {
119156 if (
120157 intervalId &&
121158 target . state !== 'prompt' &&
122- microphonePermission . state !== 'prompt'
159+ ( cameraPermission ? .state ?? 'prompt' ) !== 'prompt'
123160 ) {
124161 clearInterval ( intervalId )
125162 intervalId = undefined
126163 }
127164 }
128165
129- cameraPermission . addEventListener ( 'change' , handleCameraChange )
130- microphonePermission . addEventListener ( 'change' , handleMicrophoneChange )
131-
132- cleanup = ( ) => {
133- cameraPermission . removeEventListener ( 'change' , handleCameraChange )
134- microphonePermission . removeEventListener (
166+ if ( cameraPermission ) {
167+ cameraPermission . addEventListener ( 'change' , handleCameraChange )
168+ }
169+ if ( microphonePermission ) {
170+ microphonePermission . addEventListener (
135171 'change' ,
136172 handleMicrophoneChange
137173 )
174+ }
175+
176+ cleanup = ( ) => {
177+ if ( cameraPermission ) {
178+ cameraPermission . removeEventListener ( 'change' , handleCameraChange )
179+ }
180+ if ( microphonePermission ) {
181+ microphonePermission . removeEventListener (
182+ 'change' ,
183+ handleMicrophoneChange
184+ )
185+ }
138186 if ( intervalId ) {
139187 clearInterval ( intervalId )
140188 intervalId = undefined
0 commit comments