fix(voice): send screen shares at the quality the user picked (#2840)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-19 16:46:22 +02:00
1 parent 2119e10ed5
commit 9027cbdf3e
102 files changed
+12454 -20847

No files matched your search

@@ -11,6 +11,7 @@ import {
ensureAudioNackAndStereo,
ensureOpusFmtp,
ensureVideoDDExtension,
placeholderMidsFromTransceivers,
videoSectionCanReceiveAV1,
} from './PCTransport.ts';
import {ddExtensionURI} from './utils.ts';
@@ -239,3 +240,25 @@ describe('ensureVideoDDExtension', () => {
expect(sectionOf(sdp, '2').ext).toHaveLength(2);
});
});
describe('placeholderMidsFromTransceivers', () => {
function transceiver(
mid: string | null,
track: MediaStreamTrack | null,
currentDirection: RTCRtpTransceiverDirection | null,
): RTCRtpTransceiver {
return {mid, currentDirection, sender: {track}} as unknown as RTCRtpTransceiver;
}
it('keeps the trackless recvonly sections that still hold an m-line', () => {
const mids = placeholderMidsFromTransceivers([
transceiver('3', null, 'recvonly'),
transceiver('7', {} as MediaStreamTrack, 'sendonly'),
]);
expect(mids).toEqual(new Set(['3']));
});
it('drops a transceiver that unpublish stopped so its recycled m-section is not fmtp-conformed', () => {
expect(placeholderMidsFromTransceivers([transceiver('7', null, 'stopped')])).toEqual(new Set());
});
});
@@ -849,6 +849,9 @@ export function collectStereoMids(
export function placeholderMidsFromTransceivers(transceivers: ReadonlyArray<RTCRtpTransceiver>): Set<string> {
const mids = new Set<string>();
for (const transceiver of transceivers) {
if (transceiver.currentDirection === 'stopped') {
continue;
}
if (transceiver.mid && !transceiver.sender.track) {
mids.add(transceiver.mid);
}
@@ -106,11 +106,6 @@ export class PCTransportManager {
this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
this.publisher = new PCTransport(rtcConfig, loggerOptions);
if (subscriberVideoCodecExclusions?.length) {
for (const codec of subscriberVideoCodecExclusions) {
this.publisher.excludedVideoDecoderMimeTypes.add(`video/${codec}`);
}
}
this._mode = mode;
if (mode !== 'publisher-only') {
this.subscriber = new PCTransport(rtcConfig, loggerOptions);
@@ -2,7 +2,8 @@
//
// SPDX-License-Identifier: Apache-2.0
import {describe, expect, it} from 'vitest';
import {selectPublisherCodecPreferences} from './RTCEngine.ts';
import type {InternalRoomOptions} from '../options.ts';
import RTCEngine, {selectPublisherCodecPreferences} from './RTCEngine.ts';
function codec(
mimeType: string,
@@ -112,3 +113,42 @@ describe('selectPublisherCodecPreferences', () => {
expect(selectPublisherCodecPreferences('av1', [codec('video/VP8'), codec('video/rtx')])).toEqual([]);
});
});
describe('publisher data channels before negotiation', () => {
function engineWithPublisherChannels(hasPublisherChannels: boolean) {
const engine = new RTCEngine({} as InternalRoomOptions);
const created: Array<string> = [];
const internals = engine as unknown as {
_isClosed: boolean;
pcManager: unknown;
dataChannels: {hasPublisherChannels: boolean; createPublisherChannels: () => void};
};
internals._isClosed = false;
internals.dataChannels = {
hasPublisherChannels,
createPublisherChannels: () => created.push('publisher'),
};
internals.pcManager = {
requirePublisher: () => {},
negotiate: async () => {},
publisher: {
off: () => {},
once: () => {},
getTransceivers: () => [{} as RTCRtpTransceiver],
},
};
return {engine, created};
}
it('creates them on a renegotiation that already carries transceivers', async () => {
const {engine, created} = engineWithPublisherChannels(false);
await engine.negotiate();
expect(created).toEqual(['publisher']);
});
it('leaves the existing channels alone', async () => {
const {engine, created} = engineWithPublisherChannels(true);
await engine.negotiate();
expect(created).toEqual([]);
});
});
@@ -1559,7 +1559,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
}
this.pcManager.requirePublisher();
if (this.pcManager.publisher.getTransceivers().length === 0 && !this.dataChannels.hasPublisherChannels) {
if (!this.dataChannels.hasPublisherChannels) {
this.createDataChannels();
}
@@ -108,6 +108,7 @@ import {
isWeb,
selectPreferredVideoCodec,
sleep,
stopTransceiversForSender,
supportsVideoCodec,
usesLegacySVCEncodings,
} from '../utils.ts';
@@ -436,8 +437,9 @@ export default class LocalParticipant extends Participant {
enabled: boolean,
options?: ScreenShareCaptureOptions,
publishOptions?: TrackPublishOptions,
audioPublishOptions?: TrackPublishOptions,
): Promise<LocalTrackPublication | undefined> {
return this.setTrackEnabled(Track.Source.ScreenShare, enabled, options, publishOptions);
return this.setTrackEnabled(Track.Source.ScreenShare, enabled, options, publishOptions, audioPublishOptions);
}
async setE2EEEnabled(enabled: boolean) {
@@ -474,12 +476,14 @@ export default class LocalParticipant extends Participant {
enabled: boolean,
options?: ScreenShareCaptureOptions,
publishOptions?: TrackPublishOptions,
audioPublishOptions?: TrackPublishOptions,
): Promise<LocalTrackPublication | undefined>;
private async setTrackEnabled(
source: Track.Source,
enabled: true,
options?: VideoCaptureOptions | AudioCaptureOptions | ScreenShareCaptureOptions,
publishOptions?: TrackPublishOptions,
audioPublishOptions?: TrackPublishOptions,
) {
this.log.debug('setTrackEnabled', {source, enabled});
if (this.republishPromise) {
@@ -548,7 +552,12 @@ export default class LocalParticipant extends Participant {
for (const localTrack of localTracks) {
this.log.info('publishing track', getLogContextFromTrack(localTrack));
publishPromises.push(this.publishTrack(localTrack, publishOptions));
publishPromises.push(
this.publishTrack(
localTrack,
audioPublishOptions && isAudioTrack(localTrack) ? audioPublishOptions : publishOptions,
),
);
}
const publishedTracks = await Promise.all(publishPromises);
@@ -1421,29 +1430,30 @@ export default class LocalParticipant extends Participant {
const trackSender = track.sender;
track.sender = undefined;
if (this.engine.pcManager && this.engine.pcManager.currentState < PCTransportState.FAILED && trackSender) {
const publisher = this.engine.pcManager.publisher;
try {
for (const transceiver of this.engine.pcManager.publisher.getTransceivers()) {
if (transceiver.sender === trackSender) {
transceiver.direction = 'inactive';
negotiationNeeded = true;
}
}
try {
negotiationNeeded = this.engine.removeTrack(trackSender);
} catch (e) {
this.log.warn(e);
negotiationNeeded = true;
}
if (stopTransceiversForSender(publisher.getTransceivers(), trackSender)) {
negotiationNeeded = true;
}
if (isLocalVideoTrack(track)) {
for (const [, trackInfo] of track.simulcastCodecs) {
if (trackInfo.sender) {
try {
negotiationNeeded = this.engine.removeTrack(trackInfo.sender);
negotiationNeeded = this.engine.removeTrack(trackInfo.sender) || negotiationNeeded;
} catch (e) {
this.log.warn(e);
negotiationNeeded = true;
}
if (stopTransceiversForSender(publisher.getTransceivers(), trackInfo.sender)) {
negotiationNeeded = true;
}
trackInfo.sender = undefined;
}
}
@@ -3,7 +3,14 @@
// SPDX-License-Identifier: Apache-2.0
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
import {screenCaptureToDisplayMediaStreamOptions} from './track/utils.ts';
import {selectPreferredVideoCodec, supportsAV1, supportsH265, supportsVideoCodec, supportsVP9} from './utils.ts';
import {
selectPreferredVideoCodec,
stopTransceiversForSender,
supportsAV1,
supportsH265,
supportsVideoCodec,
supportsVP9,
} from './utils.ts';
const originalNavigator = globalThis.navigator;
const originalSender = globalThis.RTCRtpSender;
@@ -91,3 +98,46 @@ describe('screenCaptureToDisplayMediaStreamOptions', () => {
expect(options.systemAudio).toBe('exclude');
});
});
describe('stopTransceiversForSender', () => {
type FakeTransceiver = {sender: RTCRtpSender; direction: string; stopCalls: number; stop?: () => void};
function fakeTransceiver(sender: RTCRtpSender, canStop = true): FakeTransceiver {
const transceiver: FakeTransceiver = {sender, direction: 'sendonly', stopCalls: 0};
if (canStop) {
transceiver.stop = () => {
transceiver.stopCalls += 1;
transceiver.direction = 'stopped';
};
}
return transceiver;
}
function asTransceivers(list: Array<FakeTransceiver>): ReadonlyArray<RTCRtpTransceiver> {
return list as unknown as ReadonlyArray<RTCRtpTransceiver>;
}
it('stops the transceiver holding the sender so the m-section can be recycled', () => {
const sender = {} as RTCRtpSender;
const mine = fakeTransceiver(sender);
const theirs = fakeTransceiver({} as RTCRtpSender);
expect(stopTransceiversForSender(asTransceivers([theirs, mine]), sender)).toBe(true);
expect(mine.stopCalls).toBe(1);
expect(mine.direction).toBe('stopped');
expect(theirs.stopCalls).toBe(0);
expect(theirs.direction).toBe('sendonly');
});
it('reports no match when the sender is not on the connection', () => {
const mine = fakeTransceiver({} as RTCRtpSender);
expect(stopTransceiversForSender(asTransceivers([mine]), {} as RTCRtpSender)).toBe(false);
expect(mine.stopCalls).toBe(0);
});
it('falls back to inactive where stop is unavailable', () => {
const sender = {} as RTCRtpSender;
const mine = fakeTransceiver(sender, false);
expect(stopTransceiversForSender(asTransceivers([mine]), sender)).toBe(true);
expect(mine.direction).toBe('inactive');
});
});
@@ -158,6 +158,25 @@ export function negotiateDependencyDescriptor(transceiver: RTCRtpTransceiver): b
}
}
export function stopTransceiversForSender(
transceivers: ReadonlyArray<RTCRtpTransceiver>,
sender: RTCRtpSender,
): boolean {
let matched = false;
for (const transceiver of transceivers) {
if (transceiver.sender !== sender) {
continue;
}
matched = true;
if (typeof transceiver.stop === 'function') {
transceiver.stop();
} else {
transceiver.direction = 'inactive';
}
}
return matched;
}
export function isSVCSimulcast(codec?: string, options?: {simulcast?: boolean; scalabilityMode?: string}): boolean {
return isSVCCodec(codec) && !!options?.simulcast && !!options.scalabilityMode?.startsWith('L1T');
}