fix(voice): keep screen share resolution under constraint (#2413)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-03 04:35:12 +02:00
1 parent 9bd0019759
commit 8158d44732
8 files changed
+72 -37

No files matched your search

@@ -260,7 +260,6 @@ export async function pushActiveStreamSettings(
resolution,
frameRate,
includeAudio,
streamingMode: normalisedMode,
contentHint,
preferredDisplaySurface,
});
@@ -41,6 +41,11 @@ interface MigrationContext {
readonly previousTrackName: string | undefined;
}
type ScreenShareVideoConstraints = MediaTrackConstraints & {
colorSpace?: string;
cursor?: 'always' | 'motion' | 'never';
};
interface MigrationState {
candidateMediaStreamTrack: MediaStreamTrack | null;
candidatePublication: LocalTrackPublication | null;
@@ -85,14 +90,17 @@ export class VoiceEngineV2AppScreenShareCodecMigration {
): Promise<boolean> {
assert.ok(screenShareTrack);
assert.ok(resolution);
const nextConstraints: MediaTrackConstraints = {};
const mediaStreamTrack = screenShareTrack.mediaStreamTrack;
const currentConstraints = mediaStreamTrack.getConstraints() as ScreenShareVideoConstraints;
const nextConstraints: ScreenShareVideoConstraints = {...currentConstraints};
if (resolution.width > 0) nextConstraints.width = {ideal: resolution.width};
if (resolution.height > 0) nextConstraints.height = {ideal: resolution.height};
if (resolution.frameRate !== undefined) {
nextConstraints.frameRate = {ideal: resolution.frameRate, max: resolution.frameRate};
}
if (JSON.stringify(currentConstraints) === JSON.stringify(nextConstraints)) return true;
try {
await screenShareTrack.mediaStreamTrack.applyConstraints(nextConstraints);
await mediaStreamTrack.applyConstraints(nextConstraints);
return true;
} catch (error) {
logger.warn('Failed to update active screen share constraints', {error, resolution});
@@ -10,6 +10,7 @@ import {ScreenShareAudioCaptureError} from '@app/features/voice/utils/ScreenShar
import type {ScreenShareCaptureOptions} from 'livekit-client';
type DisplayMediaVideoConstraints = MediaTrackConstraints & {
colorSpace?: string;
cursor?: 'always' | 'motion' | 'never';
displaySurface?: 'browser' | 'monitor' | 'window';
};
@@ -92,6 +93,21 @@ export function getDisplayMediaOptions(options?: ScreenShareCaptureOptions): Dis
} as DisplayMediaStreamOptions;
}
function buildCapturedDisplayMediaConstraints(
displayMediaOptions: DisplayMediaStreamOptions,
cursor: 'always' | 'motion' | 'never',
): MediaTrackConstraints {
const requestedVideo =
typeof displayMediaOptions.video === 'object' && displayMediaOptions.video
? (displayMediaOptions.video as DisplayMediaVideoConstraints)
: undefined;
const constraints: DisplayMediaVideoConstraints = {colorSpace: 'rec709', cursor};
if (requestedVideo?.width !== undefined) constraints.width = requestedVideo.width;
if (requestedVideo?.height !== undefined) constraints.height = requestedVideo.height;
if (requestedVideo?.frameRate !== undefined) constraints.frameRate = requestedVideo.frameRate;
return constraints;
}
export async function createDisplayScreenShareTracks(
options?: ScreenShareCaptureOptions,
captureContext?: DisplayScreenShareCaptureContext,
@@ -99,7 +115,8 @@ export async function createDisplayScreenShareTracks(
if (!navigator.mediaDevices.getDisplayMedia) {
throw new Error('getDisplayMedia not supported');
}
const stream = await navigator.mediaDevices.getDisplayMedia(getDisplayMediaOptions(options));
const displayMediaOptions = getDisplayMediaOptions(options);
const stream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
try {
const videoTrack = stream.getVideoTracks()[0];
if (!videoTrack) {
@@ -108,11 +125,10 @@ export async function createDisplayScreenShareTracks(
if (options?.contentHint) {
videoTrack.contentHint = options.contentHint;
}
await videoTrack.applyConstraints({colorSpace: 'rec709'} as MediaTrackConstraints).catch(() => undefined);
const cursor = resolveCapturedDisplayMediaCursorCapture(videoTrack, options);
if ((videoTrack.getSettings() as DisplayMediaTrackSettings).cursor !== cursor) {
await videoTrack.applyConstraints({cursor} as MediaTrackConstraints).catch(() => undefined);
}
await videoTrack
.applyConstraints(buildCapturedDisplayMediaConstraints(displayMediaOptions, cursor))
.catch(() => undefined);
const capturedAudioTrack = stream.getAudioTracks()[0];
const audioTrack = capturedAudioTrack?.readyState === 'live' ? capturedAudioTrack : undefined;
if (captureContext?.requireAudio && !audioTrack) {
@@ -57,7 +57,7 @@ const PREMIUM_SCREENSHARE_RESOLUTIONS: ReadonlySet<ScreenshareResolution> = new
export const CAMERA_EFFECT_STRENGTH_MIN = 0;
export const CAMERA_EFFECT_STRENGTH_MAX = 100;
export const CAMERA_EFFECT_STRENGTH_DEFAULT = 50;
export const DEFAULT_SCREEN_SHARE_CONTENT_HINT: ScreenShareContentHint = 'auto';
export const DEFAULT_SCREEN_SHARE_CONTENT_HINT: ScreenShareContentHint = 'text';
export const DEFAULT_SCREEN_SHARE_ENCODER_MODE: ScreenShareEncoderMode = 'auto';
export const DEFAULT_SCREEN_SHARE_SOFTWARE_QUALITY: ScreenShareSoftwareQuality = 'balanced';
export const DEFAULT_SCREEN_SHARE_SCALABILITY_MODE: ScreenShareScalabilityModePreference = 'auto';
@@ -249,6 +249,17 @@ function applyStreamingModeDefaultMigrationV1(parsed: Record<string, unknown>):
return true;
}
function applyScreenShareContentHintDefaultMigrationV1(parsed: Record<string, unknown>): boolean {
if (parsed.screenShareContentHintDefaultMigratedV1 === true) {
return false;
}
if (parsed.screenShareContentHintPrefV2 === undefined || parsed.screenShareContentHintPrefV2 === 'auto') {
parsed.screenShareContentHintPrefV2 = DEFAULT_SCREEN_SHARE_CONTENT_HINT;
}
parsed.screenShareContentHintDefaultMigratedV1 = true;
return true;
}
function applyOutputVolumeRecalibrationMigrationV1(parsed: Record<string, unknown>): boolean {
if (parsed.outputVolumeRecalibratedV1 === true) {
return false;
@@ -361,6 +372,7 @@ class VoiceSettings {
screenShareHevcOptOutMigratedV1 = false;
emulatedDecodeVideoCodecCap: CodecPreference = 'auto';
screenShareContentHintPrefV2: ScreenShareContentHint = DEFAULT_SCREEN_SHARE_CONTENT_HINT;
screenShareContentHintDefaultMigratedV1 = false;
screenShareEncoderModePrefV2: ScreenShareEncoderMode = DEFAULT_SCREEN_SHARE_ENCODER_MODE;
screenShareSoftwareQualityPrefV2: ScreenShareSoftwareQuality = DEFAULT_SCREEN_SHARE_SOFTWARE_QUALITY;
screenShareScalabilityModePrefV2: ScreenShareScalabilityModePreference = DEFAULT_SCREEN_SHARE_SCALABILITY_MODE;
@@ -479,6 +491,7 @@ class VoiceSettings {
changed = applyScreenShareAudioConsentMigrationV1(parsed) || changed;
changed = applyScreenShareAudioDefaultOnMigrationV1(parsed) || changed;
changed = applyStreamingModeDefaultMigrationV1(parsed) || changed;
changed = applyScreenShareContentHintDefaultMigrationV1(parsed) || changed;
changed = applyOutputVolumeRecalibrationMigrationV1(parsed) || changed;
changed = applyNoiseSuppressionStandardDefaultMigrationV1(parsed) || changed;
changed = applyScreenShareAv1OptOutMigrationV1(parsed) || changed;
@@ -545,6 +558,7 @@ class VoiceSettings {
'screenShareHevcOptOutMigratedV1',
'emulatedDecodeVideoCodecCap',
'screenShareContentHintPrefV2',
'screenShareContentHintDefaultMigratedV1',
'screenShareEncoderModePrefV2',
'screenShareSoftwareQualityPrefV2',
'screenShareScalabilityModePrefV2',
@@ -18,7 +18,10 @@ vi.mock('@app/features/app/utils/LimitUtils', () => ({
const AppStorage = (await import('@app/features/platform/state/PersistentStorage')).default;
AppStorage.setItem('VoiceSettings', JSON.stringify({preferredScreenShareCodec: 'av1'}));
AppStorage.setItem(
'VoiceSettings',
JSON.stringify({preferredScreenShareCodec: 'av1', screenShareContentHintPrefV2: 'auto'}),
);
const storageWrites: Array<string> = [];
AppStorage.subscribe(
@@ -132,3 +135,19 @@ describe('AV1/HEVC screen-share opt-in', () => {
expect(VoiceSettings.getPreferredScreenShareCodec()).toBe('vp9');
});
});
describe('screen share content hint default', () => {
it('moves a stored automatic content hint to text on first launch', () => {
const migrated = JSON.parse(storageWrites[0]);
expect(migrated.screenShareContentHintPrefV2).toBe('text');
expect(migrated.screenShareContentHintDefaultMigratedV1).toBe(true);
expect(VoiceSettings.getScreenShareContentHint()).toBe('text');
expect(VoiceSettings.getScreenShareContentHintOverride()).toBe('text');
});
it('keeps an automatic content hint chosen after the migration', () => {
VoiceSettings.updateSettings({screenShareContentHint: 'auto'});
expect(VoiceSettings.getScreenShareContentHint()).toBe('auto');
expect(VoiceSettings.getScreenShareContentHintOverride()).toBeUndefined();
});
});
@@ -85,31 +85,21 @@ describe('buildScreenShareOptions', () => {
windowAudio: 'exclude',
});
});
it('prefers framerate for detail-oriented shares', () => {
it('prefers resolution for detail-oriented shares', () => {
const {publishOptions} = buildScreenShareOptions({
resolution: 'medium',
frameRate: 30,
includeAudio: true,
});
expect(publishOptions.degradationPreference).toBe('maintain-framerate');
expect(publishOptions.degradationPreference).toBe('maintain-resolution');
});
it('prefers framerate for non-gaming high-framerate shares', () => {
it('keeps resolution for gaming-style high-framerate shares', () => {
const {publishOptions} = buildScreenShareOptions({
resolution: 'ultra',
frameRate: 60,
includeAudio: true,
streamingMode: 'screenshare',
});
expect(publishOptions.degradationPreference).toBe('maintain-framerate');
});
it('prefers framerate degradation for gaming streams', () => {
const {publishOptions} = buildScreenShareOptions({
resolution: 'ultra',
frameRate: 60,
includeAudio: true,
streamingMode: 'gaming',
});
expect(publishOptions.degradationPreference).toBe('maintain-framerate');
expect(publishOptions.degradationPreference).toBe('maintain-resolution');
});
it('passes the selected content hint through capture options', () => {
const {captureOptions} = buildScreenShareOptions({
@@ -18,13 +18,8 @@ const DIMENSIONS: Record<
source: {width: 3840, height: 2160},
};
export const SCREEN_SHARE_FORCED_VIDEO_BITRATE_BPS = 7000000;
export const SCREEN_SHARE_GAMING_DEGRADATION_PREFERENCE: NonNullable<TrackPublishOptions['degradationPreference']> =
'maintain-framerate';
export const SCREEN_SHARE_DEFAULT_DEGRADATION_PREFERENCE: NonNullable<TrackPublishOptions['degradationPreference']> =
'maintain-framerate';
export const SCREEN_SHARE_DEGRADATION_PREFERENCE: NonNullable<TrackPublishOptions['degradationPreference']> =
SCREEN_SHARE_DEFAULT_DEGRADATION_PREFERENCE;
export const SCREEN_SHARE_CONTENT_HINT: NonNullable<ScreenShareCaptureOptions['contentHint']> = 'motion';
'maintain-resolution';
export const SUPPORTED_SCREEN_SHARE_FRAME_RATES = [15, 30, 60, 90, 120] as const;
export type SupportedScreenShareFrameRate = (typeof SUPPORTED_SCREEN_SHARE_FRAME_RATES)[number];
@@ -85,7 +80,6 @@ export interface ScreenShareBuildConfig {
resolution: ScreenshareResolution;
frameRate: number;
includeAudio: boolean;
streamingMode?: StreamingMode;
contentHint?: ScreenShareCaptureOptions['contentHint'];
sourceDimensions?: {
width: number;
@@ -140,10 +134,6 @@ export function buildScreenShareOptions(
cursor: resolveScreenShareCursorCapture(config.preferredDisplaySurface),
...(config.preferredDisplaySurface ? {displaySurface: config.preferredDisplaySurface} : {}),
};
const degradationPreference =
config.streamingMode === 'gaming'
? SCREEN_SHARE_GAMING_DEGRADATION_PREFERENCE
: SCREEN_SHARE_DEFAULT_DEGRADATION_PREFERENCE;
return {
captureOptions: {
audio: config.includeAudio,
@@ -157,7 +147,7 @@ export function buildScreenShareOptions(
video,
},
publishOptions: {
degradationPreference,
degradationPreference: SCREEN_SHARE_DEGRADATION_PREFERENCE,
screenShareEncoding: getScreenShareEncoding(resolvedFrameRate),
},
};
@@ -312,7 +312,6 @@ function getConfiguredScreenShareOptions(
resolution,
frameRate,
includeAudio,
streamingMode: normalisedStreamingMode,
contentHint,
sourceDimensions,
preferredDisplaySurface,