diff --git a/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx b/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx index e3ca7eb9..381a957f 100644 --- a/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx +++ b/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx @@ -9,11 +9,13 @@ import { RiNotification3Line, RiSettings3Line, RiSpeakerLine, + RiVideoOnLine, } from '@remixicon/react' import { AccountTab } from './tabs/AccountTab' import { NotificationsTab } from './tabs/NotificationsTab' import { GeneralTab } from './tabs/GeneralTab' import { AudioTab } from './tabs/AudioTab' +import { VideoTab } from './tabs/VideoTab' import { useRef } from 'react' import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery' @@ -79,10 +81,14 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => { {isWideScreen && t('tabs.audio')} + + {isWideScreen && t('tabs.video')} + + {isWideScreen && t('tabs.general')} - + {isWideScreen && t('tabs.notifications')} @@ -91,8 +97,9 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
- - + + +
diff --git a/src/frontend/src/features/settings/components/tabs/VideoTab.tsx b/src/frontend/src/features/settings/components/tabs/VideoTab.tsx new file mode 100644 index 00000000..7f18b7f0 --- /dev/null +++ b/src/frontend/src/features/settings/components/tabs/VideoTab.tsx @@ -0,0 +1,170 @@ +import { DialogProps, Field, H } from '@/primitives' + +import { TabPanel, TabPanelProps } from '@/primitives/Tabs' +import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react' +import { useTranslation } from 'react-i18next' +import { HStack } from '@/styled-system/jsx' +import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices' +import { ReactNode, useCallback, useEffect, useState } from 'react' +import { css } from '@/styled-system/css' +import { createLocalVideoTrack, LocalVideoTrack } from 'livekit-client' + +type RowWrapperProps = { + heading: string + children: ReactNode[] +} + +const RowWrapper = ({ heading, children }: RowWrapperProps) => { + return ( + <> + {heading} + +
+ {children[0]} +
+
+ {children[1]} +
+
+ + ) +} + +export type VideoTabProps = Pick & + Pick + +type DeviceItems = Array<{ value: string; label: string }> + +export const VideoTab = ({ id }: VideoTabProps) => { + const { t } = useTranslation('settings') + const { localParticipant } = useRoomContext() + + const { + userChoices: { videoDeviceId }, + saveVideoInputDeviceId, + } = usePersistentUserChoices() + const [videoElement, setVideoElement] = useState( + null + ) + + const videoCallbackRef = useCallback((element: HTMLVideoElement | null) => { + setVideoElement(element) + }, []) + + const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } = + useMediaDeviceSelect({ kind: 'videoinput' }) + + const itemsIn: DeviceItems = devicesIn.map((d) => ({ + value: d.deviceId, + label: d.label, + })) + + // The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for camera permissions + // may raise an error. As a workaround, we infer camera permission status by checking if the list of camera input + // devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted camera access. + const isCamEnabled = devicesIn?.length > 0 + + const disabledProps = isCamEnabled + ? {} + : { + placeholder: t('video.permissionsRequired'), + isDisabled: true, + } + + useEffect(() => { + let videoTrack: LocalVideoTrack | null = null + + const setUpVideoTrack = async () => { + if (videoElement) { + videoTrack = await createLocalVideoTrack({ deviceId: videoDeviceId }) + videoTrack.attach(videoElement) + } + } + + setUpVideoTrack() + + return () => { + if (videoElement && videoTrack) { + videoTrack.detach() + videoTrack.stop() + } + } + }, [videoDeviceId, videoElement]) + + return ( + + + { + await setActiveMediaDeviceIn(key as string) + saveVideoInputDeviceId(key as string) + }} + {...disabledProps} + style={{ + width: '100%', + }} + /> +
+ {localParticipant.isCameraEnabled ? ( + <> + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +
+
+
+ ) +} diff --git a/src/frontend/src/locales/de/settings.json b/src/frontend/src/locales/de/settings.json index 1fdb4c42..d4a367af 100644 --- a/src/frontend/src/locales/de/settings.json +++ b/src/frontend/src/locales/de/settings.json @@ -30,6 +30,18 @@ }, "permissionsRequired": "Berechtigungen erforderlich" }, + "video": { + "camera": { + "heading": "Kamera", + "label": "Wählen Sie Ihre Videoeingabe aus", + "disabled": "Kamera deaktiviert", + "previewAriaLabel": { + "enabled": "Videovorschau aktiviert", + "disabled": "Videovorschau deaktiviert" + } + }, + "permissionsRequired": "Berechtigungen erforderlich" + }, "notifications": { "heading": "Tonbenachrichtigungen", "label": "Tonbenachrichtigungen für", @@ -54,6 +66,7 @@ "tabs": { "account": "Profil", "audio": "Audio", + "video": "Video", "general": "Allgemein", "notifications": "Benachrichtigungen" } diff --git a/src/frontend/src/locales/en/settings.json b/src/frontend/src/locales/en/settings.json index bc5ee6ac..4283a37b 100644 --- a/src/frontend/src/locales/en/settings.json +++ b/src/frontend/src/locales/en/settings.json @@ -30,6 +30,18 @@ }, "permissionsRequired": "Permissions required" }, + "video": { + "camera": { + "heading": "Camera", + "label": "Select your video input", + "disabled": "Camera disabled", + "previewAriaLabel": { + "enabled": "Video preview enabled", + "disabled": "Video preview disabled" + } + }, + "permissionsRequired": "Permissions required" + }, "notifications": { "heading": "Sound notifications", "label": "sound notifications for", @@ -54,6 +66,7 @@ "tabs": { "account": "Profile", "audio": "Audio", + "video": "Video", "general": "General", "notifications": "Notifications" } diff --git a/src/frontend/src/locales/fr/settings.json b/src/frontend/src/locales/fr/settings.json index 6a8a7fbc..b7d66269 100644 --- a/src/frontend/src/locales/fr/settings.json +++ b/src/frontend/src/locales/fr/settings.json @@ -30,6 +30,18 @@ }, "permissionsRequired": "Autorisations nécessaires" }, + "video": { + "camera": { + "heading": "Caméra", + "label": "Sélectionner votre entrée vidéo", + "disabled": "Caméra désactivée", + "previewAriaLabel": { + "enabled": "Aperçu vidéo activé", + "disabled": "Aperçu vidéo désactivé" + } + }, + "permissionsRequired": "Autorisations nécessaires" + }, "notifications": { "heading": "Notifications sonores", "label": "la notification sonore pour", @@ -54,6 +66,7 @@ "tabs": { "account": "Profil", "audio": "Audio", + "video": "Vidéo", "general": "Général", "notifications": "Notifications" } diff --git a/src/frontend/src/locales/nl/settings.json b/src/frontend/src/locales/nl/settings.json index 95ad95d4..32354ab7 100644 --- a/src/frontend/src/locales/nl/settings.json +++ b/src/frontend/src/locales/nl/settings.json @@ -30,6 +30,18 @@ }, "permissionsRequired": "Machtigingen vereist" }, + "video": { + "camera": { + "heading": "Camera", + "label": "Selecteer uw video-ingang", + "disabled": "Camera uitgeschakeld", + "previewAriaLabel": { + "enabled": "Videovoorbeeld ingeschakeld", + "disabled": "Videovoorbeeld uitgeschakeld" + } + }, + "permissionsRequired": "Machtigingen vereist" + }, "notifications": { "heading": "Geluidsmeldingen", "label": "Geluidsmeldingen voor", @@ -54,6 +66,7 @@ "tabs": { "account": "Profiel", "audio": "Audio", + "video": "Video", "general": "Algemeen", "notifications": "Meldingen" }