@stream-io/video-client 1.55.2 → 1.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/index.browser.es.js +216 -119
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +216 -119
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +216 -119
- package/dist/index.es.js.map +1 -1
- package/dist/src/helpers/BlockedAudioTracker.d.ts +16 -1
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
- package/dist/src/rtc/Publisher.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +9 -2
- package/dist/src/store/rxUtils.d.ts +5 -0
- package/package.json +11 -11
- package/src/Call.ts +1 -0
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -2
- package/src/devices/devicePersistence.ts +1 -2
- package/src/helpers/BlockedAudioTracker.ts +71 -13
- package/src/helpers/DynascaleManager.ts +14 -3
- package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +54 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +45 -0
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
- package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
- package/src/reporting/ClientEventReporter.ts +39 -39
- package/src/reporting/__tests__/ClientEventReporter.test.ts +159 -44
- package/src/rtc/BasePeerConnection.ts +1 -0
- package/src/rtc/Publisher.ts +78 -58
- package/src/rtc/__tests__/Publisher.test.ts +297 -0
- package/src/rtc/types.ts +14 -13
- package/src/stats/rtc/types.ts +8 -2
- package/src/store/CallState.ts +6 -19
- package/src/store/rxUtils.ts +15 -0
- package/src/types.ts +1 -4
package/src/rtc/Publisher.ts
CHANGED
|
@@ -40,6 +40,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
40
40
|
private readonly clonedTracks = new Set<MediaStreamTrack>();
|
|
41
41
|
private publishOptions: PublishOption[];
|
|
42
42
|
private readonly selfSubEnabled: boolean;
|
|
43
|
+
private readonly transceiverLockKey = `pub.tx.${this.lock}`;
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Constructs a new `Publisher` instance.
|
|
@@ -107,28 +108,31 @@ export class Publisher extends BasePeerConnection {
|
|
|
107
108
|
trackType: TrackType,
|
|
108
109
|
options: TrackPublishOptions = {},
|
|
109
110
|
) => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// create a clone of the track as otherwise the same trackId will
|
|
118
|
-
// appear in the SDP in multiple transceivers
|
|
119
|
-
const trackToPublish = this.cloneTrack(track);
|
|
111
|
+
return withoutConcurrency(this.transceiverLockKey, async () => {
|
|
112
|
+
const publishOptions = this.publishOptions.filter(
|
|
113
|
+
(o) => o.trackType === trackType,
|
|
114
|
+
);
|
|
115
|
+
if (!publishOptions.length) {
|
|
116
|
+
throw new Error(`No publish options found for ${TrackType[trackType]}`);
|
|
117
|
+
}
|
|
120
118
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!
|
|
128
|
-
this.
|
|
119
|
+
for (const publishOption of publishOptions) {
|
|
120
|
+
// create a clone of the track as otherwise the same trackId will
|
|
121
|
+
// appear in the SDP in multiple transceivers
|
|
122
|
+
const trackToPublish = this.cloneTrack(track);
|
|
123
|
+
|
|
124
|
+
const bundle = this.transceiverCache.get(publishOption);
|
|
125
|
+
if (!bundle) {
|
|
126
|
+
await this.addTransceiver(trackToPublish, publishOption, options);
|
|
127
|
+
} else {
|
|
128
|
+
const previousTrack = bundle.transceiver.sender.track;
|
|
129
|
+
await this.updateTransceiver(bundle, trackToPublish, options);
|
|
130
|
+
if (!isReactNative()) {
|
|
131
|
+
this.stopTrack(previousTrack);
|
|
132
|
+
}
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
|
-
}
|
|
135
|
+
});
|
|
132
136
|
};
|
|
133
137
|
|
|
134
138
|
/**
|
|
@@ -139,6 +143,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
139
143
|
publishOption: PublishOption,
|
|
140
144
|
options: TrackPublishOptions,
|
|
141
145
|
) => {
|
|
146
|
+
const trackType = publishOption.trackType;
|
|
142
147
|
const encodings = isAudioTrackType(publishOption.trackType)
|
|
143
148
|
? computeAudioLayers(publishOption, options)
|
|
144
149
|
: computeVideoLayers(track, publishOption);
|
|
@@ -149,6 +154,12 @@ export class Publisher extends BasePeerConnection {
|
|
|
149
154
|
direction: 'sendonly',
|
|
150
155
|
sendEncodings,
|
|
151
156
|
});
|
|
157
|
+
// claim the cache entry in the same task that created the transceiver:
|
|
158
|
+
// an await in between would let a concurrent caller add a second one
|
|
159
|
+
// for the same publish option
|
|
160
|
+
this.transceiverCache.add({ publishOption, transceiver, options });
|
|
161
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
162
|
+
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
152
163
|
|
|
153
164
|
const params = transceiver.sender.getParameters();
|
|
154
165
|
params.degradationPreference =
|
|
@@ -156,11 +167,6 @@ export class Publisher extends BasePeerConnection {
|
|
|
156
167
|
'maintain-framerate';
|
|
157
168
|
await transceiver.sender.setParameters(params);
|
|
158
169
|
|
|
159
|
-
const trackType = publishOption.trackType;
|
|
160
|
-
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
161
|
-
this.transceiverCache.add({ publishOption, transceiver, options });
|
|
162
|
-
this.trackIdToTrackType.set(track.id, trackType);
|
|
163
|
-
|
|
164
170
|
await this.negotiate();
|
|
165
171
|
};
|
|
166
172
|
|
|
@@ -225,38 +231,40 @@ export class Publisher extends BasePeerConnection {
|
|
|
225
231
|
* Synchronizes the current Publisher state with the provided publish options.
|
|
226
232
|
*/
|
|
227
233
|
private syncPublishOptions = async () => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
234
|
+
return withoutConcurrency(this.transceiverLockKey, async () => {
|
|
235
|
+
// enable publishing with new options -> [av1, vp9]
|
|
236
|
+
for (const publishOption of this.publishOptions) {
|
|
237
|
+
const { trackType } = publishOption;
|
|
238
|
+
if (!this.isPublishing(trackType)) continue;
|
|
239
|
+
if (this.transceiverCache.has(publishOption)) continue;
|
|
240
|
+
|
|
241
|
+
const item = this.transceiverCache.find(
|
|
242
|
+
(i) =>
|
|
243
|
+
!!i.transceiver.sender.track &&
|
|
244
|
+
i.publishOption.trackType === trackType,
|
|
245
|
+
);
|
|
246
|
+
if (!item) continue;
|
|
247
|
+
|
|
248
|
+
// take the track from the existing transceiver for the same track type,
|
|
249
|
+
// clone it and publish it with the new publish options
|
|
250
|
+
const track = this.cloneTrack(item.transceiver.sender.track!);
|
|
251
|
+
await this.addTransceiver(track, publishOption, item.options);
|
|
252
|
+
}
|
|
246
253
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
// stop publishing with options not required anymore -> [vp9]
|
|
255
|
+
for (const item of this.transceiverCache.items()) {
|
|
256
|
+
const { publishOption, transceiver } = item;
|
|
257
|
+
const hasPublishOption = this.publishOptions.some(
|
|
258
|
+
(option) =>
|
|
259
|
+
option.id === publishOption.id &&
|
|
260
|
+
option.trackType === publishOption.trackType,
|
|
261
|
+
);
|
|
262
|
+
if (hasPublishOption) continue;
|
|
263
|
+
// it is safe to stop the track here, it is a clone
|
|
264
|
+
this.stopTrack(transceiver.sender.track);
|
|
265
|
+
await this.updateTransceiver(item, null);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
260
268
|
};
|
|
261
269
|
|
|
262
270
|
/**
|
|
@@ -456,13 +464,25 @@ export class Publisher extends BasePeerConnection {
|
|
|
456
464
|
private negotiate = async (options?: RTCOfferOptions): Promise<void> => {
|
|
457
465
|
return withoutConcurrency(`publisher.negotiate.${this.lock}`, async () => {
|
|
458
466
|
const offer = await this.pc.createOffer(options);
|
|
459
|
-
const tracks = this.getAnnouncedTracks(offer.sdp);
|
|
460
|
-
if (!tracks.length) throw new Error(`Can't negotiate without any tracks`);
|
|
461
|
-
|
|
462
467
|
try {
|
|
463
468
|
this.isIceRestarting = options?.iceRestart ?? false;
|
|
464
469
|
await this.pc.setLocalDescription(offer);
|
|
465
470
|
|
|
471
|
+
// the announced tracks have to be read after `setLocalDescription`.
|
|
472
|
+
// `transceiver.mid` is null until the offer is applied, and reading the
|
|
473
|
+
// tracks earlier makes `extractMid` guess the mid from the offer SDP.
|
|
474
|
+
// That guess goes stale after a rolled-back negotiation, and a mid that
|
|
475
|
+
// doesn't match the m-section carrying the track stops the SFU from
|
|
476
|
+
// correlating the two: it then answers with every offered codec instead
|
|
477
|
+
// of the announced one, and we publish the first codec of that list.
|
|
478
|
+
// Per https://www.w3.org/TR/webrtc/#set-description, applying a local
|
|
479
|
+
// offer associates the transceiver with its m-section and sets
|
|
480
|
+
// `[[Mid]]` to `[[JsepMid]]`; a rollback disassociates a transceiver
|
|
481
|
+
// that wasn't associated before and resets both slots to null.
|
|
482
|
+
const tracks = this.getAnnouncedTracks(offer.sdp);
|
|
483
|
+
if (!tracks.length)
|
|
484
|
+
throw new Error(`Can't negotiate without any tracks`);
|
|
485
|
+
|
|
466
486
|
const { sdp: baseSdp = '' } = offer;
|
|
467
487
|
const {
|
|
468
488
|
dangerouslyForceCodec,
|
|
@@ -24,6 +24,7 @@ import { IceTrickleBuffer } from '../IceTrickleBuffer';
|
|
|
24
24
|
import { StreamClient } from '../../coordinator/connection/client';
|
|
25
25
|
import { TransceiverCache } from '../TransceiverCache';
|
|
26
26
|
import { promiseWithResolvers } from '../../helpers/promise';
|
|
27
|
+
import { settled } from '../../helpers/concurrency';
|
|
27
28
|
import { isFirefox } from '../../helpers/browsers';
|
|
28
29
|
|
|
29
30
|
vi.mock('../../StreamSfuClient', () => {
|
|
@@ -1318,6 +1319,97 @@ describe('Publisher', () => {
|
|
|
1318
1319
|
});
|
|
1319
1320
|
});
|
|
1320
1321
|
|
|
1322
|
+
describe('mid assignment during negotiation', () => {
|
|
1323
|
+
const sdpWith = (
|
|
1324
|
+
sections: Array<{ mid: string; trackId?: string; port: number }>,
|
|
1325
|
+
) =>
|
|
1326
|
+
[
|
|
1327
|
+
'v=0',
|
|
1328
|
+
'o=- 0 0 IN IP4 127.0.0.1',
|
|
1329
|
+
's=-',
|
|
1330
|
+
't=0 0',
|
|
1331
|
+
...sections.flatMap((s) => [
|
|
1332
|
+
`m=video ${s.port} UDP/TLS/RTP/SAVPF 96`,
|
|
1333
|
+
'c=IN IP4 0.0.0.0',
|
|
1334
|
+
`a=mid:${s.mid}`,
|
|
1335
|
+
...(s.trackId
|
|
1336
|
+
? [`a=msid:stream-id ${s.trackId}`, 'a=sendonly']
|
|
1337
|
+
: ['a=inactive']),
|
|
1338
|
+
]),
|
|
1339
|
+
'',
|
|
1340
|
+
].join('\r\n');
|
|
1341
|
+
|
|
1342
|
+
it('announces the mid the browser assigned, not the one guessed from the pending offer', async () => {
|
|
1343
|
+
const track = new MediaStreamTrack();
|
|
1344
|
+
const transceiver = new RTCRtpTransceiver();
|
|
1345
|
+
// @ts-expect-error test setup
|
|
1346
|
+
transceiver.sender.track = track;
|
|
1347
|
+
// @ts-expect-error readonly field
|
|
1348
|
+
transceiver.mid = null;
|
|
1349
|
+
|
|
1350
|
+
publisher['transceiverCache'].add({
|
|
1351
|
+
publishOption: publisher['publishOptions'][0],
|
|
1352
|
+
transceiver,
|
|
1353
|
+
options: {},
|
|
1354
|
+
negotiated: false,
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
const firstOffer = {
|
|
1358
|
+
type: 'offer',
|
|
1359
|
+
sdp: sdpWith([{ mid: '0', trackId: track.id, port: 9 }]),
|
|
1360
|
+
};
|
|
1361
|
+
const secondOffer = {
|
|
1362
|
+
type: 'offer',
|
|
1363
|
+
sdp: sdpWith([
|
|
1364
|
+
{ mid: '0', port: 0 },
|
|
1365
|
+
{ mid: '1', trackId: track.id, port: 9 },
|
|
1366
|
+
]),
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
const pc = publisher['pc'];
|
|
1370
|
+
vi.spyOn(pc, 'createOffer')
|
|
1371
|
+
// @ts-expect-error TS picks up the wrong overload
|
|
1372
|
+
.mockResolvedValueOnce(firstOffer)
|
|
1373
|
+
// @ts-expect-error TS picks up the wrong overload
|
|
1374
|
+
.mockResolvedValueOnce(secondOffer);
|
|
1375
|
+
|
|
1376
|
+
vi.spyOn(pc, 'setLocalDescription').mockImplementation(async (desc) => {
|
|
1377
|
+
// @ts-expect-error readonly field
|
|
1378
|
+
transceiver.mid =
|
|
1379
|
+
desc?.sdp === firstOffer.sdp
|
|
1380
|
+
? '0'
|
|
1381
|
+
: desc?.sdp === secondOffer.sdp
|
|
1382
|
+
? '1'
|
|
1383
|
+
: null; // rollback reverts the mid of a never-negotiated transceiver
|
|
1384
|
+
// @ts-expect-error readonly field
|
|
1385
|
+
pc.signalingState =
|
|
1386
|
+
desc?.type === 'rollback' ? 'stable' : 'have-local-offer';
|
|
1387
|
+
});
|
|
1388
|
+
vi.spyOn(pc, 'setRemoteDescription').mockResolvedValue();
|
|
1389
|
+
|
|
1390
|
+
sfuClient.setPublisher = vi
|
|
1391
|
+
.fn()
|
|
1392
|
+
.mockRejectedValueOnce(new Error('SetPublisherTimeout'));
|
|
1393
|
+
await expect(publisher['negotiate']()).rejects.toThrow(
|
|
1394
|
+
'SetPublisherTimeout',
|
|
1395
|
+
);
|
|
1396
|
+
expect(pc.setLocalDescription).toHaveBeenLastCalledWith({
|
|
1397
|
+
type: 'rollback',
|
|
1398
|
+
});
|
|
1399
|
+
expect(transceiver.mid).toBeNull();
|
|
1400
|
+
|
|
1401
|
+
sfuClient.setPublisher = vi
|
|
1402
|
+
.fn()
|
|
1403
|
+
.mockResolvedValue({ response: { sdp: 'answer-sdp' } });
|
|
1404
|
+
await publisher['negotiate']();
|
|
1405
|
+
|
|
1406
|
+
expect(sfuClient.setPublisher).toHaveBeenCalledWith({
|
|
1407
|
+
sdp: secondOffer.sdp,
|
|
1408
|
+
tracks: [expect.objectContaining({ trackId: track.id, mid: '1' })],
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1321
1413
|
describe('Firefox unpublish workaround', () => {
|
|
1322
1414
|
const mockSenderParams = (
|
|
1323
1415
|
transceiver: RTCRtpTransceiver,
|
|
@@ -1845,4 +1937,209 @@ describe('Publisher', () => {
|
|
|
1845
1937
|
expect(trackStopSpy).toHaveBeenCalled();
|
|
1846
1938
|
});
|
|
1847
1939
|
});
|
|
1940
|
+
|
|
1941
|
+
describe('concurrent transceiver management', () => {
|
|
1942
|
+
const videoOption = (id: number, codecName: string): PublishOption =>
|
|
1943
|
+
fromPartial({
|
|
1944
|
+
id,
|
|
1945
|
+
trackType: TrackType.VIDEO,
|
|
1946
|
+
bitrate: 1000,
|
|
1947
|
+
codec: { name: codecName },
|
|
1948
|
+
fps: 30,
|
|
1949
|
+
maxTemporalLayers: 3,
|
|
1950
|
+
maxSpatialLayers: 3,
|
|
1951
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
const makeTrack = (): MediaStreamTrack => {
|
|
1955
|
+
const track = new MediaStreamTrack();
|
|
1956
|
+
// @ts-expect-error readonly field
|
|
1957
|
+
track.id = crypto.randomUUID();
|
|
1958
|
+
vi.spyOn(track, 'clone').mockImplementation(() => makeTrack());
|
|
1959
|
+
return track;
|
|
1960
|
+
};
|
|
1961
|
+
|
|
1962
|
+
const makeTransceiver = (track: MediaStreamTrack) => {
|
|
1963
|
+
const transceiver = new RTCRtpTransceiver();
|
|
1964
|
+
// @ts-expect-error test setup
|
|
1965
|
+
transceiver.sender.track = track;
|
|
1966
|
+
transceiver.sender.replaceTrack = vi.fn(async (next) => {
|
|
1967
|
+
// @ts-expect-error test setup
|
|
1968
|
+
transceiver.sender.track = next;
|
|
1969
|
+
});
|
|
1970
|
+
return transceiver;
|
|
1971
|
+
};
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* The shared mock returns the same transceiver for every call, which would
|
|
1975
|
+
* hide duplicates. Give every `addTransceiver` call its own transceiver,
|
|
1976
|
+
* with the track bound to the sender like the browser does.
|
|
1977
|
+
*/
|
|
1978
|
+
const withDistinctTransceivers = (
|
|
1979
|
+
decorate: (transceiver: RTCRtpTransceiver) => void = () => {},
|
|
1980
|
+
) => {
|
|
1981
|
+
vi.spyOn(publisher['pc'], 'addTransceiver').mockImplementation(
|
|
1982
|
+
// @ts-expect-error the mock only implements what the Publisher uses
|
|
1983
|
+
(track: MediaStreamTrack) => {
|
|
1984
|
+
const transceiver = makeTransceiver(track);
|
|
1985
|
+
decorate(transceiver);
|
|
1986
|
+
return transceiver;
|
|
1987
|
+
},
|
|
1988
|
+
);
|
|
1989
|
+
};
|
|
1990
|
+
|
|
1991
|
+
const announcedKeys = () =>
|
|
1992
|
+
publisher
|
|
1993
|
+
.getAnnouncedTracks(undefined)
|
|
1994
|
+
.map((track) => `${track.trackType}:${track.publishOptionId}`);
|
|
1995
|
+
|
|
1996
|
+
beforeEach(() => {
|
|
1997
|
+
withDistinctTransceivers();
|
|
1998
|
+
// @ts-expect-error private method
|
|
1999
|
+
vi.spyOn(publisher, 'negotiate').mockResolvedValue();
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
it('announces one track per publish option when publish() runs concurrently', async () => {
|
|
2003
|
+
await Promise.all([
|
|
2004
|
+
publisher.publish(makeTrack(), TrackType.VIDEO),
|
|
2005
|
+
publisher.publish(makeTrack(), TrackType.VIDEO),
|
|
2006
|
+
]);
|
|
2007
|
+
|
|
2008
|
+
expect(publisher['transceiverCache'].items()).toHaveLength(1);
|
|
2009
|
+
expect(announcedKeys()).toEqual([`${TrackType.VIDEO}:1`]);
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
it('announces one track per publish option when publish() runs during a codec switch', async () => {
|
|
2013
|
+
const track = makeTrack();
|
|
2014
|
+
await publisher.publish(track, TrackType.VIDEO);
|
|
2015
|
+
|
|
2016
|
+
// stall the first await inside addTransceiver, so the codec switch is
|
|
2017
|
+
// still in-flight when the next publish() looks up the cache
|
|
2018
|
+
let releaseCodecSwitch!: () => void;
|
|
2019
|
+
const codecSwitch = new Promise<void>((resolve) => {
|
|
2020
|
+
releaseCodecSwitch = resolve;
|
|
2021
|
+
});
|
|
2022
|
+
withDistinctTransceivers((transceiver) => {
|
|
2023
|
+
transceiver.sender.setParameters = vi.fn(() => codecSwitch);
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
dispatcher.dispatch(
|
|
2027
|
+
SfuEvent.create({
|
|
2028
|
+
eventPayload: {
|
|
2029
|
+
oneofKind: 'changePublishOptions',
|
|
2030
|
+
changePublishOptions: {
|
|
2031
|
+
publishOptions: [videoOption(1, 'vp9'), videoOption(2, 'av1')],
|
|
2032
|
+
reason: 'test',
|
|
2033
|
+
},
|
|
2034
|
+
},
|
|
2035
|
+
}) as DispatchableMessage<'changePublishOptions'>,
|
|
2036
|
+
'test',
|
|
2037
|
+
);
|
|
2038
|
+
await vi.waitFor(() =>
|
|
2039
|
+
expect(publisher['pc'].addTransceiver).toHaveBeenCalled(),
|
|
2040
|
+
);
|
|
2041
|
+
|
|
2042
|
+
const publishTask = publisher.publish(track, TrackType.VIDEO);
|
|
2043
|
+
// a macrotask boundary drains the pending microtasks, which is as far
|
|
2044
|
+
// as an unserialized publish() can get: up to the cache lookup for the
|
|
2045
|
+
// new publish option. It cannot resolve earlier under load, so this
|
|
2046
|
+
// does not depend on wall-clock timing.
|
|
2047
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2048
|
+
releaseCodecSwitch();
|
|
2049
|
+
await publishTask;
|
|
2050
|
+
await settled(publisher['eventLockKey']('changePublishOptions'));
|
|
2051
|
+
|
|
2052
|
+
expect(announcedKeys()).toEqual([
|
|
2053
|
+
`${TrackType.VIDEO}:1`,
|
|
2054
|
+
`${TrackType.VIDEO}:2`,
|
|
2055
|
+
]);
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
it('rejects a queued publish whose publish options were retired', async () => {
|
|
2059
|
+
// stall the first negotiation, so the next publish() has to queue
|
|
2060
|
+
const firstNegotiation = promiseWithResolvers<void>();
|
|
2061
|
+
let negotiations = 0;
|
|
2062
|
+
// @ts-expect-error private method
|
|
2063
|
+
vi.spyOn(publisher, 'negotiate').mockImplementation(() =>
|
|
2064
|
+
++negotiations === 1 ? firstNegotiation.promise : Promise.resolve(),
|
|
2065
|
+
);
|
|
2066
|
+
|
|
2067
|
+
const firstPublish = publisher.publish(makeTrack(), TrackType.VIDEO);
|
|
2068
|
+
await vi.waitFor(() => expect(negotiations).toBe(1));
|
|
2069
|
+
|
|
2070
|
+
const queuedPublish = publisher.publish(makeTrack(), TrackType.VIDEO);
|
|
2071
|
+
// video is retired while the second publish waits for the lock
|
|
2072
|
+
publisher['publishOptions'] = [];
|
|
2073
|
+
|
|
2074
|
+
firstNegotiation.resolve();
|
|
2075
|
+
await firstPublish;
|
|
2076
|
+
|
|
2077
|
+
// it must not report success without having published anything
|
|
2078
|
+
await expect(queuedPublish).rejects.toThrow(
|
|
2079
|
+
'No publish options found for VIDEO',
|
|
2080
|
+
);
|
|
2081
|
+
expect(publisher['pc'].addTransceiver).toHaveBeenCalledTimes(1);
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
it('does not keep publishing with an option retired mid-publish', async () => {
|
|
2085
|
+
publisher['publishOptions'] = [
|
|
2086
|
+
videoOption(1, 'vp9'),
|
|
2087
|
+
videoOption(2, 'av1'),
|
|
2088
|
+
];
|
|
2089
|
+
|
|
2090
|
+
// stall the first negotiation, so publish() is suspended in its loop
|
|
2091
|
+
// after option 1 was added but before option 2 is reached
|
|
2092
|
+
const firstNegotiation = promiseWithResolvers<void>();
|
|
2093
|
+
let negotiations = 0;
|
|
2094
|
+
// @ts-expect-error private method
|
|
2095
|
+
vi.spyOn(publisher, 'negotiate').mockImplementation(() =>
|
|
2096
|
+
++negotiations === 1 ? firstNegotiation.promise : Promise.resolve(),
|
|
2097
|
+
);
|
|
2098
|
+
|
|
2099
|
+
const publishTask = publisher.publish(makeTrack(), TrackType.VIDEO);
|
|
2100
|
+
await vi.waitFor(() => expect(negotiations).toBe(1));
|
|
2101
|
+
|
|
2102
|
+
dispatcher.dispatch(
|
|
2103
|
+
SfuEvent.create({
|
|
2104
|
+
eventPayload: {
|
|
2105
|
+
oneofKind: 'changePublishOptions',
|
|
2106
|
+
changePublishOptions: {
|
|
2107
|
+
publishOptions: [videoOption(1, 'vp9')],
|
|
2108
|
+
reason: 'test',
|
|
2109
|
+
},
|
|
2110
|
+
},
|
|
2111
|
+
}) as DispatchableMessage<'changePublishOptions'>,
|
|
2112
|
+
'test',
|
|
2113
|
+
);
|
|
2114
|
+
// the handler runs synchronously, so the retired options are in place
|
|
2115
|
+
// and syncPublishOptions is already queued behind this publish
|
|
2116
|
+
expect(publisher['publishOptions']).toHaveLength(1);
|
|
2117
|
+
|
|
2118
|
+
firstNegotiation.resolve();
|
|
2119
|
+
await publishTask;
|
|
2120
|
+
await settled(publisher['eventLockKey']('changePublishOptions'));
|
|
2121
|
+
|
|
2122
|
+
expect(announcedKeys()).toEqual([`${TrackType.VIDEO}:1`]);
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
it('reuses the transceiver on republish after setParameters failed', async () => {
|
|
2126
|
+
withDistinctTransceivers((transceiver) => {
|
|
2127
|
+
transceiver.sender.setParameters = vi
|
|
2128
|
+
.fn()
|
|
2129
|
+
.mockRejectedValueOnce(new Error('InvalidModificationError'));
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
await expect(
|
|
2133
|
+
publisher.publish(makeTrack(), TrackType.VIDEO),
|
|
2134
|
+
).rejects.toThrow('InvalidModificationError');
|
|
2135
|
+
|
|
2136
|
+
// the transceiver the browser created is tracked, so the republish
|
|
2137
|
+
// reuses it instead of adding a second one for the same publish option
|
|
2138
|
+
await publisher.publish(makeTrack(), TrackType.VIDEO);
|
|
2139
|
+
|
|
2140
|
+
expect(publisher['pc'].addTransceiver).toHaveBeenCalledTimes(1);
|
|
2141
|
+
expect(publisher['transceiverCache'].items()).toHaveLength(1);
|
|
2142
|
+
expect(announcedKeys()).toEqual([`${TrackType.VIDEO}:1`]);
|
|
2143
|
+
});
|
|
2144
|
+
});
|
|
1848
2145
|
});
|
package/src/rtc/types.ts
CHANGED
|
@@ -39,8 +39,7 @@ export const ReconnectReason = {
|
|
|
39
39
|
} as const;
|
|
40
40
|
|
|
41
41
|
export type ReconnectReason =
|
|
42
|
-
|
|
43
|
-
| (string & {});
|
|
42
|
+
(typeof ReconnectReason)[keyof typeof ReconnectReason] | (string & {});
|
|
44
43
|
|
|
45
44
|
export type OnReconnectionNeeded = (
|
|
46
45
|
kind: WebsocketReconnectStrategy,
|
|
@@ -60,18 +59,20 @@ export type OnIceConnected = (peerType: PeerType) => void;
|
|
|
60
59
|
* Snapshot of the peer connection's ICE and DTLS state surfaced to telemetry
|
|
61
60
|
* consumers (e.g. `ClientEventReporter`). Fired on every transition of
|
|
62
61
|
* either `iceConnectionState` or `peerConnectionState`.
|
|
62
|
+
*
|
|
63
|
+
* `iceConnectionState` is the live `RTCPeerConnection.iceConnectionState` read
|
|
64
|
+
* at the moment the event fires, regardless of which transition triggered it.
|
|
65
|
+
* Consumers must use it (rather than assuming a value from `stateType`) because
|
|
66
|
+
* the browser does not guarantee the relative ordering of the `ice` and
|
|
67
|
+
* `peerConnection` `failed` transitions.
|
|
63
68
|
*/
|
|
64
|
-
export type PeerConnectionStateChangeEvent =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
peerType: PeerType;
|
|
72
|
-
stateType: 'peerConnection';
|
|
73
|
-
state: RTCPeerConnectionState;
|
|
74
|
-
};
|
|
69
|
+
export type PeerConnectionStateChangeEvent = {
|
|
70
|
+
peerType: PeerType;
|
|
71
|
+
iceConnectionState: RTCIceConnectionState;
|
|
72
|
+
} & (
|
|
73
|
+
| { stateType: 'ice'; state: RTCIceConnectionState }
|
|
74
|
+
| { stateType: 'peerConnection'; state: RTCPeerConnectionState }
|
|
75
|
+
);
|
|
75
76
|
|
|
76
77
|
export type OnPeerConnectionStateChange = (
|
|
77
78
|
event: PeerConnectionStateChangeEvent,
|
package/src/stats/rtc/types.ts
CHANGED
|
@@ -12,9 +12,15 @@ export type RTCStatsDataType =
|
|
|
12
12
|
| boolean
|
|
13
13
|
| RTCOfferOptions
|
|
14
14
|
| [string | RTCDataChannelInit | undefined] // createDataChannel
|
|
15
|
-
| (
|
|
15
|
+
| (
|
|
16
|
+
| RTCOfferOptions
|
|
17
|
+
| undefined // createOffer | createAnswer
|
|
18
|
+
)
|
|
16
19
|
| RTCSessionDescriptionInit
|
|
17
|
-
| (
|
|
20
|
+
| (
|
|
21
|
+
| RTCIceCandidateInit
|
|
22
|
+
| RTCIceCandidate // addIceCandidate
|
|
23
|
+
)
|
|
18
24
|
| object
|
|
19
25
|
| number
|
|
20
26
|
| null
|
package/src/store/CallState.ts
CHANGED
|
@@ -341,8 +341,7 @@ export class CallState {
|
|
|
341
341
|
|
|
342
342
|
private readonly eventHandlers: {
|
|
343
343
|
[EventType in VideoEvent['type']]:
|
|
344
|
-
|
|
345
|
-
| undefined;
|
|
344
|
+
((event: Extract<VideoEvent, { type: EventType }>) => void) | undefined;
|
|
346
345
|
};
|
|
347
346
|
|
|
348
347
|
/**
|
|
@@ -408,21 +407,6 @@ export class CallState {
|
|
|
408
407
|
this.thumbnails$ = this.thumbnailsSubject.asObservable();
|
|
409
408
|
this.closedCaptions$ = this.closedCaptionsSubject.asObservable();
|
|
410
409
|
|
|
411
|
-
/**
|
|
412
|
-
* Performs shallow comparison of two arrays.
|
|
413
|
-
* Expects primitive values: [1, 2, 3] is equal to [2, 1, 3].
|
|
414
|
-
*/
|
|
415
|
-
const isShallowEqual = <T>(a: Array<T>, b: Array<T>): boolean => {
|
|
416
|
-
if (a.length !== b.length) return false;
|
|
417
|
-
for (const item of a) {
|
|
418
|
-
if (!b.includes(item)) return false;
|
|
419
|
-
}
|
|
420
|
-
for (const item of b) {
|
|
421
|
-
if (!a.includes(item)) return false;
|
|
422
|
-
}
|
|
423
|
-
return true;
|
|
424
|
-
};
|
|
425
|
-
|
|
426
410
|
/**
|
|
427
411
|
* Creates an Observable from the given subject by piping to the
|
|
428
412
|
* `distinctUntilChanged()` operator.
|
|
@@ -436,7 +420,10 @@ export class CallState {
|
|
|
436
420
|
this.anonymousParticipantCount$ = duc(
|
|
437
421
|
this.anonymousParticipantCountSubject,
|
|
438
422
|
);
|
|
439
|
-
this.blockedUserIds$ = duc(
|
|
423
|
+
this.blockedUserIds$ = duc(
|
|
424
|
+
this.blockedUserIdsSubject,
|
|
425
|
+
RxUtils.isShallowArrayEqual,
|
|
426
|
+
);
|
|
440
427
|
this.backstage$ = duc(this.backstageSubject);
|
|
441
428
|
this.callingState$ = duc(this.callingStateSubject);
|
|
442
429
|
this.ownCapabilities$ = combineLatest([
|
|
@@ -471,7 +458,7 @@ export class CallState {
|
|
|
471
458
|
|
|
472
459
|
return nextCapabilities;
|
|
473
460
|
}),
|
|
474
|
-
distinctUntilChanged(
|
|
461
|
+
distinctUntilChanged(RxUtils.isShallowArrayEqual),
|
|
475
462
|
shareReplay({ bufferSize: 1, refCount: true }),
|
|
476
463
|
);
|
|
477
464
|
this.participantCount$ = duc(this.participantCountSubject);
|
package/src/store/rxUtils.ts
CHANGED
|
@@ -10,6 +10,21 @@ type AsyncFunctionPatch<T> = (currentValue: T) => Promise<T>;
|
|
|
10
10
|
*/
|
|
11
11
|
export type Patch<T> = T | FunctionPatch<T>;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Performs shallow comparison of two arrays.
|
|
15
|
+
* Expects primitive values: [1, 2, 3] is equal to [2, 1, 3].
|
|
16
|
+
*/
|
|
17
|
+
export const isShallowArrayEqual = <T>(a: Array<T>, b: Array<T>): boolean => {
|
|
18
|
+
if (a.length !== b.length) return false;
|
|
19
|
+
for (const item of a) {
|
|
20
|
+
if (!b.includes(item)) return false;
|
|
21
|
+
}
|
|
22
|
+
for (const item of b) {
|
|
23
|
+
if (!a.includes(item)) return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
|
|
13
28
|
/**
|
|
14
29
|
* Checks if the provided update is a function patch.
|
|
15
30
|
*
|
package/src/types.ts
CHANGED
|
@@ -143,10 +143,7 @@ export interface StreamVideoParticipant extends Participant {
|
|
|
143
143
|
export type VideoTrackType = 'videoTrack' | 'screenShareTrack';
|
|
144
144
|
export type AudioTrackType = 'audioTrack' | 'screenShareAudioTrack';
|
|
145
145
|
export type TrackMuteType =
|
|
146
|
-
| '
|
|
147
|
-
| 'video'
|
|
148
|
-
| 'screenshare'
|
|
149
|
-
| 'screenshare_audio';
|
|
146
|
+
'audio' | 'video' | 'screenshare' | 'screenshare_audio';
|
|
150
147
|
|
|
151
148
|
/**
|
|
152
149
|
* Represents a participant's pin state.
|