mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-17 20:39:19 +00:00
Merge branch 'livekit' into valere/rtc/remove_well_known_fallback
This commit is contained in:
@@ -196,13 +196,23 @@
|
|||||||
"room_auth_view_ssla_caption": "By clicking \"Join call now\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",
|
"room_auth_view_ssla_caption": "By clicking \"Join call now\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",
|
||||||
"screenshare_button_label": "Share screen",
|
"screenshare_button_label": "Share screen",
|
||||||
"settings": {
|
"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": {
|
"audio_tab": {
|
||||||
"effect_volume_description": "Adjust the volume at which reactions and hand raised effects play.",
|
"effect_volume_description": "Adjust the volume at which reactions and hand raised effects play.",
|
||||||
"effect_volume_label": "Sound effect volume"
|
"effect_volume_label": "Sound effect volume"
|
||||||
},
|
},
|
||||||
|
"auto_gain_control_label": "Automatic gain control",
|
||||||
"background_blur_header": "Background",
|
"background_blur_header": "Background",
|
||||||
"background_blur_label": "Blur the background of the video",
|
"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.)",
|
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
|
||||||
|
"camera_header": "Camera quality",
|
||||||
|
"codec_label": "Codec",
|
||||||
"developer_tab_title": "Developer",
|
"developer_tab_title": "Developer",
|
||||||
"devices": {
|
"devices": {
|
||||||
"activating": "Activating…",
|
"activating": "Activating…",
|
||||||
@@ -218,12 +228,15 @@
|
|||||||
"speaker": "Speaker",
|
"speaker": "Speaker",
|
||||||
"speaker_numbered": "Speaker {{n}}"
|
"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_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_description_label": "Your feedback",
|
||||||
"feedback_tab_h4": "Submit feedback",
|
"feedback_tab_h4": "Submit feedback",
|
||||||
"feedback_tab_send_logs_label": "Include debug logs",
|
"feedback_tab_send_logs_label": "Include debug logs",
|
||||||
"feedback_tab_thank_you": "Thanks, we received your feedback!",
|
"feedback_tab_thank_you": "Thanks, we received your feedback!",
|
||||||
"feedback_tab_title": "Feedback",
|
"feedback_tab_title": "Feedback",
|
||||||
|
"framerate_label": "Framerate",
|
||||||
|
"noise_suppression_label": "Noise suppression",
|
||||||
"opt_in_description": "<0></0><1></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.",
|
"opt_in_description": "<0></0><1></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": {
|
"preferences_tab": {
|
||||||
"developer_mode_label": "Developer mode",
|
"developer_mode_label": "Developer mode",
|
||||||
@@ -235,7 +248,9 @@
|
|||||||
"reactions_show_label": "Show reactions",
|
"reactions_show_label": "Show reactions",
|
||||||
"show_hand_raised_timer_description": "Show a timer when a participant raises their hand",
|
"show_hand_raised_timer_description": "Show a timer when a participant raises their hand",
|
||||||
"show_hand_raised_timer_label": "Show hand raise duration"
|
"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_one": "{{count}} star",
|
||||||
"star_rating_input_label_other": "{{count}} stars",
|
"star_rating_input_label_other": "{{count}} stars",
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ interface Props {
|
|||||||
max: number;
|
max: number;
|
||||||
step: number;
|
step: number;
|
||||||
disabled?: boolean;
|
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<Props> = ({
|
|||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
disabled,
|
disabled,
|
||||||
|
tooltipFormatter,
|
||||||
}) => {
|
}) => {
|
||||||
const onValueChange = useCallback(
|
const onValueChange = useCallback(
|
||||||
([v]: number[]) => onValueChangeProp(v),
|
([v]: number[]) => onValueChangeProp(v),
|
||||||
@@ -71,7 +77,14 @@ export const Slider: FC<Props> = ({
|
|||||||
<Range className={styles.highlight} />
|
<Range className={styles.highlight} />
|
||||||
</Track>
|
</Track>
|
||||||
{/* Note: This is expected not to be visible on mobile.*/}
|
{/* Note: This is expected not to be visible on mobile.*/}
|
||||||
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
|
<Tooltip
|
||||||
|
placement="top"
|
||||||
|
label={
|
||||||
|
tooltipFormatter
|
||||||
|
? tooltipFormatter(value)
|
||||||
|
: Math.round(value * 100).toString() + "%"
|
||||||
|
}
|
||||||
|
>
|
||||||
<Thumb className={styles.handle} aria-label={label} />
|
<Thumb className={styles.handle} aria-label={label} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Root>
|
</Root>
|
||||||
|
|||||||
@@ -102,6 +102,61 @@ export interface ConfigOptions {
|
|||||||
*/
|
*/
|
||||||
ssla?: string;
|
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?: {
|
media_devices?: {
|
||||||
/**
|
/**
|
||||||
* Defines whether participants should start with audio enabled by default.
|
* Defines whether participants should start with audio enabled by default.
|
||||||
@@ -182,6 +237,24 @@ export interface ConfigOptions {
|
|||||||
export interface ResolvedConfigOptions extends ConfigOptions {
|
export interface ResolvedConfigOptions extends ConfigOptions {
|
||||||
sync_disconnect_grace_period_ms: number;
|
sync_disconnect_grace_period_ms: number;
|
||||||
ssla: string;
|
ssla: string;
|
||||||
|
media_quality: Required<
|
||||||
|
Pick<NonNullable<ConfigOptions["media_quality"]>, "video_codec">
|
||||||
|
> & {
|
||||||
|
video: Required<
|
||||||
|
Pick<
|
||||||
|
NonNullable<NonNullable<ConfigOptions["media_quality"]>["video"]>,
|
||||||
|
"max_resolution" | "max_bitrate" | "max_framerate"
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
screen_share: Required<
|
||||||
|
Pick<
|
||||||
|
NonNullable<
|
||||||
|
NonNullable<ConfigOptions["media_quality"]>["screen_share"]
|
||||||
|
>,
|
||||||
|
"max_resolution" | "max_bitrate" | "max_framerate"
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
};
|
||||||
matrix_rtc_session: {
|
matrix_rtc_session: {
|
||||||
wait_for_key_rotation_ms?: number;
|
wait_for_key_rotation_ms?: number;
|
||||||
delayed_leave_event_delay_ms: number;
|
delayed_leave_event_delay_ms: number;
|
||||||
@@ -198,6 +271,19 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
|||||||
},
|
},
|
||||||
sync_disconnect_grace_period_ms: 10000,
|
sync_disconnect_grace_period_ms: 10000,
|
||||||
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
matrix_rtc_session: {
|
matrix_rtc_session: {
|
||||||
delayed_leave_event_delay_ms: 10000,
|
delayed_leave_event_delay_ms: 10000,
|
||||||
network_error_retry_ms: 1000,
|
network_error_retry_ms: 1000,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ReactNode } from "react";
|
import { type FC, type ReactNode } from "react";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
@@ -13,6 +13,9 @@ import { type CallLayout } from "./CallLayout";
|
|||||||
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/layout-types.ts";
|
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/layout-types.ts";
|
||||||
import styles from "./SpotlightLandscapeLayout.module.css";
|
import styles from "./SpotlightLandscapeLayout.module.css";
|
||||||
import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||||
|
import { type MediaViewModel } from "../state/media/MediaViewModel.ts";
|
||||||
|
import { type Behavior } from "../state/Behavior.ts";
|
||||||
|
import { useBehavior } from "../useBehavior.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An implementation of the "spotlight landscape" layout, in which the spotlight
|
* An implementation of the "spotlight landscape" layout, in which the spotlight
|
||||||
@@ -54,16 +57,10 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
|||||||
useUpdateLayout();
|
useUpdateLayout();
|
||||||
useVisibleTiles(model.setVisibleTiles);
|
useVisibleTiles(model.setVisibleTiles);
|
||||||
useObservableEagerState(minBounds$);
|
useObservableEagerState(minBounds$);
|
||||||
const withIndicators =
|
|
||||||
useObservableEagerState(model.spotlight.media$).length > 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={styles.layer}>
|
<div ref={ref} className={styles.layer}>
|
||||||
<div
|
<SpotlightSlot media$={model.spotlight.media$} />
|
||||||
className={classNames(styles.spotlight, {
|
|
||||||
[styles.withIndicators]: withIndicators,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{model.grid.map((m) => (
|
{model.grid.map((m) => (
|
||||||
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
|
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
|
||||||
@@ -73,3 +70,20 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface SpotlightSlotProps {
|
||||||
|
media$: Behavior<MediaViewModel[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This component isolates the subscription to the spotlight media so that it
|
||||||
|
// can change without causing the whole layout to re-render
|
||||||
|
const SpotlightSlot: FC<SpotlightSlotProps> = ({ media$ }) => {
|
||||||
|
const withIndicators = useBehavior(media$).length > 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.spotlight, {
|
||||||
|
[styles.withIndicators]: withIndicators,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
|
|
||||||
import { getUrlParams } from "./UrlParams";
|
import { getUrlParams } from "./UrlParams";
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
|
import { seedSettingsFromConfig } from "./settings/settings";
|
||||||
import { platform } from "./Platform";
|
import { platform } from "./Platform";
|
||||||
import { isFailure } from "./utils/fetch";
|
import { isFailure } from "./utils/fetch";
|
||||||
import { initializeWidget } from "./widget";
|
import { initializeWidget } from "./widget";
|
||||||
@@ -237,6 +238,7 @@ export class Initializer {
|
|||||||
this.loadStates.config = LoadState.Loading;
|
this.loadStates.config = LoadState.Loading;
|
||||||
Config.init().then(
|
Config.init().then(
|
||||||
() => {
|
() => {
|
||||||
|
seedSettingsFromConfig(Config.get().media_quality);
|
||||||
this.loadStates.config = LoadState.Loaded;
|
this.loadStates.config = LoadState.Loaded;
|
||||||
this.initStep(resolve);
|
this.initStep(resolve);
|
||||||
},
|
},
|
||||||
|
|||||||
213
src/livekit/options.test.ts
Normal file
213
src/livekit/options.test.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/*
|
||||||
|
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, type VideoPreset } 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 baseVideoConfig = {
|
||||||
|
max_resolution: 1080,
|
||||||
|
max_bitrate: 3_000_000,
|
||||||
|
max_framerate: 60,
|
||||||
|
};
|
||||||
|
const opts1080 = buildLiveKitOptions({
|
||||||
|
video: baseVideoConfig,
|
||||||
|
});
|
||||||
|
const opts1440 = buildLiveKitOptions({
|
||||||
|
video: { ...baseVideoConfig, max_resolution: 1440 },
|
||||||
|
});
|
||||||
|
const opts2160 = buildLiveKitOptions({
|
||||||
|
video: { ...baseVideoConfig, max_resolution: 2160 },
|
||||||
|
});
|
||||||
|
expect(opts1080.videoCaptureDefaults?.resolution).toEqual(
|
||||||
|
VideoPresets.h1080.resolution,
|
||||||
|
);
|
||||||
|
expect(opts1440.videoCaptureDefaults?.resolution).toEqual(
|
||||||
|
VideoPresets.h1440.resolution,
|
||||||
|
);
|
||||||
|
expect(opts2160.videoCaptureDefaults?.resolution).toEqual(
|
||||||
|
VideoPresets.h2160.resolution,
|
||||||
|
);
|
||||||
|
expect(opts1080.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("maps low resolutions to the closest preset, rounding up", () => {
|
||||||
|
const expectations: [number, VideoPreset][] = [
|
||||||
|
[180, VideoPresets.h180],
|
||||||
|
[360, VideoPresets.h360],
|
||||||
|
[480, VideoPresets.h540],
|
||||||
|
[540, VideoPresets.h540],
|
||||||
|
[720, VideoPresets.h720],
|
||||||
|
];
|
||||||
|
for (const [height, preset] of expectations) {
|
||||||
|
const opts = buildLiveKitOptions({ video: { max_resolution: height } });
|
||||||
|
expect(opts.videoCaptureDefaults?.resolution).toEqual(preset.resolution);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("screen share layers fall back to max_framerate, then 30", () => {
|
||||||
|
const fromMax = buildLiveKitOptions({
|
||||||
|
screen_share: {
|
||||||
|
max_framerate: 15,
|
||||||
|
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
fromMax.publishDefaults?.screenShareSimulcastLayers?.[0],
|
||||||
|
).toMatchObject({ encoding: { maxFramerate: 15 } });
|
||||||
|
|
||||||
|
const fromDefault = buildLiveKitOptions({
|
||||||
|
screen_share: {
|
||||||
|
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
fromDefault.publishDefaults?.screenShareSimulcastLayers?.[0],
|
||||||
|
).toMatchObject({ encoding: { 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<typeof Config.get>);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,46 +9,144 @@ import {
|
|||||||
AudioPresets,
|
AudioPresets,
|
||||||
DefaultReconnectPolicy,
|
DefaultReconnectPolicy,
|
||||||
type RoomOptions,
|
type RoomOptions,
|
||||||
ScreenSharePresets,
|
|
||||||
type TrackPublishDefaults,
|
type TrackPublishDefaults,
|
||||||
type VideoPreset,
|
type VideoPreset,
|
||||||
VideoPresets,
|
VideoPresets,
|
||||||
|
VideoPreset as VideoPresetClass,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
|
|
||||||
const defaultLiveKitPublishOptions: TrackPublishDefaults = {
|
import { Config } from "../config/Config";
|
||||||
audioPreset: AudioPresets.music,
|
import { DEFAULT_CONFIG, type ConfigOptions } from "../config/ConfigOptions";
|
||||||
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
|
* Find the closest matching VideoPreset for a given height.
|
||||||
adaptiveStream: true,
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// optimize publishing bandwidth and CPU for published tracks
|
/**
|
||||||
dynacast: true,
|
* 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;
|
||||||
|
|
||||||
// capture settings
|
// Camera video encoding
|
||||||
videoCaptureDefaults: {
|
const videoEncoding = {
|
||||||
resolution: VideoPresets.h720.resolution,
|
maxBitrate: videoConf?.max_bitrate ?? defaults.video.max_bitrate,
|
||||||
},
|
maxFramerate: videoConf?.max_framerate ?? defaults.video.max_framerate,
|
||||||
|
};
|
||||||
|
|
||||||
// publish settings
|
// Camera simulcast layers
|
||||||
publishDefaults: defaultLiveKitPublishOptions,
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
// default LiveKit options that seem to be sane
|
// Screen share encoding
|
||||||
stopLocalTrackOnUnpublish: true,
|
const screenShareEncoding = {
|
||||||
reconnectPolicy: new DefaultReconnectPolicy(),
|
maxBitrate: screenConf?.max_bitrate ?? defaults.screen_share.max_bitrate,
|
||||||
disconnectOnPageLeave: true,
|
maxFramerate:
|
||||||
webAudioMix: false,
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import {
|
import {
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type FC,
|
type FC,
|
||||||
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -29,12 +30,14 @@ import {
|
|||||||
InlineField,
|
InlineField,
|
||||||
Label,
|
Label,
|
||||||
RadioControl,
|
RadioControl,
|
||||||
|
Separator,
|
||||||
} from "@vector-im/compound-web";
|
} from "@vector-im/compound-web";
|
||||||
import { type Room as LivekitRoom } from "livekit-client";
|
import { type Room as LivekitRoom } from "livekit-client";
|
||||||
|
|
||||||
import { FieldRow, InputField } from "../input/Input";
|
import { FieldRow, InputField } from "../input/Input";
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import {
|
import {
|
||||||
|
type Setting,
|
||||||
useSetting,
|
useSetting,
|
||||||
duplicateTiles as duplicateTilesSetting,
|
duplicateTiles as duplicateTilesSetting,
|
||||||
debugTileLayout as debugTileLayoutSetting,
|
debugTileLayout as debugTileLayoutSetting,
|
||||||
@@ -43,10 +46,26 @@ import {
|
|||||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||||
matrixRTCMode as matrixRTCModeSetting,
|
matrixRTCMode as matrixRTCModeSetting,
|
||||||
customLivekitUrl as customLivekitUrlSetting,
|
customLivekitUrl as customLivekitUrlSetting,
|
||||||
|
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,
|
||||||
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
|
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
|
||||||
} from "./settings";
|
} from "./settings";
|
||||||
import { MatrixRTCMode } from "../config/ConfigOptions";
|
import { MatrixRTCMode } from "../config/ConfigOptions";
|
||||||
import styles from "./DeveloperSettingsTab.module.css";
|
import styles from "./DeveloperSettingsTab.module.css";
|
||||||
|
import settingsStyles from "./SettingsModal.module.css";
|
||||||
|
import { Slider } from "../Slider";
|
||||||
import { useUrlParams } from "../UrlParams";
|
import { useUrlParams } from "../UrlParams";
|
||||||
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||||
|
|
||||||
@@ -138,6 +157,185 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
|||||||
return null;
|
return null;
|
||||||
}, [livekitRooms]);
|
}, [livekitRooms]);
|
||||||
|
|
||||||
|
const MediaQualitySettings: React.FC<{
|
||||||
|
id: string;
|
||||||
|
header: string;
|
||||||
|
toggleLabel: string;
|
||||||
|
description: string;
|
||||||
|
toggleSetting: Setting<boolean>;
|
||||||
|
resolutionSetting: Setting<string>;
|
||||||
|
framerateSetting: Setting<number>;
|
||||||
|
bitrateSetting: Setting<number>;
|
||||||
|
codecSetting: Setting<VideoCodec>;
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h4>{header}</h4>
|
||||||
|
<FieldRow>
|
||||||
|
<InputField
|
||||||
|
id={`${id}Toggle`}
|
||||||
|
label={toggleLabel}
|
||||||
|
description={description}
|
||||||
|
type="checkbox"
|
||||||
|
checked={advancedEnabled}
|
||||||
|
onChange={(e): void => setAdvancedEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
{advancedEnabled && (
|
||||||
|
<>
|
||||||
|
<div className={settingsStyles.volumeSlider}>
|
||||||
|
<label htmlFor={`${id}Resolution`}>
|
||||||
|
{t("settings.resolution_label", "Resolution")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${id}Resolution`}
|
||||||
|
value={resolution}
|
||||||
|
onChange={(e): void => setResolution(e.target.value)}
|
||||||
|
>
|
||||||
|
{resolutionOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={settingsStyles.volumeSlider}>
|
||||||
|
<label>
|
||||||
|
{t("settings.framerate_label", "Framerate")}
|
||||||
|
{": "}
|
||||||
|
<span className={settingsStyles.settingValue}>
|
||||||
|
{framerateRaw} fps
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
label={t("settings.framerate_label", "Framerate")}
|
||||||
|
value={framerateRaw}
|
||||||
|
onValueChange={setFramerateRaw}
|
||||||
|
onValueCommit={setFramerate}
|
||||||
|
min={5}
|
||||||
|
max={60}
|
||||||
|
step={5}
|
||||||
|
tooltipFormatter={(v): string => `${v} fps`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={settingsStyles.volumeSlider}>
|
||||||
|
<label>
|
||||||
|
{t("settings.bitrate_label", "Bitrate")}
|
||||||
|
{": "}
|
||||||
|
<span className={settingsStyles.settingValue}>
|
||||||
|
{(bitrateRaw / 1_000_000).toFixed(1)} Mbps
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
label={t("settings.bitrate_label", "Bitrate")}
|
||||||
|
value={bitrateRaw}
|
||||||
|
onValueChange={setBitrateRaw}
|
||||||
|
onValueCommit={setBitrate}
|
||||||
|
min={bitrateRange.min}
|
||||||
|
max={bitrateRange.max}
|
||||||
|
step={bitrateRange.step}
|
||||||
|
tooltipFormatter={(v): string =>
|
||||||
|
`${(v / 1_000_000).toFixed(1)} Mbps`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={settingsStyles.volumeSlider}>
|
||||||
|
<label htmlFor={`${id}Codec`}>
|
||||||
|
{t("settings.codec_label", "Codec")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${id}Codec`}
|
||||||
|
value={codec}
|
||||||
|
onChange={(e): void => setCodec(e.target.value as VideoCodec)}
|
||||||
|
>
|
||||||
|
<option value="vp8">VP8</option>
|
||||||
|
<option value="vp9">VP9</option>
|
||||||
|
<option value="h264">H.264</option>
|
||||||
|
<option value="av1">AV1</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioProcessingSettings: React.FC = (): ReactNode => {
|
||||||
|
const [echoCancellation, setEchoCancellation] = useSetting(
|
||||||
|
echoCancellationSetting,
|
||||||
|
);
|
||||||
|
const [noiseSuppression, setNoiseSuppression] = useSetting(
|
||||||
|
noiseSuppressionSetting,
|
||||||
|
);
|
||||||
|
const [autoGainControl, setAutoGainControl] = useSetting(
|
||||||
|
autoGainControlSetting,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4>{t("settings.audio_processing_header", "Audio processing")}</h4>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"settings.audio_processing_description",
|
||||||
|
"Changes apply on next call join.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<FieldRow>
|
||||||
|
<InputField
|
||||||
|
id="echoCancellation"
|
||||||
|
label={t("settings.echo_cancellation_label", "Echo cancellation")}
|
||||||
|
type="checkbox"
|
||||||
|
checked={echoCancellation}
|
||||||
|
onChange={(e): void => setEchoCancellation(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<InputField
|
||||||
|
id="noiseSuppression"
|
||||||
|
label={t("settings.noise_suppression_label", "Noise suppression")}
|
||||||
|
type="checkbox"
|
||||||
|
checked={noiseSuppression}
|
||||||
|
onChange={(e): void => setNoiseSuppression(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<InputField
|
||||||
|
id="autoGainControl"
|
||||||
|
label={t(
|
||||||
|
"settings.auto_gain_control_label",
|
||||||
|
"Automatic gain control",
|
||||||
|
)}
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoGainControl}
|
||||||
|
onChange={(e): void => setAutoGainControl(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
@@ -166,6 +364,7 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
|||||||
id: client.getDeviceId() || "unknown",
|
id: client.getDeviceId() || "unknown",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
<Separator />
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<InputField
|
<InputField
|
||||||
id="duplicateTiles"
|
id="duplicateTiles"
|
||||||
@@ -315,6 +514,7 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
|||||||
<ErrorMessage>{customLivekitUrlUpdateError}</ErrorMessage>
|
<ErrorMessage>{customLivekitUrlUpdateError}</ErrorMessage>
|
||||||
)}
|
)}
|
||||||
</EditInPlace>
|
</EditInPlace>
|
||||||
|
<Separator />
|
||||||
<Heading as="h3" type="body" weight="semibold" size="lg">
|
<Heading as="h3" type="body" weight="semibold" size="lg">
|
||||||
{t("developer_mode.matrixRTCMode.title")}
|
{t("developer_mode.matrixRTCMode.title")}
|
||||||
</Heading>
|
</Heading>
|
||||||
@@ -403,6 +603,61 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<Separator />
|
||||||
|
<MediaQualitySettings
|
||||||
|
id="camera"
|
||||||
|
header={t("settings.camera_header", "Camera quality")}
|
||||||
|
toggleLabel={t(
|
||||||
|
"settings.advanced_camera_label",
|
||||||
|
"Advanced camera settings",
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"settings.advanced_camera_description",
|
||||||
|
"Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.",
|
||||||
|
)}
|
||||||
|
toggleSetting={advancedCameraSetting}
|
||||||
|
resolutionSetting={cameraResolutionSetting}
|
||||||
|
framerateSetting={cameraFramerateSetting}
|
||||||
|
bitrateSetting={cameraBitrateSetting}
|
||||||
|
codecSetting={cameraCodecSetting}
|
||||||
|
resolutionOptions={[
|
||||||
|
{ value: "640x360", label: "360p" },
|
||||||
|
{ value: "960x540", label: "540p" },
|
||||||
|
{ value: "1280x720", label: "720p" },
|
||||||
|
{ value: "1920x1080", label: "1080p" },
|
||||||
|
{ value: "2560x1440", label: "1440p" },
|
||||||
|
]}
|
||||||
|
bitrateRange={{ min: 200_000, max: 8_000_000, step: 100_000 }}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<MediaQualitySettings
|
||||||
|
id="screenShare"
|
||||||
|
header={t("settings.screen_share_header", "Screen sharing")}
|
||||||
|
toggleLabel={t(
|
||||||
|
"settings.advanced_screen_share_label",
|
||||||
|
"Advanced screen share settings",
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"settings.advanced_screen_share_description",
|
||||||
|
"Configure resolution, framerate, bitrate, and codec for screen sharing",
|
||||||
|
)}
|
||||||
|
toggleSetting={advancedScreenShareSetting}
|
||||||
|
resolutionSetting={screenShareResolutionSetting}
|
||||||
|
framerateSetting={screenShareFramerateSetting}
|
||||||
|
bitrateSetting={screenShareBitrateSetting}
|
||||||
|
codecSetting={screenShareCodecSetting}
|
||||||
|
resolutionOptions={[
|
||||||
|
{ value: "1024x576", label: "576p" },
|
||||||
|
{ value: "1280x720", label: "720p" },
|
||||||
|
{ value: "1920x1080", label: "1080p" },
|
||||||
|
{ value: "2560x1440", label: "1440p" },
|
||||||
|
{ value: "3840x2160", label: "4K" },
|
||||||
|
]}
|
||||||
|
bitrateRange={{ min: 500_000, max: 15_000_000, step: 500_000 }}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<AudioProcessingSettings />
|
||||||
|
<Separator />
|
||||||
<p>{t("developer_mode.environment_variables")}</p>
|
<p>{t("developer_mode.environment_variables")}</p>
|
||||||
<pre>{JSON.stringify(env, null, 2)}</pre>
|
<pre>{JSON.stringify(env, null, 2)}</pre>
|
||||||
<p>{t("developer_mode.url_params")}</p>
|
<p>{t("developer_mode.url_params")}</p>
|
||||||
|
|||||||
@@ -33,3 +33,27 @@ Please see LICENSE in the repository root for full details.
|
|||||||
.volumeSlider > p {
|
.volumeSlider > p {
|
||||||
color: var(--cpd-color-text-secondary);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,7 +152,13 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.volumeSlider}>
|
<div className={styles.volumeSlider}>
|
||||||
<label>{t("settings.audio_tab.effect_volume_label")}</label>
|
<label>
|
||||||
|
{t("settings.audio_tab.effect_volume_label")}
|
||||||
|
{": "}
|
||||||
|
<span className={styles.settingValue}>
|
||||||
|
{Math.round(soundVolumeRaw * 100)}%
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<p>{t("settings.audio_tab.effect_volume_description")}</p>
|
<p>{t("settings.audio_tab.effect_volume_description")}</p>
|
||||||
<Slider
|
<Slider
|
||||||
label={t("video_tile.volume")}
|
label={t("video_tile.volume")}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
|||||||
<p>
|
<p>
|
||||||
Device ID: DEVICE123
|
Device ID: DEVICE123
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
class="_separator_13qwf_8"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="horizontal"
|
||||||
|
role="separator"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="_fieldRow_1bd8c0"
|
class="_fieldRow_1bd8c0"
|
||||||
>
|
>
|
||||||
@@ -255,6 +261,12 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<div
|
||||||
|
class="_separator_13qwf_8"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="horizontal"
|
||||||
|
role="separator"
|
||||||
|
/>
|
||||||
<h3
|
<h3
|
||||||
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74"
|
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74"
|
||||||
>
|
>
|
||||||
@@ -463,6 +475,242 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
|||||||
</p>
|
</p>
|
||||||
<ul />
|
<ul />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="_separator_13qwf_8"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="horizontal"
|
||||||
|
role="separator"
|
||||||
|
/>
|
||||||
|
<h4>
|
||||||
|
Camera quality
|
||||||
|
</h4>
|
||||||
|
<div
|
||||||
|
class="_fieldRow_1bd8c0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-describedby="_r_k_"
|
||||||
|
id="cameraToggle"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="cameraToggle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_checkbox_1bd8c0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="#000"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m20 6-11 11-5-5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Advanced camera settings
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
class="_description_1bd8c0"
|
||||||
|
id="_r_k_"
|
||||||
|
>
|
||||||
|
Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="_separator_13qwf_8"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="horizontal"
|
||||||
|
role="separator"
|
||||||
|
/>
|
||||||
|
<h4>
|
||||||
|
Screen sharing
|
||||||
|
</h4>
|
||||||
|
<div
|
||||||
|
class="_fieldRow_1bd8c0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-describedby="_r_l_"
|
||||||
|
id="screenShareToggle"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="screenShareToggle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_checkbox_1bd8c0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="#000"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m20 6-11 11-5-5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Advanced screen share settings
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
class="_description_1bd8c0"
|
||||||
|
id="_r_l_"
|
||||||
|
>
|
||||||
|
Configure resolution, framerate, bitrate, and codec for screen sharing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="_separator_13qwf_8"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="horizontal"
|
||||||
|
role="separator"
|
||||||
|
/>
|
||||||
|
<h4>
|
||||||
|
Audio processing
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
Changes apply on next call join.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="_fieldRow_1bd8c0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-describedby="_r_m_"
|
||||||
|
checked=""
|
||||||
|
id="echoCancellation"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="echoCancellation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_checkbox_1bd8c0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="#000"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m20 6-11 11-5-5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Echo cancellation
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="_fieldRow_1bd8c0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-describedby="_r_n_"
|
||||||
|
checked=""
|
||||||
|
id="noiseSuppression"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="noiseSuppression"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_checkbox_1bd8c0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="#000"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m20 6-11 11-5-5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Noise suppression
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="_fieldRow_1bd8c0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-describedby="_r_o_"
|
||||||
|
checked=""
|
||||||
|
id="autoGainControl"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="autoGainControl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_checkbox_1bd8c0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="#000"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="m20 6-11 11-5-5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
Automatic gain control
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="_separator_13qwf_8"
|
||||||
|
data-kind="primary"
|
||||||
|
data-orientation="horizontal"
|
||||||
|
role="separator"
|
||||||
|
/>
|
||||||
<p>
|
<p>
|
||||||
Environment variables
|
Environment variables
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
111
src/settings/settings.test.ts
Normal file
111
src/settings/settings.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
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, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Setting,
|
||||||
|
parseResolution,
|
||||||
|
seedSettingsFromConfig,
|
||||||
|
screenShareCodec,
|
||||||
|
screenShareResolution,
|
||||||
|
screenShareFramerate,
|
||||||
|
screenShareBitrate,
|
||||||
|
cameraCodec,
|
||||||
|
cameraResolution,
|
||||||
|
cameraFramerate,
|
||||||
|
cameraBitrate,
|
||||||
|
} from "./settings";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseResolution", () => {
|
||||||
|
it("parses a WIDTHxHEIGHT string", () => {
|
||||||
|
expect(parseResolution("1920x1080")).toEqual({
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses non-standard resolutions", () => {
|
||||||
|
expect(parseResolution("640x360")).toEqual({ width: 640, height: 360 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Setting", () => {
|
||||||
|
describe("seedFromConfig", () => {
|
||||||
|
it("updates value when no localStorage value exists", () => {
|
||||||
|
const setting = new Setting<string>("test-seed", "default");
|
||||||
|
setting.seedFromConfig("from-config");
|
||||||
|
expect(setting.getValue()).toBe("from-config");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not override an existing localStorage value", () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"matrix-setting-test-seed-existing",
|
||||||
|
JSON.stringify("user-set"),
|
||||||
|
);
|
||||||
|
const setting = new Setting<string>("test-seed-existing", "default");
|
||||||
|
expect(setting.getValue()).toBe("user-set");
|
||||||
|
setting.seedFromConfig("from-config");
|
||||||
|
expect(setting.getValue()).toBe("user-set");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seedSettingsFromConfig", () => {
|
||||||
|
it("does nothing when config is undefined", () => {
|
||||||
|
const codecBefore = screenShareCodec.getValue();
|
||||||
|
seedSettingsFromConfig(undefined);
|
||||||
|
expect(screenShareCodec.getValue()).toBe(codecBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds video codec to both camera and screen share", () => {
|
||||||
|
seedSettingsFromConfig({ video_codec: "av1" });
|
||||||
|
expect(screenShareCodec.getValue()).toBe("av1");
|
||||||
|
expect(cameraCodec.getValue()).toBe("av1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds screen share settings from config", () => {
|
||||||
|
seedSettingsFromConfig({
|
||||||
|
screen_share: {
|
||||||
|
max_resolution: 720,
|
||||||
|
max_framerate: 15,
|
||||||
|
max_bitrate: 2_000_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screenShareResolution.getValue()).toBe("1280x720");
|
||||||
|
expect(screenShareFramerate.getValue()).toBe(15);
|
||||||
|
expect(screenShareBitrate.getValue()).toBe(2_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds camera/video settings from config", () => {
|
||||||
|
seedSettingsFromConfig({
|
||||||
|
video: {
|
||||||
|
max_resolution: 1080,
|
||||||
|
max_framerate: 60,
|
||||||
|
max_bitrate: 4_000_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cameraResolution.getValue()).toBe("1920x1080");
|
||||||
|
expect(cameraFramerate.getValue()).toBe(60);
|
||||||
|
expect(cameraBitrate.getValue()).toBe(4_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only seeds provided fields, leaves others at defaults", () => {
|
||||||
|
const defaultFramerate = screenShareFramerate.getValue();
|
||||||
|
seedSettingsFromConfig({
|
||||||
|
screen_share: {
|
||||||
|
max_bitrate: 3_000_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(screenShareBitrate.getValue()).toBe(3_000_000);
|
||||||
|
expect(screenShareFramerate.getValue()).toBe(defaultFramerate);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ export class Setting<T> {
|
|||||||
this.key = `matrix-setting-${key}`;
|
this.key = `matrix-setting-${key}`;
|
||||||
|
|
||||||
const storedValue = localStorage.getItem(this.key);
|
const storedValue = localStorage.getItem(this.key);
|
||||||
|
this.hasStoredValue = storedValue !== null;
|
||||||
let initialValue = defaultValue;
|
let initialValue = defaultValue;
|
||||||
if (storedValue !== null) {
|
if (storedValue !== null) {
|
||||||
try {
|
try {
|
||||||
@@ -40,6 +41,7 @@ export class Setting<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly key: string;
|
private readonly key: string;
|
||||||
|
private readonly hasStoredValue: boolean;
|
||||||
|
|
||||||
private readonly _value$: BehaviorSubject<T>;
|
private readonly _value$: BehaviorSubject<T>;
|
||||||
private readonly _lastUpdateReason$: BehaviorSubject<string | null>;
|
private readonly _lastUpdateReason$: BehaviorSubject<string | null>;
|
||||||
@@ -54,6 +56,17 @@ export class Setting<T> {
|
|||||||
public readonly getValue = (): T => {
|
public readonly getValue = (): T => {
|
||||||
return this._value$.getValue();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,3 +157,130 @@ export const customLivekitUrl = new Setting<string | null>(
|
|||||||
"custom-livekit-url",
|
"custom-livekit-url",
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
} {
|
||||||
|
const [w, h] = res.split("x").map(Number);
|
||||||
|
return { width: w, height: h };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const advancedScreenShare = new Setting<boolean>(
|
||||||
|
"advanced-screen-share",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const screenShareResolution = new Setting<string>(
|
||||||
|
"screen-share-resolution",
|
||||||
|
"1920x1080",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const screenShareFramerate = new Setting<number>(
|
||||||
|
"screen-share-framerate",
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const screenShareBitrate = new Setting<number>(
|
||||||
|
"screen-share-bitrate",
|
||||||
|
5_000_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const screenShareCodec = new Setting<VideoCodec>(
|
||||||
|
"screen-share-codec",
|
||||||
|
"vp9",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Camera video quality settings
|
||||||
|
export const advancedCamera = new Setting<boolean>("advanced-camera", false);
|
||||||
|
|
||||||
|
export const cameraResolution = new Setting<string>(
|
||||||
|
"camera-resolution",
|
||||||
|
"1280x720",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const cameraFramerate = new Setting<number>("camera-framerate", 30);
|
||||||
|
|
||||||
|
export const cameraBitrate = new Setting<number>("camera-bitrate", 1_700_000);
|
||||||
|
|
||||||
|
export const cameraCodec = new Setting<VideoCodec>("camera-codec", "vp8");
|
||||||
|
|
||||||
|
// Audio processing settings
|
||||||
|
export const echoCancellationSetting = new Setting<boolean>(
|
||||||
|
"echo-cancellation",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const noiseSuppressionSetting = new Setting<boolean>(
|
||||||
|
"noise-suppression",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const autoGainControlSetting = new Setting<boolean>(
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import { useClient } from "../ClientContext";
|
|||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { type RageshakeRequestModal } from "../room/RageshakeRequestModal";
|
import { type RageshakeRequestModal } from "../room/RageshakeRequestModal";
|
||||||
import { getUrlParams } from "../UrlParams";
|
import { getUrlParams } from "../UrlParams";
|
||||||
|
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||||
|
import { advancedCamera as advancedCameraSetting } from "./settings";
|
||||||
|
import { advancedScreenShare as advancedScreenShareSetting } from "./settings";
|
||||||
|
import { DEFAULT_CONFIG } from "../config/ConfigOptions";
|
||||||
const gzip = async (text: string): Promise<Blob> => {
|
const gzip = async (text: string): Promise<Blob> => {
|
||||||
// pako is relatively large (200KB), so we only import it when needed
|
// pako is relatively large (200KB), so we only import it when needed
|
||||||
const { gzip: pakoGzip } = await import("pako");
|
const { gzip: pakoGzip } = await import("pako");
|
||||||
@@ -245,6 +248,20 @@ export function useSubmitRageshake(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add custom media related information to the rageshake issue description.
|
||||||
|
// Used to quickly identify issues due to untested configurations.
|
||||||
|
if (
|
||||||
|
!deepCompare(Config.get().media_quality, DEFAULT_CONFIG.media_quality)
|
||||||
|
) {
|
||||||
|
body.append("custom_media_quality_in_config", "true");
|
||||||
|
}
|
||||||
|
if (advancedCameraSetting.getValue()) {
|
||||||
|
body.append("devTools_advancedCameraSettings", "true");
|
||||||
|
}
|
||||||
|
if (advancedScreenShareSetting.getValue()) {
|
||||||
|
body.append("devTools_advancedScreenShareSetting", "true");
|
||||||
|
}
|
||||||
|
|
||||||
if (navigator.storage && navigator.storage.estimate) {
|
if (navigator.storage && navigator.storage.estimate) {
|
||||||
try {
|
try {
|
||||||
const estimate: {
|
const estimate: {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { useSubmitRageshake, getRageshakeSubmitUrl } from "./submit-rageshake";
|
|||||||
import { ClientContextProvider } from "../ClientContext";
|
import { ClientContextProvider } from "../ClientContext";
|
||||||
import { getUrlParams } from "../UrlParams";
|
import { getUrlParams } from "../UrlParams";
|
||||||
import { mockConfig } from "../utils/test";
|
import { mockConfig } from "../utils/test";
|
||||||
|
import { DEFAULT_CONFIG } from "../config/ConfigOptions";
|
||||||
|
import { advancedCamera, advancedScreenShare } from "./settings";
|
||||||
|
|
||||||
vi.mock("../UrlParams", () => ({ getUrlParams: vi.fn() }));
|
vi.mock("../UrlParams", () => ({ getUrlParams: vi.fn() }));
|
||||||
|
|
||||||
@@ -201,6 +203,60 @@ describe("useSubmitRageshake", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("media quality metadata", () => {
|
||||||
|
const submitAndGetBody = async (): Promise<FormData> => {
|
||||||
|
const fetchFn = vi.fn().mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchFn);
|
||||||
|
|
||||||
|
renderWithMockClient(() => "https://rageshake.localhost/foo", false);
|
||||||
|
screen.getByTestId("submit").click();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("sent").textContent).toBe("true");
|
||||||
|
});
|
||||||
|
return fetchFn.mock.calls[0][1].body as FormData;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
advancedCamera.setValue(advancedCamera.defaultValue);
|
||||||
|
advancedScreenShare.setValue(advancedScreenShare.defaultValue);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits media quality fields when config and settings are default", async () => {
|
||||||
|
mockConfig({});
|
||||||
|
const body = await submitAndGetBody();
|
||||||
|
expect(body.get("custom_media_quality_in_config")).toBeNull();
|
||||||
|
expect(body.get("devTools_advancedCameraSettings")).toBeNull();
|
||||||
|
expect(body.get("devTools_advancedScreenShareSetting")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes custom_media_quality_in_config when media_quality differs from default", async () => {
|
||||||
|
mockConfig({
|
||||||
|
media_quality: {
|
||||||
|
...DEFAULT_CONFIG.media_quality,
|
||||||
|
video_codec: "h264",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await submitAndGetBody();
|
||||||
|
expect(body.get("custom_media_quality_in_config")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes devTools flags when advanced media settings are enabled", async () => {
|
||||||
|
mockConfig({});
|
||||||
|
advancedCamera.setValue(true);
|
||||||
|
advancedScreenShare.setValue(true);
|
||||||
|
const body = await submitAndGetBody();
|
||||||
|
expect(body.get("devTools_advancedCameraSettings")).toBe("true");
|
||||||
|
expect(body.get("devTools_advancedScreenShareSetting")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("when rageshake is not available", () => {
|
describe("when rageshake is not available", () => {
|
||||||
it("starts unsent", () => {
|
it("starts unsent", () => {
|
||||||
renderWithMockClient(() => undefined, false);
|
renderWithMockClient(() => undefined, false);
|
||||||
|
|||||||
@@ -513,8 +513,6 @@ export function createCallViewModel$(
|
|||||||
livekitKeyProvider,
|
livekitKeyProvider,
|
||||||
getUrlParams().controlledAudioDevices,
|
getUrlParams().controlledAudioDevices,
|
||||||
options.livekitRoomFactory,
|
options.livekitRoomFactory,
|
||||||
getUrlParams().echoCancellation,
|
|
||||||
getUrlParams().noiseSuppression,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectionManager = createConnectionManager$({
|
const connectionManager = createConnectionManager$({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
type ScreenShareCaptureOptions,
|
type ScreenShareCaptureOptions,
|
||||||
|
type TrackPublishOptions,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
@@ -53,6 +54,14 @@ import {
|
|||||||
import { ElementWidgetActions, widget } from "../../../widget.ts";
|
import { ElementWidgetActions, widget } from "../../../widget.ts";
|
||||||
import { getUrlParams } from "../../../UrlParams.ts";
|
import { getUrlParams } from "../../../UrlParams.ts";
|
||||||
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
||||||
|
import {
|
||||||
|
advancedScreenShare,
|
||||||
|
screenShareResolution,
|
||||||
|
screenShareFramerate,
|
||||||
|
screenShareBitrate,
|
||||||
|
screenShareCodec,
|
||||||
|
parseResolution,
|
||||||
|
} from "../../../settings/settings.ts";
|
||||||
import { MatrixRTCMode } from "../../../config/ConfigOptions.ts";
|
import { MatrixRTCMode } from "../../../config/ConfigOptions.ts";
|
||||||
import { Config } from "../../../config/Config.ts";
|
import { Config } from "../../../config/Config.ts";
|
||||||
import {
|
import {
|
||||||
@@ -718,6 +727,43 @@ export const createLocalMembership$ = ({
|
|||||||
surfaceSwitching: "include",
|
surfaceSwitching: "include",
|
||||||
systemAudio: "include",
|
systemAudio: "include",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let publishOptions: TrackPublishOptions | undefined;
|
||||||
|
|
||||||
|
if (advancedScreenShare.getValue()) {
|
||||||
|
// User has advanced screen share settings enabled
|
||||||
|
const { width, height } = parseResolution(
|
||||||
|
screenShareResolution.getValue(),
|
||||||
|
);
|
||||||
|
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;
|
const targetScreenshareState = !sharingScreen$.value;
|
||||||
logger.info(
|
logger.info(
|
||||||
`toggleScreenSharing called. Switching ${
|
`toggleScreenSharing called. Switching ${
|
||||||
@@ -733,7 +779,11 @@ export const createLocalMembership$ = ({
|
|||||||
// is still initializing or publishing tracks, because there's no
|
// is still initializing or publishing tracks, because there's no
|
||||||
// technical reason to disallow this. LiveKit will publish if it can.
|
// technical reason to disallow this. LiveKit will publish if it can.
|
||||||
participant$.value
|
participant$.value
|
||||||
?.setScreenShareEnabled(targetScreenshareState, screenshareSettings)
|
?.setScreenShareEnabled(
|
||||||
|
targetScreenshareState,
|
||||||
|
screenshareSettings,
|
||||||
|
publishOptions,
|
||||||
|
)
|
||||||
.catch(logger.error);
|
.catch(logger.error);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type E2EEManagerOptions,
|
type E2EEManagerOptions,
|
||||||
type BaseE2EEManager,
|
type BaseE2EEManager,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { logger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
// imported as inline to support worker when loaded from a cdn (cross domain)
|
// imported as inline to support worker when loaded from a cdn (cross domain)
|
||||||
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
|
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
@@ -27,7 +27,18 @@ import type {
|
|||||||
import type { MediaDevices } from "../../MediaDevices.ts";
|
import type { MediaDevices } from "../../MediaDevices.ts";
|
||||||
import type { Behavior } from "../../Behavior.ts";
|
import type { Behavior } from "../../Behavior.ts";
|
||||||
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
||||||
import { defaultLiveKitOptions } from "../../../livekit/options.ts";
|
import { getLiveKitOptions } from "../../../livekit/options.ts";
|
||||||
|
import {
|
||||||
|
advancedCamera,
|
||||||
|
cameraResolution,
|
||||||
|
cameraFramerate,
|
||||||
|
cameraBitrate,
|
||||||
|
cameraCodec,
|
||||||
|
parseResolution,
|
||||||
|
echoCancellationSetting,
|
||||||
|
noiseSuppressionSetting,
|
||||||
|
autoGainControlSetting,
|
||||||
|
} from "../../../settings/settings.ts";
|
||||||
|
|
||||||
// TODO evaluate if this should be done like the Publisher Factory
|
// TODO evaluate if this should be done like the Publisher Factory
|
||||||
export interface ConnectionFactory {
|
export interface ConnectionFactory {
|
||||||
@@ -53,8 +64,6 @@ export class ECConnectionFactory implements ConnectionFactory {
|
|||||||
* @param livekitKeyProvider - Optional key provider for end-to-end encryption.
|
* @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 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 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(
|
public constructor(
|
||||||
private client: OpenIDClientParts,
|
private client: OpenIDClientParts,
|
||||||
@@ -64,25 +73,22 @@ export class ECConnectionFactory implements ConnectionFactory {
|
|||||||
livekitKeyProvider: BaseKeyProvider | undefined,
|
livekitKeyProvider: BaseKeyProvider | undefined,
|
||||||
private controlledAudioDevices: boolean,
|
private controlledAudioDevices: boolean,
|
||||||
livekitRoomFactory?: () => LivekitRoom,
|
livekitRoomFactory?: () => LivekitRoom,
|
||||||
echoCancellation: boolean = true,
|
|
||||||
noiseSuppression: boolean = true,
|
|
||||||
) {
|
) {
|
||||||
const defaultFactory = (): LivekitRoom =>
|
const defaultFactory = (): LivekitRoom => {
|
||||||
new LivekitRoom(
|
const roomOptions = generateRoomOption({
|
||||||
generateRoomOption({
|
devices: this.devices,
|
||||||
devices: this.devices,
|
processorState: this.processorState$.value,
|
||||||
processorState: this.processorState$.value,
|
e2eeLivekitOptions: livekitKeyProvider && {
|
||||||
e2eeLivekitOptions: livekitKeyProvider && {
|
keyProvider: livekitKeyProvider,
|
||||||
keyProvider: livekitKeyProvider,
|
// It's important that every room use a separate E2EE worker.
|
||||||
// It's important that every room use a separate E2EE worker.
|
// They get confused if given streams from multiple rooms.
|
||||||
// They get confused if given streams from multiple rooms.
|
worker: new E2EEWorker(),
|
||||||
worker: new E2EEWorker(),
|
},
|
||||||
},
|
controlledAudioDevices: this.controlledAudioDevices,
|
||||||
controlledAudioDevices: this.controlledAudioDevices,
|
});
|
||||||
echoCancellation,
|
logger.info("[ECConnectionFactory] livekit room options: ", roomOptions);
|
||||||
noiseSuppression,
|
return new LivekitRoom(roomOptions);
|
||||||
}),
|
};
|
||||||
);
|
|
||||||
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
|
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,14 +125,13 @@ export class ECConnectionFactory implements ConnectionFactory {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
|
* 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({
|
function generateRoomOption({
|
||||||
devices,
|
devices,
|
||||||
processorState,
|
processorState,
|
||||||
e2eeLivekitOptions,
|
e2eeLivekitOptions,
|
||||||
controlledAudioDevices,
|
controlledAudioDevices,
|
||||||
echoCancellation,
|
|
||||||
noiseSuppression,
|
|
||||||
}: {
|
}: {
|
||||||
devices: MediaDevices;
|
devices: MediaDevices;
|
||||||
processorState: ProcessorState;
|
processorState: ProcessorState;
|
||||||
@@ -135,21 +140,44 @@ function generateRoomOption({
|
|||||||
| { e2eeManager: BaseE2EEManager }
|
| { e2eeManager: BaseE2EEManager }
|
||||||
| undefined;
|
| undefined;
|
||||||
controlledAudioDevices: boolean;
|
controlledAudioDevices: boolean;
|
||||||
echoCancellation: boolean;
|
|
||||||
noiseSuppression: boolean;
|
|
||||||
}): RoomOptions {
|
}): 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 { width, height } = parseResolution(cameraResolution.getValue());
|
||||||
|
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 {
|
return {
|
||||||
...defaultLiveKitOptions,
|
...liveKitOptions,
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults,
|
||||||
...defaultLiveKitOptions.videoCaptureDefaults,
|
publishDefaults,
|
||||||
deviceId: devices.videoInput.selected$.value?.id,
|
|
||||||
processor: processorState.processor,
|
|
||||||
},
|
|
||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
...defaultLiveKitOptions.audioCaptureDefaults,
|
...liveKitOptions.audioCaptureDefaults,
|
||||||
deviceId: devices.audioInput.selected$.value?.id,
|
deviceId: devices.audioInput.selected$.value?.id,
|
||||||
echoCancellation,
|
echoCancellation: echoCancellationSetting.getValue(),
|
||||||
noiseSuppression,
|
noiseSuppression: noiseSuppressionSetting.getValue(),
|
||||||
|
autoGainControl: autoGainControlSetting.getValue(),
|
||||||
},
|
},
|
||||||
audioOutput: {
|
audioOutput: {
|
||||||
// When using controlled audio devices, we don't want to set the
|
// When using controlled audio devices, we don't want to set the
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ import {
|
|||||||
} from "../../../utils/test.ts";
|
} from "../../../utils/test.ts";
|
||||||
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
||||||
import { constant } from "../../Behavior";
|
import { constant } from "../../Behavior";
|
||||||
|
import {
|
||||||
|
echoCancellationSetting,
|
||||||
|
noiseSuppressionSetting,
|
||||||
|
autoGainControlSetting,
|
||||||
|
advancedCamera,
|
||||||
|
cameraResolution,
|
||||||
|
cameraFramerate,
|
||||||
|
cameraBitrate,
|
||||||
|
cameraCodec,
|
||||||
|
} from "../../../settings/settings.ts";
|
||||||
|
|
||||||
// At the top of your test file, after imports
|
// At the top of your test file, after imports
|
||||||
vi.mock("livekit-client", async (importOriginal) => {
|
vi.mock("livekit-client", async (importOriginal) => {
|
||||||
@@ -58,11 +68,14 @@ describe("ECConnectionFactory - Audio inputs options", () => {
|
|||||||
{ echo: false, noise: true },
|
{ echo: false, noise: true },
|
||||||
{ echo: false, noise: false },
|
{ 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 }) => {
|
({ echo, noise }) => {
|
||||||
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
|
|
||||||
const RoomConstructor = vi.mocked(LivekitRoom);
|
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||||
|
|
||||||
|
// Set audio processing settings
|
||||||
|
echoCancellationSetting.setValue(echo);
|
||||||
|
noiseSuppressionSetting.setValue(noise);
|
||||||
|
|
||||||
const ecConnectionFactory = new ECConnectionFactory(
|
const ecConnectionFactory = new ECConnectionFactory(
|
||||||
mockClient,
|
mockClient,
|
||||||
"!roomid:example.org",
|
"!roomid:example.org",
|
||||||
@@ -73,9 +86,6 @@ describe("ECConnectionFactory - Audio inputs options", () => {
|
|||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
false,
|
false,
|
||||||
undefined,
|
|
||||||
echo,
|
|
||||||
noise,
|
|
||||||
);
|
);
|
||||||
ecConnectionFactory.createConnection(
|
ecConnectionFactory.createConnection(
|
||||||
testScope,
|
testScope,
|
||||||
@@ -101,9 +111,13 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
|
|||||||
test.each([{ controlled: true }, { controlled: false }])(
|
test.each([{ controlled: true }, { controlled: false }])(
|
||||||
"it sets controlledAudioDevice=$controlled then uses deviceId accordingly",
|
"it sets controlledAudioDevice=$controlled then uses deviceId accordingly",
|
||||||
({ controlled }) => {
|
({ controlled }) => {
|
||||||
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
|
|
||||||
const RoomConstructor = vi.mocked(LivekitRoom);
|
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||||
|
|
||||||
|
// Explicitly set audio settings so the test doesn't depend on defaults
|
||||||
|
echoCancellationSetting.setValue(true);
|
||||||
|
noiseSuppressionSetting.setValue(true);
|
||||||
|
autoGainControlSetting.setValue(true);
|
||||||
|
|
||||||
const ecConnectionFactory = new ECConnectionFactory(
|
const ecConnectionFactory = new ECConnectionFactory(
|
||||||
mockClient,
|
mockClient,
|
||||||
"!roomid:example.org",
|
"!roomid:example.org",
|
||||||
@@ -120,9 +134,6 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
|
|||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
controlled,
|
controlled,
|
||||||
undefined,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
ecConnectionFactory.createConnection(
|
ecConnectionFactory.createConnection(
|
||||||
testScope,
|
testScope,
|
||||||
@@ -143,6 +154,114 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ECConnectionFactory - Camera quality settings", () => {
|
||||||
|
test("it uses default video options when advancedCamera is disabled", () => {
|
||||||
|
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||||
|
advancedCamera.setValue(false);
|
||||||
|
|
||||||
|
const ecConnectionFactory = new ECConnectionFactory(
|
||||||
|
mockClient,
|
||||||
|
"!roomid:example.org",
|
||||||
|
mockMediaDevices({}),
|
||||||
|
new BehaviorSubject<ProcessorState>({
|
||||||
|
supported: true,
|
||||||
|
processor: undefined,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ecConnectionFactory.createConnection(
|
||||||
|
testScope,
|
||||||
|
exampleTransport,
|
||||||
|
ownMemberMock,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
// publishDefaults should use config defaults (vp8), not custom settings
|
||||||
|
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
publishDefaults: expect.objectContaining({
|
||||||
|
videoCodec: "vp8",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it applies custom camera resolution, encoding, and codec when advancedCamera is enabled", () => {
|
||||||
|
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||||
|
|
||||||
|
advancedCamera.setValue(true);
|
||||||
|
cameraResolution.setValue("1920x1080");
|
||||||
|
cameraFramerate.setValue(60);
|
||||||
|
cameraBitrate.setValue(4_000_000);
|
||||||
|
cameraCodec.setValue("vp9");
|
||||||
|
|
||||||
|
const ecConnectionFactory = new ECConnectionFactory(
|
||||||
|
mockClient,
|
||||||
|
"!roomid:example.org",
|
||||||
|
mockMediaDevices({}),
|
||||||
|
new BehaviorSubject<ProcessorState>({
|
||||||
|
supported: true,
|
||||||
|
processor: undefined,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ecConnectionFactory.createConnection(
|
||||||
|
testScope,
|
||||||
|
exampleTransport,
|
||||||
|
ownMemberMock,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
videoCaptureDefaults: expect.objectContaining({
|
||||||
|
resolution: { width: 1920, height: 1080, frameRate: 60 },
|
||||||
|
}),
|
||||||
|
publishDefaults: expect.objectContaining({
|
||||||
|
videoEncoding: { maxBitrate: 4_000_000, maxFramerate: 60 },
|
||||||
|
videoCodec: "vp9",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it applies autoGainControl from settings", () => {
|
||||||
|
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||||
|
|
||||||
|
autoGainControlSetting.setValue(false);
|
||||||
|
echoCancellationSetting.setValue(true);
|
||||||
|
noiseSuppressionSetting.setValue(true);
|
||||||
|
|
||||||
|
const ecConnectionFactory = new ECConnectionFactory(
|
||||||
|
mockClient,
|
||||||
|
"!roomid:example.org",
|
||||||
|
mockMediaDevices({}),
|
||||||
|
new BehaviorSubject<ProcessorState>({
|
||||||
|
supported: true,
|
||||||
|
processor: undefined,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ecConnectionFactory.createConnection(
|
||||||
|
testScope,
|
||||||
|
exampleTransport,
|
||||||
|
ownMemberMock,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
audioCaptureDefaults: expect.objectContaining({
|
||||||
|
autoGainControl: false,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
testScope.end();
|
testScope.end();
|
||||||
fetchMock.reset();
|
fetchMock.reset();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const createRoomWidgetClientSpy = vi.mocked(createRoomWidgetClient);
|
|||||||
vi.mock("./config/Config", () => ({
|
vi.mock("./config/Config", () => ({
|
||||||
Config: {
|
Config: {
|
||||||
init: vi.fn().mockImplementation(async () => Promise.resolve()),
|
init: vi.fn().mockImplementation(async () => Promise.resolve()),
|
||||||
|
get: vi.fn().mockReturnValue({}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
const configInitSpy = vi.mocked(Config.init);
|
const configInitSpy = vi.mocked(Config.init);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { IWidgetApiRequest } from "matrix-widget-api";
|
|||||||
import { LazyEventEmitter } from "./LazyEventEmitter";
|
import { LazyEventEmitter } from "./LazyEventEmitter";
|
||||||
import { getUrlParams } from "./UrlParams";
|
import { getUrlParams } from "./UrlParams";
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
|
import { seedSettingsFromConfig } from "./settings/settings";
|
||||||
import { ElementCallReactionEventType } from "./reactions";
|
import { ElementCallReactionEventType } from "./reactions";
|
||||||
|
|
||||||
// Subset of the actions in element-web
|
// Subset of the actions in element-web
|
||||||
@@ -196,6 +197,7 @@ export const initializeWidget = (
|
|||||||
// Wait for the config file to be ready (we load very early on so it might not
|
// Wait for the config file to be ready (we load very early on so it might not
|
||||||
// be otherwise)
|
// be otherwise)
|
||||||
await Config.init();
|
await Config.init();
|
||||||
|
seedSettingsFromConfig(Config.get().media_quality);
|
||||||
await client.startClient();
|
await client.startClient();
|
||||||
return client;
|
return client;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user