@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +216 -119
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +216 -119
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +216 -119
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/helpers/BlockedAudioTracker.d.ts +16 -1
  9. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +4 -0
  10. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  11. package/dist/src/rtc/Publisher.d.ts +1 -0
  12. package/dist/src/rtc/types.d.ts +9 -2
  13. package/dist/src/store/rxUtils.d.ts +5 -0
  14. package/package.json +11 -11
  15. package/src/Call.ts +1 -0
  16. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +1 -2
  17. package/src/devices/devicePersistence.ts +1 -2
  18. package/src/helpers/BlockedAudioTracker.ts +71 -13
  19. package/src/helpers/DynascaleManager.ts +14 -3
  20. package/src/helpers/MediaPlaybackWatchdog.ts +35 -16
  21. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +54 -6
  22. package/src/helpers/__tests__/DynascaleManager.test.ts +45 -0
  23. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +137 -8
  24. package/src/helpers/__tests__/ViewportTracker.test.ts +1 -2
  25. package/src/reporting/ClientEventReporter.ts +39 -39
  26. package/src/reporting/__tests__/ClientEventReporter.test.ts +159 -44
  27. package/src/rtc/BasePeerConnection.ts +1 -0
  28. package/src/rtc/Publisher.ts +78 -58
  29. package/src/rtc/__tests__/Publisher.test.ts +297 -0
  30. package/src/rtc/types.ts +14 -13
  31. package/src/stats/rtc/types.ts +8 -2
  32. package/src/store/CallState.ts +6 -19
  33. package/src/store/rxUtils.ts +15 -0
  34. package/src/types.ts +1 -4
@@ -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
- if (!this.publishOptions.some((o) => o.trackType === trackType)) {
111
- throw new Error(`No publish options found for ${TrackType[trackType]}`);
112
- }
113
-
114
- for (const publishOption of this.publishOptions) {
115
- if (publishOption.trackType !== trackType) continue;
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 bundle = this.transceiverCache.get(publishOption);
122
- if (!bundle) {
123
- await this.addTransceiver(trackToPublish, publishOption, options);
124
- } else {
125
- const previousTrack = bundle.transceiver.sender.track;
126
- await this.updateTransceiver(bundle, trackToPublish, options);
127
- if (!isReactNative()) {
128
- this.stopTrack(previousTrack);
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
- // enable publishing with new options -> [av1, vp9]
229
- for (const publishOption of this.publishOptions) {
230
- const { trackType } = publishOption;
231
- if (!this.isPublishing(trackType)) continue;
232
- if (this.transceiverCache.has(publishOption)) continue;
233
-
234
- const item = this.transceiverCache.find(
235
- (i) =>
236
- !!i.transceiver.sender.track &&
237
- i.publishOption.trackType === trackType,
238
- );
239
- if (!item) continue;
240
-
241
- // take the track from the existing transceiver for the same track type,
242
- // clone it and publish it with the new publish options
243
- const track = this.cloneTrack(item.transceiver.sender.track!);
244
- await this.addTransceiver(track, publishOption, item.options);
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
- // stop publishing with options not required anymore -> [vp9]
248
- for (const item of this.transceiverCache.items()) {
249
- const { publishOption, transceiver } = item;
250
- const hasPublishOption = this.publishOptions.some(
251
- (option) =>
252
- option.id === publishOption.id &&
253
- option.trackType === publishOption.trackType,
254
- );
255
- if (hasPublishOption) continue;
256
- // it is safe to stop the track here, it is a clone
257
- this.stopTrack(transceiver.sender.track);
258
- await this.updateTransceiver(item, null);
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
- | (typeof ReconnectReason)[keyof typeof ReconnectReason]
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
- peerType: PeerType;
67
- stateType: 'ice';
68
- state: RTCIceConnectionState;
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,
@@ -12,9 +12,15 @@ export type RTCStatsDataType =
12
12
  | boolean
13
13
  | RTCOfferOptions
14
14
  | [string | RTCDataChannelInit | undefined] // createDataChannel
15
- | (RTCOfferOptions | undefined) // createOffer | createAnswer
15
+ | (
16
+ | RTCOfferOptions
17
+ | undefined // createOffer | createAnswer
18
+ )
16
19
  | RTCSessionDescriptionInit
17
- | (RTCIceCandidateInit | RTCIceCandidate) // addIceCandidate
20
+ | (
21
+ | RTCIceCandidateInit
22
+ | RTCIceCandidate // addIceCandidate
23
+ )
18
24
  | object
19
25
  | number
20
26
  | null
@@ -341,8 +341,7 @@ export class CallState {
341
341
 
342
342
  private readonly eventHandlers: {
343
343
  [EventType in VideoEvent['type']]:
344
- | ((event: Extract<VideoEvent, { type: EventType }>) => void)
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(this.blockedUserIdsSubject, isShallowEqual);
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(isShallowEqual),
461
+ distinctUntilChanged(RxUtils.isShallowArrayEqual),
475
462
  shareReplay({ bufferSize: 1, refCount: true }),
476
463
  );
477
464
  this.participantCount$ = duc(this.participantCountSubject);
@@ -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
- | 'audio'
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.