Compare commits

...

24 Commits

Author SHA1 Message Date
lebaudantoine 511247c14b ♻️(frontend) refactor audio/video tabs to share common layout components
Extract shared layout components from audio and video tabs to eliminate
code duplication and improve maintainability.

Creates reusable layout components that both tabs can utilize, reducing
redundancy and ensuring consistent styling and behavior across settings
tabs.
2025-08-20 12:23:26 +02:00
lebaudantoine 1380412a39 (frontend) add subscription video quality selector to video tab
Add configuration option allowing users to set maximum video quality
for subscribed tracks, requested by users who prefer manual control
over automatic behavior.

Implements custom handling for existing and new video tracks since
LiveKit lacks simple global subscription parameter mechanism. Users can
now override automatic quality decisions with their own preferences.
2025-08-20 12:23:26 +02:00
lebaudantoine 3f91271ab2 ♻️(frontend) refactor video tab i18n strings with common prefix
Reorganize internationalization strings for video tab to use shared
"video" prefix, improving code maintainability and consistency.
2025-08-20 12:23:26 +02:00
lebaudantoine 2b1f863a78 (frontend) add persistence for subscribed video resolution preferences
Implement user choice persistence for video resolution settings of
subscribed tracks from other participants.

Maintains user preferences for received video quality across sessions,
allowing consistent bandwidth optimization and viewing experience
without reconfiguring subscription settings each visit.
2025-08-20 12:23:26 +02:00
lebaudantoine b8d9bc7921 (frontend) add video resolution selector for publishing control
Introduce select option allowing users to set maximum publishing
resolution that instantly changes video track resolution for other
participants.

Essential for low bandwidth networks and follows common patterns across
major videoconferencing solutions. Users can optimize their video
quality based on network conditions without leaving the call.
2025-08-20 12:23:26 +02:00
lebaudantoine 2e6560570a (frontend) add persistence for video publishing resolution setting
Implement user choice persistence for video publishing resolution
configuration to maintain user preferences across sessions.

Stores selected video resolution in user settings, ensuring consistent
video quality preferences without requiring reconfiguration on each
visit.
2025-08-20 12:23:26 +02:00
lebaudantoine f08fe90f08 (frontend) add video tab to settings modal for camera configuration
Introduce new video tab in settings modal requested by users who found
it misleading to lack camera configuration options in settings.

Currently implements basic camera device selection. Future commits will
expand functionality to include resolution management, subscription
settings, and other video-related configurations.
2025-08-13 22:52:24 +02:00
lebaudantoine e4a70fdda6 ️(frontend) fix incorrect aria label on tab component
Correct wrong aria label that was accidentally copied from another
component during development.
2025-08-13 10:34:48 +02:00
lebaudantoine 4e655a0a64 🚸(frontend) prioritize UI reactivity over error handling in track toggle
Update track muting/unmuting to prioritize immediate UI state changes
over error handling to prevent weird UX delays.

Ensures toggle buttons reflect new state instantly rather than waiting
for operation completion. Users expect immediate visual feedback when
interacting with mute controls, even if errors occur later.
2025-08-12 00:09:08 +02:00
lebaudantoine d2bde299be 🚸(frontend) auto-select single audio output device for smoother UX
When browsers don't return 'default' audio output device ID and only
one device is available, automatically select the single option to
improve user experience.

Prevents unnecessary user interaction when there's only one choice
available, making the device selection flow smoother and more intuitive
for users with single audio output setups.

This is necessary only for audio output because we don't create
a preview track, compare to video or mic.
2025-08-12 00:09:08 +02:00
lebaudantoine 9b44ed0974 🩹(frontend) disable Safari speaker select for LiveKit compatibility
Disable speaker selection on prejoin screen for Safari based on LiveKit
documentation stating audio output selection isn't supported, though
this needs further verification.

Maintains consistency with audio tab behavior until Safari audio output
support can be confirmed. Feature remains available on other browsers
where support is verified.
2025-08-12 00:09:08 +02:00
lebaudantoine aeaa9b7ffd 🐛(frontend) link audio output permissions to mic to prevent prompts
Fix issue where requesting audio output devices triggers microphone
permission prompts in certain browsers by linking audio output select
permissions with microphone permissions.

Ensures no unexpected permission prompts occur before preview tracks
are acquired, maintaining smooth user flow during device selection
without interrupting the permission sequence.
2025-08-12 00:09:08 +02:00
lebaudantoine da73424f72 🌐(frontend) fix Dutch plural forms in join screen translations
Correct plural form usage in Dutch language translations for the join
screen interface elements.
2025-08-12 00:09:08 +02:00
lebaudantoine 2b9b977f57 (frontend) add speaker select component for audio output configuration
Introduce speaker selection component requested by users to allow audio
output device configuration before entering calls.

Enables users to test and configure their preferred audio output device
during prejoin setup, ensuring proper audio routing before call begins.
Improves user experience by preventing audio issues during meetings.
2025-08-12 00:09:08 +02:00
lebaudantoine 355db6ef9a (frontend) add localStorage persistence for audio output device
Implement audio output device persistence in localStorage since LiveKit
doesn't handle this by default.

