Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine e7f9522943 ✨(frontend) allow hosts to make a participant speaker
When a host locks microphone, camera or screen sharing for the
room, the only way to let one person speak was to promote them to
co-host, which also grants moderation powers, or to unlock the room
for everyone. Promotion is not even possible for anonymous
participants, who are often the ones invited to ask a question.

A speaker is exempt from the room locks without any other
privilege. Speakers get the same publish sources as co-hosts, so a
deployment that restricts its default sources stays consistent.
Room-wide toggles leave speakers untouched; once nothing is locked
anymore the role has no purpose and is cleared.

The role lives in LiveKit only, through the existing
update-participant endpoint, so it is lost on reconnection. The
host can grant it again; persisting it would require a backend
change that is not worth it yet.

In an upcoming commit, when a participant who is not a speaker
raises their hand in a locked room, the notification will let the
host make them speaker directly.
2026-10-11 01:03:37 +02:00
41 changed files with 329 additions and 606 deletions
-1
View File
@@ -10,7 +10,6 @@ and this project adheres to
### Added
- ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds
### Changed
-11
View File
@@ -44,17 +44,6 @@ 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,
-1
View File
@@ -6,5 +6,4 @@ export const keys = {
waitingParticipants: 'waitingParticipants',
roomCreationCallback: 'roomCreationCallback',
files: 'files',
startRecording: 'startRecording',
}
-7
View File
@@ -36,13 +36,6 @@ 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
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.59456 12.8115C8.53273 12.5511 8.5 12.2794 8.5 12C8.5 11.7207 8.53272 11.449 8.59454 11.1886L7.60291 10.6161L8.60291 8.88402L9.59523 9.45694C9.98834 9.08508 10.4676 8.80338 11 8.64494V7.5H13V8.64494C13.5324 8.80337 14.0116 9.08506 14.4047 9.4569L15.3971 8.88393L16.3972 10.616L15.4054 11.1885C15.4673 11.449 15.5 11.7207 15.5 12C15.5 12.2793 15.4673 12.551 15.4055 12.8114L16.3972 13.3839L15.3972 15.116L14.4048 14.543C14.0117 14.9149 13.5325 15.1966 13.0001 15.355V16.5H11.0001V15.3551C10.4677 15.1967 9.98844 14.915 9.59532 14.5431L8.60297 15.1161L7.60291 13.384L8.59456 12.8115ZM12 13.5C12.8284 13.5 13.5 12.8284 13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716 10.5 12C10.5 12.8284 11.1716 13.5 12 13.5ZM15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z"></path></svg>

Before

Width:  |  Height:  |  Size: 994 B

