diff --git a/CHANGELOG.md b/CHANGELOG.md index 770c43861..79a1c207b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(frontend) let users pick the recording encoding - ✨(backend) introduce a token exchange endpoint for iframe embeds ### Changed diff --git a/src/backend/core/api/__init__.py b/src/backend/core/api/__init__.py index 803b3e8fb..721ea36c9 100644 --- a/src/backend/core/api/__init__.py +++ b/src/backend/core/api/__init__.py @@ -44,6 +44,17 @@ def get_frontend_configuration(request): "available_modes": settings.RECORDING_WORKER_CLASSES.keys(), "expiration_days": settings.RECORDING_EXPIRATION_DAYS, "max_duration": settings.RECORDING_MAX_DURATION, + "encoding": { + "is_custom_enabled": settings.RECORDING_CUSTOM_ENCODING_ENABLED, + "available_resolutions": list( + settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.keys() + ), + "available_profiles": list( + settings.RECORDING_ENCODING_AVAILABLE_PROFILES.keys() + ), + "default_resolution": settings.RECORDING_ENCODING_DEFAULT_RESOLUTION, + "default_profile": settings.RECORDING_ENCODING_DEFAULT_PROFILE, + }, }, "background_image": { "upload_is_enabled": settings.FILE_UPLOAD_ENABLED, diff --git a/src/frontend/src/api/queryKeys.ts b/src/frontend/src/api/queryKeys.ts index 13f2805e4..7bc99dfbd 100644 --- a/src/frontend/src/api/queryKeys.ts +++ b/src/frontend/src/api/queryKeys.ts @@ -6,4 +6,5 @@ export const keys = { waitingParticipants: 'waitingParticipants', roomCreationCallback: 'roomCreationCallback', files: 'files', + startRecording: 'startRecording', } diff --git a/src/frontend/src/api/useConfig.ts b/src/frontend/src/api/useConfig.ts index 22b2e372e..59c4f758a 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -36,6 +36,13 @@ export interface ApiConfig { available_modes?: RecordingMode[] expiration_days?: number max_duration?: number + encoding?: { + is_custom_enabled: boolean + available_resolutions: string[] + available_profiles: string[] + default_resolution?: string | null + default_profile?: string | null + } } background_image: { upload_is_enabled: boolean diff --git a/src/frontend/src/assets/icons/file_settings_line.svg b/src/frontend/src/assets/icons/file_settings_line.svg new file mode 100644 index 000000000..daaf789bd --- /dev/null +++ b/src/frontend/src/assets/icons/file_settings_line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/src/features/recording/api/startRecording.ts b/src/frontend/src/features/recording/api/startRecording.ts index 37950459b..2329403b5 100644 --- a/src/frontend/src/features/recording/api/startRecording.ts +++ b/src/frontend/src/features/recording/api/startRecording.ts @@ -2,12 +2,22 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { fetchApi } from '@/api/fetchApi' import type { ApiError } from '@/api/ApiError' import type { ApiRoom } from '@/features/rooms/api/ApiRoom' +import { keys } from '@/api/queryKeys' import { RecordingMode } from '../types' +import type { RecordingEncoding } from '../hooks/useRecordingEncoding' + +export interface StartRecordingOptions { + language?: string + transcribe?: boolean + collect_metadata?: boolean + original_mode?: RecordingMode + encoding?: RecordingEncoding +} export interface StartRecordingParams { id: string mode?: RecordingMode - options?: Record + options?: StartRecordingOptions } const startRecording = ({ @@ -28,6 +38,7 @@ export function useStartRecording( options?: UseMutationOptions ) { return useMutation({ + mutationKey: [keys.startRecording], mutationFn: startRecording, onSuccess: options?.onSuccess, onError: options?.onError, diff --git a/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx b/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx index 2e429a9a0..40e8e1c58 100644 --- a/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx +++ b/src/frontend/src/features/recording/components/ScreenRecordingSidePanel.tsx @@ -1,4 +1,4 @@ -import { Div, H, Text } from '@/primitives' +import { Button, Div, H, Text } from '@/primitives' import { css } from '@/styled-system/css' import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId' @@ -22,8 +22,13 @@ import { ControlsButton } from './ControlsButton' import { RowWrapper } from './RowWrapper' import { VStack } from '@/styled-system/jsx' import { Checkbox } from '@/primitives/Checkbox' -import { useTranscriptionLanguage } from '@/features/settings' +import { + SettingsDialogExtendedKey, + useTranscriptionLanguage, +} from '@/features/settings' import { useMutateRecording } from '../hooks/useMutateRecording' +import { useRecordingEncoding } from '../hooks/useRecordingEncoding' +import { openSettingsDialog } from '@/stores/settings' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { FeatureFlags } from '@/features/analytics/enums' @@ -53,6 +58,8 @@ export const ScreenRecordingSidePanel = () => { const { selectedLanguageKey, isLanguageSetToAuto } = useTranscriptionLanguage() + const { encoding, isCustomEncodingEnabled } = useRecordingEncoding() + const roomId = useRoomId() const { startRecording, isPendingToStart, stopRecording, isPendingToStop } = @@ -93,6 +100,7 @@ export const ScreenRecordingSidePanel = () => { language: selectedLanguageKey, }), ...(includeTranscript && hasTranscriptAccess && { transcribe: true }), + ...(encoding && { encoding }), } await startRecording({ @@ -107,6 +115,8 @@ export const ScreenRecordingSidePanel = () => { captureEvent('screen-recording-started', { includeTranscript: includeTranscript, language: selectedLanguageKey, + resolution: encoding?.resolution, + profile: encoding?.profile, }) } } catch (error) { @@ -183,9 +193,41 @@ export const ScreenRecordingSidePanel = () => { {t('details.destination')} - + {t('details.receiver')} + {isCustomEncodingEnabled && ( + + + {t('details.quality')} + + + + + + )} {hasTranscriptAccess && ( <>
diff --git a/src/frontend/src/features/recording/components/TranscriptSidePanel.tsx b/src/frontend/src/features/recording/components/TranscriptSidePanel.tsx index ee7ad99e0..a99e22e17 100644 --- a/src/frontend/src/features/recording/components/TranscriptSidePanel.tsx +++ b/src/frontend/src/features/recording/components/TranscriptSidePanel.tsx @@ -29,6 +29,7 @@ import { NoAccessView } from './NoAccessView' import { ControlsButton } from './ControlsButton' import { RowWrapper } from './RowWrapper' import { useMutateRecording } from '../hooks/useMutateRecording' +import { useRecordingEncoding } from '../hooks/useRecordingEncoding' import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnabled' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' @@ -66,6 +67,8 @@ export const TranscriptSidePanel = () => { const isMetadataCollectorEnabled = useIsMetadataCollectorEnabled() + const { encoding } = useRecordingEncoding() + const roomId = useRoomId() const { startRecording, isPendingToStart, stopRecording, isPendingToStop } = @@ -114,6 +117,7 @@ export const TranscriptSidePanel = () => { ...(withScreenRecording && { transcribe: true, original_mode: RecordingMode.Transcript, + ...(encoding && { encoding }), }), collect_metadata: isMetadataCollectorEnabled, } diff --git a/src/frontend/src/features/recording/hooks/useIsRecordingLocked.ts b/src/frontend/src/features/recording/hooks/useIsRecordingLocked.ts new file mode 100644 index 000000000..1037c3dbd --- /dev/null +++ b/src/frontend/src/features/recording/hooks/useIsRecordingLocked.ts @@ -0,0 +1,17 @@ +import { useIsMutating } from '@tanstack/react-query' +import { keys } from '@/api/queryKeys' +import { RecordingMode } from '../types' +import { useRecordingStatuses } from './useRecordingStatuses' + +/** + * True while a recording is being requested, starting, started or saving. + * Recording settings must not change during that window, as they were (or + * are about to be) sent to the backend for the current recording. + */ +export const useIsRecordingLocked = () => { + const screenRecording = useRecordingStatuses(RecordingMode.ScreenRecording) + const transcript = useRecordingStatuses(RecordingMode.Transcript) + const pendingStarts = useIsMutating({ mutationKey: [keys.startRecording] }) + + return pendingStarts > 0 || screenRecording.isActive || transcript.isActive +} diff --git a/src/frontend/src/features/recording/hooks/useRecordingEncoding.ts b/src/frontend/src/features/recording/hooks/useRecordingEncoding.ts new file mode 100644 index 000000000..c0b52501e --- /dev/null +++ b/src/frontend/src/features/recording/hooks/useRecordingEncoding.ts @@ -0,0 +1,107 @@ +import { useCallback, useMemo } from 'react' +import { useSnapshot } from 'valtio' +import { useConfig } from '@/api/useConfig' +import { useUser } from '@/features/auth/api/useUser' +import { + recordingEncodingStore, + setRecordingEncodingPreference, +} from '@/stores/recordingEncoding' + +// Fallback storage slot when no authenticated user is available. +const ANONYMOUS_USER_KEY = 'anonymous' + +export type RecordingEncoding = { + resolution: string + profile?: string +} + +/** + * Pick the stored value if the backend still offers it, otherwise the + * backend default, otherwise the first available value. + */ +const resolveValue = ( + available: string[], + stored?: string, + fallback?: string | null +): string | undefined => { + if (stored && available.includes(stored)) return stored + if (fallback && available.includes(fallback)) return fallback + return available[0] +} + +/** + * Per-user recording encoding preferences (resolution + profile). + * + * Values are persisted in localStorage, scoped by user id, and always + * reconciled against what the backend currently exposes, so a stale value + * (e.g. a profile removed by the operator) never reaches the API. + */ +export const useRecordingEncoding = () => { + const { data } = useConfig() + const { user } = useUser() + const snap = useSnapshot(recordingEncodingStore) + + const encodingConfig = data?.recording?.encoding + const availableResolutions = useMemo( + () => encodingConfig?.available_resolutions ?? [], + [encodingConfig] + ) + const availableProfiles = useMemo( + () => encodingConfig?.available_profiles ?? [], + [encodingConfig] + ) + + const isCustomEncodingEnabled = + !!encodingConfig?.is_custom_enabled && availableResolutions.length > 0 + + const userKey = user?.id ?? ANONYMOUS_USER_KEY + const stored = snap.byUser[userKey] + + const resolution = resolveValue( + availableResolutions, + stored?.resolution, + encodingConfig?.default_resolution + ) + const profile = resolveValue( + availableProfiles, + stored?.profile, + encodingConfig?.default_profile + ) + + const setResolution = useCallback( + (value: string) => { + if (!availableResolutions.includes(value)) return + setRecordingEncodingPreference(userKey, { resolution: value }) + }, + [availableResolutions, userKey] + ) + + const setProfile = useCallback( + (value: string) => { + if (!availableProfiles.includes(value)) return + setRecordingEncodingPreference(userKey, { profile: value }) + }, + [availableProfiles, userKey] + ) + + // Payload for the start-recording API. Undefined when per-recording + // encoding is disabled, as the backend would reject it. + const encoding: RecordingEncoding | undefined = useMemo(() => { + if (!isCustomEncodingEnabled || !resolution) return undefined + return { + resolution, + ...(profile && { profile }), + } + }, [isCustomEncodingEnabled, resolution, profile]) + + return { + isCustomEncodingEnabled, + availableResolutions, + availableProfiles, + resolution, + profile, + setResolution, + setProfile, + encoding, + } +} diff --git a/src/frontend/src/features/recording/index.ts b/src/frontend/src/features/recording/index.ts index e9887a36b..3660d1ad2 100644 --- a/src/frontend/src/features/recording/index.ts +++ b/src/frontend/src/features/recording/index.ts @@ -3,6 +3,9 @@ export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled' export { useHasRecordingAccess } from './hooks/useHasRecordingAccess' export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights' export { useRecordingStatuses } from './hooks/useRecordingStatuses' +export { useRecordingEncoding } from './hooks/useRecordingEncoding' +export type { RecordingEncoding } from './hooks/useRecordingEncoding' +export { useIsRecordingLocked } from './hooks/useIsRecordingLocked' // api export { useStartRecording } from './api/startRecording' diff --git a/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx b/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx index 5f6f6cca1..6fc6188b8 100644 --- a/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx +++ b/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx @@ -13,6 +13,7 @@ import { RiVideoOnLine, RiEyeLine, RiKeyboardBoxLine, + RiRecordCircleLine, } from '@remixicon/react' import { AccountTab } from './tabs/AccountTab' import { NotificationsTab } from './tabs/NotificationsTab' @@ -26,6 +27,12 @@ import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery' import { SettingsDialogExtendedKey } from '@/features/settings/type' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { AccessibilityTab } from './tabs/AccessibilityTab' +import { RecordingTab } from './tabs/RecordingTab' +import { + RecordingMode, + useIsRecordingModeEnabled, + useRecordingEncoding, +} from '@/features/recording' const tabsStyle = css({ maxHeight: '40.625rem', // fixme size copied from meet settings modal @@ -67,6 +74,12 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => { const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel const isAdminOrOwner = useIsAdminOrOwner() + const isScreenRecordingEnabled = useIsRecordingModeEnabled( + RecordingMode.ScreenRecording + ) + const { isCustomEncodingEnabled } = useRecordingEncoding() + const showRecordingTab = + isAdminOrOwner && !!isScreenRecordingEnabled && isCustomEncodingEnabled return ( @@ -121,6 +134,13 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => { t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)} )} + {showRecordingTab && ( + + + {isWideScreen && + t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)} + + )} {isWideScreen && @@ -140,6 +160,8 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => { {/* Transcription tab won't be accessible if the tab is not active in the tab list */} + {/* Recording tab won't be accessible if the tab is not active in the tab list */} +
diff --git a/src/frontend/src/features/settings/components/tabs/RecordingTab.tsx b/src/frontend/src/features/settings/components/tabs/RecordingTab.tsx new file mode 100644 index 000000000..eb2866ca2 --- /dev/null +++ b/src/frontend/src/features/settings/components/tabs/RecordingTab.tsx @@ -0,0 +1,84 @@ +import { TabPanel, type TabPanelProps } from '@/primitives/Tabs' +import { Field, H, Text } from '@/primitives' +import { useTranslation } from 'react-i18next' +import { useMemo } from 'react' +import { + useIsRecordingLocked, + useRecordingEncoding, +} from '@/features/recording' + +export type RecordingTabProps = Pick + +export const RecordingTab = ({ id }: RecordingTabProps) => { + const { t } = useTranslation('settings', { keyPrefix: 'recording' }) + + const { + availableResolutions, + availableProfiles, + resolution, + profile, + setResolution, + setProfile, + } = useRecordingEncoding() + + const isLocked = useIsRecordingLocked() + + // Resolutions and profiles are operator-defined: fall back to the raw key + // when no translation exists for a custom value. + const resolutionItems = useMemo( + () => + availableResolutions.map((key) => ({ + value: key, + label: t(`resolution.options.${key}`, { defaultValue: key }), + })), + [availableResolutions, t] + ) + + const profileItems = useMemo( + () => + availableProfiles.map((key) => ({ + value: key, + label: t(`profile.options.${key}.label`, { defaultValue: key }), + })), + [availableProfiles, t] + ) + + const profileDescription = profile + ? t(`profile.options.${profile}.description`, { defaultValue: '' }) + : '' + + return ( + + {t('heading')} + {isLocked && ( + + {t('locked')} + + )} + { + if (isLocked || key === null) return + setProfile(String(key)) + }} + /> + { + if (isLocked || key === null) return + setResolution(String(key)) + }} + /> + + ) +} diff --git a/src/frontend/src/features/settings/type.ts b/src/frontend/src/features/settings/type.ts index 95fcc1e00..b781f314c 100644 --- a/src/frontend/src/features/settings/type.ts +++ b/src/frontend/src/features/settings/type.ts @@ -7,4 +7,5 @@ export enum SettingsDialogExtendedKey { TRANSCRIPTION = 'transcription', SHORTCUTS = 'shortcuts', ACCESSIBILITY = 'accessibility', + RECORDING = 'recording', } diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index f9f714fa0..36d45bc94 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -535,7 +535,9 @@ "destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert", "loading": "Aufzeichnung wird gestartet", "linkMore": "Mehr erfahren", - "transcription": "Diese Aufzeichnung transkribieren" + "transcription": "Diese Aufzeichnung transkribieren", + "quality": "Videoqualität anpassbar.", + "qualityEdit": "Ändern" }, "button": { "start": "Meeting-Aufzeichnung starten", diff --git a/src/frontend/src/locales/de/settings.json b/src/frontend/src/locales/de/settings.json index 5d7b86106..c58e17c55 100644 --- a/src/frontend/src/locales/de/settings.json +++ b/src/frontend/src/locales/de/settings.json @@ -184,10 +184,45 @@ "accessibility": "Barrierefreiheit", "transcription": "Transkription", "shortcuts": "Tastenkürzel", - "rooms": "Räume" + "rooms": "Räume", + "recording": "Aufzeichnung" }, "roomDefaults": { "heading": "Standardeinstellungen für Räume", "description": "Wählen Sie die Einstellungen, die standardmäßig auf neue von Ihnen erstellte Räume angewendet werden. Sie können sie für jedes Meeting weiterhin in den Moderationseinstellungen ändern." + }, + "recording": { + "heading": "Aufzeichnungsqualität", + "locked": "Diese Einstellungen können nicht geändert werden, während eine Aufzeichnung startet oder läuft.", + "profile": { + "label": "Inhaltstyp", + "options": { + "talking_heads": { + "label": "Sprechende Personen", + "description": "Optimiert für Personen vor der Kamera mit gelegentlichen Folien. Kleinste Dateien." + }, + "text": { + "label": "Präsentationen und Text", + "description": "Hält Text beim Teilen von Folien, Dokumenten oder Code scharf." + }, + "mixed": { + "label": "Gemischte Inhalte", + "description": "Ausgewogen für Kameras, Bildschirmfreigabe und moderate Bewegung." + }, + "full": { + "label": "Maximale Qualität", + "description": "Höchste Wiedergabetreue und flüssigste Bewegung. Größte Dateien." + } + } + }, + "resolution": { + "label": "Auflösung", + "description": "Höhere Auflösungen erzeugen größere Dateien.", + "options": { + "540p": "540p (qHD)", + "720p": "720p (HD)", + "1080p": "1080p (Full HD)" + } + } } } diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 35f5d4235..0d2486729 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -535,7 +535,9 @@ "destination": "This recording will be temporarily stored on our servers", "loading": "Starting recording", "linkMore": "Learn more", - "transcription": "Transcribe this recording" + "transcription": "Transcribe this recording", + "quality": "Adjustable video quality.", + "qualityEdit": "Change" }, "button": { "start": "Start recording the meeting", diff --git a/src/frontend/src/locales/en/settings.json b/src/frontend/src/locales/en/settings.json index 0ca3cac61..fd4ae32d6 100644 --- a/src/frontend/src/locales/en/settings.json +++ b/src/frontend/src/locales/en/settings.json @@ -184,10 +184,45 @@ "accessibility": "Accessibility", "transcription": "Transcription", "shortcuts": "Shortcuts", - "rooms": "Rooms" + "rooms": "Rooms", + "recording": "Recording" }, "roomDefaults": { "heading": "Default room settings", "description": "Choose the settings applied by default to the new rooms you create. You can still change them for each meeting from the host settings." + }, + "recording": { + "heading": "Recording quality", + "locked": "These settings can't be changed while a recording is starting or in progress.", + "profile": { + "label": "Content type", + "options": { + "talking_heads": { + "label": "Talking heads", + "description": "Optimized for people speaking on camera with occasional slides. Smallest files." + }, + "text": { + "label": "Presentations and text", + "description": "Keeps text sharp when sharing slides, documents or code." + }, + "mixed": { + "label": "Mixed content", + "description": "Balanced for a mix of cameras, screen sharing and moderate motion." + }, + "full": { + "label": "Maximum quality", + "description": "Highest fidelity and smoothest motion. Largest files." + } + } + }, + "resolution": { + "label": "Resolution", + "description": "Higher resolutions produce larger files.", + "options": { + "540p": "540p (qHD)", + "720p": "720p (HD)", + "1080p": "1080p (Full HD)" + } + } } } diff --git a/src/frontend/src/locales/es/rooms.json b/src/frontend/src/locales/es/rooms.json index caa239c8d..242a2b258 100644 --- a/src/frontend/src/locales/es/rooms.json +++ b/src/frontend/src/locales/es/rooms.json @@ -534,7 +534,9 @@ "destination": "Esta grabación se conservará temporalmente en nuestros servidores", "loading": "Iniciando la grabación", "linkMore": "Más información", - "transcription": "Transcribir esta grabación" + "transcription": "Transcribir esta grabación", + "quality": "Calidad de vídeo ajustable.", + "qualityEdit": "Cambiar" }, "button": { "start": "Empezar a grabar la reunión", diff --git a/src/frontend/src/locales/es/settings.json b/src/frontend/src/locales/es/settings.json index 784e2c7e5..a14185cf1 100644 --- a/src/frontend/src/locales/es/settings.json +++ b/src/frontend/src/locales/es/settings.json @@ -184,10 +184,45 @@ "accessibility": "Accesibilidad", "transcription": "Transcripción", "shortcuts": "Atajos", - "rooms": "Reuniones" + "rooms": "Reuniones", + "recording": "Grabación" }, "roomDefaults": { "heading": "Ajustes predeterminados de las reuniones", "description": "Elige la configuración que se aplica de forma predeterminada a las nuevas reuniones que crees. Siempre podrás modificarla para cada reunión desde el menu de configuración." + }, + "recording": { + "heading": "Calidad de la grabación", + "locked": "Estos ajustes no se pueden modificar mientras una grabación se está iniciando o está en curso.", + "profile": { + "label": "Tipo de contenido", + "options": { + "talking_heads": { + "label": "Personas hablando", + "description": "Optimizado para personas ante la cámara con diapositivas ocasionales. Archivos más ligeros." + }, + "text": { + "label": "Presentaciones y texto", + "description": "Mantiene el texto nítido al compartir diapositivas, documentos o código." + }, + "mixed": { + "label": "Contenido mixto", + "description": "Equilibrado entre cámaras, pantalla compartida y movimiento moderado." + }, + "full": { + "label": "Calidad máxima", + "description": "Máxima fidelidad y movimiento más fluido. Archivos más pesados." + } + } + }, + "resolution": { + "label": "Resolución", + "description": "Las resoluciones más altas generan archivos más pesados.", + "options": { + "540p": "540p (qHD)", + "720p": "720p (HD)", + "1080p": "1080p (Full HD)" + } + } } } diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index bbfb5e06d..73dec24ff 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -535,7 +535,9 @@ "destination": "Cet enregistrement sera conservé temporairement sur nos serveurs", "loading": "Démarrage de l'enregistrement", "linkMore": "En savoir plus", - "transcription": "Transcrire cet enregistrement" + "transcription": "Transcrire cet enregistrement", + "quality": "Qualité vidéo ajustable.", + "qualityEdit": "Modifier" }, "button": { "start": "Commencer à enregistrer la réunion", diff --git a/src/frontend/src/locales/fr/settings.json b/src/frontend/src/locales/fr/settings.json index 2f61798b7..7b6536a9c 100644 --- a/src/frontend/src/locales/fr/settings.json +++ b/src/frontend/src/locales/fr/settings.json @@ -184,10 +184,45 @@ "accessibility": "Accessibilité", "transcription": "Transcription", "shortcuts": "Raccourcis", - "rooms": "Réunions" + "rooms": "Réunions", + "recording": "Enregistrement" }, "roomDefaults": { "heading": "Paramètres par défaut des réunions", "description": "Choisissez les paramètres appliqués par défaut aux nouvelles réunions que vous créez. Vous pourrez toujours les modifier pour chaque réunion depuis les paramètres d’administration." + }, + "recording": { + "heading": "Qualité de l'enregistrement", + "locked": "Ces paramètres ne peuvent pas être modifiés pendant le démarrage ou le déroulement d'un enregistrement.", + "profile": { + "label": "Type de contenu", + "options": { + "talking_heads": { + "label": "Visioconférence", + "description": "Optimisé pour des personnes à la caméra avec quelques diapositives. Fichiers les plus légers." + }, + "text": { + "label": "Présentations et texte", + "description": "Garde le texte net lors du partage de diapositives, de documents ou de code." + }, + "mixed": { + "label": "Contenu mixte", + "description": "Équilibré entre caméras, partage d'écran et mouvements modérés." + }, + "full": { + "label": "Qualité maximale", + "description": "Meilleure fidélité et mouvements les plus fluides. Fichiers les plus lourds." + } + } + }, + "resolution": { + "label": "Résolution", + "description": "Une résolution plus élevée produit des fichiers plus lourds.", + "options": { + "540p": "540p (qHD)", + "720p": "720p (HD)", + "1080p": "1080p (Full HD)" + } + } } } diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 07db7bfc1..cc110aee5 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -535,7 +535,9 @@ "destination": "Deze opname wordt tijdelijk op onze servers bewaard", "loading": "Opname wordt gestart", "linkMore": "Meer informatie", - "transcription": "Transcribeer deze opname" + "transcription": "Transcribeer deze opname", + "quality": "Videokwaliteit aanpasbaar.", + "qualityEdit": "Wijzigen" }, "button": { "start": "Start met opnemen van de vergadering", diff --git a/src/frontend/src/locales/nl/settings.json b/src/frontend/src/locales/nl/settings.json index b73e509f1..25388ec6b 100644 --- a/src/frontend/src/locales/nl/settings.json +++ b/src/frontend/src/locales/nl/settings.json @@ -184,10 +184,45 @@ "accessibility": "Toegankelijkheid", "transcription": "Transcriptie", "shortcuts": "Sneltoetsen", - "rooms": "Vergaderingen" + "rooms": "Vergaderingen", + "recording": "Opname" }, "roomDefaults": { "heading": "Standaardinstellingen voor vergaderingen", "description": "Kies de instellingen die standaard worden toegepast op nieuwe vergaderingen die u aanmaakt. U kunt ze voor elke vergadering nog steeds wijzigen via de hostinstellingen." + }, + "recording": { + "heading": "Opnamekwaliteit", + "locked": "Deze instellingen kunnen niet worden gewijzigd terwijl een opname start of bezig is.", + "profile": { + "label": "Type inhoud", + "options": { + "talking_heads": { + "label": "Sprekende personen", + "description": "Geoptimaliseerd voor mensen voor de camera met af en toe dia's. Kleinste bestanden." + }, + "text": { + "label": "Presentaties en tekst", + "description": "Houdt tekst scherp bij het delen van dia's, documenten of code." + }, + "mixed": { + "label": "Gemengde inhoud", + "description": "Gebalanceerd voor camera's, schermdeling en matige beweging." + }, + "full": { + "label": "Maximale kwaliteit", + "description": "Hoogste getrouwheid en vloeiendste beweging. Grootste bestanden." + } + } + }, + "resolution": { + "label": "Resolutie", + "description": "Hogere resoluties leveren grotere bestanden op.", + "options": { + "540p": "540p (qHD)", + "720p": "720p (HD)", + "1080p": "1080p (Full HD)" + } + } } } diff --git a/src/frontend/src/stores/recordingEncoding.ts b/src/frontend/src/stores/recordingEncoding.ts new file mode 100644 index 000000000..3851cddcd --- /dev/null +++ b/src/frontend/src/stores/recordingEncoding.ts @@ -0,0 +1,57 @@ +import { proxy, subscribe } from 'valtio' +import { STORAGE_KEYS } from '@/utils/storageKeys' +import { reportError } from '@/features/analytics/telemetry' + +export type RecordingEncodingPreference = { + resolution?: string + profile?: string +} + +/** + * Recording encoding preferences, keyed by user id so that several accounts + * sharing the same browser don't overwrite each other's choices. + */ +type State = { + byUser: Record +} + +const DEFAULT_STATE: State = { + byUser: {}, +} + +function getRecordingEncodingState(): State { + try { + const stored = localStorage.getItem(STORAGE_KEYS.RECORDING_ENCODING) + if (!stored) return DEFAULT_STATE + const parsed = JSON.parse(stored) + if (!parsed || typeof parsed.byUser !== 'object') return DEFAULT_STATE + return { + ...DEFAULT_STATE, + ...parsed, + } + } catch (error: unknown) { + reportError('generic_failure', error, { + context: '[RecordingEncodingStore] Failed to parse stored settings:', + }) + return DEFAULT_STATE + } +} + +export const recordingEncodingStore = proxy(getRecordingEncodingState()) + +subscribe(recordingEncodingStore, () => { + localStorage.setItem( + STORAGE_KEYS.RECORDING_ENCODING, + JSON.stringify(recordingEncodingStore) + ) +}) + +export const setRecordingEncodingPreference = ( + userId: string, + preference: RecordingEncodingPreference +) => { + recordingEncodingStore.byUser[userId] = { + ...recordingEncodingStore.byUser[userId], + ...preference, + } +} diff --git a/src/frontend/src/utils/storageKeys.tsx b/src/frontend/src/utils/storageKeys.tsx index 7c39c68cb..54e13b827 100644 --- a/src/frontend/src/utils/storageKeys.tsx +++ b/src/frontend/src/utils/storageKeys.tsx @@ -6,4 +6,5 @@ export const STORAGE_KEYS = { USER_PREFERENCES: 'app_user_preferences', USER: 'app_user', ACCESSIBILITY: 'app_accessibility_settings', + RECORDING_ENCODING: 'app_recording_encoding', } as const