@stream-io/video-client 1.54.0 → 1.55.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 (64) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +9641 -8767
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +9638 -8764
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +9639 -8765
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +13 -1
  9. package/dist/src/StreamSfuClient.d.ts +11 -3
  10. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  11. package/dist/src/gen/google/protobuf/struct.d.ts +3 -1
  12. package/dist/src/gen/google/protobuf/timestamp.d.ts +3 -1
  13. package/dist/src/gen/video/sfu/event/events.d.ts +22 -1
  14. package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
  15. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +23 -2
  16. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  17. package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
  18. package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
  19. package/dist/src/rtc/Publisher.d.ts +5 -2
  20. package/dist/src/rtc/Subscriber.d.ts +8 -0
  21. package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
  22. package/dist/src/rtc/types.d.ts +2 -0
  23. package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
  25. package/dist/src/stats/rtc/Tracer.d.ts +9 -2
  26. package/dist/src/stats/rtc/types.d.ts +10 -4
  27. package/package.json +5 -3
  28. package/src/Call.ts +83 -35
  29. package/src/StreamSfuClient.ts +36 -21
  30. package/src/__tests__/StreamSfuClient.test.ts +159 -1
  31. package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
  32. package/src/coordinator/connection/__tests__/connection.test.ts +22 -0
  33. package/src/coordinator/connection/connection.ts +8 -5
  34. package/src/gen/google/protobuf/struct.ts +7 -12
  35. package/src/gen/google/protobuf/timestamp.ts +6 -7
  36. package/src/gen/video/sfu/event/events.ts +22 -25
  37. package/src/gen/video/sfu/models/models.ts +10 -1
  38. package/src/gen/video/sfu/signal_rpc/signal.client.ts +24 -29
  39. package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
  40. package/src/helpers/__tests__/browsers.test.ts +12 -12
  41. package/src/helpers/browsers.ts +5 -5
  42. package/src/reporting/ClientEventReporter.ts +17 -12
  43. package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
  44. package/src/rtc/BasePeerConnection.ts +15 -34
  45. package/src/rtc/IceTrickleBuffer.ts +105 -12
  46. package/src/rtc/Publisher.ts +23 -19
  47. package/src/rtc/Subscriber.ts +97 -36
  48. package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
  49. package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
  50. package/src/rtc/__tests__/Publisher.test.ts +2 -31
  51. package/src/rtc/__tests__/Subscriber.test.ts +271 -20
  52. package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
  53. package/src/rtc/helpers/degradationPreference.ts +1 -0
  54. package/src/rtc/helpers/iceCandiates.ts +35 -0
  55. package/src/rtc/helpers/sdp.ts +3 -2
  56. package/src/rtc/helpers/tracks.ts +2 -0
  57. package/src/rtc/types.ts +2 -0
  58. package/src/stats/SfuStatsReporter.ts +149 -49
  59. package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
  60. package/src/stats/rtc/StatsTracer.ts +90 -32
  61. package/src/stats/rtc/Tracer.ts +23 -2
  62. package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
  63. package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
  64. package/src/stats/rtc/types.ts +11 -4
