From c7c41abcb5fa86ba4949983fac89f3793f6a9c7f Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sun, 11 Oct 2026 00:33:39 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20let=20users=20pick=20the?= =?UTF-8?q?=20recording=20encoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Recording tab to the settings dialog with two selectors, a content profile and a resolution, building on the per-recording encoding API. The goal is to let an instance default to a cheaper profile and resolution to save egress CPU, while users who really need a smooth HD recording can still opt in. Most users only need a recording of acceptable quality, so the default stays cheap and configuring it is optional. Profiles are labelled by content ("Talking heads", "Presentations and text", ...) rather than by fps or bitrate: users know what they are about to record, not which encoder settings fit it. Each profile shows a one-line description of its trade-off, so picking one requires no technical knowledge. The panel gets a third row, matching the meeting language row of the transcript panel. It is a shortcut to the settings, without adding mental load about how to configure a recording. It reads as a short statement with a trailing "Change" link, like the informational rows above it, and stays on a single line in every locale: the statement truncates if a translation gets too long, the link never wraps. The selected options are intentionally not shown in the panel. Several users can access it, and each would see their own local configuration while the running recording may use another user's encoding, which would be misleading. The issue is milder in the settings tab, which is frozen while a recording is starting or in progress. The selectors are disabled from the moment a start request is sent until the recording is saved, for both screen recordings and transcripts. Settings are sent once, when the recording starts, so changing them afterwards would have no effect and suggest otherwise. Choices are persisted in localStorage to keep things simple, consistent with the meeting language, which is already stored client-side. The backend now exposes the available resolutions and profiles, and their defaults, in the frontend configuration, so the UI follows whatever the operator configures instead of hardcoding values. Stored choices are checked against it before being sent: a profile removed by the operator falls back to the instance default instead of being rejected by the API. No encoding is sent when per-recording encoding is disabled, as the backend would reject it. Operator-defined values without a translation are shown by their raw key. Alternatives considered: - Storing the preference in room metadata: it would turn a pre-start, personal choice into shared editable state, requiring a write endpoint, permission checks and handling of concurrent edits. - Notifying participants of the chosen encoding: notifications are one-off, so users who join or reload after the recording started would miss them, and the information is noise for most participants. Next steps: This is a deliberately small first iteration, meant to deliver most of the value and evolve with feedback and usage. Possible next steps: - persist the preferences in the backend instead of localStorage - store the encoding chosen for a recording on the room (or on a session, if such a model is introduced) and read it from there rather than from user settings, so every user sees the same value --- CHANGELOG.md | 1 + src/backend/core/api/__init__.py | 11 ++ src/frontend/src/api/queryKeys.ts | 1 + src/frontend/src/api/useConfig.ts | 7 + .../src/assets/icons/file_settings_line.svg | 1 + .../features/recording/api/startRecording.ts | 13 +- .../components/ScreenRecordingSidePanel.tsx | 48 ++++++- .../components/TranscriptSidePanel.tsx | 4 + .../recording/hooks/useIsRecordingLocked.ts | 17 +++ .../recording/hooks/useRecordingEncoding.ts | 121 ++++++++++++++++++ src/frontend/src/features/recording/index.ts | 6 + .../components/SettingsDialogExtended.tsx | 22 ++++ .../settings/components/tabs/RecordingTab.tsx | 104 +++++++++++++++ src/frontend/src/features/settings/type.ts | 1 + src/frontend/src/locales/de/rooms.json | 4 +- src/frontend/src/locales/de/settings.json | 38 +++++- src/frontend/src/locales/en/rooms.json | 4 +- src/frontend/src/locales/en/settings.json | 38 +++++- src/frontend/src/locales/es/rooms.json | 4 +- src/frontend/src/locales/es/settings.json | 38 +++++- src/frontend/src/locales/fr/rooms.json | 4 +- src/frontend/src/locales/fr/settings.json | 38 +++++- src/frontend/src/locales/nl/rooms.json | 4 +- src/frontend/src/locales/nl/settings.json | 38 +++++- src/frontend/src/stores/recordingEncoding.ts | 48 +++++++ src/frontend/src/utils/storageKeys.tsx | 1 + 26 files changed, 602 insertions(+), 14 deletions(-) create mode 100644 src/frontend/src/assets/icons/file_settings_line.svg create mode 100644 src/frontend/src/features/recording/hooks/useIsRecordingLocked.ts create mode 100644 src/frontend/src/features/recording/hooks/useRecordingEncoding.ts create mode 100644 src/frontend/src/features/settings/components/tabs/RecordingTab.tsx create mode 100644 src/frontend/src/stores/recordingEncoding.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fceac8935..6ce552242 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..c10e3380e --- /dev/null +++ b/src/frontend/src/features/recording/hooks/useRecordingEncoding.ts @@ -0,0 +1,121 @@ +import { useCallback, useMemo } from 'react' +import { useSnapshot } from 'valtio' +import { useConfig } from '@/api/useConfig' +import { + recordingEncodingStore, + setRecordingEncodingPreference, +} from '@/stores/recordingEncoding' + +export type RecordingEncoding = { + resolution?: string + profile?: string +} + +export const DEFAULT_ENCODING_OPTION = '__default__' + +/** + * Pick the stored value if the backend still offers it, otherwise the + * backend default, otherwise the "Default" option. + */ +const resolveValue = ( + available: string[], + stored?: string, + fallback?: string | null +): string => { + if (stored && available.includes(stored)) return stored + if (fallback && available.includes(fallback)) return fallback + return DEFAULT_ENCODING_OPTION +} + +/** + * Recording encoding preferences (resolution + profile). + * + * Values are persisted in localStorage, per browser, 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 snap = useSnapshot(recordingEncodingStore) + + const encodingConfig = data?.recording?.encoding + const availableResolutions = useMemo( + () => encodingConfig?.available_resolutions ?? [], + [encodingConfig] + ) + const availableProfiles = useMemo( + () => encodingConfig?.available_profiles ?? [], + [encodingConfig] + ) + const hasDefaultResolution = availableResolutions.includes( + encodingConfig?.default_resolution ?? '' + ) + const hasDefaultProfile = availableProfiles.includes( + encodingConfig?.default_profile ?? '' + ) + + const isCustomEncodingEnabled = !!encodingConfig?.is_custom_enabled + + const resolution = resolveValue( + availableResolutions, + snap.resolution, + encodingConfig?.default_resolution + ) + const profile = resolveValue( + availableProfiles, + snap.profile, + encodingConfig?.default_profile + ) + + const setResolution = useCallback( + (value: string) => { + if ( + value !== DEFAULT_ENCODING_OPTION && + !availableResolutions.includes(value) + ) + return + setRecordingEncodingPreference({ resolution: value }) + }, + [availableResolutions] + ) + + const setProfile = useCallback( + (value: string) => { + if ( + value !== DEFAULT_ENCODING_OPTION && + !availableProfiles.includes(value) + ) + return + setRecordingEncodingPreference({ profile: value }) + }, + [availableProfiles] + ) + + // Payload for the start-recording API. Undefined when per-recording + // encoding is disabled, as the backend would reject it. + const encoding: RecordingEncoding | undefined = useMemo(() => { + // The API requires a resolution whenever the encoding is modified: a + // profile cannot be sent alone. So if the resolution is "Default", send + // no payload at all and let the backend apply its default encoding. + if (!isCustomEncodingEnabled || resolution === DEFAULT_ENCODING_OPTION) + return undefined + return { + resolution, + // Omitted when "Default": the backend applies its default profile. + ...(profile !== DEFAULT_ENCODING_OPTION && { profile }), + } + }, [isCustomEncodingEnabled, resolution, profile]) + + return { + isCustomEncodingEnabled, + availableResolutions, + availableProfiles, + hasDefaultResolution, + hasDefaultProfile, + 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..8867ab16c 100644 --- a/src/frontend/src/features/recording/index.ts +++ b/src/frontend/src/features/recording/index.ts @@ -3,6 +3,12 @@ export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled' export { useHasRecordingAccess } from './hooks/useHasRecordingAccess' export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights' export { useRecordingStatuses } from './hooks/useRecordingStatuses' +export { + useRecordingEncoding, + DEFAULT_ENCODING_OPTION, +} 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..9a9400417 --- /dev/null +++ b/src/frontend/src/features/settings/components/tabs/RecordingTab.tsx @@ -0,0 +1,104 @@ +import { TabPanel, type TabPanelProps } from '@/primitives/Tabs' +import { Field, H, Text } from '@/primitives' +import { useTranslation } from 'react-i18next' +import { useMemo } from 'react' +import { + DEFAULT_ENCODING_OPTION, + useIsRecordingLocked, + useRecordingEncoding, +} from '@/features/recording' + +export type RecordingTabProps = Pick + +export const RecordingTab = ({ id }: RecordingTabProps) => { + const { t } = useTranslation('settings', { keyPrefix: 'recording' }) + + const { + availableResolutions, + availableProfiles, + hasDefaultResolution, + hasDefaultProfile, + resolution, + profile, + setResolution, + setProfile, + } = useRecordingEncoding() + + const isLocked = useIsRecordingLocked() + + // The API requires a resolution to modify the encoding: with a "Default" + // resolution no payload is sent, so the profile cannot apply. Show it as + // "Default" and disabled until a resolution is chosen. + const isResolutionDefault = resolution === DEFAULT_ENCODING_OPTION + const selectedProfile = isResolutionDefault ? '' : profile + + const defaultItem = { + value: DEFAULT_ENCODING_OPTION, + label: t('defaultOption'), + } + + // Resolutions and profiles are operator-defined: fall back to the raw key + // when no translation exists for a custom value. + const resolutionItems = useMemo( + () => [ + ...(hasDefaultResolution ? [] : [defaultItem]), + ...availableResolutions.map((key) => ({ + value: key, + label: t(`resolution.options.${key}`, { defaultValue: key }), + })), + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [hasDefaultResolution, availableResolutions, t] + ) + + const profileItems = useMemo( + () => [ + ...(hasDefaultProfile ? [] : [defaultItem]), + ...availableProfiles.map((key) => ({ + value: key, + label: t(`profile.options.${key}.label`, { defaultValue: key }), + })), + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [hasDefaultProfile, availableProfiles, t] + ) + + const profileDescription = selectedProfile + ? t(`profile.options.${selectedProfile}.description`, { defaultValue: '' }) + : '' + + return ( + + {t('heading')} + {isLocked && ( + + {t('locked')} + + )} + { + if (isLocked || key === null) return + setResolution(String(key)) + }} + /> + { + if (isLocked || isResolutionDefault || key === null) return + setProfile(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..4bf950ea6 100644 --- a/src/frontend/src/locales/de/settings.json +++ b/src/frontend/src/locales/de/settings.json @@ -184,10 +184,46 @@ "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)" + } + }, + "defaultOption": "Standard" } } 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..b0899cf4b 100644 --- a/src/frontend/src/locales/en/settings.json +++ b/src/frontend/src/locales/en/settings.json @@ -184,10 +184,46 @@ "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)" + } + }, + "defaultOption": "Default" } } 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..3db3ab89c 100644 --- a/src/frontend/src/locales/es/settings.json +++ b/src/frontend/src/locales/es/settings.json @@ -184,10 +184,46 @@ "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)" + } + }, + "defaultOption": "Predeterminado" } } 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..84e6f9ea8 100644 --- a/src/frontend/src/locales/fr/settings.json +++ b/src/frontend/src/locales/fr/settings.json @@ -184,10 +184,46 @@ "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)" + } + }, + "defaultOption": "Par défaut" } } 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..c58c056ac 100644 --- a/src/frontend/src/locales/nl/settings.json +++ b/src/frontend/src/locales/nl/settings.json @@ -184,10 +184,46 @@ "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)" + } + }, + "defaultOption": "Standaard" } } diff --git a/src/frontend/src/stores/recordingEncoding.ts b/src/frontend/src/stores/recordingEncoding.ts new file mode 100644 index 000000000..fa50b63f9 --- /dev/null +++ b/src/frontend/src/stores/recordingEncoding.ts @@ -0,0 +1,48 @@ +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, shared by everyone using this browser. + */ +type State = RecordingEncodingPreference + +const DEFAULT_STATE: State = {} + +function getRecordingEncodingState(): State { + try { + const stored = localStorage.getItem(STORAGE_KEYS.RECORDING_ENCODING) + if (!stored) return DEFAULT_STATE + const parsed = JSON.parse(stored) + if (!parsed) 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 = ( + preference: RecordingEncodingPreference +) => { + Object.assign(recordingEncodingStore, 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