From 390dc22c967eed2c99d8b7bf19d128bf6bd1a463 Mon Sep 17 00:00:00 2001 From: Ryan Emmick Date: Wed, 11 Feb 2026 01:43:56 -0600 Subject: [PATCH 1/9] feat: configurable media quality via config.json Add a `media_quality` section to config.json that allows self-hosters to configure video codec, resolution, bitrate, framerate, and simulcast layers for both camera and screen sharing. This addresses the long-standing request in #249 for configurable media quality settings. The LiveKit SDK already supports all of these options; this change exposes them through the existing config system. New config.json fields: - media_quality.video_codec: preferred codec (vp8/vp9/h264/av1) - media_quality.video: camera resolution, bitrate, framerate, simulcast layers - media_quality.screen_share: screen share resolution, bitrate, framerate, simulcast layers (enables 3+ layer simulcast for screen sharing) All fields are optional and fall back to the existing defaults (VP8, 720p camera, 1080p screen share) when not specified. Signed-off-by: Ryan Emmick --- src/config/ConfigOptions.ts | 55 ++++++ src/livekit/options.ts | 181 ++++++++++++++---- .../CallViewModel/localMember/LocalMember.ts | 10 + .../remoteMembers/ConnectionFactory.ts | 12 +- 4 files changed, 217 insertions(+), 41 deletions(-) diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 1b120546cd..914efbd2b6 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -85,6 +85,61 @@ export interface ConfigOptions { */ ssla?: string; + /** + * Media quality settings for video and screen sharing. + * These override the hardcoded LiveKit defaults. + */ + media_quality?: { + /** + * Video codec preference. The server must also have the codec enabled. + * @default "vp8" + */ + video_codec?: "vp8" | "vp9" | "h264" | "av1"; + + /** + * Camera video settings. + */ + video?: { + /** Max resolution height in pixels (e.g. 720, 1080, 1440). @default 720 */ + max_resolution?: number; + /** Max bitrate in bits per second. @default 1700000 */ + max_bitrate?: number; + /** Max framerate. @default 30 */ + max_framerate?: number; + /** + * Simulcast layers as an array of {height, bitrate} objects, + * ordered from lowest to highest quality. + * @default [{height: 180, bitrate: 160000}, {height: 360, bitrate: 450000}] + */ + simulcast_layers?: Array<{ + height: number; + bitrate: number; + }>; + }; + + /** + * Screen share settings. + */ + screen_share?: { + /** Max resolution height in pixels. @default 1080 */ + max_resolution?: number; + /** Max bitrate in bits per second. @default 5000000 */ + max_bitrate?: number; + /** Max framerate. @default 30 */ + max_framerate?: number; + /** + * Simulcast layers for screen sharing as an array of {height, bitrate, framerate} objects, + * ordered from lowest to highest quality. If omitted, LiveKit SDK defaults apply (1 extra + * layer at half resolution). + */ + simulcast_layers?: Array<{ + height: number; + bitrate: number; + framerate?: number; + }>; + }; + }; + media_devices?: { /** * Defines whether participants should start with audio enabled by default. diff --git a/src/livekit/options.ts b/src/livekit/options.ts index 1d4cad774e..8b98d61133 100644 --- a/src/livekit/options.ts +++ b/src/livekit/options.ts @@ -13,42 +13,149 @@ import { type TrackPublishDefaults, type VideoPreset, VideoPresets, + VideoPreset as VideoPresetClass, } from "livekit-client"; -const defaultLiveKitPublishOptions: TrackPublishDefaults = { - audioPreset: AudioPresets.music, - dtx: true, - // disable red because the livekit server strips out red packets for clients - // that don't support it (firefox) but of course that doesn't work with e2ee. - red: false, - forceStereo: false, - simulcast: true, - videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360] as VideoPreset[], - screenShareEncoding: ScreenSharePresets.h1080fps30.encoding, - stopMicTrackOnMute: false, - videoCodec: "vp8", - videoEncoding: VideoPresets.h720.encoding, - backupCodec: { codec: "vp8", encoding: VideoPresets.h720.encoding }, -} as const; - -export const defaultLiveKitOptions: RoomOptions = { - // automatically manage subscribed video quality - adaptiveStream: true, - - // optimize publishing bandwidth and CPU for published tracks - dynacast: true, - - // capture settings - videoCaptureDefaults: { - resolution: VideoPresets.h720.resolution, - }, - - // publish settings - publishDefaults: defaultLiveKitPublishOptions, - - // default LiveKit options that seem to be sane - stopLocalTrackOnUnpublish: true, - reconnectPolicy: new DefaultReconnectPolicy(), - disconnectOnPageLeave: true, - webAudioMix: false, -}; +import { Config } from "../config/Config"; +import type { ConfigOptions } from "../config/ConfigOptions"; + +/** + * Find the closest matching VideoPreset for a given height. + */ +function videoPresetForHeight(height: number): VideoPreset { + if (height <= 180) return VideoPresets.h180; + if (height <= 360) return VideoPresets.h360; + if (height <= 540) return VideoPresets.h540; + if (height <= 720) return VideoPresets.h720; + if (height <= 1080) return VideoPresets.h1080; + if (height <= 1440) return VideoPresets.h1440; + return VideoPresets.h2160; +} + +/** + * Build LiveKit publish options from config, falling back to sensible defaults. + */ +function buildPublishOptions( + mediaQuality: ConfigOptions["media_quality"], +): TrackPublishDefaults { + const videoConf = mediaQuality?.video; + const screenConf = mediaQuality?.screen_share; + const codec = mediaQuality?.video_codec ?? "vp8"; + + // Camera video encoding + const videoHeight = videoConf?.max_resolution ?? 720; + const basePreset = videoPresetForHeight(videoHeight); + const videoEncoding = { + maxBitrate: videoConf?.max_bitrate ?? basePreset.encoding.maxBitrate, + maxFramerate: videoConf?.max_framerate ?? basePreset.encoding.maxFramerate, + }; + + // Camera simulcast layers + let videoSimulcastLayers: VideoPreset[]; + if (videoConf?.simulcast_layers) { + videoSimulcastLayers = videoConf.simulcast_layers.map( + (layer) => + new VideoPresetClass( + Math.round((layer.height * 16) / 9), + layer.height, + layer.bitrate, + videoConf?.max_framerate ?? 30, + ), + ); + } else { + videoSimulcastLayers = [VideoPresets.h180, VideoPresets.h360]; + } + + // Screen share encoding + const screenHeight = screenConf?.max_resolution ?? 1080; + const screenBasePreset = + screenHeight <= 720 + ? ScreenSharePresets.h720fps30 + : ScreenSharePresets.h1080fps30; + const screenShareEncoding = { + maxBitrate: screenConf?.max_bitrate ?? screenBasePreset.encoding.maxBitrate, + maxFramerate: + screenConf?.max_framerate ?? screenBasePreset.encoding.maxFramerate, + }; + + // Screen share simulcast layers + let screenShareSimulcastLayers: VideoPreset[] | undefined; + if (screenConf?.simulcast_layers) { + screenShareSimulcastLayers = screenConf.simulcast_layers.map( + (layer) => + new VideoPresetClass( + Math.round((layer.height * 16) / 9), + layer.height, + layer.bitrate, + layer.framerate ?? screenConf?.max_framerate ?? 30, + ), + ); + } + + return { + audioPreset: AudioPresets.music, + dtx: true, + // disable red because the livekit server strips out red packets for clients + // that don't support it (firefox) but of course that doesn't work with e2ee. + red: false, + forceStereo: false, + simulcast: true, + videoSimulcastLayers: videoSimulcastLayers as VideoPreset[], + screenShareEncoding, + ...(screenShareSimulcastLayers && { + screenShareSimulcastLayers: screenShareSimulcastLayers as VideoPreset[], + }), + stopMicTrackOnMute: false, + videoCodec: codec, + videoEncoding, + backupCodec: { codec: "vp8", encoding: videoEncoding }, + } as TrackPublishDefaults; +} + +/** + * Build LiveKit RoomOptions from config. + * Call this after Config.init() has resolved. + */ +export function buildLiveKitOptions( + mediaQuality?: ConfigOptions["media_quality"], +): RoomOptions { + const videoHeight = mediaQuality?.video?.max_resolution ?? 720; + const basePreset = videoPresetForHeight(videoHeight); + + return { + // automatically manage subscribed video quality + adaptiveStream: true, + + // optimize publishing bandwidth and CPU for published tracks + dynacast: true, + + // capture settings + videoCaptureDefaults: { + resolution: basePreset.resolution, + }, + + // publish settings + publishDefaults: buildPublishOptions(mediaQuality), + + // default LiveKit options that seem to be sane + stopLocalTrackOnUnpublish: true, + reconnectPolicy: new DefaultReconnectPolicy(), + disconnectOnPageLeave: true, + webAudioMix: false, + }; +} + +/** + * Get LiveKit options, reading from the loaded Config singleton. + * Falls back to defaults if Config is not yet initialized. + */ +export function getLiveKitOptions(): RoomOptions { + try { + return buildLiveKitOptions(Config.get().media_quality); + } catch { + return buildLiveKitOptions(); + } +} + +// Keep backward-compatible export for existing consumers +export const defaultLiveKitOptions: RoomOptions = buildLiveKitOptions(); diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 10a0076744..5cbe855a90 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -12,6 +12,8 @@ import { type ScreenShareCaptureOptions, RoomEvent, MediaDeviceFailure, + type ScreenSharePreset, + VideoPreset as VideoPresetClass, } from "livekit-client"; import { observeParticipantEvents } from "@livekit/components-core"; import { @@ -661,6 +663,7 @@ export const createLocalMembership$ = ({ !getUrlParams().hideScreensharing ) { toggleScreenSharing = (): void => { + const screenConf = Config.get().media_quality?.screen_share; const screenshareSettings: ScreenShareCaptureOptions = { // Screen share audio shouldn't have any filtering. // "echoCancellation" is purposely excluded, as setting it to @@ -674,6 +677,13 @@ export const createLocalMembership$ = ({ selfBrowserSurface: "include", surfaceSwitching: "include", systemAudio: "include", + ...(screenConf?.max_resolution && { + resolution: { + width: Math.round((screenConf.max_resolution * 16) / 9), + height: screenConf.max_resolution, + frameRate: screenConf.max_framerate ?? 30, + }, + }), }; const targetScreenshareState = !sharingScreen$.value; logger.info( diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 38a09898b1..fb26d17800 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -27,7 +27,10 @@ import type { import type { MediaDevices } from "../../MediaDevices.ts"; import type { Behavior } from "../../Behavior.ts"; import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; -import { defaultLiveKitOptions } from "../../../livekit/options.ts"; +import { + defaultLiveKitOptions, + getLiveKitOptions, +} from "../../../livekit/options.ts"; // TODO evaluate if this should be done like the Publisher Factory export interface ConnectionFactory { @@ -138,15 +141,16 @@ function generateRoomOption({ echoCancellation: boolean; noiseSuppression: boolean; }): RoomOptions { + const liveKitOptions = getLiveKitOptions(); return { - ...defaultLiveKitOptions, + ...liveKitOptions, videoCaptureDefaults: { - ...defaultLiveKitOptions.videoCaptureDefaults, + ...liveKitOptions.videoCaptureDefaults, deviceId: devices.videoInput.selected$.value?.id, processor: processorState.processor, }, audioCaptureDefaults: { - ...defaultLiveKitOptions.audioCaptureDefaults, + ...liveKitOptions.audioCaptureDefaults, deviceId: devices.audioInput.selected$.value?.id, echoCancellation, noiseSuppression, From 342b61f6332014a8aab2e5240ea23a849df5764f Mon Sep 17 00:00:00 2001 From: Ryan Emmick Date: Wed, 11 Feb 2026 02:03:08 -0600 Subject: [PATCH 2/9] feat: add user-configurable screen share quality settings UI Adds a "Screen sharing" section to Settings > Video with controls for: - Resolution (576p to 4K) - Framerate (5-60 fps slider) - Bitrate (0.5-15 Mbps slider) - Codec (VP8/VP9/H.264/AV1) Gated behind an "Advanced screen share settings" toggle. When enabled, settings are passed to LiveKit's setScreenShareEnabled as both capture constraints and publish options. When disabled, falls back to config.json media_quality defaults. Settings are persisted in localStorage via the existing Setting system. The Slider component is extended with a tooltipFormatter prop for custom tooltip display. Inspired by pirosuki's advanced-screen-share-settings branch, but reimplemented cleanly: settings are read directly in LocalMember.ts (no signature changes), the existing Slider is extended (no component duplication), and proper form components are used throughout. Signed-off-by: Ryan Emmick --- src/Slider.tsx | 8 +- src/settings/SettingsModal.tsx | 112 ++++++++++++++++++ src/settings/settings.ts | 27 +++++ .../CallViewModel/localMember/LocalMember.ts | 59 +++++++-- 4 files changed, 195 insertions(+), 11 deletions(-) diff --git a/src/Slider.tsx b/src/Slider.tsx index c6520e4227..c2465874be 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -31,6 +31,11 @@ interface Props { max: number; step: number; disabled?: boolean; + /** + * Custom formatter for the tooltip label. If not provided, the value is + * displayed as a percentage. + */ + tooltipFormatter?: (value: number) => string; } /** @@ -46,6 +51,7 @@ export const Slider: FC = ({ max, step, disabled, + tooltipFormatter, }) => { const onValueChange = useCallback( ([v]: number[]) => onValueChangeProp(v), @@ -71,7 +77,7 @@ export const Slider: FC = ({ {/* Note: This is expected not to be visible on mobile.*/} - + diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 30ac36185a..6ccafec1e0 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -24,6 +24,12 @@ import { soundEffectVolume as soundEffectVolumeSetting, backgroundBlur as backgroundBlurSetting, developerMode, + advancedScreenShare as advancedScreenShareSetting, + screenShareResolution as screenShareResolutionSetting, + screenShareFramerate as screenShareFramerateSetting, + screenShareBitrate as screenShareBitrateSetting, + screenShareCodec as screenShareCodecSetting, + type VideoCodec, } from "./settings"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { Slider } from "../Slider"; @@ -98,6 +104,110 @@ export const SettingsModal: FC = ({ ); }; + const ScreenShareSettings: React.FC = (): ReactNode => { + const [advancedEnabled, setAdvancedEnabled] = useSetting( + advancedScreenShareSetting, + ); + const [resolution, setResolution] = useSetting( + screenShareResolutionSetting, + ); + const [framerate, setFramerate] = useSetting(screenShareFramerateSetting); + const [framerateRaw, setFramerateRaw] = useState(framerate); + const [bitrate, setBitrate] = useSetting(screenShareBitrateSetting); + const [bitrateRaw, setBitrateRaw] = useState(bitrate); + const [codec, setCodec] = useSetting(screenShareCodecSetting); + + return ( + <> +