@@ -169,70 +169,70 @@ describe('browsers', () => {
169
169
 
170
170
  it('should return true for supported Chrome version', async () => {
171
171
  vi.mocked(getClientDetails).mockResolvedValue({
172
- browser: { name: 'Chrome', version: '124' },
172
+ browser: { name: 'Chrome', version: '136' },
173
173
  } as ClientDetails);
174
174
  expect(await isSupportedBrowser()).toBe(true);
175
175
  });
176
176
 
177
177
  it('should return true for supported Chrome detailed version', async () => {
178
178
  vi.mocked(getClientDetails).mockResolvedValue({
179
- browser: { name: 'Chrome', version: '124.0.7204.158' },
179
+ browser: { name: 'Chrome', version: '136.0.7204.158' },
180
180
  } as ClientDetails);
181
181
  expect(await isSupportedBrowser()).toBe(true);
182
182
  });
183
183
 
184
184
  it('should return false for unsupported Chrome version', async () => {
185
185
  vi.mocked(getClientDetails).mockResolvedValue({
186
- browser: { name: 'Chrome', version: '123' },
186
+ browser: { name: 'Chrome', version: '135' },
187
187
  } as ClientDetails);
188
188
  expect(await isSupportedBrowser()).toBe(false);
189
189
  });
190
190
 
191
191
  it('should return false for unsupported Chrome detailed version', async () => {
192
192
  vi.mocked(getClientDetails).mockResolvedValue({
193
- browser: { name: 'Chrome', version: '123.0.1234.99' },
193
+ browser: { name: 'Chrome', version: '135.0.1234.99' },
194
194
  } as ClientDetails);
195
195
  expect(await isSupportedBrowser()).toBe(false);
196
196
  });
197
197
 
198
198
  it('should return true for supported Edge version', async () => {
199
199
  vi.mocked(getClientDetails).mockResolvedValue({
200
- browser: { name: 'Edge', version: '124' },
200
+ browser: { name: 'Edge', version: '136' },
201
201
  } as ClientDetails);
202
202
  expect(await isSupportedBrowser()).toBe(true);
203
203
  });
204
204
 
205
205
  it('should return false for unsupported Edge version', async () => {
206
206
  vi.mocked(getClientDetails).mockResolvedValue({
207
- browser: { name: 'Edge', version: '123' },
207
+ browser: { name: 'Edge', version: '135' },
208
208
  } as ClientDetails);
209
209
  expect(await isSupportedBrowser()).toBe(false);
210
210
  });
211
211
 
212
212
  it('should return true for supported Firefox version', async () => {
213
213
  vi.mocked(getClientDetails).mockResolvedValue({
214
- browser: { name: 'Firefox', version: '124' },
214
+ browser: { name: 'Firefox', version: '137' },
215
215
  } as ClientDetails);
216
216
  expect(await isSupportedBrowser()).toBe(true);
217
217
  });
218
218
 
219
219
  it('should return false for unsupported Firefox version', async () => {
220
220
  vi.mocked(getClientDetails).mockResolvedValue({
221
- browser: { name: 'Firefox', version: '123' },
221
+ browser: { name: 'Firefox', version: '136' },
222
222
  } as ClientDetails);
223
223
  expect(await isSupportedBrowser()).toBe(false);
224
224
  });
225
225
 
226
226
  it('should return true for supported Safari version', async () => {
227
227
  vi.mocked(getClientDetails).mockResolvedValue({
228
- browser: { name: 'Safari', version: '17' },
228
+ browser: { name: 'Safari', version: '18' },
229
229
  } as ClientDetails);
230
230
  expect(await isSupportedBrowser()).toBe(true);
231
231
  });
232
232
 
233
233
  it('should return false for unsupported Safari version', async () => {
234
234
  vi.mocked(getClientDetails).mockResolvedValue({
235
- browser: { name: 'Safari', version: '16' },
235
+ browser: { name: 'Safari', version: '17' },
236
236
  } as ClientDetails);
237
237
  expect(await isSupportedBrowser()).toBe(false);
238
238
  });
@@ -253,14 +253,14 @@ describe('browsers', () => {
253
253
 
254
254
  it('should return true for supported WebView version (WebView on Android)', async () => {
255
255
  vi.mocked(getClientDetails).mockResolvedValue({
256
- browser: { name: 'WebView', version: '124' },
256
+ browser: { name: 'WebView', version: '136' },
257
257
  } as ClientDetails);
258
258
  expect(await isSupportedBrowser()).toBe(true);
259
259
  });
260
260
 
261
261
  it('should return false for unsupported WebView version (WebView on Android)', async () => {
262
262
  vi.mocked(getClientDetails).mockResolvedValue({
263
- browser: { name: 'WebView', version: '123' },
263
+ browser: { name: 'WebView', version: '135' },
264
264
  } as ClientDetails);
265
265
  expect(await isSupportedBrowser()).toBe(false);
266
266
  });
@@ -62,11 +62,11 @@ export const isSupportedBrowser = async (): Promise<boolean> => {
62
62
  const [major] = browser.version.split('.');
63
63
  const version = parseInt(major, 10);
64
64
  return (
65
- (name.includes('chrome') && version >= 124) ||
66
- (name.includes('edge') && version >= 124) ||
67
- (name.includes('firefox') && version >= 124) ||
68
- (name.includes('safari') && version >= 17) ||
65
+ (name.includes('chrome') && version >= 136) ||
66
+ (name.includes('edge') && version >= 136) ||
67
+ (name.includes('firefox') && version >= 137) ||
68
+ (name.includes('safari') && version >= 18) ||
69
69
  (name.includes('webkit') && version >= 605) || // WebView on iOS
70
- (name.includes('webview') && version >= 124) // WebView on Android
70
+ (name.includes('webview') && version >= 136) // WebView on Android
71
71
  );
72
72
  };
@@ -219,7 +219,7 @@ export class ClientEventReporter {
219
219
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
220
220
  };
221
221
 
222
- this.send({
222
+ this.sendForCall(cid, {
223
223
  ...this.buildCommon(cid, 'MediaDevicePermission', pair),
224
224
  ...this.sessionIdField(cid),
225
225
  microphone_permission_status: readPermissionStatus(
@@ -322,7 +322,7 @@ export class ClientEventReporter {
322
322
  };
323
323
 
324
324
  const resolvedSfuId = this.getSfuId(cid);
325
- this.send({
325
+ this.sendForCall(cid, {
326
326
  ...this.buildCommon(cid, stage, pair),
327
327
  ...this.sessionIdField(cid),
328
328
  ...(resolvedSfuId && { sfu_id: resolvedSfuId }),
@@ -395,7 +395,7 @@ export class ClientEventReporter {
395
395
  const coordinatorConnectId = this.coordinatorConnectId;
396
396
  const ctx = this.callContexts.get(cid);
397
397
 
398
- this.send({
398
+ this.sendForCall(cid, {
399
399
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
400
400
  type: ctx?.callType,
401
401
  id: ctx?.callId,
@@ -454,7 +454,7 @@ export class ClientEventReporter {
454
454
  joinReasonSnapshot: this.joinReasons.get(cid),
455
455
  };
456
456
  this.coordinatorPairs.set(cid, pair);
457
- this.send({
457
+ this.sendForCall(cid, {
458
458
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
459
459
  ...(pair.joinReasonSnapshot && {
460
460
  join_reason: pair.joinReasonSnapshot,
@@ -469,7 +469,7 @@ export class ClientEventReporter {
469
469
  private succeedCoordinator = (cid: string) => {
470
470
  const pair = this.coordinatorPairs.get(cid);
471
471
  if (!pair) return;
472
- this.send({
472
+ this.sendForCall(cid, {
473
473
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
474
474
  ...this.sessionIdField(cid),
475
475
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
@@ -488,7 +488,7 @@ export class ClientEventReporter {
488
488
  return;
489
489
  }
490
490
  const { reason, code } = pair.lastError;
491
- this.send({
491
+ this.sendForCall(cid, {
492
492
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
493
493
  ...this.sessionIdField(cid),
494
494
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
@@ -513,7 +513,7 @@ export class ClientEventReporter {
513
513
  };
514
514
  this.wsPairs.set(cid, pair);
515
515
  const sfuId = this.getSfuId(cid);
516
- this.send({
516
+ this.sendForCall(cid, {
517
517
  ...this.buildCommon(cid, 'WSJoin', pair),
518
518
  ...this.sessionIdField(cid),
519
519
  ...(sfuId && { sfu_id: sfuId }),
@@ -529,7 +529,7 @@ export class ClientEventReporter {
529
529
  const pair = this.wsPairs.get(cid);
530
530
  if (!pair) return;
531
531
  const sfuId = this.getSfuId(cid);
532
- this.send({
532
+ this.sendForCall(cid, {
533
533
  ...this.buildCommon(cid, 'WSJoin', pair),
534
534
  ...this.sessionIdField(cid),
535
535
  ...(sfuId && { sfu_id: sfuId }),
@@ -552,7 +552,7 @@ export class ClientEventReporter {
552
552
  const { reason, code } = pair.lastError;
553
553
  const sfuId = this.getSfuId(cid);
554
554
 
555
- this.send({
555
+ this.sendForCall(cid, {
556
556
  ...this.buildCommon(cid, 'WSJoin', pair),
557
557
  ...this.sessionIdField(cid),
558
558
  event_type: 'completed',
@@ -627,7 +627,7 @@ export class ClientEventReporter {
627
627
  };
628
628
  this.peerConnectionPairs.set(key, pair);
629
629
 
630
- this.send({
630
+ this.sendForCall(cid, {
631
631
  ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
632
632
  ...this.sessionIdField(cid),
633
633
  peer_connection: role,
@@ -648,7 +648,7 @@ export class ClientEventReporter {
648
648
  const pair = this.peerConnectionPairs.get(key);
649
649
  if (!pair) return;
650
650
 
651
- this.send({
651
+ this.sendForCall(cid, {
652
652
  ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
653
653
  ...this.sessionIdField(cid),
654
654
  peer_connection: role,
@@ -676,7 +676,7 @@ export class ClientEventReporter {
676
676
  const pair = this.peerConnectionPairs.get(key);
677
677
  if (!pair) return;
678
678
 
679
- this.send({
679
+ this.sendForCall(cid, {
680
680
  ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
681
681
  ...this.sessionIdField(cid),
682
682
  peer_connection: role,
@@ -738,6 +738,11 @@ export class ClientEventReporter {
738
738
  void this.sendWithRetry(body);
739
739
  };
740
740
 
741
+ private sendForCall = (cid: string, body: Record<string, unknown>) => {
742
+ if (!this.callContexts.has(cid)) return;
743
+ this.send(body);
744
+ };
745
+
741
746
  private sendWithRetry = async (
742
747
  body: Record<string, unknown>,
743
748
  ): Promise<boolean> => {
@@ -617,6 +617,58 @@ describe('ClientEventReporter', () => {
617
617
  expect(ws1.every((e) => e.sfu_id === 'sfu-1')).toBe(true);
618
618
  expect(ws2.every((e) => e.sfu_id === 'sfu-2')).toBe(true);
619
619
  });
620
+
621
+ describe('drops events for an unregistered call', () => {
622
+ it('does not emit stage events after the call is unregistered', async () => {
623
+ reporter.startCorrelation(cid, 'first-attempt');
624
+ reporter.unregisterCall(cid);
625
+ await flush();
626
+ doAxiosRequest.mockClear();
627
+
628
+ await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok'));
629
+ reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1');
630
+ await flush();
631
+
632
+ expect(doAxiosRequest).not.toHaveBeenCalled();
633
+ });
634
+
635
+ it('does not emit a stage completion when unregistered mid-flight', async () => {
636
+ reporter.startCorrelation(cid, 'first-attempt');
637
+ await flush();
638
+ doAxiosRequest.mockClear();
639
+
640
+ await reporter.track(cid, 'WSJoin', async () => {
641
+ reporter.unregisterCall(cid);
642
+ return 'ok';
643
+ });
644
+ await flush();
645
+
646
+ const completed = postedEvents().filter(
647
+ (e) => e.stage === 'WSJoin' && e.event_type === 'completed',
648
+ );
649
+ expect(completed).toHaveLength(0);
650
+ });
651
+
652
+ it('does not emit JoinInitiated for an unregistered call', async () => {
653
+ reporter.unregisterCall(cid);
654
+ reporter.startCorrelation(cid, 'first-attempt');
655
+ await flush();
656
+
657
+ const joinInitiated = postedEvents().filter(
658
+ (e) => e.stage === 'JoinInitiated',
659
+ );
660
+ expect(joinInitiated).toHaveLength(0);
661
+ });
662
+
663
+ it('still emits CoordinatorWS events (connection-scoped, not call-gated)', async () => {
664
+ reporter.unregisterCall(cid);
665
+ await reporter.trackCoordinatorWs(() => Promise.resolve('ok'));
666
+ await flush();
667
+
668
+ const ws = postedEvents().filter((e) => e.stage === 'CoordinatorWS');
669
+ expect(ws.length).toBeGreaterThan(0);
670
+ });
671
+ });
620
672
  });
621
673
 
622
674
  describe('ClientEventReporter (disabled)', () => {
@@ -13,6 +13,7 @@ import { StreamSfuClient } from '../StreamSfuClient';
13
13
  import { AllSfuEvents, Dispatcher } from './Dispatcher';
14
14
  import { withoutConcurrency } from '../helpers/concurrency';
15
15
  import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats';
16
+ import { toJSON } from './helpers/iceCandiates';
16
17
  import {
17
18
  BasePeerConnectionOpts,
18
19
  OnIceConnected,
@@ -37,7 +38,7 @@ export abstract class BasePeerConnection {
37
38
  protected tag: string;
38
39
  protected sfuClient: StreamSfuClient;
39
40
 
40
- private onReconnectionNeeded?: OnReconnectionNeeded;
41
+ protected onReconnectionNeeded?: OnReconnectionNeeded;
41
42
  private onIceConnected?: OnIceConnected;
42
43
  private onPeerConnectionStateChange?: OnPeerConnectionStateChange;
43
44
  protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
@@ -224,11 +225,17 @@ export abstract class BasePeerConnection {
224
225
  * Appends the trickled ICE candidates to the `RTCPeerConnection`.
225
226
  */
226
227
  protected addTrickledIceCandidates = () => {
228
+ // Declare the ICE generation this negotiation established so the buffer
229
+ // only replays candidates of the current generation.
227
230
  const { iceTrickleBuffer } = this.sfuClient;
231
+ const sdp = this.pc.remoteDescription?.sdp;
232
+ iceTrickleBuffer.updateActiveGeneration(this.peerType, sdp);
233
+
234
+ const { subscriber, publisher } = iceTrickleBuffer;
228
235
  const observable =
229
236
  this.peerType === PeerType.SUBSCRIBER
230
- ? iceTrickleBuffer.subscriberCandidates
231
- : iceTrickleBuffer.publisherCandidates;
237
+ ? subscriber.candidates
238
+ : publisher.candidates;
232
239
 
233
240
  this.unsubscribeIceTrickle?.();
234
241
  this.unsubscribeIceTrickle = createSafeAsyncSubscription(
@@ -283,20 +290,6 @@ export abstract class BasePeerConnection {
283
290
  return !failedStates.has(iceState) && !failedStates.has(connectionState);
284
291
  };
285
292
 
286
- /**
287
- * Returns true only when the peer connection is currently fully established
288
- * (ICE `connected`/`completed` AND connection state `connected`).
289
- * Transient states like `disconnected`, `checking`, or `new` return false.
290
- */
291
- isStable = () => {
292
- const iceState = this.pc.iceConnectionState;
293
- const connectionState = this.pc.connectionState;
294
- return (
295
- (iceState === 'connected' || iceState === 'completed') &&
296
- connectionState === 'connected'
297
- );
298
- };
299
-
300
293
  /**
301
294
  * Handles the ICECandidate event and
302
295
  * Initiates an ICE Trickle process with the SFU.
@@ -308,7 +301,7 @@ export abstract class BasePeerConnection {
308
301
  return;
309
302
  }
310
303
 
311
- const iceCandidate = this.asJSON(candidate);
304
+ const iceCandidate = toJSON(candidate);
312
305
  this.sfuClient
313
306
  .iceTrickle({ peerType: this.peerType, iceCandidate })
314
307
  .catch((err) => {
@@ -317,20 +310,6 @@ export abstract class BasePeerConnection {
317
310
  });
318
311
  };
319
312
 
320
- /**
321
- * Converts the ICE candidate to a JSON string.
322
- */
323
- private asJSON = (candidate: RTCIceCandidate): string => {
324
- if (!candidate.usernameFragment) {
325
- // react-native-webrtc doesn't include usernameFragment in the candidate
326
- const segments = candidate.candidate.split(' ');
327
- const ufragIndex = segments.findIndex((s) => s === 'ufrag') + 1;
328
- const usernameFragment = segments[ufragIndex];
329
- return JSON.stringify({ ...candidate, usernameFragment });
330
- }
331
- return JSON.stringify(candidate.toJSON());
332
- };
333
-
334
313
  /**
335
314
  * Handles the ConnectionStateChange event.
336
315
  */
@@ -343,8 +322,10 @@ export abstract class BasePeerConnection {
343
322
  });
344
323
  if (this.tracer && (state === 'connected' || state === 'failed')) {
345
324
  try {
346
- const stats = await this.stats.get();
347
- this.tracer.trace('getstats', stats.delta);
325
+ // Sample stats into the delivery chain at connect/fail. The reporter
326
+ // ships and commits the un-acked chain, so we must not trace the delta
327
+ // separately here (that would double-send it and corrupt the chain).
328
+ await this.stats.takeSample();
348
329
  } catch (err) {
349
330
  this.tracer.trace('getstatsOnFailure', (err as Error).toString());
350
331
  }
@@ -1,33 +1,126 @@
1
- import { ReplaySubject } from 'rxjs';
1
+ import { Observable, Subject } from 'rxjs';
2
2
  import { ICETrickle } from '../gen/video/sfu/event/events';
3
3
  import { PeerType } from '../gen/video/sfu/models/models';
4
4
  import { videoLoggerSystem } from '../logger';
5
+ import { getCandidateUfrag, parseIceUfrag } from './helpers/iceCandiates';
6
+ import { ensureExhausted } from '../helpers/ensureExhausted';
5
7
 
6
8
  /**
7
9
  * A buffer for ICE Candidates. Used for ICE Trickle:
8
10
  * - https://bloggeek.me/webrtcglossary/trickle-ice/
11
+ *
12
+ * The buffer is generation-aware: each peer connection tells it which ICE
13
+ * generation is current via `updateActiveGeneration` (whenever it applies an
14
+ * offer/answer). Candidate streams then emit only candidates of the active
15
+ * generation, hold candidates of a not-yet-applied (future) generation until
16
+ * it becomes active, and drop candidates of a superseded generation so they
17
+ * are never replayed. Candidates with no detectable generation, or before any
18
+ * generation is set, are emitted as-is (fail open).
9
19
  */
10
20
  export class IceTrickleBuffer {
11
- readonly subscriberCandidates = new ReplaySubject<RTCIceCandidateInit>();
12
- readonly publisherCandidates = new ReplaySubject<RTCIceCandidateInit>();
21
+ readonly subscriber = new CandidateGenerationBuffer();
22
+ readonly publisher = new CandidateGenerationBuffer();
13
23
 
14
24
  push = (iceTrickle: ICETrickle) => {
15
25
  const iceCandidate = toIceCandidate(iceTrickle);
16
26
  if (!iceCandidate) return;
17
27
 
18
- if (iceTrickle.peerType === PeerType.SUBSCRIBER) {
19
- this.subscriberCandidates.next(iceCandidate);
20
- } else if (iceTrickle.peerType === PeerType.PUBLISHER_UNSPECIFIED) {
21
- this.publisherCandidates.next(iceCandidate);
22
- } else {
23
- const logger = videoLoggerSystem.getLogger('sfu-client');
24
- logger.warn(`ICETrickle, Unknown peer type`, iceTrickle);
28
+ const { peerType } = iceTrickle;
29
+ switch (peerType) {
30
+ case PeerType.SUBSCRIBER:
31
+ this.subscriber.push(iceCandidate);
32
+ break;
33
+ case PeerType.PUBLISHER_UNSPECIFIED:
34
+ this.publisher.push(iceCandidate);
35
+ break;
36
+ default:
37
+ ensureExhausted(peerType, `ICETrickle, Unknown peer type`);
38
+ }
39
+ };
40
+
41
+ /**
42
+ * Declares the ICE generation that is now current for the given peer type,
43
+ * derived from the `ice-ufrag` of the just-applied remote description.
44
+ * Candidates of superseded generations are evicted; candidates of the active
45
+ * generation flow to subscribers.
46
+ */
47
+ updateActiveGeneration = (peerType: PeerType, sdp: string | undefined) => {
48
+ const ufrag = parseIceUfrag(sdp);
49
+ switch (peerType) {
50
+ case PeerType.SUBSCRIBER:
51
+ this.subscriber.updateActiveGeneration(ufrag);
52
+ break;
53
+ case PeerType.PUBLISHER_UNSPECIFIED:
54
+ this.publisher.updateActiveGeneration(ufrag);
55
+ break;
56
+ default:
57
+ ensureExhausted(peerType, `updateActiveGeneration, Unknown peer type`);
25
58
  }
26
59
  };
27
60
 
28
61
  dispose = () => {
29
- this.subscriberCandidates.complete();
30
- this.publisherCandidates.complete();
62
+ this.subscriber.dispose();
63
+ this.publisher.dispose();
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Per-peer-connection generation-aware candidate store. Retains trickled
69
+ * candidates and replays the active generation to each new subscriber, then
70
+ * forwards matching live candidates.
71
+ */
72
+ class CandidateGenerationBuffer {
73
+ private readonly store: RTCIceCandidateInit[] = [];
74
+ private readonly live = new Subject<RTCIceCandidateInit>();
75
+ private readonly seenUfrags = new Set<string>();
76
+ private activeUfrag: string | undefined;
77
+
78
+ readonly candidates = new Observable<RTCIceCandidateInit>((subscriber) => {
79
+ for (const candidate of this.store.slice()) {
80
+ if (this.isCurrent(candidate)) subscriber.next(candidate);
81
+ }
82
+ const subscription = this.live.subscribe((candidate) => {
83
+ if (this.isCurrent(candidate)) subscriber.next(candidate);
84
+ });
85
+ return () => subscription.unsubscribe();
86
+ });
87
+
88
+ push = (candidate: RTCIceCandidateInit) => {
89
+ this.store.push(candidate);
90
+ this.live.next(candidate);
91
+ };
92
+
93
+ updateActiveGeneration = (ufrag: string | undefined) => {
94
+ if (ufrag) this.seenUfrags.add(ufrag);
95
+ this.activeUfrag = ufrag;
96
+ // evict candidates from superseded generations (a generation we have
97
+ // applied before but is no longer current); keep future generations.
98
+ for (let i = this.store.length - 1; i >= 0; i--) {
99
+ const candidateUfrag = getCandidateUfrag(this.store[i]);
100
+ if (
101
+ candidateUfrag &&
102
+ candidateUfrag !== this.activeUfrag &&
103
+ this.seenUfrags.has(candidateUfrag)
104
+ ) {
105
+ this.store.splice(i, 1);
106
+ }
107
+ }
108
+ };
109
+
110
+ dispose = () => {
111
+ this.store.length = 0;
112
+ this.live.complete();
113
+ };
114
+
115
+ /**
116
+ * A candidate belongs to the current generation when its ufrag matches the
117
+ * active one. Fail open when either the candidate's generation or the active
118
+ * generation is unknown, so untagged candidates are never withheld.
119
+ */
120
+ private isCurrent = (candidate: RTCIceCandidateInit): boolean => {
121
+ const candidateUfrag = getCandidateUfrag(candidate);
122
+ if (!candidateUfrag || !this.activeUfrag) return true;
123
+ return candidateUfrag === this.activeUfrag;
31
124
  };
32
125
  }
33
126
 
@@ -1,34 +1,34 @@
1
- import { BasePeerConnection } from './BasePeerConnection';
2
- import type {
3
- BasePeerConnectionOpts,
4
- PublishBundle,
5
- TrackPublishOptions,
6
- } from './types';
7
- import { NegotiationError } from './NegotiationError';
8
- import { TransceiverCache } from './TransceiverCache';
1
+ import { VideoSender } from '../gen/video/sfu/event/events';
9
2
  import {
10
3
  PeerType,
11
4
  PublishOption,
12
5
  TrackInfo,
13
6
  TrackType,
14
7
  } from '../gen/video/sfu/models/models';
15
- import { VideoSender } from '../gen/video/sfu/event/events';
16
- import {
17
- computeAudioLayers,
18
- computeVideoLayers,
19
- toSvcEncodings,
20
- toVideoLayers,
21
- } from './layers';
8
+ import { isFirefox } from '../helpers/browsers';
9
+ import { withoutConcurrency } from '../helpers/concurrency';
10
+ import { isReactNative } from '../helpers/platforms';
11
+ import { BasePeerConnection } from './BasePeerConnection';
22
12
  import { isSvcCodec } from './codecs';
23
13
  import {
24
14
  fromRTCDegradationPreference,
25
15
  toRTCDegradationPreference,
26
16
  } from './helpers/degradationPreference';
27
- import { isAudioTrackType } from './helpers/tracks';
28
17
  import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
29
- import { withoutConcurrency } from '../helpers/concurrency';
30
- import { isReactNative } from '../helpers/platforms';
31
- import { isFirefox } from '../helpers/browsers';
18
+ import { isAudioTrackType } from './helpers/tracks';
19
+ import {
20
+ computeAudioLayers,
21
+ computeVideoLayers,
22
+ toSvcEncodings,
23
+ toVideoLayers,
24
+ } from './layers';
25
+ import { NegotiationError } from './NegotiationError';
26
+ import { TransceiverCache } from './TransceiverCache';
27
+ import type {
28
+ BasePeerConnectionOpts,
29
+ PublishBundle,
30
+ TrackPublishOptions,
31
+ } from './types';
32
32
 
33
33
  /**
34
34
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
@@ -39,6 +39,7 @@ export class Publisher extends BasePeerConnection {
39
39
  private readonly transceiverCache = new TransceiverCache();
40
40
  private readonly clonedTracks = new Set<MediaStreamTrack>();
41
41
  private publishOptions: PublishOption[];
42
+ private readonly selfSubEnabled: boolean;
42
43
 
43
44
  /**
44
45
  * Constructs a new `Publisher` instance.
@@ -46,9 +47,11 @@ export class Publisher extends BasePeerConnection {
46
47
  constructor(
47
48
  baseOptions: BasePeerConnectionOpts,
48
49
  publishOptions: PublishOption[],
50
+ opts: { selfSubEnabled?: boolean } = {},
49
51
  ) {
50
52
  super(PeerType.PUBLISHER_UNSPECIFIED, baseOptions);
51
53
  this.publishOptions = publishOptions;
54
+ this.selfSubEnabled = opts.selfSubEnabled ?? false;
52
55
 
53
56
  this.on('iceRestart', (iceRestart) => {
54
57
  if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED) return;
@@ -576,6 +579,7 @@ export class Publisher extends BasePeerConnection {
576
579
  muted: !isTrackLive,
577
580
  codec: publishOption.codec,
578
581
  publishOptionId: publishOption.id,
582
+ selfSubAudioVideo: this.selfSubEnabled,
579
583
  };
580
584
  };
581
585