Files
fluxer/fluxer_app/src/features/voice/components/modals/CameraPreviewModal.tsx
T

908 lines
33 KiB
TypeScript

// 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<void>;
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> = [
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<CameraPreviewParticipantState>(() => 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<void>;
}
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<HTMLVideoElement | null>,
isCurrentInitialization: () => boolean,
): Promise<HTMLVideoElement | null> {
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<void> {
return new Promise<void>((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<LocalVideoTrack | null>;
processorRef: React.MutableRefObject<CameraPreviewProcessor | null>;
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 (
<div className={styles.effectStrengthSlider} data-flx={dataFlx}>
<div className={styles.effectStrengthLabelRow} data-flx={`${dataFlx}.label-row`}>
<div className={styles.effectStrengthLabel} data-flx={`${dataFlx}.label`}>
{label}
</div>
<SliderResetIconButton
canReset={canResetSliderValue(draftValue, CAMERA_EFFECT_STRENGTH_DEFAULT)}
onReset={() => commitValue(CAMERA_EFFECT_STRENGTH_DEFAULT)}
ariaLabel={resetLabel}
dataFlx={`${dataFlx}.reset`}
data-flx="voice.camera-preview-modal.camera-effect-strength-slider.slider-reset-icon-button"
/>
</div>
<Slider
value={draftValue}
defaultValue={value}
factoryDefaultValue={CAMERA_EFFECT_STRENGTH_DEFAULT}
minValue={CAMERA_EFFECT_STRENGTH_MIN}
maxValue={CAMERA_EFFECT_STRENGTH_MAX}
step={1}
onValueRender={formatRoundedPercentage}
asValueChanges={setDraftValue}
onValueChange={commitValue}
onPointerInteractionChange={handlePointerInteractionChange}
data-flx={`${dataFlx}.slider`}
/>
</div>
);
};
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<Array<MediaDeviceInfo>>([]);
const [status, setStatus] = useState<
'idle' | 'initializing' | 'ready' | 'error' | 'fixing' | 'fix-settling' | 'fix-switching-back'
>('initializing');
const [error, setError] = useState<string | null>(null);
const [backgroundOverrideId, setBackgroundOverrideId] = useState<string | null>(null);
const [cameraPermissionGranted, setCameraPermissionGranted] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<LocalVideoTrack | null>(null);
const processorRef = useRef<CameraPreviewProcessor | null>(null);
const isMountedRef = useRef(true);
const isIOSRef = useRef(/iPad|iPhone|iPod/.test(navigator.userAgent) && !('MSStream' in window));
const prevConfigRef = useRef<CameraPreviewConfig | null>(null);
const needsResolutionFixRef = useRef(false);
const isApplyingFixRef = useRef(false);
const initializationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const settleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const switchBackTimeoutRef = useRef<NodeJS.Timeout | null>(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(() => (
<BackgroundImageGalleryModal data-flx="voice.camera-preview-modal.handle-open-background-gallery.background-image-gallery-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 (
<Modal.Root size="medium" data-flx="voice.camera-preview-modal.camera-preview-modal-content.modal-root">
<Modal.Header
title={i18n._(CAMERA_PREVIEW_DESCRIPTOR)}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.modal-header"
/>
<Modal.Content data-flx="voice.camera-preview-modal.camera-preview-modal-content.modal-content">
<div className={styles.content} data-flx="voice.camera-preview-modal.camera-preview-modal-content.content">
<div data-flx="voice.camera-preview-modal.camera-preview-modal-content.div">
<Combobox
label={i18n._(CAMERA_DESCRIPTOR)}
value={effectiveVideoDeviceId}
options={videoDeviceOptions}
onChange={handleDeviceChange}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.select.device-change"
/>
</div>
{voiceBackgroundsAvailable && (
<div
className={styles.backgroundSection}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.background-section"
>
<div
className={styles.backgroundLabel}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.background-label"
>
<Trans>Background</Trans>
</div>
<Button
variant="primary"
onClick={handleOpenBackgroundGallery}
leftIcon={
<ImageIcon
size={remFromPx(16)}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.image-icon"
/>
}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.button.open-background-gallery"
>
<Trans>Change background</Trans>
</Button>
{selectedBackgroundImageId === BLUR_BACKGROUND_ID && (
<CameraEffectStrengthSlider
label={i18n._(BLUR_STRENGTH_DESCRIPTOR)}
value={voiceSettings.backgroundBlurStrength}
onChange={(value) => 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"
/>
)}
</div>
)}
<div
className={styles.videoContainer}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.video-container"
>
<video
ref={videoRef}
autoPlay
playsInline
muted
className={previewVideoClassName}
aria-label={i18n._(CAMERA_PREVIEW_DESCRIPTOR)}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.video"
/>
{(status === 'initializing' || status === 'fixing' || status === 'fix-switching-back') && (
<div
className={styles.overlay}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.overlay"
>
<Spinner data-flx="voice.camera-preview-modal.camera-preview-modal-content.spinner" />
<div
className={styles.overlayText}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.overlay-text"
>
<div
className={styles.overlayTextMedium}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.overlay-text-medium"
>
{status === 'fixing' ? (
<Trans>Optimizing camera...</Trans>
) : status === 'fix-switching-back' ? (
<Trans>Finalizing camera...</Trans>
) : (
<Trans>Initializing camera...</Trans>
)}
</div>
</div>
</div>
)}
{status === 'error' && (
<div
className={styles.errorOverlay}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.error-overlay"
>
<div
className={styles.errorText}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.error-text"
>
<div
className={styles.errorTitle}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.error-title"
>
<Trans>Camera error</Trans>
</div>
<div
className={styles.errorDetail}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.error-detail"
>
{error}
</div>
</div>
</div>
)}
</div>
</div>
</Modal.Content>
<Modal.Footer
className={styles.footer}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.modal-footer"
>
<div
className={styles.footerStart}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.footer-start"
>
<Switch
compact
className={styles.footerMirrorSwitch}
label={i18n._(MIRROR_CAMERA_DESCRIPTOR)}
value={voiceSettings.mirrorCamera}
onChange={(value) => VoiceSettingsCommands.update({mirrorCamera: value})}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.footer-mirror-switch.update"
/>
</div>
<Button
variant="secondary"
onClick={() => ModalCommands.pop()}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.button.pop"
>
<Trans>Cancel</Trans>
</Button>
{showEnableCameraButton &&
!cameraAlreadyOn &&
(canStream && !cameraCapBlocked ? (
<Button
onClick={handleEnableCamera}
leftIcon={
<CameraIcon
size={remFromPx(16)}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.camera-icon"
/>
}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.button.enable-camera"
>
{i18n._(VOICE_TURN_ON_CAMERA_DESCRIPTOR)}
</Button>
) : (
<Tooltip
text={
canStream
? i18n._(VOICE_CAMERA_USER_LIMIT_REACHED_DESCRIPTOR, {
voiceChannelCameraUserLimit: VOICE_CHANNEL_CAMERA_USER_LIMIT,
})
: i18n._(YOU_DON_T_HAVE_PERMISSION_TO_TURN_ON_DESCRIPTOR)
}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.tooltip"
>
<Button
onClick={undefined}
leftIcon={
<CameraIcon
size={remFromPx(16)}
data-flx="voice.camera-preview-modal.camera-preview-modal-content.camera-icon--2"
/>
}
disabled
data-flx="voice.camera-preview-modal.camera-preview-modal-content.button.undefined"
>
{i18n._(VOICE_TURN_ON_CAMERA_DESCRIPTOR)}
</Button>
</Tooltip>
))}
</Modal.Footer>
</Modal.Root>
);
});
export const CameraPreviewModalInRoom: React.FC<Omit<CameraPreviewModalProps, 'localParticipant' | 'isCameraEnabled'>> =
observer((props) => {
const {localParticipant, isCameraEnabled} = useCameraPreviewParticipantState();
return (
<CameraPreviewModalContent
localParticipant={localParticipant}
isCameraEnabled={isCameraEnabled}
data-flx="voice.camera-preview-modal.camera-preview-modal-in-room.camera-preview-modal-content"
{...props}
/>
);
});
export const CameraPreviewModalStandalone: React.FC<CameraPreviewModalProps> = observer((props) => {
return (
<CameraPreviewModalContent
localParticipant={undefined}
isCameraEnabled={false}
data-flx="voice.camera-preview-modal.camera-preview-modal-standalone.camera-preview-modal-content"
{...props}
/>
);
});