From 16daf7b8d3a0ef8a1ad8ac4d48409c1dcc838a62 Mon Sep 17 00:00:00 2001 From: Florent Chehab Date: Tue, 3 Mar 2026 17:49:53 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20custom=20virtual=20?= =?UTF-8?q?background=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a custom virtual background feature. If the backend supports uploading files, backgrounds are stored in the backend for the user. Otherwise, only one background image can be selected. --- CHANGELOG.md | 1 + env.d/development/common.dist | 2 +- src/frontend/src/api/fetchApi.ts | 14 +- src/frontend/src/api/queryKeys.ts | 1 + src/frontend/src/api/useConfig.ts | 7 + .../src/features/files/api/createFile.ts | 90 ++ .../src/features/files/api/deleteFile.ts | 33 + .../src/features/files/api/listFiles.ts | 70 ++ src/frontend/src/features/files/api/types.ts | 34 + .../features/rooms/components/Conference.tsx | 4 +- .../src/features/rooms/components/Join.tsx | 31 +- .../blur/BackgroundCustomProcessor.ts | 41 +- .../components/blur/FaceLandmarksProcessor.ts | 11 - .../blur/UnifiedBackgroundTrackProcessor.ts | 47 +- .../rooms/livekit/components/blur/index.ts | 45 +- .../controls/Device/VideoDeviceControl.tsx | 4 +- .../livekit/components/effects/Effects.tsx | 5 - .../effects/EffectsConfiguration.tsx | 904 +++++++++++++----- .../components/effects/FunnyEffects.tsx | 2 +- .../livekit/hooks/usePersistentUserChoices.ts | 8 +- .../settings/components/tabs/VideoTab.tsx | 4 +- src/frontend/src/locales/de/rooms.json | 40 +- src/frontend/src/locales/en/rooms.json | 41 +- src/frontend/src/locales/fr/rooms.json | 41 +- src/frontend/src/locales/nl/rooms.json | 39 +- src/frontend/src/primitives/Text.tsx | 4 + src/frontend/src/stores/userChoices.ts | 18 +- src/frontend/src/styles/index.css | 11 + src/frontend/vite.config.ts | 8 + 29 files changed, 1133 insertions(+), 427 deletions(-) create mode 100644 src/frontend/src/features/files/api/createFile.ts create mode 100644 src/frontend/src/features/files/api/deleteFile.ts create mode 100644 src/frontend/src/features/files/api/listFiles.ts create mode 100644 src/frontend/src/features/files/api/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcef235..66eda099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ and this project adheres to ### Added - ✨(backend) add file upload feature #1030 +- ✨(frontend) custom background #1067 ## [1.9.0] - 2026-03-02 diff --git a/env.d/development/common.dist b/env.d/development/common.dist index f3fe3e00..4dcc335c 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -27,7 +27,7 @@ AWS_S3_DOMAIN_REPLACE=http://localhost:9000 AWS_S3_ENDPOINT_URL=http://minio:9000 AWS_S3_ACCESS_KEY_ID=meet AWS_S3_SECRET_ACCESS_KEY=password -MEDIA_BASE_URL=http://localhost:8083 +MEDIA_BASE_URL=http://localhost:3000 FILE_UPLOAD_ENABLED=True # OIDC diff --git a/src/frontend/src/api/fetchApi.ts b/src/frontend/src/api/fetchApi.ts index 3514b9b9..a3c5039d 100644 --- a/src/frontend/src/api/fetchApi.ts +++ b/src/frontend/src/api/fetchApi.ts @@ -15,7 +15,19 @@ export const fetchApi = async >( ...options?.headers, }, }) - const result = await response.json() + + let result: T + if (response.status === 204) { + result = undefined as T + } else { + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) { + result = undefined as T + } else { + result = (await response.json()) as T + } + } + if (!response.ok) { throw new ApiError(response.status, result) } diff --git a/src/frontend/src/api/queryKeys.ts b/src/frontend/src/api/queryKeys.ts index ccb21d29..13f2805e 100644 --- a/src/frontend/src/api/queryKeys.ts +++ b/src/frontend/src/api/queryKeys.ts @@ -5,4 +5,5 @@ export const keys = { requestEntry: 'requestEntry', waitingParticipants: 'waitingParticipants', roomCreationCallback: 'roomCreationCallback', + files: 'files', } diff --git a/src/frontend/src/api/useConfig.ts b/src/frontend/src/api/useConfig.ts index 67a67738..e2a255e9 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -30,6 +30,13 @@ export interface ApiConfig { expiration_days?: number max_duration?: number } + background_image: { + upload_is_enabled: boolean + max_size: number + max_count_by_user: number + allowed_extensions: string[] + allowed_mimetypes: string[] + } subtitle: { enabled: boolean } diff --git a/src/frontend/src/features/files/api/createFile.ts b/src/frontend/src/features/files/api/createFile.ts new file mode 100644 index 00000000..3ffc6d39 --- /dev/null +++ b/src/frontend/src/features/files/api/createFile.ts @@ -0,0 +1,90 @@ +import { fetchApi } from '@/api/fetchApi' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { ApiFileItem } from '@/features/files/api/types.ts' +import { keys } from '@/api/queryKeys.ts' + +/** + * Upload a file, using XHR so we can report on progress through a handler. + * + * @param url The URL to PUT the file to. + * @param file The file to upload. + * @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`. + */ +export const uploadFile = ( + url: string, + file: File, + progressHandler: (progress: number) => void +) => + new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('PUT', url) + xhr.setRequestHeader('X-amz-acl', 'private') + xhr.setRequestHeader('Content-Type', file.type) + + xhr.addEventListener('error', reject) + xhr.addEventListener('abort', reject) + + xhr.addEventListener('readystatechange', () => { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + // Make sure to always set the progress to 100% when the upload is done. + // Because 'progress' event listener is not called when the file size is 0. + progressHandler(100) + return resolve(true) + } + reject(new Error(`Failed to perform the upload on ${url}.`)) + } + }) + + xhr.upload.addEventListener('progress', (progressEvent) => { + if (progressEvent.lengthComputable) { + progressHandler( + Math.floor((progressEvent.loaded / progressEvent.total) * 100) + ) + } + }) + + xhr.send(file) + }) + +/** + * Asynchronously creates a new file and uploads it to the server. + * + * @param {object} params - The parameters for the file creation and upload process. + * @param {File} params.file - The file object to be uploaded. + * @param {function} params.onProgress - A callback function that receives the upload progress as a number (0 to 100). + * @returns {Promise} A promise that resolves when the file has been successfully uploaded and the server process is completed. + */ +export const createFile = async ({ + file, + onProgress, +}: { + file: File + onProgress: (progress: number) => void +}): Promise => { + const res = await fetchApi(`/files/`, { + method: 'POST', + body: JSON.stringify({ filename: file.name, type: 'background_image' }), + }) + if (res.upload_state !== 'pending') { + throw new Error('State should be pending right after creation') + } + const policy = res.policy + await uploadFile(policy, file, onProgress) + return await fetchApi(`/files/${res.id}/upload-ended/`, { + method: 'POST', + }) +} + +export const useCreateFile = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: createFile, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [keys.files], + }) + }, + }) +} diff --git a/src/frontend/src/features/files/api/deleteFile.ts b/src/frontend/src/features/files/api/deleteFile.ts new file mode 100644 index 00000000..0fdd2bdc --- /dev/null +++ b/src/frontend/src/features/files/api/deleteFile.ts @@ -0,0 +1,33 @@ +import { fetchApi } from '@/api/fetchApi' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { keys } from '@/api/queryKeys.ts' + +/** + * Deletes a file specified by its unique identifier. + * + * @param {Object} params - The parameters required for deleting the file. + * @param {string} params.fileId - The unique identifier of the file to be deleted. + * @returns {Promise} A promise that resolves when the file is successfully deleted. + */ +export const deleteFile = async ({ + fileId, +}: { + fileId: string +}): Promise => { + await fetchApi(`/files/${fileId}/`, { + method: 'DELETE', + }) +} + +export const useDeleteFile = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: deleteFile, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [keys.files], + }) + }, + }) +} diff --git a/src/frontend/src/features/files/api/listFiles.ts b/src/frontend/src/features/files/api/listFiles.ts new file mode 100644 index 00000000..db57a4a1 --- /dev/null +++ b/src/frontend/src/features/files/api/listFiles.ts @@ -0,0 +1,70 @@ +import { fetchApi } from '@/api/fetchApi' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { keys } from '@/api/queryKeys' +import { + ApiFileItem, + ApiFileType, + ApiFileUploadState, +} from '@/features/files/api/types.ts' +import { useUser } from '@/features/auth' +import { useConfig } from '@/api/useConfig.ts' + +type ListFilesResponse = { + count: number + next: string | null + previous: string | null + results: ApiFileItem[] +} + +type ListFilesFilters = { + is_creator_me?: boolean + type?: ApiFileType + upload_state?: ApiFileUploadState + is_deleted?: boolean +} + +export type ListFilesParams = { + filters?: ListFilesFilters + pagination: { + page: number + pageSize: number + } +} + +export const listMyFiles = async ({ + filters = {}, + pagination: { page, pageSize }, +}: ListFilesParams): Promise => { + const query = new URLSearchParams() + query.append('page', page.toString()) + query.append('page_size', pageSize.toString()) + if (filters?.is_creator_me ?? true) { + query.append('is_creator_me', 'true') + } + if (filters?.type) { + query.append('type', filters.type) + } + if (filters?.upload_state) { + query.append('upload_state', filters.upload_state) + } + if (typeof filters?.is_deleted === 'boolean') { + query.append('is_deleted', filters.is_deleted ? 'true' : 'false') + } + + return fetchApi(`/files?${query.toString()}`, { + method: 'GET', + }) +} + +export const useListMyFiles = (params: Parameters[0]) => { + const { isLoggedIn } = useUser() + const { data: appConfig } = useConfig() + return useQuery({ + queryKey: [keys.files, params], + queryFn: () => listMyFiles(params), + refetchOnMount: 'always', + placeholderData: keepPreviousData, + enabled: + isLoggedIn && appConfig?.background_image?.upload_is_enabled === true, + }) +} diff --git a/src/frontend/src/features/files/api/types.ts b/src/frontend/src/features/files/api/types.ts new file mode 100644 index 00000000..5c7891d6 --- /dev/null +++ b/src/frontend/src/features/files/api/types.ts @@ -0,0 +1,34 @@ +export type ApiFileCreator = { + id: string // UUID + full_name: string | null + short_name: string | null +} + +export type ApiFileType = 'background_image' +export type ApiFileUploadState = 'pending' | 'ready' + +export type ApiFileItem = { + id: string // UUID + created_at: string // ISO datetime string + updated_at: string // ISO datetime string + title: string + type: ApiFileType + creator: ApiFileCreator + deleted_at: string | null + hard_deleted_at: string | null + filename: string + upload_state: ApiFileUploadState + mimetype: string // e.g. "image/png" + size: number // file size in bytes + description: string | null +} & ( + | { + upload_state: 'ready' + url: string + } + | { + upload_state: 'pending' + policy: string + url: null + } +) diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 496cce6d..d7c5b5f1 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -215,8 +215,8 @@ export const Conference = ({ audio={userConfig.audioEnabled} video={ userConfig.videoEnabled && { - processor: BackgroundProcessorFactory.deserializeProcessor( - userConfig.processorSerialized + processor: BackgroundProcessorFactory.fromProcessorConfig( + userConfig.processorConfig ), } } diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 09668b09..77b99785 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -4,15 +4,15 @@ import { css } from '@/styled-system/css' import { Screen } from '@/layout/Screen' import { useEffect, useMemo, useRef, useState } from 'react' import { - createLocalVideoTrack, createLocalAudioTrack, + createLocalVideoTrack, LocalAudioTrack, LocalVideoTrack, Track, } from 'livekit-client' import { H } from '@/primitives/H' import { Field } from '@/primitives/Field' -import { Button, Dialog, Text, Form } from '@/primitives' +import { Button, Dialog, Form, Text } from '@/primitives' import { VStack } from '@/styled-system/jsx' import { Heading } from 'react-aria-components' import { RiImageCircleAiFill } from '@remixicon/react' @@ -44,8 +44,7 @@ const onError = (e: Error) => console.error('ERROR', e) const Effects = ({ videoTrack, - onSubmit, -}: Pick) => { +}: Pick) => { const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' }) const [isDialogOpen, setIsDialogOpen] = useState(false) const openDialog = () => setIsDialogOpen(true) @@ -81,7 +80,7 @@ const Effects = ({ > {t('subTitle')} - + + + ) + )} + {!canUploadBackground && + uploadNotPossibleSnap.imageBackgroundConfig && ( + + { + toggleEffect( + uploadNotPossibleSnap.imageBackgroundConfig! + ) + }} + isSelected={ + deriveIdFromProcessorConfig( + uploadNotPossibleSnap.imageBackgroundConfig + ) === selectedId + } + className={css({ + bgSize: 'cover', + })} + style={{ + backgroundImage: `url(${uploadNotPossibleSnap.imageBackgroundConfig.imagePath})`, + }} + data-attr={`toggle-virtual-local`} + /> + + )} + { + if (e && e.item(0)) { + const file = e.item(0) as File + handleNewBackgroundFilePicked(file) + } + }} + > + + + + {!isLoggedIn && ( + + {t('virtual.personal.notLoggedInWarning')} + + )} + {!canUploadBackground && isLoggedIn && ( + + {t('virtual.personal.warningUploadDisabled')} + + )} + {hasReachedMaxNbBackgrounds && ( + + {t('virtual.personal.uploadLimitReached')} + + )} + +
+ + {t('virtual.presets.title')} +
- {[...Array(8).keys()].map((i) => { - const imagePath = `/assets/backgrounds/${i + 1}.jpg` - const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg` - const tooltipText = tooltipVirtualBackground(i) - return ( - - - await toggleEffect(ProcessorType.VIRTUAL, { - imagePath, - }) - } - isSelected={isSelected(ProcessorType.VIRTUAL, { - imagePath, - })} - className={css({ - bgSize: 'cover', - })} - style={{ - backgroundImage: `url(${thumbnailPath})`, - }} - data-attr={`toggle-virtual-${i}`} - /> - - ) - })} + {processorOptions.virtualBackgrounds.map((option) => ( + + toggleEffect(option.config)} + isSelected={option.isSelected} + className={css({ + bgSize: 'cover', + })} + style={{ + backgroundImage: `url(${option.thumbnailPath})`, + }} + data-attr={`toggle-virtual-preset-${option.index}`} + /> + + ))}
@@ -483,6 +859,32 @@ export const EffectsConfiguration = ({ )} + setPersonalBackgroundHasError(false)} + onOpenChange={() => setPersonalBackgroundHasError(false)} + > +

+ {t( + `virtual.personal.errors.${personalBackgroundError}.description`, + filePickerErrorContext + )} +

+ + + +
) } diff --git a/src/frontend/src/features/rooms/livekit/components/effects/FunnyEffects.tsx b/src/frontend/src/features/rooms/livekit/components/effects/FunnyEffects.tsx index c19eb43a..c2d7bba6 100644 --- a/src/frontend/src/features/rooms/livekit/components/effects/FunnyEffects.tsx +++ b/src/frontend/src/features/rooms/livekit/components/effects/FunnyEffects.tsx @@ -27,7 +27,7 @@ export const FunnyEffects = ({ showFrench: false, } } - return processor.serialize().options + return { ...processor.options } } const options = getOptions() diff --git a/src/frontend/src/features/rooms/livekit/hooks/usePersistentUserChoices.ts b/src/frontend/src/features/rooms/livekit/hooks/usePersistentUserChoices.ts index 445b38ee..8edc2b82 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/usePersistentUserChoices.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/usePersistentUserChoices.ts @@ -1,7 +1,7 @@ import { useSnapshot } from 'valtio' import { userChoicesStore } from '@/stores/userChoices' import type { VideoResolution } from '@/stores/userChoices' -import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur' +import { ProcessorConfig } from '@/features/rooms/livekit/components/blur' import type { VideoQuality } from 'livekit-client' export function usePersistentUserChoices() { @@ -36,10 +36,8 @@ export function usePersistentUserChoices() { saveNoiseReductionEnabled: (enabled: boolean) => { userChoicesStore.noiseReductionEnabled = enabled }, - saveProcessorSerialized: ( - processorSerialized: ProcessorSerialized | undefined - ) => { - userChoicesStore.processorSerialized = processorSerialized + saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => { + userChoicesStore.processorConfig = processorConfig }, } } diff --git a/src/frontend/src/features/settings/components/tabs/VideoTab.tsx b/src/frontend/src/features/settings/components/tabs/VideoTab.tsx index 7a9dbedd..101da4a8 100644 --- a/src/frontend/src/features/settings/components/tabs/VideoTab.tsx +++ b/src/frontend/src/features/settings/components/tabs/VideoTab.tsx @@ -31,7 +31,7 @@ export const VideoTab = ({ id }: VideoTabProps) => { const { userChoices: { videoDeviceId, - processorSerialized, + processorConfig, videoPublishResolution, videoSubscribeQuality, }, @@ -78,7 +78,7 @@ export const VideoTab = ({ id }: VideoTabProps) => { resolution: VideoPresets[key].resolution, deviceId: { exact: videoDeviceId }, processor: - BackgroundProcessorFactory.deserializeProcessor(processorSerialized), + BackgroundProcessorFactory.fromProcessorConfig(processorConfig), }) } } diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index df9821d2..c58078ad 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -273,17 +273,39 @@ "title": "Virtueller Hintergrund", "selectedLabel": "Hintergrund angewendet:", "apply": "Ersetze deinen Hintergrund:", - "descriptions": { - "0": "Gerilltes Holzmöbel", - "1": "Besprechungsraum", - "2": "Loft mit schwarzer Glaswand", - "3": "Esszimmer", - "4": "Holzregale", - "5": "Holztreppe", - "6": "Graue Bibliothek", - "7": "Kaffeetheke" + "personal": { + "title": "Meine Hintergründe", + "selectFileTooltip": "Wähle ein Bild aus, das als persönlicher Hintergrund verwendet werden soll", + "notLoggedInWarning": "Du bist nicht angemeldet, der persönliche Hintergrund wird nicht von einem Meeting zum anderen gespeichert.", + "warningUploadDisabled": "Persönliche Hintergründe werden derzeit nicht von einem Meeting zum anderen gespeichert.", + "uploadLimitReached": "Du kannst keine weiteren persönlichen Hintergründe hinzufügen.", + "errors": { + "close": "Schließen", + "file_too_large": { + "title": "Datei zu groß", + "description": "Die Datei ist zu groß. Bitte wähle eine Datei mit weniger als {{maxSize, number}} MB." + }, + "invalid_file_type": { + "title": "Ungültiger Dateityp", + "description": "Der Dateityp wird nicht unterstützt. Bitte wähle eine {{allowedExtension}}-Datei." + } + } + }, + "presets": { + "title": "Vorschläge", + "descriptions": { + "0": "Gerilltes Holzmöbel", + "1": "Besprechungsraum", + "2": "Loft mit schwarzer Glaswand", + "3": "Esszimmer", + "4": "Holzregale", + "5": "Holztreppe", + "6": "Graue Bibliothek", + "7": "Kaffeetheke" + } } }, + "faceLandmarks": { "title": "Visuelle Effekte", "glasses": { diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 034246b6..0210fd36 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -270,18 +270,39 @@ } }, "virtual": { - "title": "Virtual background", + "title": "Virtual backgrounds", "selectedLabel": "Background applied:", "apply": "Replace your background:", - "descriptions": { - "0": "Fluted wooden furniture", - "1": "Meeting room", - "2": "Loft with black glass partition", - "3": "Dining room", - "4": "Wooden shelves", - "5": "Wooden staircase", - "6": "Gray library", - "7": "Coffee counter" + "personal": { + "title": "My backgrounds", + "selectFileTooltip": "Select an image file to use as a personal background", + "notLoggedInWarning": "You are not logged-in, personal backgrounds won't be saved from one meeting to the other.", + "warningUploadDisabled": "Personal backgrounds are currently not saved from one meeting to the other.", + "uploadLimitReached": "You cannot upload more personal backgrounds.", + "errors": { + "close": "Close", + "file_too_large": { + "title": "File too large", + "description": "The file is too large. Please choose a file smaller than {{maxSize, number}} MB." + }, + "invalid_file_type": { + "title": "Invalid file type", + "description": "The file type is not supported. Please choose a {{allowedExtension, list(type: 'disjunction')}} file." + } + } + }, + "presets": { + "title": "Suggestions", + "descriptions": { + "0": "Fluted wooden furniture", + "1": "Meeting room", + "2": "Loft with black glass partition", + "3": "Dining room", + "4": "Wooden shelves", + "5": "Wooden staircase", + "6": "Gray library", + "7": "Coffee counter" + } } }, "faceLandmarks": { diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index f06afb32..242d6bff 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -270,18 +270,39 @@ } }, "virtual": { - "title": "Arrière-plan virtuel", + "title": "Arrière-plans virtuels", "selectedLabel": "Arrière-plan appliqué :", "apply": "Remplacer votre arrière plan :", - "descriptions": { - "0": "Meuble cannelé en bois", - "1": "Salle de réunion", - "2": "Loft avec verrière noire", - "3": "Salle à manger", - "4": "Étagères en bois", - "5": "Escalier en bois", - "6": "Bibliothèque grise", - "7": "Comptoir de café" + "personal": { + "title": "Mes arrière-plans", + "selectFileTooltip": "Sélectionnez une image à utiliser comme arrière-plan", + "notLoggedInWarning": "Vous n'êtes pas connecté, l'arrière-plan personnel ne sera pas sauvegardé d'une réunion à l'autre.", + "warningUploadDisabled": "Les arrière-plans personnels ne sont actuellement pas sauvegardés d'une réunion à l'autre.", + "uploadLimitReached": "Vous ne pouvez pas ajouter plus d'arrière-plans personnels.", + "errors": { + "close": "Fermer", + "file_too_large": { + "title": "Fichier trop volumineux", + "description": "Le fichier est trop volumineux. Veuillez choisir un fichier de moins de {{maxSize, number}} Mo." + }, + "invalid_file_type": { + "title": "Type de fichier non valide", + "description": "Le type de fichier n'est pas pris en charge. Veuillez choisir un fichier {{allowedExtension}}." + } + } + }, + "presets": { + "title": "Suggestions", + "descriptions": { + "0": "Meuble cannelé en bois", + "1": "Salle de réunion", + "2": "Loft avec verrière noire", + "3": "Salle à manger", + "4": "Étagères en bois", + "5": "Escalier en bois", + "6": "Bibliothèque grise", + "7": "Comptoir de café" + } } }, "faceLandmarks": { diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 4979c86c..bdae2c6c 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -273,15 +273,36 @@ "title": "Virtuele achtergrond", "selectedLabel": "Achtergrond toegepast:", "apply": "Vervang je achtergrond:", - "descriptions": { - "0": "Geprofileerd houten meubel", - "1": "Vergaderruimte", - "2": "Loft met zwarte glaswand", - "3": "Eetkamer", - "4": "Houten planken", - "5": "Houten trap", - "6": "Grijze bibliotheek", - "7": "Koffiebar" + "personal": { + "title": "Mijn achtergronden", + "selectFileTooltip": "Selecteer een afbeeldingsbestand om te gebruiken als persoonlijke achtergrond", + "notLoggedInWarning": "U bent niet ingelogd, persoonlijke achtergronden worden niet opgeslagen van de ene vergadering naar de andere.", + "warningUploadDisabled": "Persoonlijke achtergronden worden momenteel niet opgeslagen van de ene vergadering naar de andere.", + "uploadLimitReached": "U kunt geen persoonlijke achtergronden meer uploaden.", + "errors": { + "close": "Sluiten", + "file_too_large": { + "title": "Bestand te groot", + "description": "Het bestand is te groot. Kies een bestand kleiner dan {{maxSize, number}} MB." + }, + "invalid_file_type": { + "title": "Ongeldig bestandstype", + "description": "Het bestandstype wordt niet ondersteund. Kies een {{allowedExtension, list(type: 'disjunction')}} bestand." + } + } + }, + "presets": { + "title": "Suggesties", + "descriptions": { + "0": "Geprofileerd houten meubel", + "1": "Vergaderruimte", + "2": "Loft met zwarte glaswand", + "3": "Eetkamer", + "4": "Houten planken", + "5": "Houten trap", + "6": "Grijze bibliotheek", + "7": "Koffiebar" + } } }, "faceLandmarks": { diff --git a/src/frontend/src/primitives/Text.tsx b/src/frontend/src/primitives/Text.tsx index fea0f9d5..dfb8c915 100644 --- a/src/frontend/src/primitives/Text.tsx +++ b/src/frontend/src/primitives/Text.tsx @@ -36,6 +36,10 @@ export const text = cva({ textStyle: 'body', fontWeight: 'bold', }, + bodyXsMedium: { + textStyle: 'body', + fontWeight: 'medium', + }, body: { textStyle: 'body', }, diff --git a/src/frontend/src/stores/userChoices.ts b/src/frontend/src/stores/userChoices.ts index 345838cb..3ab607ef 100644 --- a/src/frontend/src/stores/userChoices.ts +++ b/src/frontend/src/stores/userChoices.ts @@ -1,16 +1,19 @@ import { proxy, subscribe } from 'valtio' -import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur' +import { + ProcessorConfig, + ProcessorType, +} from '@/features/rooms/livekit/components/blur' import { loadUserChoices, - saveUserChoices, LocalUserChoices as LocalUserChoicesLK, + saveUserChoices, } from '@livekit/components-core' import { VideoQuality } from 'livekit-client' export type VideoResolution = 'h720' | 'h360' | 'h180' export type LocalUserChoices = LocalUserChoicesLK & { - processorSerialized?: ProcessorSerialized + processorConfig?: ProcessorConfig noiseReductionEnabled?: boolean audioOutputDeviceId?: string videoPublishResolution?: VideoResolution @@ -28,7 +31,14 @@ function getUserChoicesState(): LocalUserChoices { } export const userChoicesStore = proxy(getUserChoicesState()) - subscribe(userChoicesStore, () => { saveUserChoices(userChoicesStore, false) }) + +if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) { + if (userChoicesStore.processorConfig.imagePath.startsWith('blob:')) { + // this happens when a not authenticated user had changed their background image + // we restore their last processor config to avoid displaying a black screen. + userChoicesStore.processorConfig = undefined + } +} diff --git a/src/frontend/src/styles/index.css b/src/frontend/src/styles/index.css index 900d7a3a..39504da3 100644 --- a/src/frontend/src/styles/index.css +++ b/src/frontend/src/styles/index.css @@ -95,3 +95,14 @@ html:has(.lk-video-conference) { U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } + +.hoverGroup .hoverGroupChild { + opacity: 0; + pointer-events: none; +} + +.hoverGroup:hover .hoverGroupChild, +.hoverGroup:focus-within .hoverGroupChild { + opacity: 1; + pointer-events: auto; +} diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index fd6b5d5c..0887b027 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -14,6 +14,14 @@ export default defineConfig(({ mode }) => { port: parseInt(env.VITE_PORT) || 3000, host: env.VITE_HOST ?? 'localhost', allowedHosts: ['.nip.io'], + // In a local dev setup, we proxy the media server ourselves to avoid CORS issues + proxy: { + '/media': { + target: 'http://localhost:8083', + changeOrigin: true, + secure: false + } + } }, } })