Ensures user's preferred audio output selection is remembered across
sessions, improving user experience by maintaining device preferences
without requiring re-selection on each visit.
2025-08-12 00:09:08 +02:00
lebaudantoine 890e043d29 🚨(frontend) remove redundant undefined type or optional specifier
Fix TypeScript warning by removing either 'undefined' type annotation
or '?' optional specifier where both were redundantly specified.
2025-08-11 19:04:59 +02:00
lebaudantoine 9eb412758a 🚨(frontend) fix unnecessary destructuring rename in devices assignment
Remove unnecessary renaming in destructuring assignment where variable
was renamed to the same name, violating TypeScript rule S6650.
2025-08-11 19:04:59 +02:00
lebaudantoine fa27afdfdf 💄(frontend) add symmetric shadows for white button contrast enhancement
Add symmetric shadows at top and bottom of white circle buttons to
ensure sufficient visual contrast against varying background colors.

Improves button visibility and accessibility by providing consistent
visual definition regardless of background context.
2025-08-11 19:04:59 +02:00
lebaudantoine cb8b415ef9 ♻️(frontend) refactor device select for controlled behavior
Major refactor of device select component with several key improvements:

* Set permission=true for Firefox compatibility - without this flag,
  device list returns empty on Firefox
* Implement controlled component pattern for active device selection,
  ensuring sync with preview track state
* Remove default device handling as controlled behavior eliminates need
* Render selectors only after permissions granted to prevent double
  permission prompts (separate for mic/camera)

Ensures usePreviewTrack handles initial permission request, then
selectors allow specific device choice once access is granted.
2025-08-11 19:04:59 +02:00
lebaudantoine 7c6182cc4e 🐛(frontend) fix default device ID mismatch with actual preview track
Update default device IDs when preview track starts to match the
actual device being used. LiveKit returns 'default' string which may
not exist in device list, causing ID mismatch.

Prevents misleading situation where default device ID doesn't
correspond to the device actually used by the preview track. Now
synchronizes IDs once preview starts for accurate device tracking.
2025-08-11 19:04:59 +02:00
lebaudantoine 2d47e90a1a 🐛(frontend) reset video ref on track stop for state transitions
Reset video element reference when track stops to ensure "camera
starting" to "camera started" message transitions work correctly on
repeated camera toggles.

Previously only worked on initial video element load. Now properly
handles state transitions for multiple camera enable/disable cycles.
2025-08-11 19:04:59 +02:00
lebaudantoine 56ec2dd8cb ♻️(frontend) refactor select to show arrow up when menu opens upward
Update select toggle device component used in conference to display
upward arrow when dropdown menu opens above the select component.

Improves visual consistency by matching arrow direction with actual
menu opening direction, providing clearer user interface feedback.
2025-08-11 19:04:59 +02:00
lebaudantoine 361de29780 🩹(frontend) refactor track muting for proper audio/video control
Fix useTrackToggle hook that wasn't properly muting/unmuting tracks
outside room context. Previously only toggled boolean state via
setUpManualToggle without actual track control.

This caused misleading visual feedback where prejoin showed "camera
disabled" while hardware remained active. Users could see camera/mic
LEDs still on despite UI indicating otherwise.

Refactor provides genuine track muting for accurate user feedback.
2025-08-11 19:04:59 +02:00
lebaudantoine e4d5ca64b9 ♻️(frontend) refactor prejoin screen for room context flexibility
Decouple prejoin components from conference context to enable different
behaviors when inside vs outside room environments. Components can now
evolve independently with lighter coupling.

