Skip to content

Commit 99ecbfe

Browse files
committed
🐛(frontend) fix camera/microphone permission detection on mobile browsers
1 parent 0cb3fb8 commit 99ecbfe

2 files changed

Lines changed: 107 additions & 45 deletions

File tree

src/frontend/src/features/rooms/hooks/useWatchPermissions.ts

Lines changed: 93 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from 'react'
22
import { permissionsStore } from '@/stores/permissions'
3-
import { isSafari } from '@/utils/livekit'
3+
import { hasUnreliablePermissionsEvents } from '@/utils/livekit'
44

55
const 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

src/frontend/src/utils/livekit.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ export function isSafari(): boolean {
2222
return getBrowser()?.name === 'Safari'
2323
}
2424

25+
/**
26+
* Detects browsers where the Permissions API change events are unreliable.
27+
* This includes:
28+
* - Safari (known issue with permission change events)
29+
* - All iOS browsers (they all use WebKit under Apple's policy, sharing Safari's limitations)
30+
* - Firefox (unreliable permission change events on Android)
31+
*/
32+
export function hasUnreliablePermissionsEvents(): boolean {
33+
const isIOS =
34+
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
35+
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
36+
return isSafari() || isIOS || isFireFox()
37+
}
38+
2539
export function isLocal(p: Participant) {
2640
return p instanceof LocalParticipant
2741
}

0 commit comments

Comments
 (0)