@stream-io/video-client 1.55.1 → 1.56.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.
@@ -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
@@ -60,18 +60,20 @@ export type OnIceConnected = (peerType: PeerType) => void;
60
60
  * Snapshot of the peer connection's ICE and DTLS state surfaced to telemetry
61
61
  * consumers (e.g. `ClientEventReporter`). Fired on every transition of
62
62
  * either `iceConnectionState` or `peerConnectionState`.
63
+ *
64
+ * `iceConnectionState` is the live `RTCPeerConnection.iceConnectionState` read
65
+ * at the moment the event fires, regardless of which transition triggered it.
66
+ * Consumers must use it (rather than assuming a value from `stateType`) because
67
+ * the browser does not guarantee the relative ordering of the `ice` and
68
+ * `peerConnection` `failed` transitions.
63
69
  */
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
- };
70
+ export type PeerConnectionStateChangeEvent = {
71
+ peerType: PeerType;
72
+ iceConnectionState: RTCIceConnectionState;
73
+ } & (
74
+ | { stateType: 'ice'; state: RTCIceConnectionState }
75
+ | { stateType: 'peerConnection'; state: RTCPeerConnectionState }
76
+ );
75
77
 
76
78
  export type OnPeerConnectionStateChange = (
77
79
  event: PeerConnectionStateChangeEvent,
@@ -408,21 +408,6 @@ export class CallState {
408
408
  this.thumbnails$ = this.thumbnailsSubject.asObservable();
409
409
  this.closedCaptions$ = this.closedCaptionsSubject.asObservable();
410
410
 
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
411
  /**
427
412
  * Creates an Observable from the given subject by piping to the
428
413
  * `distinctUntilChanged()` operator.
@@ -436,7 +421,10 @@ export class CallState {
436
421
  this.anonymousParticipantCount$ = duc(
437
422
  this.anonymousParticipantCountSubject,
438
423
  );
439
- this.blockedUserIds$ = duc(this.blockedUserIdsSubject, isShallowEqual);
424
+ this.blockedUserIds$ = duc(
425
+ this.blockedUserIdsSubject,
426
+ RxUtils.isShallowArrayEqual,
427
+ );
440
428
  this.backstage$ = duc(this.backstageSubject);
441
429
  this.callingState$ = duc(this.callingStateSubject);
442
430
  this.ownCapabilities$ = combineLatest([
@@ -471,7 +459,7 @@ export class CallState {
471
459
 
472
460
  return nextCapabilities;
473
461
  }),
474
- distinctUntilChanged(isShallowEqual),
462
+ distinctUntilChanged(RxUtils.isShallowArrayEqual),
475
463
  shareReplay({ bufferSize: 1, refCount: true }),
476
464
  );
477
465
  this.participantCount$ = duc(this.participantCountSubject);
@@ -601,6 +589,13 @@ export class CallState {
601
589
  this.setCurrentValue(this.participantsSubject, (ps) => ps);
602
590
  };
603
591
 
592
+ /**
593
+ * Returns the comparator currently used to sort the participants.
594
+ */
595
+ getSortParticipantsBy = (): Comparator<StreamVideoParticipant> => {
596
+ return this.sortParticipantsBy;
597
+ };
598
+
604
599
  /**
605
600
  * Gets the current value of an observable, or undefined if the observable has
606
601
  * not emitted a value yet.
@@ -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
  *