From 8a72ad45eea3850d4d2e718118f15d34c1257d79 Mon Sep 17 00:00:00 2001
From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com>
Date: Tue, 29 Sep 2026 11:33:49 +0000
Subject: [PATCH] Allow joining when the camera or microphone is missing
The permission prompt asked for camera and microphone in one
getUserMedia call. When either device is missing the call rejects with
NotFoundError, and the page treated that as a refusal and showed
'Permission denied'. Fall back to each device on its own and only show
the denied page when the user refuses.
---
app/components/EnsurePermissions.tsx | 14 ++---
app/utils/requestMediaPermissions.test.ts | 72 +++++++++++++++++++++++
app/utils/requestMediaPermissions.ts | 63 ++++++++++++++++++++
3 files changed, 140 insertions(+), 9 deletions(-)
create mode 100644 app/utils/requestMediaPermissions.test.ts
create mode 100644 app/utils/requestMediaPermissions.ts
diff --git a/app/components/EnsurePermissions.tsx b/app/components/EnsurePermissions.tsx
index 73ee39dd..1e2ed1f8 100644
--- a/app/components/EnsurePermissions.tsx
+++ b/app/components/EnsurePermissions.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { requestMediaPermissions } from '~/utils/requestMediaPermissions'
import { Button } from './Button'
export interface EnsurePermissionsProps {
@@ -61,18 +62,13 @@ export function EnsurePermissions(props: EnsurePermissionsProps) {
{
- navigator.mediaDevices
- .getUserMedia({
- video: true,
- audio: true,
- })
- .then((ms) => {
+ requestMediaPermissions((constraints) =>
+ navigator.mediaDevices.getUserMedia(constraints)
+ )
+ .then(({ micId, cameraId }) => {
if (mountedRef.current) setPermissionState('granted')
- const micId = ms.getAudioTracks()[0].getSettings().deviceId
if (micId) props.onMicSelected(micId)
- const cameraId = ms.getVideoTracks()[0].getSettings().deviceId
if (cameraId) props.onCameraSelected(cameraId)
- ms.getTracks().forEach((t) => t.stop())
})
.catch(() => {
if (mountedRef.current) setPermissionState('denied')
diff --git a/app/utils/requestMediaPermissions.test.ts b/app/utils/requestMediaPermissions.test.ts
new file mode 100644
index 00000000..43dd4a01
--- /dev/null
+++ b/app/utils/requestMediaPermissions.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, test } from 'vitest'
+import { requestMediaPermissions } from './requestMediaPermissions'
+
+function domError(name: string) {
+ const error = new Error(name)
+ error.name = name
+ return error
+}
+
+function fakeStream(devices: { audio?: string; video?: string }) {
+ const track = (deviceId: string) => ({
+ getSettings: () => ({ deviceId }),
+ stop: () => {},
+ })
+ return {
+ getAudioTracks: () => (devices.audio ? [track(devices.audio)] : []),
+ getVideoTracks: () => (devices.video ? [track(devices.video)] : []),
+ getTracks: () => [
+ ...(devices.audio ? [track(devices.audio)] : []),
+ ...(devices.video ? [track(devices.video)] : []),
+ ],
+ } as unknown as MediaStream
+}
+
+// Mimics a machine with the given hardware. Browsers reject with
+// NotFoundError when any requested kind of device is missing.
+function machine(hardware: { audio?: string; video?: string }) {
+ return async (constraints: MediaStreamConstraints) => {
+ if (
+ (constraints.audio && !hardware.audio) ||
+ (constraints.video && !hardware.video)
+ ) {
+ throw domError('NotFoundError')
+ }
+ return fakeStream({
+ audio: constraints.audio ? hardware.audio : undefined,
+ video: constraints.video ? hardware.video : undefined,
+ })
+ }
+}
+
+describe('requestMediaPermissions', () => {
+ test('returns both devices when both exist', async () => {
+ await expect(
+ requestMediaPermissions(machine({ audio: 'mic1', video: 'cam1' }))
+ ).resolves.toEqual({ micId: 'mic1', cameraId: 'cam1' })
+ })
+
+ test('grants when the machine has a microphone but no camera', async () => {
+ await expect(
+ requestMediaPermissions(machine({ audio: 'mic1' }))
+ ).resolves.toEqual({ micId: 'mic1', cameraId: undefined })
+ })
+
+ test('grants when the machine has a camera but no microphone', async () => {
+ await expect(
+ requestMediaPermissions(machine({ video: 'cam1' }))
+ ).resolves.toEqual({ micId: undefined, cameraId: 'cam1' })
+ })
+
+ test('grants when the machine has neither device', async () => {
+ await expect(requestMediaPermissions(machine({}))).resolves.toEqual({})
+ })
+
+ test('rejects when the user refuses permission', async () => {
+ await expect(
+ requestMediaPermissions(async () => {
+ throw domError('NotAllowedError')
+ })
+ ).rejects.toMatchObject({ name: 'NotAllowedError' })
+ })
+})
diff --git a/app/utils/requestMediaPermissions.ts b/app/utils/requestMediaPermissions.ts
new file mode 100644
index 00000000..0190f6b4
--- /dev/null
+++ b/app/utils/requestMediaPermissions.ts
@@ -0,0 +1,63 @@
+export interface GrantedDevices {
+ micId?: string
+ cameraId?: string
+}
+
+type GetUserMedia = (
+ constraints: MediaStreamConstraints
+) => Promise
+
+function isPermissionError(error: unknown) {
+ return (
+ error instanceof Error &&
+ (error.name === 'NotAllowedError' || error.name === 'SecurityError')
+ )
+}
+
+function readDevices(stream: MediaStream): GrantedDevices {
+ const devices = {
+ micId: stream.getAudioTracks()[0]?.getSettings().deviceId,
+ cameraId: stream.getVideoTracks()[0]?.getSettings().deviceId,
+ }
+ stream.getTracks().forEach((t) => t.stop())
+ return devices
+}
+
+/**
+ * Asks for camera and microphone permission. Rejects only when the user (or
+ * the browser) refuses. A missing camera or microphone is not a refusal, so
+ * the request falls back to each device on its own and resolves with
+ * whichever devices exist, which can be none.
+ */
+export async function requestMediaPermissions(
+ getUserMedia: GetUserMedia
+): Promise {
+ try {
+ return readDevices(await getUserMedia({ audio: true, video: true }))
+ } catch (error) {
+ if (isPermissionError(error)) throw error
+ }
+
+ const results = await Promise.allSettled([
+ getUserMedia({ audio: true }),
+ getUserMedia({ video: true }),
+ ])
+ const refused = results.find(
+ (r): r is PromiseRejectedResult =>
+ r.status === 'rejected' && isPermissionError(r.reason)
+ )
+ if (refused) {
+ results.forEach((r) => {
+ if (r.status === 'fulfilled') readDevices(r.value)
+ })
+ throw refused.reason
+ }
+ const devices: GrantedDevices = {}
+ for (const r of results) {
+ if (r.status !== 'fulfilled') continue
+ const { micId, cameraId } = readDevices(r.value)
+ if (micId) devices.micId = micId
+ if (cameraId) devices.cameraId = cameraId
+ }
+ return devices
+}