// SPDX-License-Identifier: AGPL-3.0-or-later import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands'; import * as Modal from '@app/features/app/components/dialogs/Modal'; import {CAMERA_DESCRIPTOR, SOMETHING_WENT_WRONG_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import Permission from '@app/features/permissions/state/Permission'; import {Logger} from '@app/features/platform/utils/AppLogger'; import BackgroundImageGalleryModal from '@app/features/theme/components/modals/BackgroundImageGalleryModal'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {Button} from '@app/features/ui/button/Button'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import {Combobox} from '@app/features/ui/components/form/FormCombobox'; import {Switch} from '@app/features/ui/components/form/FormSwitch'; import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider'; import {Spinner} from '@app/features/ui/components/Spinner'; import {canResetSliderValue, SliderResetIconButton} from '@app/features/ui/components/slider/SliderResetIconButton'; import MobileLayout from '@app/features/ui/state/MobileLayout'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import {formatRoundedPercentage} from '@app/features/ui/utils/PercentageFormatting'; import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands'; import styles from '@app/features/voice/components/modals/CameraPreviewModal.module.css'; import {useMaybeVoiceRoom} from '@app/features/voice/components/VoiceRoomContext'; import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade'; import {VOICE_CAMERA_USER_LIMIT_REACHED_DESCRIPTOR} from '@app/features/voice/engine/media_engine_facade/shared'; import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState'; import {getCameraCaptureDimensions} from '@app/features/voice/engine/v2/VoiceEngineV2AppCameraResolutionPresets'; import {useCameraUserCapBlocked} from '@app/features/voice/hooks/useCameraUserCapBlocked'; import VoiceSettings, { BLUR_BACKGROUND_ID, CAMERA_EFFECT_STRENGTH_DEFAULT, CAMERA_EFFECT_STRENGTH_MAX, CAMERA_EFFECT_STRENGTH_MIN, NONE_BACKGROUND_ID, } from '@app/features/voice/state/VoiceSettings'; import {applyBackgroundProcessor} from '@app/features/voice/utils/VideoBackgroundProcessor'; import {areVoiceBackgroundsAvailable} from '@app/features/voice/utils/VoiceBackgroundAvailability'; import {resolveEffectiveDeviceId, type VoiceDeviceState} from '@app/features/voice/utils/VoiceDeviceManager'; import { formatFallbackCameraLabel, VOICE_TURN_ON_CAMERA_DESCRIPTOR, } from '@app/features/voice/utils/VoiceMessageDescriptors'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {VOICE_CHANNEL_CAMERA_USER_LIMIT} from '@fluxer/constants/src/LimitConstants'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {CameraIcon, ImageIcon} from '@phosphor-icons/react'; import type {LocalParticipant, LocalVideoTrack, Room} from 'livekit-client'; import {createLocalVideoTrack, RoomEvent} from 'livekit-client'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useEffect, useRef, useState} from 'react'; const FAILED_TO_START_CAMERA_PREVIEW_PLEASE_CHECK_YOUR_DESCRIPTOR = msg({ message: 'Failed to start camera preview. Check your camera permissions.', comment: 'Error text in the camera preview modal when the camera stream cannot be opened. Prompts user to check OS / browser permissions.', }); const FAILED_TO_ENABLE_CAMERA_DESCRIPTOR = msg({ message: 'Failed to enable camera.', comment: 'Toast / inline error in the camera preview modal when enabling the camera fails.', }); const CAMERA_PREVIEW_DESCRIPTOR = msg({ message: 'Camera preview', comment: 'Title of the camera preview modal.', }); const DEFAULT_CAMERA_DESCRIPTOR = msg({ message: 'Default', comment: 'Default camera device option.', }); const MIRROR_CAMERA_DESCRIPTOR = msg({ message: 'Mirror camera', comment: 'Switch label in the camera preview modal for flipping the local camera preview horizontally.', }); const YOU_DON_T_HAVE_PERMISSION_TO_TURN_ON_DESCRIPTOR = msg({ message: "You can't turn on your camera in this channel", comment: 'Tooltip / error shown in the camera preview modal when the user lacks Video permission in the current channel. Tone stays plain.', }); const BLUR_STRENGTH_DESCRIPTOR = msg({ message: 'Blur strength', comment: 'Slider label in the camera preview modal controlling how strong the background blur effect is.', }); const logger = new Logger('CameraPreviewModal'); interface CameraPreviewModalProps { onEnabled?: () => void; onEnableCamera?: () => void | Promise; showEnableCameraButton?: boolean; localParticipant?: LocalParticipant; isCameraEnabled?: boolean; } const TARGET_ASPECT_RATIO = 16 / 9; const ASPECT_RATIO_TOLERANCE = 0.1; const RESOLUTION_WAIT_TIMEOUT = 2000; const RESOLUTION_CHECK_INTERVAL = 100; const VIDEO_ELEMENT_WAIT_TIMEOUT = 5000; const VIDEO_ELEMENT_CHECK_INTERVAL = 10; const PLAYBACK_WAIT_TIMEOUT = 5000; const PLAYBACK_CHECK_INTERVAL = 50; const VIDEO_READY_STATE_HAS_CURRENT_DATA = 2; const RESOLUTION_FIX_TRIGGER_DELAY = 800; const RESOLUTION_FIX_SETTLE_DELAY = 1200; const RESOLUTION_FIX_SWITCH_BACK_DELAY = 500; interface CameraPreviewConfig { videoDeviceId: string; backgroundImageId: string; backgroundBlurStrength: number; mirrorCamera: boolean; cameraResolution: 'low' | 'medium' | 'high'; videoFrameRate: number; } interface CameraPreviewParticipantState { localParticipant: LocalParticipant | undefined; isCameraEnabled: boolean; } const NO_CAMERA_PREVIEW_PARTICIPANT_STATE: CameraPreviewParticipantState = { localParticipant: undefined, isCameraEnabled: false, }; const CAMERA_PREVIEW_LOCAL_PARTICIPANT_EVENTS: ReadonlyArray = [ RoomEvent.ConnectionStateChanged, RoomEvent.LocalTrackPublished, RoomEvent.LocalTrackUnpublished, RoomEvent.TrackMuted, RoomEvent.TrackUnmuted, ]; function getCameraPreviewParticipantState(room: Room | undefined): CameraPreviewParticipantState { if (!room) return NO_CAMERA_PREVIEW_PARTICIPANT_STATE; const localParticipant = room.localParticipant; return { localParticipant, isCameraEnabled: localParticipant.isCameraEnabled, }; } function useCameraPreviewParticipantState(): CameraPreviewParticipantState { useMediaEngineVersion(); const room = useMaybeVoiceRoom() ?? MediaEngine.room ?? undefined; const [state, setState] = useState(() => getCameraPreviewParticipantState(room)); useEffect(() => { if (!room) { setState(NO_CAMERA_PREVIEW_PARTICIPANT_STATE); return; } const update = () => setState(getCameraPreviewParticipantState(room)); update(); for (const event of CAMERA_PREVIEW_LOCAL_PARTICIPANT_EVENTS) { room.on(event, update); } return () => { for (const event of CAMERA_PREVIEW_LOCAL_PARTICIPANT_EVENTS) { room.off(event, update); } }; }, [room]); return state; } interface CameraPreviewProcessor { destroy: () => Promise; } function isSameCameraPreviewConfig(previous: CameraPreviewConfig | null, next: CameraPreviewConfig): boolean { return previous != null && JSON.stringify(previous) === JSON.stringify(next); } function hasSameCameraPreviewTopology(previous: CameraPreviewConfig | null, next: CameraPreviewConfig): boolean { return ( previous != null && previous.videoDeviceId === next.videoDeviceId && previous.mirrorCamera === next.mirrorCamera && previous.cameraResolution === next.cameraResolution && previous.videoFrameRate === next.videoFrameRate ); } function isNear16x9AspectRatio(resolution: {width: number; height: number}): boolean { const aspectRatio = resolution.width / resolution.height; return Math.abs(aspectRatio - TARGET_ASPECT_RATIO) < ASPECT_RATIO_TOLERANCE; } async function waitForVideoElement( videoRef: React.RefObject, isCurrentInitialization: () => boolean, ): Promise { let videoElement = videoRef.current; let attempts = 0; const maxAttempts = VIDEO_ELEMENT_WAIT_TIMEOUT / VIDEO_ELEMENT_CHECK_INTERVAL; while (!videoElement && isCurrentInitialization() && attempts < maxAttempts) { await new Promise((resolve) => setTimeout(resolve, VIDEO_ELEMENT_CHECK_INTERVAL)); videoElement = videoRef.current; attempts++; } return videoElement; } function waitForPlaybackSettle(videoElement: HTMLVideoElement, isCurrentInitialization: () => boolean): Promise { return new Promise((resolve) => { let playbackAttempts = 0; const maxAttempts = PLAYBACK_WAIT_TIMEOUT / PLAYBACK_CHECK_INTERVAL; const checkPlayback = () => { if (!isCurrentInitialization()) { resolve(); return; } const hasData = videoElement.srcObject && videoElement.readyState >= VIDEO_READY_STATE_HAS_CURRENT_DATA; if (hasData) { resolve(); } else if (++playbackAttempts < maxAttempts) { setTimeout(checkPlayback, PLAYBACK_CHECK_INTERVAL); } else { resolve(); } }; checkPlayback(); }); } function waitForNegotiatedResolution( track: LocalVideoTrack, isCurrentInitialization: () => boolean, ): Promise<{width: number; height: number} | null> { return new Promise((resolve) => { let resolutionAttempts = 0; const maxAttempts = RESOLUTION_WAIT_TIMEOUT / RESOLUTION_CHECK_INTERVAL; const checkResolution = () => { if (!isCurrentInitialization()) { resolve(null); return; } const settings = track.mediaStreamTrack.getSettings(); if (settings.width && settings.height) { resolve({width: settings.width, height: settings.height}); } else if (++resolutionAttempts < maxAttempts) { setTimeout(checkResolution, RESOLUTION_CHECK_INTERVAL); } else { resolve(null); } }; checkResolution(); }); } interface CameraPreviewTrackSetupArgs { videoElement: HTMLVideoElement; effectiveVideoDeviceId: string | null; backgroundImageId: string; mirrorCamera: boolean; cameraResolution: 'low' | 'medium' | 'high'; videoFrameRate: number; isCurrentInitialization: () => boolean; trackRef: React.MutableRefObject; processorRef: React.MutableRefObject; onResolutionNegotiated: (resolution: {width: number; height: number} | null) => void; } async function setupPreviewTrackAndProcessor(args: CameraPreviewTrackSetupArgs): Promise<'cancelled' | 'ready'> { if (args.trackRef.current) { args.trackRef.current.stop(); args.trackRef.current = null; } if (args.processorRef.current) { await args.processorRef.current.destroy(); args.processorRef.current = null; } const captureDimensions = getCameraCaptureDimensions(args.cameraResolution); const track = await createLocalVideoTrack({ deviceId: args.effectiveVideoDeviceId && args.effectiveVideoDeviceId !== 'default' ? args.effectiveVideoDeviceId : undefined, resolution: { width: captureDimensions.width, height: captureDimensions.height, frameRate: args.videoFrameRate, aspectRatio: TARGET_ASPECT_RATIO, }, }); if (!args.isCurrentInitialization()) { track.stop(); return 'cancelled'; } args.trackRef.current = track; track.attach(args.videoElement); await waitForPlaybackSettle(args.videoElement, args.isCurrentInitialization); if (!args.isCurrentInitialization()) { track.stop(); return 'cancelled'; } const negotiatedResolution = await waitForNegotiatedResolution(track, args.isCurrentInitialization); if (!args.isCurrentInitialization()) { track.stop(); return 'cancelled'; } args.onResolutionNegotiated(negotiatedResolution); let processor: CameraPreviewProcessor | null = null; try { processor = await applyBackgroundProcessor(track, { backgroundImageId: args.backgroundImageId, mirrorCamera: args.mirrorCamera, }); } catch (error) { logger.warn('Camera background processing failed; falling back to basic camera', {error}); } if (!args.isCurrentInitialization()) { await processor?.destroy().catch((destroyError) => { logger.warn('Failed to destroy camera preview processor after stale initialization', { error: destroyError, }); }); track.stop(); return 'cancelled'; } args.processorRef.current = processor; return 'ready'; } interface CameraEffectStrengthSliderProps { label: string; value: number; onChange: (value: number) => void; resetLabel: string; dataFlx: string; } const CameraEffectStrengthSlider = ({label, value, onChange, resetLabel, dataFlx}: CameraEffectStrengthSliderProps) => { const [draftValue, setDraftValueState] = useState(value); const draftValueRef = useRef(value); const committedValueRef = useRef(value); const isInteractingRef = useRef(false); const setDraftValue = useCallback((nextValue: number) => { draftValueRef.current = nextValue; setDraftValueState(nextValue); }, []); const commitValue = useCallback( (nextValue: number) => { if (nextValue === committedValueRef.current) { setDraftValue(nextValue); return; } committedValueRef.current = nextValue; setDraftValue(nextValue); onChange(nextValue); }, [onChange, setDraftValue], ); const handlePointerInteractionChange = useCallback( (isInteracting: boolean) => { isInteractingRef.current = isInteracting; if (isInteracting) return; const nextValue = draftValueRef.current; if (nextValue !== committedValueRef.current) { commitValue(nextValue); } }, [commitValue], ); useEffect(() => { committedValueRef.current = value; if (isInteractingRef.current) return; setDraftValue(value); }, [setDraftValue, value]); return (
{label}
commitValue(CAMERA_EFFECT_STRENGTH_DEFAULT)} ariaLabel={resetLabel} dataFlx={`${dataFlx}.reset`} data-flx="voice.camera-preview-modal.camera-effect-strength-slider.slider-reset-icon-button" />
); }; const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => { const {i18n} = useLingui(); useMediaEngineVersion(); const {localParticipant, onEnabled, onEnableCamera, isCameraEnabled, showEnableCameraButton = true} = props; const cameraAlreadyOn = isCameraEnabled === true; const voiceBackgroundsAvailable = areVoiceBackgroundsAvailable(); const channelId = MediaEngine.channelId; const guildId = MediaEngine.guildId; const canStream = !localParticipant || !guildId || !channelId || Permission.can(Permissions.STREAM, {channelId}); const cameraCapBlocked = useCameraUserCapBlocked(cameraAlreadyOn); const selectedBackgroundImageId = voiceBackgroundsAvailable ? VoiceSettings.backgroundImageId : NONE_BACKGROUND_ID; const [videoDevices, setVideoDevices] = useState>([]); const [status, setStatus] = useState< 'idle' | 'initializing' | 'ready' | 'error' | 'fixing' | 'fix-settling' | 'fix-switching-back' >('initializing'); const [error, setError] = useState(null); const [backgroundOverrideId, setBackgroundOverrideId] = useState(null); const [cameraPermissionGranted, setCameraPermissionGranted] = useState(false); const videoRef = useRef(null); const trackRef = useRef(null); const processorRef = useRef(null); const isMountedRef = useRef(true); const isIOSRef = useRef(/iPad|iPhone|iPod/.test(navigator.userAgent) && !('MSStream' in window)); const prevConfigRef = useRef(null); const needsResolutionFixRef = useRef(false); const isApplyingFixRef = useRef(false); const initializationTimeoutRef = useRef(null); const settleTimeoutRef = useRef(null); const switchBackTimeoutRef = useRef(null); const initializationGenerationRef = useRef(0); const handleDeviceUpdate = useCallback((state: VoiceDeviceState) => { if (!isMountedRef.current) return; const videoInputs = state.videoDevices.filter((device) => device.deviceId.trim().length > 0); setVideoDevices(videoInputs); const voiceSettings = VoiceSettings; const currentDeviceId = voiceSettings.videoDeviceId; const currentDeviceExists = currentDeviceId === 'default' || videoInputs.some((device) => device.deviceId === currentDeviceId); if (videoInputs.length > 0 && !currentDeviceExists) { VoiceSettingsCommands.update({videoDeviceId: 'default'}); } }, []); const applyResolutionFix = useCallback(() => { if (!isMountedRef.current || isApplyingFixRef.current) { return; } if (!voiceBackgroundsAvailable) { setStatus('ready'); return; } isApplyingFixRef.current = true; needsResolutionFixRef.current = false; const currentBg = VoiceSettings.backgroundImageId; const overrideBg = currentBg === NONE_BACKGROUND_ID ? BLUR_BACKGROUND_ID : NONE_BACKGROUND_ID; setStatus('fixing'); setBackgroundOverrideId(overrideBg); settleTimeoutRef.current = setTimeout(() => { setStatus('fix-switching-back'); setBackgroundOverrideId(null); switchBackTimeoutRef.current = setTimeout(() => { if (isMountedRef.current) { isApplyingFixRef.current = false; setStatus('ready'); } }, RESOLUTION_FIX_SWITCH_BACK_DELAY); }, RESOLUTION_FIX_SETTLE_DELAY); }, [voiceBackgroundsAvailable]); const initializeCamera = useCallback(async () => { const generation = ++initializationGenerationRef.current; const isCurrentInitialization = () => isMountedRef.current && initializationGenerationRef.current === generation; const voiceSettings = VoiceSettings; const isMobile = MobileLayout.isMobileLayout() || isIOSRef.current; if (isMobile) { if (isCurrentInitialization()) { setStatus('ready'); } return; } if (!isCurrentInitialization()) { return; } const videoElement = await waitForVideoElement(videoRef, isCurrentInitialization); if (!isCurrentInitialization()) { return; } if (!videoElement) { setStatus('error'); setError(i18n._(FAILED_TO_START_CAMERA_PREVIEW_PLEASE_CHECK_YOUR_DESCRIPTOR)); return; } try { const effectiveVideoDeviceId = resolveEffectiveDeviceId(voiceSettings.videoDeviceId, videoDevices); const backgroundImageId = backgroundOverrideId ?? (voiceBackgroundsAvailable ? voiceSettings.backgroundImageId : NONE_BACKGROUND_ID); const currentConfig: CameraPreviewConfig = { videoDeviceId: effectiveVideoDeviceId ?? 'default', backgroundImageId, backgroundBlurStrength: voiceSettings.backgroundBlurStrength, mirrorCamera: voiceSettings.mirrorCamera, cameraResolution: voiceSettings.getCameraResolution(), videoFrameRate: voiceSettings.getVideoFrameRate(), }; if (trackRef.current && isSameCameraPreviewConfig(prevConfigRef.current, currentConfig)) { return; } if (isCurrentInitialization()) { setStatus(isApplyingFixRef.current ? 'fixing' : 'initializing'); setError(null); } videoElement.muted = true; videoElement.autoplay = true; videoElement.playsInline = true; const activeTrack = trackRef.current; const activeProcessor = processorRef.current; if (activeTrack && activeProcessor && hasSameCameraPreviewTopology(prevConfigRef.current, currentConfig)) { try { const updatedProcessor = await applyBackgroundProcessor(activeTrack, { backgroundImageId, mirrorCamera: currentConfig.mirrorCamera, }); if (!isCurrentInitialization()) { return; } processorRef.current = updatedProcessor; prevConfigRef.current = currentConfig; setStatus('ready'); return; } catch (error) { if (isCurrentInitialization()) { logger.warn('Camera background update failed; retained the previous preview processor', {error}); setStatus('ready'); } return; } } prevConfigRef.current = currentConfig; const setupResult = await setupPreviewTrackAndProcessor({ videoElement, effectiveVideoDeviceId, backgroundImageId, mirrorCamera: currentConfig.mirrorCamera, cameraResolution: voiceSettings.getCameraResolution(), videoFrameRate: voiceSettings.getVideoFrameRate(), isCurrentInitialization, trackRef, processorRef, onResolutionNegotiated: (resolution) => { if (resolution && !isApplyingFixRef.current) { needsResolutionFixRef.current = !isNear16x9AspectRatio(resolution); } }, }); if (setupResult === 'cancelled') { return; } setStatus('ready'); if (voiceBackgroundsAvailable && needsResolutionFixRef.current && !isApplyingFixRef.current) { initializationTimeoutRef.current = setTimeout(() => applyResolutionFix(), RESOLUTION_FIX_TRIGGER_DELAY); } } catch (err) { if (isCurrentInitialization()) { const message = err instanceof Error ? err.message : i18n._(FAILED_TO_START_CAMERA_PREVIEW_PLEASE_CHECK_YOUR_DESCRIPTOR); setStatus('error'); setError(message); showGenericErrorModal({ title: () => i18n._(SOMETHING_WENT_WRONG_DESCRIPTOR), message: () => i18n._(FAILED_TO_START_CAMERA_PREVIEW_PLEASE_CHECK_YOUR_DESCRIPTOR), dataFlx: 'voice.camera-preview-modal.start-preview-error-modal', }); } } }, [applyResolutionFix, backgroundOverrideId, i18n, videoDevices, voiceBackgroundsAvailable]); const handleDeviceChange = useCallback((deviceId: string) => { VoiceSettingsCommands.update({videoDeviceId: deviceId}); }, []); const handleOpenBackgroundGallery = useCallback(() => { if (!voiceBackgroundsAvailable) { return; } ModalCommands.push( modal(() => ( )), ); }, [voiceBackgroundsAvailable]); const handleEnableCamera = useCallback(async () => { try { if (!localParticipant) { await onEnableCamera?.(); onEnabled?.(); ModalCommands.pop(); return; } const voiceSettings = VoiceSettings; const effectiveVideoDeviceId = resolveEffectiveDeviceId(voiceSettings.videoDeviceId, videoDevices); const enableCamera = onEnableCamera ?? (() => MediaEngine.setCameraEnabled(true, { deviceId: effectiveVideoDeviceId && effectiveVideoDeviceId !== 'default' ? effectiveVideoDeviceId : undefined, })); await enableCamera(); onEnabled?.(); ModalCommands.pop(); } catch (_err) { showGenericErrorModal({ title: () => i18n._(SOMETHING_WENT_WRONG_DESCRIPTOR), message: () => i18n._(FAILED_TO_ENABLE_CAMERA_DESCRIPTOR), dataFlx: 'voice.camera-preview-modal.enable-camera-error-modal', }); } }, [i18n, localParticipant, onEnabled, onEnableCamera, videoDevices]); useEffect(() => { isMountedRef.current = true; const unsubscribeDevices = VoiceDevicePermissionState.subscribe(handleDeviceUpdate); void VoiceDevicePermissionState.requestPermissionFor('video') .then((granted) => { if (!isMountedRef.current) return; setCameraPermissionGranted(granted); if (!granted) { logger.warn('Camera permission was not granted for preview'); setStatus('error'); setError(i18n._(FAILED_TO_START_CAMERA_PREVIEW_PLEASE_CHECK_YOUR_DESCRIPTOR)); } }) .catch((error) => { logger.warn('Failed to enumerate camera preview devices', {error}); }); return () => { isMountedRef.current = false; initializationGenerationRef.current++; if (initializationTimeoutRef.current) clearTimeout(initializationTimeoutRef.current); if (settleTimeoutRef.current) clearTimeout(settleTimeoutRef.current); if (switchBackTimeoutRef.current) clearTimeout(switchBackTimeoutRef.current); isApplyingFixRef.current = false; if (trackRef.current) { trackRef.current.stop(); trackRef.current = null; } if (processorRef.current) { processorRef.current.destroy().catch((error) => { logger.warn('Failed to destroy camera preview processor during modal cleanup', {error}); }); processorRef.current = null; } if (videoRef.current) { try { if (videoRef.current.srcObject) { videoRef.current.srcObject = null; } } catch {} } unsubscribeDevices?.(); }; }, [handleDeviceUpdate, i18n]); useEffect(() => { if (!cameraPermissionGranted) return; const voiceSettings = VoiceSettings; const backgroundImageId = backgroundOverrideId ?? (voiceBackgroundsAvailable ? voiceSettings.backgroundImageId : NONE_BACKGROUND_ID); const currentConfig: CameraPreviewConfig = { videoDeviceId: voiceSettings.videoDeviceId, backgroundImageId, backgroundBlurStrength: voiceSettings.backgroundBlurStrength, mirrorCamera: voiceSettings.mirrorCamera, cameraResolution: voiceSettings.getCameraResolution(), videoFrameRate: voiceSettings.getVideoFrameRate(), }; if (!isSameCameraPreviewConfig(prevConfigRef.current, currentConfig)) { initializeCamera(); } }, [ cameraPermissionGranted, initializeCamera, backgroundOverrideId, VoiceSettings.videoDeviceId, VoiceSettings.backgroundImageId, VoiceSettings.backgroundBlurStrength, VoiceSettings.mirrorCamera, VoiceSettings.getCameraResolution(), VoiceSettings.getVideoFrameRate(), voiceBackgroundsAvailable, ]); const voiceSettings = VoiceSettings; const effectiveVideoDeviceId = resolveEffectiveDeviceId(voiceSettings.videoDeviceId, videoDevices) ?? 'default'; const previewVideoClassName = styles.video; const videoDeviceOptions = videoDevices.length > 0 ? videoDevices.map((device) => ({ value: device.deviceId, label: device.deviceId === 'default' ? i18n._(DEFAULT_CAMERA_DESCRIPTOR) : device.label || formatFallbackCameraLabel(i18n), })) : [{value: 'default', label: i18n._(DEFAULT_CAMERA_DESCRIPTOR)}]; return (
{voiceBackgroundsAvailable && (
Background
{selectedBackgroundImageId === BLUR_BACKGROUND_ID && ( VoiceSettingsCommands.update({backgroundBlurStrength: value})} resetLabel={i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR)} dataFlx="voice.camera-preview-modal.camera-preview-modal-content.blur-strength" data-flx="voice.camera-preview-modal.camera-preview-modal-content.camera-effect-strength-slider.update" /> )}
)}
VoiceSettingsCommands.update({mirrorCamera: value})} data-flx="voice.camera-preview-modal.camera-preview-modal-content.footer-mirror-switch.update" />
{showEnableCameraButton && !cameraAlreadyOn && (canStream && !cameraCapBlocked ? ( ) : ( ))}
); }); export const CameraPreviewModalInRoom: React.FC> = observer((props) => { const {localParticipant, isCameraEnabled} = useCameraPreviewParticipantState(); return ( ); }); export const CameraPreviewModalStandalone: React.FC = observer((props) => { return ( ); });