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/settings/SettingsModal.module.css b/src/settings/SettingsModal.module.css index b710f9d1f..bff1a30ee 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 88918e76b..74e58e05d 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 141d4449b..1ec26db60 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 e298bcfdb..d587530b1 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 fb26d1780..6192e8437 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 a66763d71..f4d86e51f 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 2e5bf743b..20da4e27f 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 2ec76e15e..25918000f 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; };