@@ -177,18 +177,31 @@ export const MainNotificationToast = () => {
changedAttributes: Record<string, string>,
participant: Participant
) => {
if (!participant.isLocal || !('room_role' in changedAttributes)) return
const newRole = changedAttributes['room_role']
toastQueue.add(
{
participant,
type: NotificationType.RoleChanged,
newRole: newRole,
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
if (!participant.isLocal) return
if ('room_role' in changedAttributes) {
toastQueue.add(
{
participant,
type: NotificationType.RoleChanged,
newRole: changedAttributes['room_role'],
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
}
if ('is_speaker' in changedAttributes) {
toastQueue.add(
{
participant,
type: NotificationType.SpeakerChanged,
isSpeaker: changedAttributes['is_speaker'] === 'true',
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
}
}
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
@@ -22,4 +22,5 @@ export enum NotificationType {
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged',
SpeakerChanged = 'speakerChanged',
}
@@ -15,6 +15,7 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest'
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
import { ToastSpeakerChanged } from './ToastSpeakerChanged'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -78,6 +79,9 @@ const renderToast = (
case NotificationType.RoleChanged:
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
case NotificationType.SpeakerChanged:
return <ToastSpeakerChanged key={toast.key} toast={toast} state={state} />
default:
return <Toast key={toast.key} toast={toast} state={state} />
}
@@ -0,0 +1,27 @@
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer'
export function ToastSpeakerChanged({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', { keyPrefix: 'speakerChanged' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(props.toast.content.isSpeaker ? 'granted' : 'removed')}
</HStack>
</StyledToastContainer>
)
}
@@ -0,0 +1,62 @@
import type { Participant, Track } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useDefaultPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
import { reportError } from '@/features/analytics/telemetry'
export const SPEAKER_ATTRIBUTE = 'is_speaker'
export const getParticipantIsSpeaker = (participant: Participant): boolean =>
participant.attributes?.[SPEAKER_ATTRIBUTE] === 'true'
export const useParticipantSpeaker = () => {
const data = useRoomData()
const defaultSources = useDefaultPublishSources()
const updateSpeaker = async (
participant: Participant,
isSpeaker: boolean,
sources: Track.Source[]
) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const attributes: Record<string, string> = {
[SPEAKER_ATTRIBUTE]: isSpeaker ? 'true' : '',
}
try {
return await fetchApi(`rooms/${data.id}/update-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
attributes,
permission: {
can_subscribe: participant.permissions?.canSubscribe,
can_publish_data: participant.permissions?.canPublishData,
can_update_metadata: participant.permissions?.canUpdateMetadata,
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
can_publish: sources.length > 0,
can_publish_sources: sources,
},
}),
})
} catch (error) {
reportError('permissions_api_failure', error, {
context: `Failed to update speaker status of ${participant.identity}`,
})
throw error
}
}
const makeSpeaker = (participant: Participant) =>
updateSpeaker(participant, true, defaultSources)
const removeSpeaker = (
participant: Participant,
roomSources: Track.Source[]
) => updateSpeaker(participant, false, roomSources)
return { makeSpeaker, removeSpeaker }
}
@@ -14,6 +14,7 @@ import { type LocalParticipant, type Participant, Track } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import {
useIsSpeaking,
useParticipantAttribute,
useTrackMutedIndicator,
} from '@livekit/components-react'
import Source = Track.Source
@@ -86,6 +87,16 @@ type ParticipantListItemProps = {
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
const isSpeaker =
useParticipantAttribute('is_speaker', { participant }) === 'true'
const roleLabels = [
getParticipantIsRoomOwner(participant) && t('participants.host'),
getParticipantIsRoomAdmin(participant) && t('participants.cohost'),
getParticipantIsRoomMember(participant) && t('participants.member'),
isSpeaker && t('participants.speaker'),
].filter(Boolean)
return (
<HStack
role="listitem"
@@ -107,12 +118,7 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
displayedName={name}
isLocal={isLocal(participant)}
/>
<Text variant="xsNote">
{getParticipantIsRoomOwner(participant) && t('participants.host')}
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
{getParticipantIsRoomMember(participant) &&
t('participants.member')}
</Text>
<Text variant="xsNote">{roleLabels.join(' · ')}</Text>
</VStack>
</HStack>
<HStack flexShrink={0}>
@@ -3,8 +3,10 @@ import type { Participant } from 'livekit-client'
import { PinMenuItem } from './items/PinMenuItem'
import { RemoveMenuItem } from './items/RemoveMenuItem'
import { PromoteMenuItem } from './items/PromoteMenuItem'
import { SpeakerMenuItem } from './items/SpeakerMenuItem'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useParticipantAttributes } from '@livekit/components-react'
import { useHasLockedSource } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
export const ParticipantMenu = ({
participant,
@@ -18,8 +20,13 @@ export const ParticipantMenu = ({
const isAdmin = attributes?.room_role === 'administrator'
const isOwner = attributes?.room_role === 'owner'
const isAuthenticated = attributes?.is_authenticated == 'true'
const isSpeaker = attributes?.is_speaker === 'true'
const hasLockedSource = useHasLockedSource()
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
const canToggleSpeaker =
canManage && !isAdmin && (isSpeaker || hasLockedSource)
return (
<RACMenu
@@ -41,6 +48,9 @@ export const ParticipantMenu = ({
displayedName={participant.name}
/>
)}
{canToggleSpeaker && (
<SpeakerMenuItem participant={participant} isSpeaker={isSpeaker} />
)}
</RACMenu>
)
}
@@ -0,0 +1,50 @@
import React, { useCallback } from 'react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import type { Participant } from 'livekit-client'
import { RiMicOffLine, RiUserVoiceLine } from '@remixicon/react'
import { useParticipantSpeaker } from '@/features/participants/api/updateParticipantSpeaker'
import { useRoomPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx'
type SpeakerMenuItemProps = {
participant: Participant
isSpeaker: boolean
}
export const SpeakerMenuItem = React.memo(
({ participant, isSpeaker }: SpeakerMenuItemProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
const { makeSpeaker, removeSpeaker } = useParticipantSpeaker()
const roomSources = useRoomPublishSources()
const label = isSpeaker ? 'removeSpeaker' : 'makeSpeaker'
const Icon = isSpeaker ? RiMicOffLine : RiUserVoiceLine
const toggleSpeaker = useCallback(
() =>
isSpeaker
? removeSpeaker(participant, roomSources)
: makeSpeaker(participant),
[isSpeaker, participant, roomSources, makeSpeaker, removeSpeaker]
)
return (
<MenuItem
aria-label={t(`${label}.ariaLabel`, {
name: participant.name || participant.identity,
})}
className={menuRecipe({ icon: true }).item}
onAction={toggleSpeaker}
>
<HStack gap={0.25} minWidth={280}>
<Icon size={20} aria-hidden />
{t(`${label}.label`)}
</HStack>
</MenuItem>
)
}
)
SpeakerMenuItem.displayName = 'SpeakerMenuItem'
@@ -2,22 +2,12 @@ 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?: StartRecordingOptions
options?: Record<string, string | boolean>
}
const startRecording = ({
@@ -38,7 +28,6 @@ export function useStartRecording(
options?: UseMutationOptions<ApiRoom, ApiError, StartRecordingParams>
) {
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
mutationKey: [keys.startRecording],
mutationFn: startRecording,
onSuccess: options?.onSuccess,
onError: options?.onError,
@@ -1,4 +1,4 @@
import { Button, Div, H, Text } from '@/primitives'
import { Div, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
@@ -22,13 +22,8 @@ import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox'
import {
SettingsDialogExtendedKey,
useTranscriptionLanguage,
} from '@/features/settings'
import { 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'
@@ -58,8 +53,6 @@ export const ScreenRecordingSidePanel = () => {
const { selectedLanguageKey, isLanguageSetToAuto } =
useTranscriptionLanguage()
const { encoding, isCustomEncodingEnabled } = useRecordingEncoding()
const roomId = useRoomId()
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
@@ -100,7 +93,6 @@ export const ScreenRecordingSidePanel = () => {
language: selectedLanguageKey,
}),
...(includeTranscript && hasTranscriptAccess && { transcribe: true }),
...(encoding && { encoding }),
}
await startRecording({
@@ -115,8 +107,6 @@ export const ScreenRecordingSidePanel = () => {
captureEvent('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
resolution: encoding?.resolution,
profile: encoding?.profile,
})
}
} catch (error) {
@@ -193,41 +183,9 @@ export const ScreenRecordingSidePanel = () => {
<RowWrapper iconName="cloud_download" position="first">
<Text variant="sm">{t('details.destination')}</Text>
</RowWrapper>
<RowWrapper
iconName="mail"
position={isCustomEncodingEnabled ? 'middle' : 'last'}
>
<RowWrapper iconName="mail" position="last">
<Text variant="sm">{t('details.receiver')}</Text>
</RowWrapper>
{isCustomEncodingEnabled && (
<RowWrapper iconName="file_settings_line" position="last">
<Text
variant="sm"
className={css({
minWidth: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
>
{t('details.quality')}
</Text>
<Text
variant="sm"
className={css({ flexShrink: 0, whiteSpace: 'nowrap' })}
>
<Button
variant="text"
size="xs"
onPress={() =>
openSettingsDialog(SettingsDialogExtendedKey.RECORDING)
}
>
{t('details.qualityEdit')}
</Button>
</Text>
</RowWrapper>
)}
{hasTranscriptAccess && (
<>
<div className={css({ height: '15px' })} />
@@ -29,7 +29,6 @@ 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'
@@ -67,8 +66,6 @@ export const TranscriptSidePanel = () => {
const isMetadataCollectorEnabled = useIsMetadataCollectorEnabled()
const { encoding } = useRecordingEncoding()
const roomId = useRoomId()
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
@@ -117,7 +114,6 @@ export const TranscriptSidePanel = () => {
...(withScreenRecording && {
transcribe: true,
original_mode: RecordingMode.Transcript,
...(encoding && { encoding }),
}),
collect_metadata: isMetadataCollectorEnabled,
}
@@ -1,17 +0,0 @@
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
}
@@ -1,107 +0,0 @@
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,
}
}
@@ -3,9 +3,6 @@ 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'
@@ -1,12 +1,19 @@
import { RoomEvent, Track } from 'livekit-client'
import { useCallback, useMemo } from 'react'
import { useConfig } from '@/api/useConfig'
import { useCallback } from 'react'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRemoteParticipants } from '@livekit/components-react'
import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import {
getParticipantIsSpeaker,
useParticipantSpeaker,
} from '@/features/participants/api/updateParticipantSpeaker'
import {
useDefaultPublishSources,
useRoomPublishSources,
} from '@/features/rooms/livekit/hooks/useRoomPublishSources'
import Source = Track.Source
import {
NotificationType,
@@ -33,8 +40,9 @@ export const usePublishSourcesManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
const data = useRoomData()
const { data: configData } = useConfig()
const configuration = data?.configuration
const currentSources = useRoomPublishSources()
const defaultSources = useDefaultPublishSources()
const { notifyParticipants } = useNotifyParticipants()
@@ -42,25 +50,18 @@ export const usePublishSourcesManager = () => {
const roomId = data?.slug
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
const { removeSpeaker } = useParticipantSpeaker()
const remoteParticipants = useRemoteParticipants({
updateOnlyOn: [RoomEvent.ParticipantAttributesChanged],
})
const unprivilegedRemoteParticipants = remoteParticipants.filter(
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
const restrictedRemoteParticipants = remoteParticipants.filter(
(participant) =>
!getParticipantIsRoomAdminOrOwner(participant) &&
!getParticipantIsSpeaker(participant)
)
const currentSources = useMemo(() => {
const defaultSources = configData?.livekit?.default_sources ?? []
if (
configuration?.can_publish_sources == undefined ||
!Array.isArray(configuration?.can_publish_sources)
) {
return defaultSources
}
return configuration.can_publish_sources
}, [configData, configuration?.can_publish_sources])
const speakers = remoteParticipants.filter(getParticipantIsSpeaker)
const updateSource = useCallback(
async (sources: Source[], enabled: boolean) => {
@@ -84,10 +85,16 @@ export const usePublishSourcesManager = () => {
})
await updateParticipantsPermissions(
unprivilegedRemoteParticipants,
restrictedRemoteParticipants,
newSources
)
if (isSubsetOf(defaultSources, newSources)) {
await Promise.all(
speakers.map((speaker) => removeSpeaker(speaker, newSources))
)
}
if (!enabled) {
/*
* We can't rely solely on the ParticipantPermissionsChanged event here,
@@ -96,7 +103,7 @@ export const usePublishSourcesManager = () => {
* */
await notifyParticipants({
type: NotificationType.PermissionsRemoved,
destinationIdentities: unprivilegedRemoteParticipants.map(
destinationIdentities: restrictedRemoteParticipants.map(
(p) => p.identity
),
additionalData: {
@@ -119,9 +126,12 @@ export const usePublishSourcesManager = () => {
notifyParticipants,
configuration,
currentSources,
defaultSources,
roomId,
patchRoom,
unprivilegedRemoteParticipants,
restrictedRemoteParticipants,
speakers,
removeSpeaker,
updateParticipantsPermissions,
]
)
@@ -0,0 +1,30 @@
import { Track } from 'livekit-client'
import { useMemo } from 'react'
import { useConfig } from '@/api/useConfig'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import Source = Track.Source
const EMPTY_SOURCES: Source[] = []
export const useDefaultPublishSources = (): Source[] => {
const { data: configData } = useConfig()
return configData?.livekit?.default_sources ?? EMPTY_SOURCES
}
export const useRoomPublishSources = (): Source[] => {
const data = useRoomData()
const defaultSources = useDefaultPublishSources()
const roomSources = data?.configuration?.can_publish_sources
return useMemo(
() => (Array.isArray(roomSources) ? roomSources : defaultSources),
[defaultSources, roomSources]
)
}
export const useHasLockedSource = (): boolean => {
const defaultSources = useDefaultPublishSources()
const roomSources = useRoomPublishSources()
return !isSubsetOf(defaultSources, roomSources)
}
@@ -13,7 +13,6 @@ import {
RiVideoOnLine,
RiEyeLine,
RiKeyboardBoxLine,
RiRecordCircleLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
@@ -27,12 +26,6 @@ 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
@@ -74,12 +67,6 @@ 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 (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
@@ -134,13 +121,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
{showRecordingTab && (
<Tab icon highlight id={SettingsDialogExtendedKey.RECORDING}>
<RiRecordCircleLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)}
</Tab>
)}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
{isWideScreen &&
@@ -160,8 +140,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
{/* Recording tab won't be accessible if the tab is not active in the tab list */}
<RecordingTab id={SettingsDialogExtendedKey.RECORDING} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
</div>
</Tabs>
@@ -1,84 +0,0 @@
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<TabPanelProps, 'id'>
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 (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('heading')}</H>
{isLocked && (
<Text variant="note" role="status" margin="sm">
{t('locked')}
</Text>
)}
<Field
type="select"
label={t('profile.label')}
description={profileDescription || undefined}
items={profileItems}
selectedKey={profile ?? null}
isDisabled={isLocked}
onSelectionChange={(key) => {
if (isLocked || key === null) return
setProfile(String(key))
}}
/>
<Field
type="select"
label={t('resolution.label')}
description={t('resolution.description')}
items={resolutionItems}
selectedKey={resolution ?? null}
isDisabled={isLocked}
onSelectionChange={(key) => {
if (isLocked || key === null) return
setResolution(String(key))
}}
/>
</TabPanel>
)
}
@@ -7,5 +7,4 @@ export enum SettingsDialogExtendedKey {
TRANSCRIPTION = 'transcription',
SHORTCUTS = 'shortcuts',
ACCESSIBILITY = 'accessibility',
RECORDING = 'recording',
}
@@ -68,5 +68,9 @@
"member": "Mitglied"
}
},
"speakerChanged": {
"granted": "Du bist jetzt Sprecher. Du kannst dein Mikrofon, deine Kamera und die Bildschirmfreigabe nutzen.",
"removed": "Du bist nicht mehr Sprecher."
},
"openMenu": "Menü öffnen"
}
+10 -3
View File
@@ -535,9 +535,7 @@
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
"loading": "Aufzeichnung wird gestartet",
"linkMore": "Mehr erfahren",
"transcription": "Diese Aufzeichnung transkribieren",
"quality": "Videoqualität anpassbar.",
"qualityEdit": "Ändern"
"transcription": "Diese Aufzeichnung transkribieren"
},
"button": {
"start": "Meeting-Aufzeichnung starten",
@@ -661,6 +659,7 @@
"host": "Host",
"cohost": "Co-host",
"member": "Mitglied",
"speaker": "Sprecher",
"contributors": "Teilnehmende",
"collapsable": {
"open": "{{name}}-Liste öffnen",
@@ -716,6 +715,14 @@
"demote": {
"label": "Co-Host-Rolle entfernen",
"ariaLabel": "Die Co-Host-Rolle von {{name}} entfernen"
},
"makeSpeaker": {
"label": "Zum Sprecher machen",
"ariaLabel": "{{name}} zum Sprecher machen"
},
"removeSpeaker": {
"label": "Sprecherrolle entfernen",
"ariaLabel": "Sprecherrolle von {{name}} entfernen"
}
},
"pinAnnouncements": {
+1 -36
View File
@@ -184,45 +184,10 @@
"accessibility": "Barrierefreiheit",
"transcription": "Transkription",
"shortcuts": "Tastenkürzel",
"rooms": "Räume",
"recording": "Aufzeichnung"
"rooms": "Räume"
},
"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)"
}
}
}
}
@@ -68,5 +68,9 @@
"member": "Member"
}
},
"speakerChanged": {
"granted": "You are now a speaker. You can use your microphone, camera and screen sharing.",
"removed": "You are no longer a speaker."
},
"openMenu": "Open menu"
}
+10 -3
View File
@@ -535,9 +535,7 @@
"destination": "This recording will be temporarily stored on our servers",
"loading": "Starting recording",
"linkMore": "Learn more",
"transcription": "Transcribe this recording",
"quality": "Adjustable video quality.",
"qualityEdit": "Change"
"transcription": "Transcribe this recording"
},
"button": {
"start": "Start recording the meeting",
@@ -661,6 +659,7 @@
"host": "Host",
"cohost": "Co-host",
"member": "Member",
"speaker": "Speaker",
"contributors": "Contributors",
"collapsable": {
"open": "Open {{name}} list",
@@ -716,6 +715,14 @@
"demote": {
"label": "Remove co-host role",
"ariaLabel": "Remove {{name}}'s co-host role"
},
"makeSpeaker": {
"label": "Make speaker",
"ariaLabel": "Make {{name}} a speaker"
},
"removeSpeaker": {
"label": "Remove speaker",
"ariaLabel": "Remove {{name}} as speaker"
}
},
"pinAnnouncements": {
+1 -36
View File
@@ -184,45 +184,10 @@
"accessibility": "Accessibility",
"transcription": "Transcription",
"shortcuts": "Shortcuts",
"rooms": "Rooms",
"recording": "Recording"
"rooms": "Rooms"
},
"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)"
}
}
}
}
@@ -68,5 +68,9 @@
"member": "Miembro"
}
},
"speakerChanged": {
"granted": "Ahora eres ponente. Puedes usar tu micrófono, tu cámara y compartir pantalla.",
"removed": "Ya no eres ponente."
},
"openMenu": "Abrir el menú"
}
+10 -3
View File
@@ -534,9 +534,7 @@
"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",
"quality": "Calidad de vídeo ajustable.",
"qualityEdit": "Cambiar"
"transcription": "Transcribir esta grabación"
},
"button": {
"start": "Empezar a grabar la reunión",
@@ -661,6 +659,7 @@
"host": "Organizador de la reunión",
"cohost": "Coorganizador",
"member": "Miembro",
"speaker": "Ponente",
"collapsable": {
"open": "Abrir la lista {{name}}",
"close": "Cerrar la lista {{name}}"
@@ -715,6 +714,14 @@
"demote": {
"label": "Retirar el rol de coorganizador",
"ariaLabel": "Retirar el rol de coorganizador a {{name}}"
},
"makeSpeaker": {
"label": "Nombrar ponente",
"ariaLabel": "Nombrar ponente a {{name}}"
},
"removeSpeaker": {
"label": "Quitar rol de ponente",
"ariaLabel": "Quitar el rol de ponente a {{name}}"
}
},
"pinAnnouncements": {
+1 -36
View File
@@ -184,45 +184,10 @@
"accessibility": "Accesibilidad",
"transcription": "Transcripción",
"shortcuts": "Atajos",
"rooms": "Reuniones",
"recording": "Grabación"
"rooms": "Reuniones"
},
"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)"
}
}
}
}
@@ -68,5 +68,9 @@
"member": "Membre"
}
},
"speakerChanged": {
"granted": "Vous êtes maintenant intervenant. Vous pouvez utiliser votre micro, votre caméra et partager votre écran.",
"removed": "Vous n'êtes plus intervenant."
},
"openMenu": "Ouvrir le menu"
}
+10 -3
View File
@@ -535,9 +535,7 @@
"destination": "Cet enregistrement sera conservé temporairement sur nos serveurs",
"loading": "Démarrage de l'enregistrement",
"linkMore": "En savoir plus",
"transcription": "Transcrire cet enregistrement",
"quality": "Qualité vidéo ajustable.",
"qualityEdit": "Modifier"
"transcription": "Transcrire cet enregistrement"
},
"button": {
"start": "Commencer à enregistrer la réunion",
@@ -662,6 +660,7 @@
"host": "Organisateur de la réunion",
"cohost": "Co-organisateur",
"member": "Membre",
"speaker": "Intervenant",
"collapsable": {
"open": "Ouvrir la liste {{name}}",
"close": "Fermer la liste {{name}}"
@@ -716,6 +715,14 @@
"demote": {
"label": "Retirer le rôle de co-organisateur",
"ariaLabel": "Retirer le rôle de co-organisateur à {{name}}"
},
"makeSpeaker": {
"label": "Désigner comme intervenant",
"ariaLabel": "Désigner {{name}} comme intervenant"
},
"removeSpeaker": {
"label": "Retirer le rôle d'intervenant",
"ariaLabel": "Retirer le rôle d'intervenant de {{name}}"
}
},
"pinAnnouncements": {
+1 -36
View File
@@ -184,45 +184,10 @@
"accessibility": "Accessibilité",
"transcription": "Transcription",
"shortcuts": "Raccourcis",
"rooms": "Réunions",
"recording": "Enregistrement"
"rooms": "Réunions"
},
"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)"
}
}
}
}
@@ -68,5 +68,9 @@
"member": "Lid"
}
},
"speakerChanged": {
"granted": "Je bent nu spreker. Je kunt je microfoon, camera en schermdeling gebruiken.",
"removed": "Je bent geen spreker meer."
},
"openMenu": "Menu openen"
}
+10 -3
View File
@@ -535,9 +535,7 @@
"destination": "Deze opname wordt tijdelijk op onze servers bewaard",
"loading": "Opname wordt gestart",
"linkMore": "Meer informatie",
"transcription": "Transcribeer deze opname",
"quality": "Videokwaliteit aanpasbaar.",
"qualityEdit": "Wijzigen"
"transcription": "Transcribeer deze opname"
},
"button": {
"start": "Start met opnemen van de vergadering",
@@ -661,6 +659,7 @@
"host": "Host",
"cohost": "Co-host",
"member": "Lid",
"speaker": "Spreker",
"contributors": "Deelnemers",
"collapsable": {
"open": "Open {{name}} lijst",
@@ -716,6 +715,14 @@
"demote": {
"label": "Co-hostrol verwijderen",
"ariaLabel": "De co-hostrol van {{name}} verwijderen"
},
"makeSpeaker": {
"label": "Spreker maken",
"ariaLabel": "{{name}} spreker maken"
},
"removeSpeaker": {
"label": "Sprekersrol verwijderen",
"ariaLabel": "Sprekersrol van {{name}} verwijderen"
}
},
"pinAnnouncements": {
+1 -36
View File
@@ -184,45 +184,10 @@
"accessibility": "Toegankelijkheid",
"transcription": "Transcriptie",
"shortcuts": "Sneltoetsen",
"rooms": "Vergaderingen",
"recording": "Opname"
"rooms": "Vergaderingen"
},
"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)"
}
}
}
}
@@ -1,57 +0,0 @@
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<string, RecordingEncodingPreference>
}
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<State>(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,
}
}
-1
View File
@@ -6,5 +6,4 @@ export const STORAGE_KEYS = {
USER_PREFERENCES: 'app_user_preferences',
USER: 'app_user',
ACCESSIBILITY: 'app_accessibility_settings',
RECORDING_ENCODING: 'app_recording_encoding',
} as const