From 5723f29cef45a6962a11cadc0e62b77a7c43d766 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 10 Aug 2026 12:57:35 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20prompt=20for=20permission?= =?UTF-8?q?s=20when=20toggling=20a=20denied=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user clicks the microphone or camera toggle while the corresponding permission is denied, trigger a permission prompt via `getUserMedia` instead of silently doing nothing. This gives users a clear path back to granting access without having to dig into the browser settings themselves. --- CHANGELOG.md | 1 + .../controls/Device/ToggleDevice.tsx | 32 ++++++++++++++----- .../rooms/livekit/hooks/useJoinTracks.ts | 21 ++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a95dcd98..d78d98a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to - 📈(frontend) capture media diagnostics on media errors - ✨(frontend) add an audio gauge to the microphone select menu - ✨(frontend) add a sound tester to the output select menu +- ✨(frontend) prompt for permissions when toggling a denied device ### Changed diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx index de274569..ebb5f9fc 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/ToggleDevice.tsx @@ -1,7 +1,7 @@ import { ToggleButton } from '@/primitives' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' -import { useMemo, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { appendShortcutLabel } from '@/features/shortcuts/utils' import { useTranslation } from 'react-i18next' import { PermissionNeededButton } from './PermissionNeededButton' @@ -16,6 +16,7 @@ import type { ButtonRecipeProps } from '@/primitives/buttonRecipe' import type { ToggleButtonProps } from '@/primitives/ToggleButton' import { openPermissionsDialog } from '@/stores/permissions' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' +import { requestDevicePermission } from '../../../hooks/useJoinTracks' import { useDeviceIcons } from '../../../hooks/useDeviceIcons' import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut' import type { @@ -93,6 +94,27 @@ export const ToggleDevice = ({ const deviceShortcut = useDeviceShortcut(kind) const announce = useScreenReaderAnnounce() + const isRequestingPermission = useRef(false) + + const onPress = async () => { + if (!cannotUseDevice) { + toggle() + return + } + if (isRequestingPermission.current) return + isRequestingPermission.current = true + try { + const granted = await requestDevicePermission(kind) + if (granted) { + toggle() + } else { + openPermissionsDialog(kind) + } + } finally { + isRequestingPermission.current = false + } + } + useRegisterKeyboardShortcut({ id: deviceShortcut?.id, handler: async () => { @@ -147,13 +169,7 @@ export const ToggleDevice = ({ isDisabled || cannotUseDevice || !enabled ? errorVariant : variant } shySelected - onPress={() => { - if (cannotUseDevice) { - openPermissionsDialog(kind) - } else { - toggle() - } - }} + onPress={onPress} aria-label={toggleLabel} tooltip={ cannotUseDevice diff --git a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts index 84f7c059..1e823512 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts @@ -33,6 +33,11 @@ const VOICE_AUDIO_CONSTRAINTS = { sampleSize: 16, } as const +const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = { + audioinput: 'microphone', + videoinput: 'camera', +} + export const onJoinPreviewError = (e: Error, kind?: PermissionKind) => { reportError('join_preview_failure', e, { path: 'join_preview' }) if ( @@ -49,6 +54,22 @@ const disableVideo = () => saveVideoInputEnabled(false) const stopAll = (stream: MediaStream) => stream.getTracks().forEach((track) => track.stop()) +export const requestDevicePermission = async ( + kind: 'audioinput' | 'videoinput' +): Promise => { + try { + const track = + kind === 'audioinput' + ? await createLocalAudioTrack() + : await createLocalVideoTrack() + track.stop() + return true + } catch (error) { + onJoinPreviewError(error as Error, PERMISSION_KIND[kind]) + return false + } +} + /** * Requests camera and microphone once on mount (one combined call → at * most one browser dialog) and releases them immediately. Returns true