Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions app/components/EnsurePermissions.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { requestMediaPermissions } from '~/utils/requestMediaPermissions'
import { Button } from './Button'

export interface EnsurePermissionsProps {
Expand Down Expand Up @@ -61,18 +62,13 @@ export function EnsurePermissions(props: EnsurePermissionsProps) {
</p>
<Button
onClick={() => {
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')
Expand Down
72 changes: 72 additions & 0 deletions app/utils/requestMediaPermissions.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
})
})
63 changes: 63 additions & 0 deletions app/utils/requestMediaPermissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
export interface GrantedDevices {
micId?: string
cameraId?: string
}

type GetUserMedia = (
constraints: MediaStreamConstraints
) => Promise<MediaStream>

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<GrantedDevices> {
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
}