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 (