diff --git a/locales/en/app.json b/locales/en/app.json index 5398930f2..aee5c0382 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/Slider.tsx b/src/Slider.tsx index c6520e422..29f9ef426 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,14 @@ export const Slider: FC = ({ {/* Note: This is expected not to be visible on mobile.*/} - + diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 1b120546c..cc625d483 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. @@ -156,6 +211,24 @@ export interface ResolvedConfigOptions extends ConfigOptions { }; }; ssla: string; + media_quality: Required< + Pick, "video_codec"> + > & { + video: Required< + Pick< + NonNullable["video"]>, + "max_resolution" | "max_bitrate" | "max_framerate" + > + >; + screen_share: Required< + Pick< + NonNullable< + NonNullable["screen_share"] + >, + "max_resolution" | "max_bitrate" | "max_framerate" + > + >; + }; } export const DEFAULT_CONFIG: ResolvedConfigOptions = { @@ -169,4 +242,17 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = { feature_use_device_session_member_events: true, }, ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", + media_quality: { + video_codec: "vp8", + video: { + max_resolution: 720, + max_bitrate: 1_700_000, + max_framerate: 30, + }, + screen_share: { + max_resolution: 1080, + max_bitrate: 5_000_000, + max_framerate: 30, + }, + }, }; diff --git a/src/initializer.tsx b/src/initializer.tsx index 2bd6f5778..fa57f3caf 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/livekit/options.test.ts b/src/livekit/options.test.ts new file mode 100644 index 000000000..e48bcad4f --- /dev/null +++ b/src/livekit/options.test.ts @@ -0,0 +1,165 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, it, vi } from "vitest"; +import { VideoPresets } from "livekit-client"; + +import { buildLiveKitOptions, getLiveKitOptions } from "./options"; +import { Config } from "../config/Config"; + +vi.mock("../config/Config", () => ({ + Config: { + get: vi.fn(), + }, +})); + +describe("buildLiveKitOptions", () => { + it("returns sensible defaults with no config", () => { + const opts = buildLiveKitOptions(); + expect(opts.adaptiveStream).toBe(true); + expect(opts.dynacast).toBe(true); + expect(opts.videoCaptureDefaults?.resolution).toEqual( + VideoPresets.h720.resolution, + ); + expect(opts.publishDefaults?.videoCodec).toBe("vp8"); + expect(opts.publishDefaults?.videoEncoding).toEqual({ + maxBitrate: 1_700_000, + maxFramerate: 30, + }); + expect(opts.publishDefaults?.screenShareEncoding).toEqual({ + maxBitrate: 5_000_000, + maxFramerate: 30, + }); + expect(opts.publishDefaults?.videoSimulcastLayers).toEqual([ + VideoPresets.h180, + VideoPresets.h360, + ]); + }); + + it("applies video codec from config", () => { + const opts = buildLiveKitOptions({ video_codec: "vp9" }); + expect(opts.publishDefaults?.videoCodec).toBe("vp9"); + }); + + it("applies video resolution and encoding from config", () => { + const opts = buildLiveKitOptions({ + video: { + max_resolution: 1080, + max_bitrate: 3_000_000, + max_framerate: 60, + }, + }); + expect(opts.videoCaptureDefaults?.resolution).toEqual( + VideoPresets.h1080.resolution, + ); + expect(opts.publishDefaults?.videoEncoding).toEqual({ + maxBitrate: 3_000_000, + maxFramerate: 60, + }); + }); + + it("applies screen share encoding from config", () => { + const opts = buildLiveKitOptions({ + screen_share: { + max_bitrate: 8_000_000, + max_framerate: 15, + }, + }); + expect(opts.publishDefaults?.screenShareEncoding).toEqual({ + maxBitrate: 8_000_000, + maxFramerate: 15, + }); + }); + + it("uses DEFAULT_CONFIG defaults when only resolution is set", () => { + const opts = buildLiveKitOptions({ + screen_share: { + max_resolution: 720, + }, + }); + // Bitrate and framerate fall back to DEFAULT_CONFIG, not the preset + expect(opts.publishDefaults?.screenShareEncoding).toEqual({ + maxBitrate: 5_000_000, + maxFramerate: 30, + }); + }); + + it("applies custom video simulcast layers", () => { + const opts = buildLiveKitOptions({ + video: { + simulcast_layers: [ + { height: 180, bitrate: 100_000 }, + { height: 360, bitrate: 300_000 }, + { height: 540, bitrate: 600_000 }, + ], + max_framerate: 24, + }, + }); + const layers = opts.publishDefaults?.videoSimulcastLayers; + expect(layers).toHaveLength(3); + expect(layers?.[0]).toMatchObject({ + width: 320, + height: 180, + encoding: { maxBitrate: 100_000, maxFramerate: 24 }, + }); + expect(layers?.[2]).toMatchObject({ + width: 960, + height: 540, + encoding: { maxBitrate: 600_000, maxFramerate: 24 }, + }); + }); + + it("applies custom screen share simulcast layers", () => { + const opts = buildLiveKitOptions({ + screen_share: { + simulcast_layers: [{ height: 540, bitrate: 1_000_000, framerate: 5 }], + }, + }); + const layers = opts.publishDefaults?.screenShareSimulcastLayers; + expect(layers).toHaveLength(1); + expect(layers?.[0]).toMatchObject({ + width: 960, + height: 540, + encoding: { maxBitrate: 1_000_000, maxFramerate: 5 }, + }); + }); + + it("does not include screenShareSimulcastLayers when not configured", () => { + const opts = buildLiveKitOptions(); + expect(opts.publishDefaults?.screenShareSimulcastLayers).toBeUndefined(); + }); + + it("backupCodec always uses stock VP8 720p encoding", () => { + const opts = buildLiveKitOptions({ + video_codec: "av1", + video: { max_bitrate: 10_000_000, max_framerate: 60 }, + }); + const backup = opts.publishDefaults?.backupCodec as { + codec: string; + encoding: { maxBitrate: number; maxFramerate: number }; + }; + expect(backup.codec).toBe("vp8"); + expect(backup.encoding).toEqual(VideoPresets.h720.encoding); + }); +}); + +describe("getLiveKitOptions", () => { + it("reads from Config singleton", () => { + vi.mocked(Config.get).mockReturnValue({ + media_quality: { video_codec: "h264" }, + } as ReturnType); + const opts = getLiveKitOptions(); + expect(opts.publishDefaults?.videoCodec).toBe("h264"); + }); + + it("throws when Config is not initialized", () => { + vi.mocked(Config.get).mockImplementation(() => { + throw new Error("Config not initialized"); + }); + expect(() => getLiveKitOptions()).toThrow("Config not initialized"); + }); +}); diff --git a/src/livekit/options.ts b/src/livekit/options.ts index 1d4cad774..a4972f002 100644 --- a/src/livekit/options.ts +++ b/src/livekit/options.ts @@ -9,46 +9,144 @@ import { AudioPresets, DefaultReconnectPolicy, type RoomOptions, - ScreenSharePresets, 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 { DEFAULT_CONFIG, 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 defaults = DEFAULT_CONFIG.media_quality; + const videoConf = mediaQuality?.video; + const screenConf = mediaQuality?.screen_share; + const codec = mediaQuality?.video_codec ?? defaults.video_codec; + + // Camera video encoding + const videoEncoding = { + maxBitrate: videoConf?.max_bitrate ?? defaults.video.max_bitrate, + maxFramerate: videoConf?.max_framerate ?? defaults.video.max_framerate, + }; + + // 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 ?? defaults.video.max_framerate, + ), + ); + } else { + videoSimulcastLayers = [VideoPresets.h180, VideoPresets.h360]; + } + + // Screen share encoding + const screenShareEncoding = { + maxBitrate: screenConf?.max_bitrate ?? defaults.screen_share.max_bitrate, + maxFramerate: + screenConf?.max_framerate ?? defaults.screen_share.max_framerate, + }; + + // 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: VideoPresets.h720.encoding, + }, + } 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 ?? + DEFAULT_CONFIG.media_quality.video.max_resolution; + 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. + * Requires Config.init() to have resolved first. + */ +export function getLiveKitOptions(): RoomOptions { + return buildLiveKitOptions(Config.get().media_quality); +} diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 9df6181f9..8e5a8594f 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details. import { type ChangeEvent, type FC, + type ReactNode, useCallback, useEffect, useMemo, @@ -29,11 +30,13 @@ import { InlineField, Label, RadioControl, + Separator, } from "@vector-im/compound-web"; import { type Room as LivekitRoom } from "livekit-client"; import { FieldRow, InputField } from "../input/Input"; import { + type Setting, useSetting, duplicateTiles as duplicateTilesSetting, debugTileLayout as debugTileLayoutSetting, @@ -43,8 +46,24 @@ import { matrixRTCMode as matrixRTCModeSetting, customLivekitUrl as customLivekitUrlSetting, MatrixRTCMode, + 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 styles from "./DeveloperSettingsTab.module.css"; +import settingsStyles from "./SettingsModal.module.css"; +import { Slider } from "../Slider"; import { useUrlParams } from "../UrlParams"; import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; @@ -127,6 +146,185 @@ export const DeveloperSettingsTab: FC = ({ return null; }, [livekitRooms]); + 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)} + /> + + + ); + }; + 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.module.css b/src/settings/SettingsModal.module.css index b07cb4c81..bff1a30ee 100644 --- a/src/settings/SettingsModal.module.css +++ b/src/settings/SettingsModal.module.css @@ -33,3 +33,27 @@ 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); +} + +.settingValue { + font-weight: normal; + color: var(--cpd-color-text-secondary); +} diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 30ac36185..0e97931e6 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -152,7 +152,13 @@ export const SettingsModal: FC = ({ />
- +

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

renders and matches snapshot 1`] = ` "answer": 42 } +