{t("settings.screen_share_header", "Screen sharing")}

+ + setAdvancedEnabled(e.target.checked)} + /> + + {advancedEnabled && ( + <> + + setResolution(e.target.value)} + > + + + + + + + +
+ + `${v} fps`} + /> +
+
+ + + `${(v / 1_000_000).toFixed(1)} Mbps` + } + /> +
+ + setCodec(e.target.value as VideoCodec)} + > + + + + + + + + )} + + ); + }; + const devices = useMediaDevices(); useEffect(() => { if (open) devices.requestDeviceNames(); @@ -183,6 +293,8 @@ export const SettingsModal: FC = ({ + + ), }; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 917c79f162..141d4449b7 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -150,3 +150,30 @@ export const customLivekitUrl = new Setting( "custom-livekit-url", null, ); + +export type VideoCodec = "vp8" | "vp9" | "h264" | "av1"; + +export const advancedScreenShare = new Setting( + "advanced-screen-share", + false, +); + +export const screenShareResolution = new Setting( + "screen-share-resolution", + "1920x1080", +); + +export const screenShareFramerate = new Setting( + "screen-share-framerate", + 30, +); + +export const screenShareBitrate = new Setting( + "screen-share-bitrate", + 5_000_000, +); + +export const screenShareCodec = new Setting( + "screen-share-codec", + "vp9", +); diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 5cbe855a90..7a954ae732 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -10,10 +10,9 @@ import { ParticipantEvent, type LocalParticipant, type ScreenShareCaptureOptions, + type TrackPublishOptions, RoomEvent, MediaDeviceFailure, - type ScreenSharePreset, - VideoPreset as VideoPresetClass, } from "livekit-client"; import { observeParticipantEvents } from "@livekit/components-core"; import { @@ -55,7 +54,14 @@ import { import { ElementWidgetActions, widget } from "../../../widget.ts"; import { getUrlParams } from "../../../UrlParams.ts"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; -import { MatrixRTCMode } from "../../../settings/settings.ts"; +import { + MatrixRTCMode, + advancedScreenShare, + screenShareResolution, + screenShareFramerate, + screenShareBitrate, + screenShareCodec, +} from "../../../settings/settings.ts"; import { Config } from "../../../config/Config.ts"; import { ConnectionState, @@ -663,7 +669,6 @@ export const createLocalMembership$ = ({ !getUrlParams().hideScreensharing ) { toggleScreenSharing = (): void => { - const screenConf = Config.get().media_quality?.screen_share; const screenshareSettings: ScreenShareCaptureOptions = { // Screen share audio shouldn't have any filtering. // "echoCancellation" is purposely excluded, as setting it to @@ -677,14 +682,44 @@ export const createLocalMembership$ = ({ selfBrowserSurface: "include", surfaceSwitching: "include", systemAudio: "include", - ...(screenConf?.max_resolution && { - resolution: { + }; + + let publishOptions: TrackPublishOptions | undefined; + + if (advancedScreenShare.getValue()) { + // User has advanced screen share settings enabled + const resParts = screenShareResolution.getValue().split("x"); + const width = Number(resParts[0]); + const height = Number(resParts[1]); + const fps = screenShareFramerate.getValue(); + const bps = screenShareBitrate.getValue(); + const codec = screenShareCodec.getValue(); + + screenshareSettings.resolution = { + width, + height, + frameRate: fps, + }; + + publishOptions = { + screenShareEncoding: { + maxBitrate: bps, + maxFramerate: fps, + }, + videoCodec: codec, + }; + } else { + // Fall back to config.json settings if available + const screenConf = Config.get().media_quality?.screen_share; + if (screenConf?.max_resolution) { + screenshareSettings.resolution = { width: Math.round((screenConf.max_resolution * 16) / 9), height: screenConf.max_resolution, frameRate: screenConf.max_framerate ?? 30, - }, - }), - }; + }; + } + } + const targetScreenshareState = !sharingScreen$.value; logger.info( `toggleScreenSharing called. Switching ${ @@ -700,7 +735,11 @@ export const createLocalMembership$ = ({ // is still initializing or publishing tracks, because there's no // technical reason to disallow this. LiveKit will publish if it can. participant$.value - ?.setScreenShareEnabled(targetScreenshareState, screenshareSettings) + ?.setScreenShareEnabled( + targetScreenshareState, + screenshareSettings, + publishOptions, + ) .catch(logger.error); }; } From bc38d15b6db44a2359e77b1505593246d2cfa799 Mon Sep 17 00:00:00 2001 From: Ryan Emmick Date: Wed, 11 Feb 2026 02:55:44 -0600 Subject: [PATCH 3/9] fix: replace InputField type="select" with native select elements InputField only supports input/textarea, not select. Passing option children caused React to crash rendering children inside a void input. Signed-off-by: Ryan Emmick --- src/settings/SettingsModal.module.css | 19 +++++++++++++++ src/settings/SettingsModal.tsx | 33 ++++++++++++++------------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/settings/SettingsModal.module.css b/src/settings/SettingsModal.module.css index b07cb4c818..b710f9d1f6 100644 --- a/src/settings/SettingsModal.module.css +++ b/src/settings/SettingsModal.module.css @@ -33,3 +33,22 @@ Please see LICENSE in the repository root for full details. .volumeSlider > p { color: var(--cpd-color-text-secondary); } + +.volumeSlider > select { + display: block; + width: 100%; + padding: 8px 12px; + margin-top: var(--cpd-space-1x); + border: 1px solid var(--cpd-color-border-interactive-primary); + border-radius: 4px; + background-color: var(--cpd-color-bg-canvas-default); + color: var(--cpd-color-text-primary); + font-size: var(--font-size-body); + font-family: inherit; + cursor: pointer; +} + +.volumeSlider > select:focus { + outline: none; + border-color: var(--cpd-color-text-link-external); +} diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 6ccafec1e0..88918e76b7 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -138,14 +138,12 @@ export const SettingsModal: FC = ({ {advancedEnabled && ( <> - - + + +
- - + + + )} From b043230680cd196c99183ef3f6414823d2113834 Mon Sep 17 00:00:00 2001 From: Ryan Emmick Date: Wed, 11 Feb 2026 03:02:51 -0600 Subject: [PATCH 4/9] feat: camera quality settings, audio processing toggles, config-seeded defaults - Add camera video quality controls (resolution/framerate/bitrate/codec) to Settings > Video, mirroring the screen share settings UI - Add audio processing toggles (echo cancellation, noise suppression, auto gain control) to Settings > Audio, replacing URL-param-only controls - Display raw values inline on all sliders (framerate, bitrate, volume) - Add config-seeded defaults: config.json media_quality values now seed Setting defaults for users who haven't explicitly set preferences - Camera settings are applied when joining a call via ConnectionFactory Signed-off-by: Ryan Emmick --- src/initializer.tsx | 2 + src/settings/SettingsModal.module.css | 5 + src/settings/SettingsModal.tsx | 197 +++++++++++++++++- src/settings/settings.ts | 103 +++++++++ src/state/CallViewModel/CallViewModel.ts | 2 - .../remoteMembers/ConnectionFactory.ts | 64 ++++-- .../remoteMembers/ECConnectionFactory.test.ts | 17 +- src/widget.test.ts | 1 + src/widget.ts | 2 + 9 files changed, 362 insertions(+), 31 deletions(-) diff --git a/src/initializer.tsx b/src/initializer.tsx index 2bd6f5778a..fa57f3caf8 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -26,6 +26,7 @@ import { import { getUrlParams } from "./UrlParams"; import { Config } from "./config/Config"; +import { seedSettingsFromConfig } from "./settings/settings"; import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; import { initializeWidget } from "./widget"; @@ -220,6 +221,7 @@ export class Initializer { this.loadStates.config = LoadState.Loading; Config.init().then( () => { + seedSettingsFromConfig(Config.get().media_quality); this.loadStates.config = LoadState.Loaded; this.initStep(resolve); }, diff --git a/src/settings/SettingsModal.module.css b/src/settings/SettingsModal.module.css index b710f9d1f6..bff1a30eee 100644 --- a/src/settings/SettingsModal.module.css +++ b/src/settings/SettingsModal.module.css @@ -52,3 +52,8 @@ Please see LICENSE in the repository root for full details. outline: none; border-color: var(--cpd-color-text-link-external); } + +.settingValue { + font-weight: normal; + color: var(--cpd-color-text-secondary); +} diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 88918e76b7..74e58e05d4 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -29,6 +29,14 @@ import { screenShareFramerate as screenShareFramerateSetting, screenShareBitrate as screenShareBitrateSetting, screenShareCodec as screenShareCodecSetting, + advancedCamera as advancedCameraSetting, + cameraResolution as cameraResolutionSetting, + cameraFramerate as cameraFramerateSetting, + cameraBitrate as cameraBitrateSetting, + cameraCodec as cameraCodecSetting, + echoCancellationSetting, + noiseSuppressionSetting, + autoGainControlSetting, type VideoCodec, } from "./settings"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; @@ -157,6 +165,8 @@ export const SettingsModal: FC = ({
= ({
= ({ ); }; + const CameraSettings: React.FC = (): ReactNode => { + const [advancedEnabled, setAdvancedEnabled] = useSetting( + advancedCameraSetting, + ); + const [resolution, setResolution] = useSetting(cameraResolutionSetting); + const [framerate, setFramerate] = useSetting(cameraFramerateSetting); + const [framerateRaw, setFramerateRaw] = useState(framerate); + const [bitrate, setBitrate] = useSetting(cameraBitrateSetting); + const [bitrateRaw, setBitrateRaw] = useState(bitrate); + const [codec, setCodec] = useSetting(cameraCodecSetting); + + return ( + <> +

{t("settings.camera_header", "Camera quality")}

+ + setAdvancedEnabled(e.target.checked)} + /> + + {advancedEnabled && ( + <> +
+ + +
+
+ + `${v} fps`} + /> +
+
+ + + `${(v / 1_000_000).toFixed(1)} Mbps` + } + /> +
+
+ + +
+ + )} + + ); + }; + + const AudioProcessingSettings: React.FC = (): ReactNode => { + const [echoCancellation, setEchoCancellation] = useSetting( + echoCancellationSetting, + ); + const [noiseSuppression, setNoiseSuppression] = useSetting( + noiseSuppressionSetting, + ); + const [autoGainControl, setAutoGainControl] = useSetting( + autoGainControlSetting, + ); + + return ( + <> +

{t("settings.audio_processing_header", "Audio processing")}

+

+ {t( + "settings.audio_processing_description", + "Changes apply on next call join.", + )} +

+ + setEchoCancellation(e.target.checked)} + /> + + + setNoiseSuppression(e.target.checked)} + /> + + + setAutoGainControl(e.target.checked)} + /> + + + ); + }; + const devices = useMediaDevices(); useEffect(() => { if (open) devices.requestDeviceNames(); @@ -263,7 +448,13 @@ export const SettingsModal: FC = ({ />
- +

{t("settings.audio_tab.effect_volume_description")}

= ({ />
+ + ), }; @@ -295,6 +488,8 @@ export const SettingsModal: FC = ({ + + ), diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 141d4449b7..1ec26db602 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -20,6 +20,7 @@ export class Setting { this.key = `matrix-setting-${key}`; const storedValue = localStorage.getItem(this.key); + this.hasStoredValue = storedValue !== null; let initialValue = defaultValue; if (storedValue !== null) { try { @@ -39,6 +40,7 @@ export class Setting { } private readonly key: string; + private readonly hasStoredValue: boolean; private readonly _value$: BehaviorSubject; private readonly _lastUpdateReason$: BehaviorSubject; @@ -53,6 +55,17 @@ export class Setting { public readonly getValue = (): T => { return this._value$.getValue(); }; + + /** + * Update the setting's value from a config source, but only if the user + * hasn't explicitly set a value in localStorage. This lets admins set + * org-wide defaults in config.json that users can override. + */ + public seedFromConfig(value: T): void { + if (!this.hasStoredValue) { + this._value$.next(value); + } + } } /** @@ -177,3 +190,93 @@ export const screenShareCodec = new Setting( "screen-share-codec", "vp9", ); + +// Camera video quality settings +export const advancedCamera = new Setting("advanced-camera", false); + +export const cameraResolution = new Setting( + "camera-resolution", + "1280x720", +); + +export const cameraFramerate = new Setting("camera-framerate", 30); + +export const cameraBitrate = new Setting( + "camera-bitrate", + 1_700_000, +); + +export const cameraCodec = new Setting("camera-codec", "vp8"); + +// Audio processing settings +export const echoCancellationSetting = new Setting( + "echo-cancellation", + true, +); + +export const noiseSuppressionSetting = new Setting( + "noise-suppression", + true, +); + +export const autoGainControlSetting = new Setting( + "auto-gain-control", + true, +); + +/** + * Seed setting defaults from config.json's media_quality section. + * Call this after Config.init() has resolved. + * Only updates settings that the user hasn't explicitly set in localStorage. + */ +export function seedSettingsFromConfig( + mediaQuality: { + video_codec?: VideoCodec; + video?: { + max_resolution?: number; + max_bitrate?: number; + max_framerate?: number; + }; + screen_share?: { + max_resolution?: number; + max_bitrate?: number; + max_framerate?: number; + }; + } | undefined, +): void { + if (!mediaQuality) return; + + const codec = mediaQuality.video_codec; + if (codec) { + screenShareCodec.seedFromConfig(codec); + cameraCodec.seedFromConfig(codec); + } + + const screen = mediaQuality.screen_share; + if (screen) { + if (screen.max_resolution) { + const width = Math.round((screen.max_resolution * 16) / 9); + screenShareResolution.seedFromConfig(`${width}x${screen.max_resolution}`); + } + if (screen.max_framerate) { + screenShareFramerate.seedFromConfig(screen.max_framerate); + } + if (screen.max_bitrate) { + screenShareBitrate.seedFromConfig(screen.max_bitrate); + } + } + + const video = mediaQuality.video; + if (video) { + if (video.max_resolution) { + const width = Math.round((video.max_resolution * 16) / 9); + cameraResolution.seedFromConfig(`${width}x${video.max_resolution}`); + } + if (video.max_framerate) { + cameraFramerate.seedFromConfig(video.max_framerate); + } + if (video.max_bitrate) { + cameraBitrate.seedFromConfig(video.max_bitrate); + } + } +} diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index e298bcfdb7..d587530b19 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -488,8 +488,6 @@ export function createCallViewModel$( livekitKeyProvider, getUrlParams().controlledAudioDevices, options.livekitRoomFactory, - getUrlParams().echoCancellation, - getUrlParams().noiseSuppression, ); const connectionManager = createConnectionManager$({ diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index fb26d17800..6192e84370 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -27,10 +27,17 @@ import type { import type { MediaDevices } from "../../MediaDevices.ts"; import type { Behavior } from "../../Behavior.ts"; import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; +import { getLiveKitOptions } from "../../../livekit/options.ts"; import { - defaultLiveKitOptions, - getLiveKitOptions, -} from "../../../livekit/options.ts"; + advancedCamera, + cameraResolution, + cameraFramerate, + cameraBitrate, + cameraCodec, + echoCancellationSetting, + noiseSuppressionSetting, + autoGainControlSetting, +} from "../../../settings/settings.ts"; // TODO evaluate if this should be done like the Publisher Factory export interface ConnectionFactory { @@ -56,8 +63,6 @@ export class ECConnectionFactory implements ConnectionFactory { * @param livekitKeyProvider - Optional key provider for end-to-end encryption. * @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app). * @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used. - * @param echoCancellation - Whether to enable echo cancellation for audio capture. - * @param noiseSuppression - Whether to enable noise suppression for audio capture. */ public constructor( private client: OpenIDClientParts, @@ -67,8 +72,6 @@ export class ECConnectionFactory implements ConnectionFactory { livekitKeyProvider: BaseKeyProvider | undefined, private controlledAudioDevices: boolean, livekitRoomFactory?: () => LivekitRoom, - echoCancellation: boolean = true, - noiseSuppression: boolean = true, ) { const defaultFactory = (): LivekitRoom => new LivekitRoom( @@ -82,8 +85,6 @@ export class ECConnectionFactory implements ConnectionFactory { worker: new E2EEWorker(), }, controlledAudioDevices: this.controlledAudioDevices, - echoCancellation, - noiseSuppression, }), ); this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory; @@ -122,14 +123,13 @@ export class ECConnectionFactory implements ConnectionFactory { /** * Generate the initial LiveKit RoomOptions based on the current media devices and processor state. + * Reads audio processing and camera quality settings directly from Settings. */ function generateRoomOption({ devices, processorState, e2eeLivekitOptions, controlledAudioDevices, - echoCancellation, - noiseSuppression, }: { devices: MediaDevices; processorState: ProcessorState; @@ -138,22 +138,46 @@ function generateRoomOption({ | { e2eeManager: BaseE2EEManager } | undefined; controlledAudioDevices: boolean; - echoCancellation: boolean; - noiseSuppression: boolean; }): RoomOptions { const liveKitOptions = getLiveKitOptions(); + + // Apply advanced camera settings if enabled + let videoCaptureDefaults = { + ...liveKitOptions.videoCaptureDefaults, + deviceId: devices.videoInput.selected$.value?.id, + processor: processorState.processor, + }; + let publishDefaults = liveKitOptions.publishDefaults; + + if (advancedCamera.getValue()) { + const resParts = cameraResolution.getValue().split("x"); + const width = Number(resParts[0]); + const height = Number(resParts[1]); + const fps = cameraFramerate.getValue(); + const bps = cameraBitrate.getValue(); + const codec = cameraCodec.getValue(); + + videoCaptureDefaults = { + ...videoCaptureDefaults, + resolution: { width, height, frameRate: fps }, + }; + publishDefaults = { + ...publishDefaults, + videoEncoding: { maxBitrate: bps, maxFramerate: fps }, + videoCodec: codec, + }; + } + return { ...liveKitOptions, - videoCaptureDefaults: { - ...liveKitOptions.videoCaptureDefaults, - deviceId: devices.videoInput.selected$.value?.id, - processor: processorState.processor, - }, + videoCaptureDefaults, + publishDefaults, audioCaptureDefaults: { ...liveKitOptions.audioCaptureDefaults, deviceId: devices.audioInput.selected$.value?.id, - echoCancellation, - noiseSuppression, + echoCancellation: echoCancellationSetting.getValue(), + noiseSuppression: noiseSuppressionSetting.getValue(), + autoGainControl: autoGainControlSetting.getValue(), }, audioOutput: { // When using controlled audio devices, we don't want to set the diff --git a/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts b/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts index a66763d718..f4d86e51f6 100644 --- a/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts +++ b/src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts @@ -22,6 +22,10 @@ import { } from "../../../utils/test.ts"; import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; import { constant } from "../../Behavior"; +import { + echoCancellationSetting, + noiseSuppressionSetting, +} from "../../../settings/settings.ts"; // At the top of your test file, after imports vi.mock("livekit-client", async (importOriginal) => { @@ -58,11 +62,14 @@ describe("ECConnectionFactory - Audio inputs options", () => { { echo: false, noise: true }, { echo: false, noise: false }, ])( - "it sets echoCancellation=$echo and noiseSuppression=$noise based on constructor parameters", + "it sets echoCancellation=$echo and noiseSuppression=$noise based on settings", ({ echo, noise }) => { - // test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => { const RoomConstructor = vi.mocked(LivekitRoom); + // Set audio processing settings + echoCancellationSetting.setValue(echo); + noiseSuppressionSetting.setValue(noise); + const ecConnectionFactory = new ECConnectionFactory( mockClient, "!roomid:example.org", @@ -73,9 +80,6 @@ describe("ECConnectionFactory - Audio inputs options", () => { }), undefined, false, - undefined, - echo, - noise, ); ecConnectionFactory.createConnection( testScope, @@ -120,9 +124,6 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => { }), undefined, controlled, - undefined, - false, - false, ); ecConnectionFactory.createConnection( testScope, diff --git a/src/widget.test.ts b/src/widget.test.ts index 2e5bf743b7..20da4e27f9 100644 --- a/src/widget.test.ts +++ b/src/widget.test.ts @@ -19,6 +19,7 @@ const createRoomWidgetClientSpy = vi.mocked(createRoomWidgetClient); vi.mock("./config/Config", () => ({ Config: { init: vi.fn().mockImplementation(async () => Promise.resolve()), + get: vi.fn().mockReturnValue({}), }, })); const configInitSpy = vi.mocked(Config.init); diff --git a/src/widget.ts b/src/widget.ts index 2ec76e15e9..25918000f7 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -21,6 +21,7 @@ import type { IWidgetApiRequest } from "matrix-widget-api"; import { LazyEventEmitter } from "./LazyEventEmitter"; import { getUrlParams } from "./UrlParams"; import { Config } from "./config/Config"; +import { seedSettingsFromConfig } from "./settings/settings"; import { ElementCallReactionEventType } from "./reactions"; // Subset of the actions in element-web @@ -195,6 +196,7 @@ export const initializeWidget = ( // Wait for the config file to be ready (we load very early on so it might not // be otherwise) await Config.init(); + seedSettingsFromConfig(Config.get().media_quality); await client.startClient({ clientWellKnownPollPeriod: 60 * 10 }); return client; }; From 297470ac2e1fc1421a87c58fc834a1d6bd321ce7 Mon Sep 17 00:00:00 2001 From: Ryan Emmick Date: Wed, 11 Feb 2026 12:04:01 -0600 Subject: [PATCH 5/9] Use relative base path for vite build Allows Element Call to be served from a subdirectory (e.g. /widgets/element-call/ in Element Web) without breaking dynamic imports for locales, workers, and other assets that were previously using absolute paths. Signed-off-by: Ryan Emmick --- vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.ts b/vite.config.ts index 801ea79aa1..8807826c7c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -102,6 +102,7 @@ export default ({ console.log("Allowed vite paths:", allow); return { + base: "./", server: { port: 3000, fs: { allow }, From 98f63c71721be68bb63cd7b40b38a008184487c9 Mon Sep 17 00:00:00 2001 From: Ryan Emmick Date: Wed, 11 Feb 2026 12:26:16 -0600 Subject: [PATCH 6/9] Clean up: deduplicate settings UI, fix backupCodec, remove dead export Signed-off-by: Ryan Emmick --- src/livekit/options.ts | 8 +- src/settings/SettingsModal.tsx | 242 +++++++----------- src/settings/settings.ts | 8 + .../CallViewModel/localMember/LocalMember.ts | 5 +- .../remoteMembers/ConnectionFactory.ts | 5 +- .../remoteMembers/ECConnectionFactory.test.ts | 7 +- 6 files changed, 113 insertions(+), 162 deletions(-) diff --git a/src/livekit/options.ts b/src/livekit/options.ts index 8b98d61133..aac7ce583c 100644 --- a/src/livekit/options.ts +++ b/src/livekit/options.ts @@ -108,7 +108,10 @@ function buildPublishOptions( stopMicTrackOnMute: false, videoCodec: codec, videoEncoding, - backupCodec: { codec: "vp8", encoding: videoEncoding }, + backupCodec: { + codec: "vp8", + encoding: VideoPresets.h720.encoding, + }, } as TrackPublishDefaults; } @@ -156,6 +159,3 @@ export function getLiveKitOptions(): RoomOptions { return buildLiveKitOptions(); } } - -// Keep backward-compatible export for existing consumers -export const defaultLiveKitOptions: RoomOptions = buildLiveKitOptions(); diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 74e58e05d4..8acc400d1f 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -20,6 +20,7 @@ import { iosDeviceMenu$ } from "../state/MediaDevices"; import { useMediaDevices } from "../MediaDevicesContext"; import { widget } from "../widget"; import { + type Setting, useSetting, soundEffectVolume as soundEffectVolumeSetting, backgroundBlur as backgroundBlurSetting, @@ -112,33 +113,47 @@ export const SettingsModal: FC = ({ ); }; - const ScreenShareSettings: React.FC = (): ReactNode => { - const [advancedEnabled, setAdvancedEnabled] = useSetting( - advancedScreenShareSetting, - ); - const [resolution, setResolution] = useSetting( - screenShareResolutionSetting, - ); - const [framerate, setFramerate] = useSetting(screenShareFramerateSetting); + const MediaQualitySettings: React.FC<{ + id: string; + header: string; + toggleLabel: string; + description: string; + toggleSetting: Setting; + resolutionSetting: Setting; + framerateSetting: Setting; + bitrateSetting: Setting; + codecSetting: Setting; + resolutionOptions: { value: string; label: string }[]; + bitrateRange: { min: number; max: number; step: number }; + }> = ({ + id, + header, + toggleLabel, + description, + toggleSetting, + resolutionSetting, + framerateSetting, + bitrateSetting, + codecSetting, + resolutionOptions, + bitrateRange, + }): ReactNode => { + const [advancedEnabled, setAdvancedEnabled] = useSetting(toggleSetting); + const [resolution, setResolution] = useSetting(resolutionSetting); + const [framerate, setFramerate] = useSetting(framerateSetting); const [framerateRaw, setFramerateRaw] = useState(framerate); - const [bitrate, setBitrate] = useSetting(screenShareBitrateSetting); + const [bitrate, setBitrate] = useSetting(bitrateSetting); const [bitrateRaw, setBitrateRaw] = useState(bitrate); - const [codec, setCodec] = useSetting(screenShareCodecSetting); + const [codec, setCodec] = useSetting(codecSetting); return ( <> -

{t("settings.screen_share_header", "Screen sharing")}

+

{header}

setAdvancedEnabled(e.target.checked)} @@ -147,140 +162,29 @@ export const SettingsModal: FC = ({ {advancedEnabled && ( <>
-
`${v} fps`} - /> -
-
- - - `${(v / 1_000_000).toFixed(1)} Mbps` - } - /> -
-
- - -
- - )} - - ); - }; - - const CameraSettings: React.FC = (): ReactNode => { - const [advancedEnabled, setAdvancedEnabled] = useSetting( - advancedCameraSetting, - ); - const [resolution, setResolution] = useSetting(cameraResolutionSetting); - const [framerate, setFramerate] = useSetting(cameraFramerateSetting); - const [framerateRaw, setFramerateRaw] = useState(framerate); - const [bitrate, setBitrate] = useSetting(cameraBitrateSetting); - const [bitrateRaw, setBitrateRaw] = useState(bitrate); - const [codec, setCodec] = useSetting(cameraCodecSetting); - - return ( - <> -

{t("settings.camera_header", "Camera quality")}

- - setAdvancedEnabled(e.target.checked)} - /> - - {advancedEnabled && ( - <> -
- - -
-
- - = ({
`${(v / 1_000_000).toFixed(1)} Mbps` } />
-
+
+ + `${v} fps`} + /> +
+
+ + + `${(v / 1_000_000).toFixed(1)} Mbps` + } + /> +
+
+ + +
+ + )} + + ); + }; + + const AudioProcessingSettings: React.FC = (): ReactNode => { + const [echoCancellation, setEchoCancellation] = useSetting( + echoCancellationSetting, + ); + const [noiseSuppression, setNoiseSuppression] = useSetting( + noiseSuppressionSetting, + ); + const [autoGainControl, setAutoGainControl] = useSetting( + autoGainControlSetting, + ); + + return ( + <> +

{t("settings.audio_processing_header", "Audio processing")}

+

+ {t( + "settings.audio_processing_description", + "Changes apply on next call join.", + )} +

+ + setEchoCancellation(e.target.checked)} + /> + + + setNoiseSuppression(e.target.checked)} + /> + + + setAutoGainControl(e.target.checked)} + /> + + + ); + }; + return ( <>

@@ -379,6 +577,60 @@ export const DeveloperSettingsTab: FC = ({

{JSON.stringify(env, null, 2)}

{t("developer_mode.url_params")}

{JSON.stringify(urlParams, null, 2)}
+ + + + + + ); }; diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 8acc400d1f..0e97931e63 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -20,25 +20,10 @@ import { iosDeviceMenu$ } from "../state/MediaDevices"; import { useMediaDevices } from "../MediaDevicesContext"; import { widget } from "../widget"; import { - type Setting, useSetting, soundEffectVolume as soundEffectVolumeSetting, backgroundBlur as backgroundBlurSetting, developerMode, - advancedScreenShare as advancedScreenShareSetting, - screenShareResolution as screenShareResolutionSetting, - screenShareFramerate as screenShareFramerateSetting, - screenShareBitrate as screenShareBitrateSetting, - screenShareCodec as screenShareCodecSetting, - advancedCamera as advancedCameraSetting, - cameraResolution as cameraResolutionSetting, - cameraFramerate as cameraFramerateSetting, - cameraBitrate as cameraBitrateSetting, - cameraCodec as cameraCodecSetting, - echoCancellationSetting, - noiseSuppressionSetting, - autoGainControlSetting, - type VideoCodec, } from "./settings"; import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; import { Slider } from "../Slider"; @@ -113,191 +98,6 @@ export const SettingsModal: FC = ({ ); }; - const MediaQualitySettings: React.FC<{ - id: string; - header: string; - toggleLabel: string; - description: string; - toggleSetting: Setting; - resolutionSetting: Setting; - framerateSetting: Setting; - bitrateSetting: Setting; - codecSetting: Setting; - resolutionOptions: { value: string; label: string }[]; - bitrateRange: { min: number; max: number; step: number }; - }> = ({ - id, - header, - toggleLabel, - description, - toggleSetting, - resolutionSetting, - framerateSetting, - bitrateSetting, - codecSetting, - resolutionOptions, - bitrateRange, - }): ReactNode => { - const [advancedEnabled, setAdvancedEnabled] = useSetting(toggleSetting); - const [resolution, setResolution] = useSetting(resolutionSetting); - const [framerate, setFramerate] = useSetting(framerateSetting); - const [framerateRaw, setFramerateRaw] = useState(framerate); - const [bitrate, setBitrate] = useSetting(bitrateSetting); - const [bitrateRaw, setBitrateRaw] = useState(bitrate); - const [codec, setCodec] = useSetting(codecSetting); - - return ( - <> -

{header}

- - setAdvancedEnabled(e.target.checked)} - /> - - {advancedEnabled && ( - <> -
- - -
-
- - `${v} fps`} - /> -
-
- - - `${(v / 1_000_000).toFixed(1)} Mbps` - } - /> -
-
- - -
- - )} - - ); - }; - - const AudioProcessingSettings: React.FC = (): ReactNode => { - const [echoCancellation, setEchoCancellation] = useSetting( - echoCancellationSetting, - ); - const [noiseSuppression, setNoiseSuppression] = useSetting( - noiseSuppressionSetting, - ); - const [autoGainControl, setAutoGainControl] = useSetting( - autoGainControlSetting, - ); - - return ( - <> -

{t("settings.audio_processing_header", "Audio processing")}

-

- {t( - "settings.audio_processing_description", - "Changes apply on next call join.", - )} -

- - setEchoCancellation(e.target.checked)} - /> - - - setNoiseSuppression(e.target.checked)} - /> - - - setAutoGainControl(e.target.checked)} - /> - - - ); - }; - const devices = useMediaDevices(); useEffect(() => { if (open) devices.requestDeviceNames(); @@ -371,8 +171,6 @@ export const SettingsModal: FC = ({ />
- - ), }; @@ -391,46 +189,6 @@ export const SettingsModal: FC = ({ - - - - ), }; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 48f5ee55ca..40d706133f 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -169,7 +169,10 @@ export type VideoCodec = "vp8" | "vp9" | "h264" | "av1"; /** * Parse a "WIDTHxHEIGHT" resolution string into numeric width and height. */ -export function parseResolution(res: string): { width: number; height: number } { +export function parseResolution(res: string): { + width: number; + height: number; +} { const [w, h] = res.split("x").map(Number); return { width: w, height: h }; } @@ -209,10 +212,7 @@ export const cameraResolution = new Setting( export const cameraFramerate = new Setting("camera-framerate", 30); -export const cameraBitrate = new Setting( - "camera-bitrate", - 1_700_000, -); +export const cameraBitrate = new Setting("camera-bitrate", 1_700_000); export const cameraCodec = new Setting("camera-codec", "vp8"); @@ -238,19 +238,21 @@ export const autoGainControlSetting = new Setting( * Only updates settings that the user hasn't explicitly set in localStorage. */ export function seedSettingsFromConfig( - mediaQuality: { - video_codec?: VideoCodec; - video?: { - max_resolution?: number; - max_bitrate?: number; - max_framerate?: number; - }; - screen_share?: { - max_resolution?: number; - max_bitrate?: number; - max_framerate?: number; - }; - } | undefined, + mediaQuality: + | { + video_codec?: VideoCodec; + video?: { + max_resolution?: number; + max_bitrate?: number; + max_framerate?: number; + }; + screen_share?: { + max_resolution?: number; + max_bitrate?: number; + max_framerate?: number; + }; + } + | undefined, ): void { if (!mediaQuality) return; diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 002dba6b88..981793f1f3 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -689,7 +689,9 @@ export const createLocalMembership$ = ({ if (advancedScreenShare.getValue()) { // User has advanced screen share settings enabled - const { width, height } = parseResolution(screenShareResolution.getValue()); + const { width, height } = parseResolution( + screenShareResolution.getValue(), + ); const fps = screenShareFramerate.getValue(); const bps = screenShareBitrate.getValue(); const codec = screenShareCodec.getValue(); From 79b9335192b4af4ddf1fe9f827c6565320d5e29c Mon Sep 17 00:00:00 2001 From: Ryan Emmick Date: Fri, 13 Mar 2026 11:13:40 -0500 Subject: [PATCH 9/9] fix: update i18n translations and developer settings snapshot Run `yarn i18n` to extract new translation keys for the media quality settings, and update the DeveloperSettingsTab snapshot to include the Camera quality, Screen sharing, and Audio processing sections. --- locales/en/app.json | 17 +- .../DeveloperSettingsTab.test.tsx.snap | 230 ++++++++++++++++++ 2 files changed, 246 insertions(+), 1 deletion(-) diff --git a/locales/en/app.json b/locales/en/app.json index 5398930f2b..aee5c0382f 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -188,13 +188,23 @@ "room_auth_view_ssla_caption": "By clicking \"Join call now\", you agree to our <2>Software and Services License Agreement (SSLA)", "screenshare_button_label": "Share screen", "settings": { + "advanced_camera_description": "Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.", + "advanced_camera_label": "Advanced camera settings", + "advanced_screen_share_description": "Configure resolution, framerate, bitrate, and codec for screen sharing", + "advanced_screen_share_label": "Advanced screen share settings", + "audio_processing_description": "Changes apply on next call join.", + "audio_processing_header": "Audio processing", "audio_tab": { "effect_volume_description": "Adjust the volume at which reactions and hand raised effects play.", "effect_volume_label": "Sound effect volume" }, + "auto_gain_control_label": "Automatic gain control", "background_blur_header": "Background", "background_blur_label": "Blur the background of the video", + "bitrate_label": "Bitrate", "blur_not_supported_by_browser": "(Background blur is not supported by this device.)", + "camera_header": "Camera quality", + "codec_label": "Codec", "developer_tab_title": "Developer", "devices": { "camera": "Camera", @@ -209,12 +219,15 @@ "speaker": "Speaker", "speaker_numbered": "Speaker {{n}}" }, + "echo_cancellation_label": "Echo cancellation", "feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.", "feedback_tab_description_label": "Your feedback", "feedback_tab_h4": "Submit feedback", "feedback_tab_send_logs_label": "Include debug logs", "feedback_tab_thank_you": "Thanks, we received your feedback!", "feedback_tab_title": "Feedback", + "framerate_label": "Framerate", + "noise_suppression_label": "Noise suppression", "opt_in_description": "<0><1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.", "preferences_tab": { "developer_mode_label": "Developer mode", @@ -226,7 +239,9 @@ "reactions_show_label": "Show reactions", "show_hand_raised_timer_description": "Show a timer when a participant raises their hand", "show_hand_raised_timer_label": "Show hand raise duration" - } + }, + "resolution_label": "Resolution", + "screen_share_header": "Screen sharing" }, "star_rating_input_label_one": "{{count}} star", "star_rating_input_label_other": "{{count}} stars", diff --git a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap index af38685a88..488b98d5e0 100644 --- a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap +++ b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap @@ -453,5 +453,235 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` "answer": 42 } +