mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
feat: camera quality settings, audio processing toggles, config-seeded defaults
- Add camera video quality controls (resolution/framerate/bitrate/codec) to Settings > Video, mirroring the screen share settings UI - Add audio processing toggles (echo cancellation, noise suppression, auto gain control) to Settings > Audio, replacing URL-param-only controls - Display raw values inline on all sliders (framerate, bitrate, volume) - Add config-seeded defaults: config.json media_quality values now seed Setting defaults for users who haven't explicitly set preferences - Camera settings are applied when joining a call via ConnectionFactory Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Props> = ({
|
||||
<div className={styles.volumeSlider}>
|
||||
<label>
|
||||
{t("settings.screen_share_framerate_label", "Framerate")}
|
||||
{": "}
|
||||
<span className={styles.settingValue}>{framerateRaw} fps</span>
|
||||
</label>
|
||||
<Slider
|
||||
label={t("settings.screen_share_framerate_label", "Framerate")}
|
||||
@@ -172,6 +182,10 @@ export const SettingsModal: FC<Props> = ({
|
||||
<div className={styles.volumeSlider}>
|
||||
<label>
|
||||
{t("settings.screen_share_bitrate_label", "Bitrate")}
|
||||
{": "}
|
||||
<span className={styles.settingValue}>
|
||||
{(bitrateRaw / 1_000_000).toFixed(1)} Mbps
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
label={t("settings.screen_share_bitrate_label", "Bitrate")}
|
||||
@@ -209,6 +223,177 @@ export const SettingsModal: FC<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<h4>{t("settings.camera_header", "Camera quality")}</h4>
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="advancedCamera"
|
||||
label={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.",
|
||||
)}
|
||||
type="checkbox"
|
||||
checked={advancedEnabled}
|
||||
onChange={(e): void => setAdvancedEnabled(e.target.checked)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{advancedEnabled && (
|
||||
<>
|
||||
<div className={styles.volumeSlider}>
|
||||
<label htmlFor="cameraResolution">
|
||||
{t("settings.camera_resolution_label", "Resolution")}
|
||||
</label>
|
||||
<select
|
||||
id="cameraResolution"
|
||||
value={resolution}
|
||||
onChange={(e): void => setResolution(e.target.value)}
|
||||
>
|
||||
<option value="640x360">360p</option>
|
||||
<option value="960x540">540p</option>
|
||||
<option value="1280x720">720p</option>
|
||||
<option value="1920x1080">1080p</option>
|
||||
<option value="2560x1440">1440p</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.volumeSlider}>
|
||||
<label>
|
||||
{t("settings.camera_framerate_label", "Framerate")}
|
||||
{": "}
|
||||
<span className={styles.settingValue}>
|
||||
{framerateRaw} fps
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
label={t("settings.camera_framerate_label", "Framerate")}
|
||||
value={framerateRaw}
|
||||
onValueChange={setFramerateRaw}
|
||||
onValueCommit={setFramerate}
|
||||
min={5}
|
||||
max={60}
|
||||
step={5}
|
||||
tooltipFormatter={(v): string => `${v} fps`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.volumeSlider}>
|
||||
<label>
|
||||
{t("settings.camera_bitrate_label", "Bitrate")}
|
||||
{": "}
|
||||
<span className={styles.settingValue}>
|
||||
{(bitrateRaw / 1_000_000).toFixed(1)} Mbps
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
label={t("settings.camera_bitrate_label", "Bitrate")}
|
||||
value={bitrateRaw}
|
||||
onValueChange={setBitrateRaw}
|
||||
onValueCommit={setBitrate}
|
||||
min={200_000}
|
||||
max={8_000_000}
|
||||
step={100_000}
|
||||
tooltipFormatter={(v): string =>
|
||||
`${(v / 1_000_000).toFixed(1)} Mbps`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.volumeSlider}>
|
||||
<label htmlFor="cameraCodec">
|
||||
{t("settings.camera_codec_label", "Codec")}
|
||||
</label>
|
||||
<select
|
||||
id="cameraCodec"
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const devices = useMediaDevices();
|
||||
useEffect(() => {
|
||||
if (open) devices.requestDeviceNames();
|
||||
@@ -263,7 +448,13 @@ export const SettingsModal: FC<Props> = ({
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Slider
|
||||
label={t("video_tile.volume")}
|
||||
@@ -276,6 +467,8 @@ export const SettingsModal: FC<Props> = ({
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
<Separator />
|
||||
<AudioProcessingSettings />
|
||||
</>
|
||||
),
|
||||
};
|
||||
@@ -295,6 +488,8 @@ export const SettingsModal: FC<Props> = ({
|
||||
<Separator />
|
||||
<BlurCheckbox />
|
||||
<Separator />
|
||||
<CameraSettings />
|
||||
<Separator />
|
||||
<ScreenShareSettings />
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -20,6 +20,7 @@ export class Setting<T> {
|
||||
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<T> {
|
||||
}
|
||||
|
||||
private readonly key: string;
|
||||
private readonly hasStoredValue: boolean;
|
||||
|
||||
private readonly _value$: BehaviorSubject<T>;
|
||||
private readonly _lastUpdateReason$: BehaviorSubject<string | null>;
|
||||
@@ -53,6 +55,17 @@ export class Setting<T> {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user