Refactor layout structure to prepare for upcoming speaker selector
introduction. This decoupling allows for more flexible component
evolution and cleaner architecture.
2025-08-11 19:04:59 +02:00
25 changed files with 998 additions and 162 deletions
@@ -12,6 +12,7 @@ import {
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
VideoPresets,
} from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
@@ -98,15 +99,23 @@ export const Conference = ({
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
resolution: userConfig.videoPublishResolution
? VideoPresets[userConfig.videoPublishResolution].resolution
: undefined,
},
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
},
audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined,
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [
userConfig.videoDeviceId,
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
@@ -5,7 +5,6 @@ import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives'
import { VStack } from '@/styled-system/jsx'
@@ -29,6 +28,10 @@ import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useSnapshot } from 'valtio'
import { openPermissionsDialog, permissionsStore } from '@/stores/permissions'
import { ToggleDevice } from './join/ToggleDevice'
import { SelectDevice } from './join/SelectDevice'
import { useResolveDefaultDeviceId } from '../livekit/hooks/useResolveDefaultDevice'
import { isSafari } from '@/utils/livekit'
const onError = (e: Error) => console.error('ERROR', e)
@@ -99,11 +102,13 @@ export const Join = ({
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
},
saveAudioInputEnabled,
saveAudioOutputDeviceId,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
@@ -139,6 +144,11 @@ export const Join = ({
[tracks]
)
// LiveKit by default populates device choices with "default" value.
// Instead, use the current device id used by the preview track as a default
useResolveDefaultDeviceId(audioDeviceId, audioTrack, saveAudioInputDeviceId)
useResolveDefaultDeviceId(videoDeviceId, videoTrack, saveVideoInputDeviceId)
const videoEl = useRef(null)
const isVideoInitiated = useRef(false)
@@ -160,7 +170,11 @@ export const Join = ({
return () => {
videoTrack?.detach()
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
if (videoElement) {
videoElement.removeEventListener('loadedmetadata', handleVideoLoaded)
videoElement.style.opacity = '0'
}
isVideoInitiated.current = false
}
}, [videoTrack, videoEnabled])
@@ -399,6 +413,28 @@ export const Join = ({
alignItems: 'center',
})}
>
<div
className={css({
position: 'absolute',
top: 0,
height: '5rem',
width: '100%',
backgroundImage:
'linear-gradient(to bottom, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 40%, rgba(0, 0, 0, 0.1) 80%, rgba(0, 0, 0, 0) 100%)',
zIndex: 1,
})}
/>
<div
className={css({
position: 'absolute',
bottom: 0,
height: '5rem',
width: '100%',
backgroundImage:
'linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 35%, rgba(0, 0, 0, 0.1) 75%, rgba(0, 0, 0, 0) 100%)',
zIndex: 1,
})}
/>
<div
className={css({
position: 'relative',
@@ -502,6 +538,33 @@ export const Join = ({
)}
</div>
</div>
<div
className={css({
position: 'absolute',
bottom: '1rem',
zIndex: '1',
display: 'flex',
gap: '1rem',
justifyContent: 'center',
left: '50%',
transform: 'translateX(-50%)',
})}
>
<ToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
/>
<ToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
/>
</div>
<div
className={css({
position: 'absolute',
@@ -528,32 +591,39 @@ export const Join = ({
marginX: 'auto',
})}
>
<div>
<SelectToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
initialDeviceId={audioDeviceId}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
variant="tertiary"
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="audioinput"
id={audioDeviceId}
onSubmit={saveAudioInputDeviceId}
/>
</div>
<div>
<SelectToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
initialDeviceId={videoDeviceId}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
variant="tertiary"
{!isSafari() && (
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
)}
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="videoinput"
id={videoDeviceId}
onSubmit={saveVideoInputDeviceId}
/>
</div>
</div>
@@ -0,0 +1,143 @@
import {
RemixiconComponentType,
RiMicLine,
RiVideoOnLine,
RiVolumeDownLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useEffect, useMemo } from 'react'
import { Select } from '@/primitives/Select'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
type DeviceItems = Array<{ value: string; label: string }>
type DeviceConfig = {
icon: RemixiconComponentType
}
type SelectDeviceProps = {
id?: string
onSubmit?: (id: string) => void
kind: MediaDeviceKind
}
type SelectDevicePermissionsProps = SelectDeviceProps & {
config: DeviceConfig
}
const SelectDevicePermissions = ({
id,
kind,
config,
onSubmit,
}: SelectDevicePermissionsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: kind, requestPermissions: true })
const items: DeviceItems = devices
.filter((d) => !!d.deviceId)
.map((d) => ({
value: d.deviceId,
label: d.label,
}))
/**
* FALLBACK AUDIO OUTPUT DEVICE SELECTION
* Auto-selects the only available audio output device when currently on 'default'
*/
useEffect(() => {
if (
kind !== 'audiooutput' ||
items.length !== 1 ||
items[0].value === 'default' ||
activeDeviceId !== 'default'
)
return
onSubmit?.(items[0].value)
setActiveMediaDevice(items[0].value)
}, [items, onSubmit, kind, setActiveMediaDevice, activeDeviceId])
return (
<Select
aria-label={t(`${kind}.choose`)}
label=""
isDisabled={items.length === 0}
items={items}
iconComponent={config?.icon}
placeholder={
items.length === 0
? t('selectDevice.loading')
: t('selectDevice.select')
}
selectedKey={id || activeDeviceId}
onSelectionChange={(key) => {
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
/>
)
}
export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const permissions = useSnapshot(permissionsStore)
const config = useMemo<DeviceConfig | undefined>(() => {
switch (kind) {
case 'audioinput':
return {
icon: RiMicLine,
}
case 'audiooutput':
return {
icon: RiVolumeDownLine,
}
case 'videoinput':
return {
icon: RiVideoOnLine,
}
}
}, [kind])
const isPermissionDeniedOrPrompted = useMemo(() => {
if (kind == 'audioinput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
if (kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
if (kind == 'audiooutput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
return false
}, [kind, permissions])
if (!config) return null
if (isPermissionDeniedOrPrompted || permissions.isLoading) {
return (
<Select
aria-label={t(`${kind}.permissionsNeeded`)}
label=""
isDisabled={true}
items={[]}
iconComponent={config?.icon}
placeholder={t('selectDevice.permissionsNeeded')}
/>
)
}
return (
<SelectDevicePermissions
id={id}
onSubmit={onSubmit}
kind={kind}
config={config}
/>
)
}
@@ -0,0 +1,78 @@
import { UseTrackToggleProps } from '@livekit/components-react'
import { ToggleDevice as BaseToggleDevice } from '../../livekit/components/controls/ToggleDevice'
import {
TOGGLE_DEVICE_CONFIG,
ToggleSource,
} from '../../livekit/config/ToggleDeviceConfig'
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useCallback, useMemo, useState } from 'react'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
type ToggleDeviceProps<T extends ToggleSource> = UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack
source: ToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
}
export const ToggleDevice = <T extends ToggleSource>({
track,
onChange,
...props
}: ToggleDeviceProps<T>) => {
const config = TOGGLE_DEVICE_CONFIG[props.source]
if (!config) {
throw new Error('Invalid source')
}
const [isTrackEnabled, setIsTrackEnabled] = useState(
props.initialState ?? false
)
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted = useMemo(() => {
if (config.kind == 'audioinput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
if (config.kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [config, permissions])
const toggle = useCallback(async () => {
if (!track) {
console.error('Track is undefined.')
return
}
try {
if (isTrackEnabled) {
setIsTrackEnabled(false)
onChange?.(false, true)
await track.mute()
} else {
setIsTrackEnabled(true)
onChange?.(true, true)
await track.unmute()
}
} catch (error) {
console.error('Failed to toggle track:', error)
}
}, [track, onChange, isTrackEnabled])
return (
<BaseToggleDevice
enabled={isTrackEnabled}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggle={toggle}
config={config}
variant="whiteCircle"
errorVariant="errorCircle"
toggleButtonProps={{
groupPosition: undefined,
}}
/>
)
}
@@ -5,14 +5,7 @@ import {
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import {
RemixiconComponentType,
RiArrowDownSLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { RiArrowUpSLine } from '@remixicon/react'
import {
LocalAudioTrack,
LocalVideoTrack,
@@ -20,8 +13,6 @@ import {
VideoCaptureOptions,
} from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
@@ -30,56 +21,17 @@ import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
export type ToggleSource = Exclude<
Track.Source,
Track.Source.ScreenShareAudio | Track.Source.Unknown
>
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
}
import {
TOGGLE_DEVICE_CONFIG,
ToggleSource,
} from '../../config/ToggleDeviceConfig'
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
track?: LocalAudioTrack | LocalVideoTrack
initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource
source: ToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light'
hideMenu?: boolean
@@ -94,7 +46,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source]
const config = TOGGLE_DEVICE_CONFIG[props.source]
if (!config) {
throw new Error('Invalid source')
}
@@ -198,7 +150,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
: 'error2'
}
>
<RiArrowDownSLine />
<RiArrowUpSLine />
</Button>
<MenuList
items={devices.map((d) => ({
@@ -3,7 +3,6 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import { PermissionNeededButton } from './PermissionNeededButton'
import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
@@ -15,13 +14,15 @@ import {
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../config/ToggleDeviceConfig'
export type ToggleDeviceProps = {
enabled: boolean
isPermissionDeniedOrPrompted?: boolean
toggle: () => void
config: SelectToggleDeviceConfig
config: ToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant']
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps>
}
@@ -30,6 +31,7 @@ export const ToggleDevice = ({
enabled,
toggle,
variant = 'primaryDark',
errorVariant = 'error2',
toggleButtonProps,
isPermissionDeniedOrPrompted,
}: ToggleDeviceProps) => {
@@ -72,7 +74,9 @@ export const ToggleDevice = ({
{isPermissionDeniedOrPrompted && <PermissionNeededButton />}
<ToggleButton
isSelected={!enabled}
variant={enabled && !isPermissionDeniedOrPrompted ? variant : 'error2'}
variant={
enabled && !isPermissionDeniedOrPrompted ? variant : errorVariant
}
shySelected
onPress={() =>
isPermissionDeniedOrPrompted ? openPermissionsDialog() : toggle()
@@ -0,0 +1,52 @@
import { Track } from 'livekit-client'
import {
RemixiconComponentType,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Shortcut } from '@/features/shortcuts/types'
export type ToggleSource = Exclude<
Track.Source,
| Track.Source.ScreenShareAudio
| Track.Source.Unknown
| Track.Source.ScreenShare
>
export type ToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type ToggleDeviceConfigMap = {
[key in ToggleSource]: ToggleDeviceConfig
}
export const TOGGLE_DEVICE_CONFIG = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
} as const satisfies ToggleDeviceConfigMap
@@ -1,6 +1,8 @@
import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices'
import type { VideoResolution } from '@/stores/userChoices'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
import type { VideoQuality } from 'livekit-client'
export function usePersistentUserChoices() {
const userChoicesSnap = useSnapshot(userChoicesStore)
@@ -16,9 +18,18 @@ export function usePersistentUserChoices() {
saveAudioInputDeviceId: (deviceId: string) => {
userChoicesStore.audioDeviceId = deviceId
},
saveAudioOutputDeviceId: (deviceId: string) => {
userChoicesStore.audioOutputDeviceId = deviceId
},
saveVideoInputDeviceId: (deviceId: string) => {
userChoicesStore.videoDeviceId = deviceId
},
saveVideoPublishResolution: (resolution: VideoResolution) => {
userChoicesStore.videoPublishResolution = resolution
},
saveVideoSubscribeQuality: (quality: VideoQuality) => {
userChoicesStore.videoSubscribeQuality = quality
},
saveUsername: (username: string) => {
userChoicesStore.username = username
},
@@ -0,0 +1,18 @@
import { useEffect } from 'react'
export const useResolveDefaultDeviceId = <
T extends { getDeviceId(): Promise<string | undefined> },
>(
currentId: string,
track: T | undefined,
save: (id: string) => void
) => {
useEffect(() => {
if (currentId !== 'default' || !track) return
const resolveDefaultDeviceId = async () => {
const actualDeviceId = await track.getDeviceId()
if (actualDeviceId && actualDeviceId !== 'default') save(actualDeviceId)
}
resolveDefaultDeviceId()
}, [currentId, track, save])
}
@@ -0,0 +1,51 @@
import { useEffect } from 'react'
import { usePersistentUserChoices } from './usePersistentUserChoices'
import { useRoomContext } from '@livekit/components-react'
import {
RemoteParticipant,
RemoteTrackPublication,
RoomEvent,
Track,
VideoQuality,
} from 'livekit-client'
/**
* This hook sets initial video quality for new participants as they join.
* LiveKit doesn't allow handling video quality preferences at the room level.
*/
export const useVideoResolutionSubscription = () => {
const {
userChoices: { videoSubscribeQuality },
} = usePersistentUserChoices()
const room = useRoomContext()
useEffect(() => {
if (!room) return
const handleTrackPublished = (
publication: RemoteTrackPublication,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_participant: RemoteParticipant
) => {
// By default, the maximum quality is set to high
if (
videoSubscribeQuality === undefined ||
videoSubscribeQuality === VideoQuality.HIGH
)
return
if (
publication.kind === Track.Kind.Video &&
publication.source !== Track.Source.ScreenShare
) {
publication.setVideoQuality(videoSubscribeQuality)
}
}
room.on(RoomEvent.TrackPublished, handleTrackPublished)
return () => {
room.off(RoomEvent.TrackPublished, handleTrackPublished)
}
}, [room, videoSubscribeQuality])
}
@@ -32,6 +32,7 @@ import { RecordingStateToast } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
const LayoutWrapper = styled(
'div',
@@ -77,6 +78,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
React.useRef<TrackReferenceOrPlaceholder | null>(null)
useConnectionObserver()
useVideoResolutionSubscription()
const tracks = useTracks(
[
@@ -9,11 +9,13 @@ import {
RiNotification3Line,
RiSettings3Line,
RiSpeakerLine,
RiVideoOnLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab'
import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
@@ -69,7 +71,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{t('dialog.heading')}
</Heading>
)}
<TabList border={false} aria-label="Chat log orientation example">
<TabList border={false}>
<Tab icon highlight id="1">
<RiAccountCircleLine />
{isWideScreen && t('tabs.account')}
@@ -79,10 +81,14 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{isWideScreen && t('tabs.audio')}
</Tab>
<Tab icon highlight id="3">
<RiVideoOnLine />
{isWideScreen && t('tabs.video')}
</Tab>
<Tab icon highlight id="4">
<RiSettings3Line />
{isWideScreen && t('tabs.general')}
</Tab>
<Tab icon highlight id="4">
<Tab icon highlight id="5">
<RiNotification3Line />
{isWideScreen && t('tabs.notifications')}
</Tab>
@@ -91,8 +97,9 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<div className={tabPanelContainerStyle}>
<AccountTab id="1" onOpenChange={props.onOpenChange} />
<AudioTab id="2" />
<GeneralTab id="3" />
<NotificationsTab id="4" />
<VideoTab id="3" />
<GeneralTab id="4" />
<NotificationsTab id="5" />
</div>
</Tabs>
</Dialog>
@@ -1,4 +1,4 @@
import { DialogProps, Field, H, Switch, Text } from '@/primitives'
import { DialogProps, Field, Switch, Text } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import {
@@ -9,78 +9,11 @@ import {
import { isSafari } from '@/utils/livekit'
import { useTranslation } from 'react-i18next'
import { SoundTester } from '@/components/SoundTester'
import { HStack } from '@/styled-system/jsx'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import posthog from 'posthog-js'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
type RowWrapperProps = {
heading: string
children: ReactNode[]
beta?: boolean
}
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: '#E8EDFF',
color: '#0063CB',
fontSize: '12px',
fontWeight: 500,
margin: '0 0 0.9375rem 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
marginTop: { base: '10px', sm: '5px' },
})}
>
Beta
</span>
)
const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
return (
<>
<HStack>
<H lvl={2}>{heading}</H>
{beta && <BetaBadge />}
</HStack>
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
{children[0]}
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
>
{children[1]}
</div>
</HStack>
</>
)
}
import posthog from 'posthog-js'
import { RowWrapper } from './layout/RowWrapper'
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
@@ -95,6 +28,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
userChoices: { noiseReductionEnabled },
saveAudioInputDeviceId,
saveNoiseReductionEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const isSpeaking = useIsSpeaking(localParticipant)
@@ -183,9 +117,10 @@ export const AudioTab = ({ id }: AudioTabProps) => {
defaultSelectedKey={
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
}
onSelectionChange={async (key) =>
onSelectionChange={(key) => {
setActiveMediaDeviceOut(key as string)
}
saveAudioOutputDeviceId(key as string)
}}
{...disabledProps}
style={{
minWidth: 0,
@@ -0,0 +1,238 @@
import { DialogProps, Field } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
import { useCallback, useEffect, useState } from 'react'
import { css } from '@/styled-system/css'
import {
createLocalVideoTrack,
LocalVideoTrack,
Track,
VideoPresets,
VideoQuality,
} from 'livekit-client'
import { BackgroundProcessorFactory } from '@/features/rooms/livekit/components/blur'
import { VideoResolution } from '@/stores/userChoices'
import { RowWrapper } from './layout/RowWrapper'
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
type DeviceItems = Array<{ value: string; label: string }>
export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant, remoteParticipants } = useRoomContext()
const {
userChoices: {
videoDeviceId,
processorSerialized,
videoPublishResolution,
videoSubscribeQuality,
},
saveVideoInputDeviceId,
saveVideoPublishResolution,
saveVideoSubscribeQuality,
} = usePersistentUserChoices()
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
null
)
const videoCallbackRef = useCallback((element: HTMLVideoElement | null) => {
setVideoElement(element)
}, [])
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
useMediaDeviceSelect({ kind: 'videoinput' })
const itemsIn: DeviceItems = devicesIn.map((d) => ({
value: d.deviceId,
label: d.label,
}))
// The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for camera permissions
// may raise an error. As a workaround, we infer camera permission status by checking if the list of camera input
// devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted camera access.
const isCamEnabled = devicesIn?.length > 0
const disabledProps = isCamEnabled
? {}
: {
placeholder: t('permissionsRequired'),
isDisabled: true,
}
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
const videoPublication = localParticipant.getTrackPublication(
Track.Source.Camera
)
const videoTrack = videoPublication?.track
if (videoTrack) {
saveVideoPublishResolution(key)
await videoTrack.restartTrack({
resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId },
processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
})
}
}
/**
* Updates video quality for all existing remote video tracks when user preference changes.
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
* so this function applies the selected quality to all existing remote video tracks.
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
*/
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
remoteParticipants.forEach((participant) => {
participant.videoTrackPublications.forEach((publication) => {
if (publication.videoQuality !== selectedQuality) {
publication.setVideoQuality(selectedQuality)
}
})
})
}
useEffect(() => {
let videoTrack: LocalVideoTrack | null = null
const setUpVideoTrack = async () => {
if (videoElement) {
videoTrack = await createLocalVideoTrack({ deviceId: videoDeviceId })
videoTrack.attach(videoElement)
}
}
setUpVideoTrack()
return () => {
if (videoElement && videoTrack) {
videoTrack.detach()
videoTrack.stop()
}
}
}, [videoDeviceId, videoElement])
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('camera.heading')}>
<Field
type="select"
label={t('camera.label')}
items={itemsIn}
selectedKey={videoDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceIn(key as string)
saveVideoInputDeviceId(key as string)
}}
{...disabledProps}
style={{
width: '100%',
}}
/>
<div
role="status"
aria-label={t(
`camera.previewAriaLabel.${localParticipant.isCameraEnabled ? 'enabled' : 'disabled'}`
)}
>
{localParticipant.isCameraEnabled ? (
<>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoCallbackRef}
width="160px"
height="56px"
style={{
display: !localParticipant.isCameraEnabled
? 'none'
: undefined,
}}
className={css({
transform: 'rotateY(180deg)',
height: '69px',
width: '160px',
})}
disablePictureInPicture
disableRemotePlayback
/>
</>
) : (
<span
className={css({
display: 'flex',
justifyContent: 'center',
textAlign: 'center',
})}
>
{t('camera.disabled')}
</span>
)}
</div>
</RowWrapper>
<RowWrapper heading={t('resolution.heading')}>
<Field
type="select"
label={t('resolution.publish.label')}
items={[
{
value: 'h720',
label: `${t('resolution.publish.items.high')} (720p)`,
},
{
value: 'h360',
label: `${t('resolution.publish.items.medium')} (360p)`,
},
{
value: 'h180',
label: `${t('resolution.publish.items.low')} (180p)`,
},
]}
selectedKey={videoPublishResolution}
onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution)
}}
style={{
width: '100%',
}}
/>
<></>
</RowWrapper>
<RowWrapper>
<Field
type="select"
label={t('resolution.subscribe.label')}
items={[
{
value: VideoQuality.HIGH.toString(),
label: t('resolution.subscribe.items.high'),
},
{
value: VideoQuality.MEDIUM.toString(),
label: t('resolution.subscribe.items.medium'),
},
{
value: VideoQuality.LOW.toString(),
label: t('resolution.subscribe.items.low'),
},
]}
selectedKey={videoSubscribeQuality?.toString()}
onSelectionChange={(key) => {
if (key == undefined) return
const selectedQuality = Number(String(key))
saveVideoSubscribeQuality(selectedQuality)
updateExistingRemoteVideoQuality(selectedQuality)
}}
style={{
width: '100%',
}}
/>
<></>
</RowWrapper>
</TabPanel>
)
}
@@ -0,0 +1,71 @@
import { ReactNode } from 'react'
import { H } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
export type RowWrapperProps = {
heading?: string
children: ReactNode[]
beta?: boolean
}
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: '#E8EDFF',
color: '#0063CB',
fontSize: '12px',
fontWeight: 500,
margin: '0 0 0.9375rem 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
marginTop: { base: '10px', sm: '5px' },
})}
>
Beta
</span>
)
export const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
return (
<>
{heading && (
<HStack>
<H lvl={2}>{heading}</H>
{beta && <BetaBadge />}
</HStack>
)}
<HStack
gap={0}
style={{
flexWrap: 'wrap',
}}
>
<div
style={{
flex: '1 1 215px',
minWidth: 0,
}}
>
{children[0]}
</div>
<div
style={{
width: '10rem',
justifyContent: 'center',
display: 'flex',
paddingLeft: '1.5rem',
}}
>
{children[1]}
</div>
</HStack>
</>
)
}
+11
View File
@@ -8,8 +8,14 @@
"back": "Dem Meeting erneut beitreten"
},
"join": {
"selectDevice": {
"loading": "Laden…",
"select": "Wählen Sie einen Wert",
"permissionsNeeded": "Genehmigung erforderlich"
},
"videoinput": {
"choose": "Kamera auswählen",
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"label": "Kamera",
@@ -17,10 +23,15 @@
},
"audioinput": {
"choose": "Mikrofon auswählen",
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"label": "Mikrofon"
},
"audiooutput": {
"choose": "Lautsprecher auswählen",
"permissionsNeeded": "Lautsprecher auswählen - genehmigung erforderlich"
},
"effects": {
"description": "Effekte anwenden",
"title": "Effekte",
+32
View File
@@ -30,6 +30,37 @@
},
"permissionsRequired": "Berechtigungen erforderlich"
},
"video": {
"camera": {
"heading": "Kamera",
"label": "Wählen Sie Ihre Videoeingabe aus",
"disabled": "Kamera deaktiviert",
"previewAriaLabel": {
"enabled": "Videovorschau aktiviert",
"disabled": "Videovorschau deaktiviert"
}
},
"resolution": {
"heading": "Auflösung",
"publish": {
"label": "Wählen Sie Ihre maximale Sendeauflösung (max.)",
"items": {
"high": "Hohe Auflösung",
"medium": "Standardauflösung",
"low": "Niedrige Auflösung"
}
},
"subscribe": {
"label": "Wählen Sie Ihre Empfangsauflösung (max.)",
"items": {
"high": "Hohe Auflösung (automatisch)",
"medium": "Standardauflösung",
"low": "Niedrige Auflösung"
}
}
},
"permissionsRequired": "Berechtigungen erforderlich"
},
"notifications": {
"heading": "Tonbenachrichtigungen",
"label": "Tonbenachrichtigungen für",
@@ -54,6 +85,7 @@
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Video",
"general": "Allgemein",
"notifications": "Benachrichtigungen"
}
+11
View File
@@ -8,8 +8,14 @@
"back": "Rejoin the meeting"
},
"join": {
"selectDevice": {
"loading": "Loading…",
"select": "Select a value",
"permissionsNeeded": "Permission needed"
},
"videoinput": {
"choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed",
"disable": "Disable camera",
"enable": "Enable camera",
"label": "Camera",
@@ -17,10 +23,15 @@
},
"audioinput": {
"choose": "Select microphone",
"permissionsNeeded": "Select microphone - permission needed",
"disable": "Disable microphone",
"enable": "Enable microphone",
"label": "Microphone"
},
"audiooutput": {
"choose": "Select speaker",
"permissionsNeeded": "Select speaker - permission needed"
},
"effects": {
"description": "Apply effects",
"title": "Effects",
+32
View File
@@ -30,6 +30,37 @@
},
"permissionsRequired": "Permissions required"
},
"video": {
"camera": {
"heading": "Camera",
"label": "Select your video input",
"disabled": "Camera disabled",
"previewAriaLabel": {
"enabled": "Video preview enabled",
"disabled": "Video preview disabled"
}
},
"resolution": {
"heading": "Resolution",
"publish": {
"label": "Select your sending resolution (max.)",
"items": {
"high": "High definition",
"medium": "Standard definition",
"low": "Low definition"
}
},
"subscribe": {
"label": "Select your reception resolution (max.)",
"items": {
"high": "High definition (automatic)",
"medium": "Standard definition",
"low": "Low definition"
}
}
},
"permissionsRequired": "Permissions required"
},
"notifications": {
"heading": "Sound notifications",
"label": "sound notifications for",
@@ -54,6 +85,7 @@
"tabs": {
"account": "Profile",
"audio": "Audio",
"video": "Video",
"general": "General",
"notifications": "Notifications"
}
+11
View File
@@ -8,8 +8,14 @@
"back": "Réintégrer la réunion"
},
"join": {
"selectDevice": {
"loading": "Chargement…",
"select": "Sélectionnez une valeur",
"permissionsNeeded": "Autorisations nécessaires"
},
"videoinput": {
"choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
"disable": "Désactiver la webcam",
"enable": "Activer la webcam",
"label": "Webcam",
@@ -17,10 +23,15 @@
},
"audioinput": {
"choose": "Choisir le micro",
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
"disable": "Désactiver le micro",
"enable": "Activer le micro",
"label": "Microphone"
},
"audiooutput": {
"choose": "Choisir le haut-parleur",
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
},
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Effets d'arrière plan",
+32
View File
@@ -30,6 +30,37 @@
},
"permissionsRequired": "Autorisations nécessaires"
},
"video": {
"camera": {
"heading": "Caméra",
"label": "Sélectionner votre entrée vidéo",
"disabled": "Caméra désactivée",
"previewAriaLabel": {
"enabled": "Aperçu vidéo activé",
"disabled": "Aperçu vidéo désactivé"
}
},
"resolution": {
"heading": "Résolution",
"publish": {
"label": "Sélectionner votre résolution d'envoi (max.)",
"items": {
"high": "Haute définition",
"medium": "Définition standard",
"low": "Basse définition"
}
},
"subscribe": {
"label": "Sélectionner votre résolution de réception (max.)",
"items": {
"high": "Haute définition (automatique)",
"medium": "Définition standard",
"low": "Basse définition"
}
}
},
"permissionsRequired": "Autorisations nécessaires"
},
"notifications": {
"heading": "Notifications sonores",
"label": "la notification sonore pour",
@@ -54,6 +85,7 @@
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Vidéo",
"general": "Général",
"notifications": "Notifications"
}
+11
View File
@@ -8,8 +8,14 @@
"back": "Sluit weer bij de vergadering aan"
},
"join": {
"selectDevice": {
"loading": "Bezig met laden…",
"select": "Selecteer een waarde",
"permissionsNeeded": "Toestemming vereist"
},
"videoinput": {
"choose": "Selecteer camera",
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
"disable": "Camera uitschakelen",
"enable": "Camera inschakelen",
"label": "Camera",
@@ -17,10 +23,15 @@
},
"audioinput": {
"choose": "Selecteer microfoon",
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
"label": "Microfoon"
},
"audiooutput": {
"choose": "Selecteer luidspreker",
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
},
"effects": {
"description": "Pas effecten toe",
"title": "Effecten",
+32
View File
@@ -30,6 +30,37 @@
},
"permissionsRequired": "Machtigingen vereist"
},
"video": {
"camera": {
"heading": "Camera",
"label": "Selecteer uw video-ingang",
"disabled": "Camera uitgeschakeld",
"previewAriaLabel": {
"enabled": "Videovoorbeeld ingeschakeld",
"disabled": "Videovoorbeeld uitgeschakeld"
}
},
"resolution": {
"heading": "Resolutie",
"publish": {
"label": "Selecteer uw verzendresolutie (max.)",
"items": {
"high": "Hoge definitie",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
}
},
"subscribe": {
"label": "Selecteer uw ontvangstresolutie (max.)",
"items": {
"high": "Hoge definitie (automatisch)",
"medium": "Standaarddefinitie",
"low": "Lage definitie"
}
}
},
"permissionsRequired": "Machtigingen vereist"
},
"notifications": {
"heading": "Geluidsmeldingen",
"label": "Geluidsmeldingen voor",
@@ -54,6 +85,7 @@
"tabs": {
"account": "Profiel",
"audio": "Audio",
"video": "Video",
"general": "Algemeen",
"notifications": "Meldingen"
}
@@ -251,6 +251,20 @@ export const buttonRecipe = cva({
color: 'error.300',
},
},
errorCircle: {
backgroundColor: 'error.500',
width: '56px',
height: '56px',
borderRadius: '100%',
color: 'white',
'&[data-hovered]': {
backgroundColor: 'error.600',
},
'&[data-pressed]': {
backgroundColor: 'error.700',
color: 'error.200',
},
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',
+9
View File
@@ -5,15 +5,24 @@ import {
saveUserChoices,
LocalUserChoices as LocalUserChoicesLK,
} from '@livekit/components-core'
import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
noiseReductionEnabled?: boolean
audioOutputDeviceId?: string
videoPublishResolution?: VideoResolution
videoSubscribeQuality?: VideoQuality
}
function getUserChoicesState(): LocalUserChoices {
return {
noiseReductionEnabled: false,
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
videoPublishResolution: 'h720',
videoSubscribeQuality: VideoQuality.HIGH,
...loadUserChoices(),
}
}