@stream-io/video-client 1.47.0 → 1.49.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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/index.browser.es.js +383 -238
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +382 -238
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +0 -1
  7. package/dist/index.es.js +383 -238
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +35 -1
  10. package/dist/src/StreamSfuClient.d.ts +8 -1
  11. package/dist/src/devices/DeviceManagerState.d.ts +13 -0
  12. package/dist/src/devices/MicrophoneManager.d.ts +0 -1
  13. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  14. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  15. package/dist/src/rtc/index.d.ts +1 -0
  16. package/dist/src/rtc/types.d.ts +33 -1
  17. package/dist/src/types.d.ts +11 -0
  18. package/index.ts +0 -1
  19. package/package.json +1 -1
  20. package/src/Call.ts +179 -18
  21. package/src/StreamSfuClient.ts +75 -12
  22. package/src/__tests__/Call.publishing.test.ts +103 -0
  23. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  24. package/src/devices/DeviceManagerState.ts +20 -0
  25. package/src/devices/MicrophoneManager.ts +9 -5
  26. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +28 -29
  27. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  28. package/src/devices/devices.ts +2 -1
  29. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  30. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  31. package/src/rpc/retryable.ts +0 -1
  32. package/src/rtc/BasePeerConnection.ts +96 -6
  33. package/src/rtc/Publisher.ts +2 -1
  34. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  35. package/src/rtc/__tests__/Publisher.test.ts +210 -0
  36. package/src/rtc/__tests__/Subscriber.test.ts +56 -0
  37. package/src/rtc/index.ts +1 -0
  38. package/src/rtc/types.ts +38 -1
  39. package/src/types.ts +9 -0
  40. package/dist/src/helpers/RNSpeechDetector.d.ts +0 -23
  41. package/src/helpers/RNSpeechDetector.ts +0 -224
  42. package/src/helpers/__tests__/RNSpeechDetector.test.ts +0 -52
@@ -4006,8 +4006,6 @@ const retryable = async (rpc, signal, maxRetries = Number.POSITIVE_INFINITY) =>
4006
4006
  do {
4007
4007
  if (attempt > 0)
4008
4008
  await sleep(retryInterval(attempt));
4009
- if (signal?.aborted)
4010
- throw new Error(signal.reason);
4011
4009
  try {
4012
4010
  result = await rpc({ attempt });
4013
4011
  }
@@ -4405,6 +4403,21 @@ class Dispatcher {
4405
4403
  }
4406
4404
  }
4407
4405
 
4406
+ /**
4407
+ * NegotiationError is thrown when there is an error during the negotiation process.
4408
+ * It extends the built-in Error class and includes an SfuError object for more details.
4409
+ */
4410
+ class NegotiationError extends Error {
4411
+ /**
4412
+ * Creates an instance of NegotiationError.
4413
+ */
4414
+ constructor(error) {
4415
+ super(error.message);
4416
+ this.name = 'NegotiationError';
4417
+ this.error = error;
4418
+ }
4419
+ }
4420
+
4408
4421
  /**
4409
4422
  * A buffer for ICE Candidates. Used for ICE Trickle:
4410
4423
  * - https://bloggeek.me/webrtcglossary/trickle-ice/
@@ -6222,21 +6235,6 @@ class CallState {
6222
6235
  }
6223
6236
  }
6224
6237
 
6225
- /**
6226
- * NegotiationError is thrown when there is an error during the negotiation process.
6227
- * It extends the built-in Error class and includes an SfuError object for more details.
6228
- */
6229
- class NegotiationError extends Error {
6230
- /**
6231
- * Creates an instance of NegotiationError.
6232
- */
6233
- constructor(error) {
6234
- super(error.message);
6235
- this.name = 'NegotiationError';
6236
- this.error = error;
6237
- }
6238
- }
6239
-
6240
6238
  /**
6241
6239
  * Flatten the stats report into an array of stats objects.
6242
6240
  *
@@ -6284,7 +6282,7 @@ const getSdkVersion = (sdk) => {
6284
6282
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6285
6283
  };
6286
6284
 
6287
- const version = "1.47.0";
6285
+ const version = "1.49.0";
6288
6286
  const [major, minor, patch] = version.split('.');
6289
6287
  let sdkInfo = {
6290
6288
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7306,6 +7304,30 @@ class Tracer {
7306
7304
  }
7307
7305
  }
7308
7306
 
7307
+ /**
7308
+ * Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
7309
+ * are still accepted at the callback boundary (e.g. when forwarding an SFU
7310
+ * error message), but only the members below influence reconnect-loop
7311
+ * behavior. In particular, `Call.reconnect` programmatically inspects
7312
+ * `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
7313
+ * canonical member when you want the SDK to react to the reason; pass a
7314
+ * free-form string when the value is purely diagnostic.
7315
+ */
7316
+ const ReconnectReason = {
7317
+ /** ICE never reached `connected`/`completed`, escalate to REJOIN. */
7318
+ ICE_NEVER_CONNECTED: 'ice_never_connected',
7319
+ /** RTCPeerConnection.connectionState became `failed`. */
7320
+ CONNECTION_FAILED: 'connection_failed',
7321
+ /** `restartIce()` rejected. */
7322
+ RESTART_ICE_FAILED: 'restart_ice_failed',
7323
+ /** SFU `goAway` event, migrate to a new SFU. */
7324
+ GO_AWAY: 'go_away',
7325
+ /** Network came back online after going offline. */
7326
+ NETWORK_BACK_ONLINE: 'network_back_online',
7327
+ /** SFU error event with no descriptive message. */
7328
+ SFU_ERROR: 'sfu_error',
7329
+ };
7330
+
7309
7331
  /**
7310
7332
  * A base class for the `Publisher` and `Subscriber` classes.
7311
7333
  * @internal
@@ -7314,7 +7336,8 @@ class BasePeerConnection {
7314
7336
  /**
7315
7337
  * Constructs a new `BasePeerConnection` instance.
7316
7338
  */
7317
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
7339
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
7340
+ this.iceHasEverConnected = false;
7318
7341
  this.isIceRestarting = false;
7319
7342
  this.isDisposed = false;
7320
7343
  this.trackIdToTrackType = new Map();
@@ -7337,13 +7360,12 @@ class BasePeerConnection {
7337
7360
  */
7338
7361
  this.tryRestartIce = () => {
7339
7362
  this.restartIce().catch((e) => {
7340
- const reason = 'restartICE() failed, initiating reconnect';
7341
- this.logger.error(reason, e);
7363
+ this.logger.error('restartICE() failed, initiating reconnect', e);
7342
7364
  const strategy = e instanceof NegotiationError &&
7343
7365
  e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
7344
7366
  ? WebsocketReconnectStrategy.FAST
7345
7367
  : WebsocketReconnectStrategy.REJOIN;
7346
- this.onReconnectionNeeded?.(strategy, reason, this.peerType);
7368
+ this.onReconnectionNeeded?.(strategy, ReconnectReason.RESTART_ICE_FAILED, this.peerType);
7347
7369
  });
7348
7370
  };
7349
7371
  /**
@@ -7412,6 +7434,17 @@ class BasePeerConnection {
7412
7434
  const connectionState = this.pc.connectionState;
7413
7435
  return !failedStates.has(iceState) && !failedStates.has(connectionState);
7414
7436
  };
7437
+ /**
7438
+ * Returns true only when the peer connection is currently fully established
7439
+ * (ICE `connected`/`completed` AND connection state `connected`).
7440
+ * Transient states like `disconnected`, `checking`, or `new` return false.
7441
+ */
7442
+ this.isStable = () => {
7443
+ const iceState = this.pc.iceConnectionState;
7444
+ const connectionState = this.pc.connectionState;
7445
+ return ((iceState === 'connected' || iceState === 'completed') &&
7446
+ connectionState === 'connected');
7447
+ };
7415
7448
  /**
7416
7449
  * Handles the ICECandidate event and
7417
7450
  * Initiates an ICE Trickle process with the SFU.
@@ -7461,7 +7494,7 @@ class BasePeerConnection {
7461
7494
  }
7462
7495
  // we can't recover from a failed connection state (contrary to ICE)
7463
7496
  if (state === 'failed') {
7464
- this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, 'Connection failed', this.peerType);
7497
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
7465
7498
  return;
7466
7499
  }
7467
7500
  this.handleConnectionStateUpdate(state);
@@ -7483,6 +7516,41 @@ class BasePeerConnection {
7483
7516
  // do nothing when ICE is restarting
7484
7517
  if (this.isIceRestarting)
7485
7518
  return;
7519
+ // Pre-connect handling: ICE has never reached `connected`/`completed`.
7520
+ // Restart is futile here (the data plane was never established), but
7521
+ // these two terminal-ish states need different treatment:
7522
+ // - `failed` is terminal, escalate to REJOIN so a new SFU/credentials
7523
+ // /PC configuration gets a chance, and let `Call.reconnect` count
7524
+ // this toward the unsupported-network budget.
7525
+ // - `disconnected` is transient, the browser may yet move back to
7526
+ // `checking`/`connected`. Don't restart, don't escalate; wait it
7527
+ // out. If it ultimately fails, ICE will transition to `failed` and
7528
+ // the branch above will take over.
7529
+ if (!this.iceHasEverConnected) {
7530
+ if (state === 'failed') {
7531
+ this.logger.info('ICE failed before connected, escalating to REJOIN');
7532
+ clearTimeout(this.preConnectStuckTimeout);
7533
+ this.preConnectStuckTimeout = undefined;
7534
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
7535
+ return;
7536
+ }
7537
+ if (state === 'disconnected') {
7538
+ this.logger.info('ICE disconnected before connected, wait to recover');
7539
+ // Watchdog: if the browser stays in `disconnected` without ever
7540
+ // reaching `connected` or transitioning to `failed`, escalate to
7541
+ // REJOIN ourselves so we don't wait silently forever. Rare but
7542
+ // observed on flaky mobile networks.
7543
+ clearTimeout(this.preConnectStuckTimeout);
7544
+ this.preConnectStuckTimeout = setTimeout(() => {
7545
+ if (!this.iceHasEverConnected &&
7546
+ this.pc.iceConnectionState === 'disconnected') {
7547
+ this.logger.info('ICE stuck in pre-connect disconnected, escalating to REJOIN');
7548
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
7549
+ }
7550
+ }, this.iceRestartDelay * 2);
7551
+ return;
7552
+ }
7553
+ }
7486
7554
  switch (state) {
7487
7555
  case 'failed':
7488
7556
  // in the `failed` state, we try to restart ICE immediately
@@ -7502,12 +7570,24 @@ class BasePeerConnection {
7502
7570
  }, this.iceRestartDelay);
7503
7571
  break;
7504
7572
  case 'connected':
7505
- // in the `connected` state, we clear the ice restart timeout if it exists
7573
+ case 'completed':
7574
+ // Fire `onIceConnected` exactly once per peer-connection lifetime —
7575
+ // the first time ICE reaches `connected`/`completed` end-to-end.
7576
+ // Used by `Call` to reset the unsupported-network failure counter
7577
+ // only after WebRTC has actually recovered, not merely on SFU join.
7578
+ if (!this.iceHasEverConnected) {
7579
+ this.iceHasEverConnected = true;
7580
+ this.onIceConnected?.(this.peerType);
7581
+ }
7582
+ // clear any scheduled restartICE since the connection is healthy
7506
7583
  if (this.iceRestartTimeout) {
7507
7584
  this.logger.info('connected connection, canceling restartICE');
7508
7585
  clearTimeout(this.iceRestartTimeout);
7509
7586
  this.iceRestartTimeout = undefined;
7510
7587
  }
7588
+ // clear the pre-connect watchdog if it was armed
7589
+ clearTimeout(this.preConnectStuckTimeout);
7590
+ this.preConnectStuckTimeout = undefined;
7511
7591
  break;
7512
7592
  }
7513
7593
  };
@@ -7540,6 +7620,7 @@ class BasePeerConnection {
7540
7620
  this.clientPublishOptions = clientPublishOptions;
7541
7621
  this.tag = tag;
7542
7622
  this.onReconnectionNeeded = onReconnectionNeeded;
7623
+ this.onIceConnected = onIceConnected;
7543
7624
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
7544
7625
  this.pc = this.createPeerConnection(connectionConfig);
7545
7626
  this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
@@ -7558,7 +7639,10 @@ class BasePeerConnection {
7558
7639
  dispose() {
7559
7640
  clearTimeout(this.iceRestartTimeout);
7560
7641
  this.iceRestartTimeout = undefined;
7642
+ clearTimeout(this.preConnectStuckTimeout);
7643
+ this.preConnectStuckTimeout = undefined;
7561
7644
  this.onReconnectionNeeded = undefined;
7645
+ this.onIceConnected = undefined;
7562
7646
  this.isDisposed = true;
7563
7647
  this.detachEventHandlers();
7564
7648
  this.pc.close();
@@ -8212,7 +8296,8 @@ class Publisher extends BasePeerConnection {
8212
8296
  const trackInfos = [];
8213
8297
  for (const publishOption of this.publishOptions) {
8214
8298
  const bundle = this.transceiverCache.get(publishOption);
8215
- if (!bundle || !bundle.transceiver.sender.track)
8299
+ const track = bundle?.transceiver.sender.track;
8300
+ if (!bundle || !track || track.readyState !== 'live')
8216
8301
  continue;
8217
8302
  trackInfos.push(this.toTrackInfo(bundle, sdp));
8218
8303
  }
@@ -8557,6 +8642,20 @@ class SfuJoinError extends Error {
8557
8642
  }
8558
8643
  }
8559
8644
 
8645
+ /**
8646
+ * Creates a fresh `joinResponseTask` with a no-op rejection handler attached
8647
+ * to the underlying promise. The handler marks the rejection path as handled
8648
+ * so a teardown-time reject (e.g., from `close()` during disposal) does not
8649
+ * surface as an `UnhandledPromiseRejection`. Explicit awaiters of
8650
+ * `StreamSfuClient.joinTask` still observe the rejection through their own
8651
+ * `then`/`catch` chain. `.catch()` returns a new promise; the original is
8652
+ * unchanged.
8653
+ */
8654
+ const makeJoinResponseTask = () => {
8655
+ const task = promiseWithResolvers();
8656
+ task.promise.catch(() => { }); // see the comment above
8657
+ return task;
8658
+ };
8560
8659
  /**
8561
8660
  * The client used for exchanging information with the SFU.
8562
8661
  */
@@ -8588,9 +8687,10 @@ class StreamSfuClient {
8588
8687
  this.subscriptionsConcurrencyTag = Symbol('subscriptionsConcurrencyTag');
8589
8688
  /**
8590
8689
  * Promise that resolves when the JoinResponse is received.
8591
- * Rejects after a certain threshold if the response is not received.
8690
+ * Rejects after a certain threshold if the response is not received,
8691
+ * or when the SFU client is disposed before a join completes.
8592
8692
  */
8593
- this.joinResponseTask = promiseWithResolvers();
8693
+ this.joinResponseTask = makeJoinResponseTask();
8594
8694
  /**
8595
8695
  * A controller to abort the current requests.
8596
8696
  */
@@ -8660,14 +8760,21 @@ class StreamSfuClient {
8660
8760
  };
8661
8761
  this.close = (code = StreamSfuClient.NORMAL_CLOSURE, reason) => {
8662
8762
  this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
8663
- if (this.signalWs.readyState === WebSocket.OPEN) {
8763
+ // Close the WebSocket whether it has fully opened (`OPEN`) or is still
8764
+ // mid-handshake (`CONNECTING`). The WebSocket spec aborts the handshake
8765
+ // when `close()` is called on a CONNECTING socket. Without this, an
8766
+ // SFU socket that opens just after teardown would dispatch events into
8767
+ // a Call instance that has already moved on.
8768
+ const ws = this.signalWs;
8769
+ if (ws.readyState === WebSocket.OPEN ||
8770
+ ws.readyState === WebSocket.CONNECTING) {
8664
8771
  this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
8665
- this.signalWs.close(code, `js-client: ${reason}`);
8666
- this.signalWs.removeEventListener('close', this.handleWebSocketClose);
8772
+ ws.close(code, `js-client: ${reason}`);
8773
+ ws.removeEventListener('close', this.handleWebSocketClose);
8667
8774
  }
8668
- this.dispose();
8775
+ this.dispose(reason);
8669
8776
  };
8670
- this.dispose = () => {
8777
+ this.dispose = (reason) => {
8671
8778
  this.logger.debug('Disposing SFU client');
8672
8779
  this.unsubscribeIceTrickle();
8673
8780
  this.unsubscribeNetworkChanged();
@@ -8676,6 +8783,13 @@ class StreamSfuClient {
8676
8783
  clearTimeout(this.migrateAwayTimeout);
8677
8784
  this.abortController.abort();
8678
8785
  this.migrationTask?.resolve();
8786
+ // Settle a pending `joinResponseTask` so `leaveAndClose`, `join()`, and
8787
+ // any other awaiters (`await this.joinTask`) don't hang indefinitely
8788
+ // when the SFU client is torn down before the SFU sent a JoinResponse.
8789
+ if (!this.joinResponseTask.isResolved() &&
8790
+ !this.joinResponseTask.isRejected()) {
8791
+ this.joinResponseTask.reject(new Error(`SFU client disposed before join completed${reason ? `: ${reason}` : ''}`));
8792
+ }
8679
8793
  this.iceTrickleBuffer.dispose();
8680
8794
  };
8681
8795
  this.getTrace = () => {
@@ -8684,8 +8798,24 @@ class StreamSfuClient {
8684
8798
  this.leaveAndClose = async (reason) => {
8685
8799
  try {
8686
8800
  this.isLeaving = true;
8687
- await this.joinTask;
8688
- await this.notifyLeave(reason);
8801
+ // Best-effort: give an in-flight join a short grace period to complete
8802
+ // so we can send a graceful `leaveCallRequest`. Bounded so we never hang
8803
+ // here if the SFU is unresponsive. If the task settles either way during
8804
+ // the wait, the re-check below decides whether to notify.
8805
+ if (!this.joinResponseTask.isResolved() &&
8806
+ !this.joinResponseTask.isRejected()) {
8807
+ await Promise.race([
8808
+ // swallow rejection — we re-check `isResolved()` below to decide
8809
+ this.joinResponseTask.promise.catch(() => { }),
8810
+ sleep(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS),
8811
+ ]);
8812
+ }
8813
+ if (this.joinResponseTask.isResolved()) {
8814
+ await this.notifyLeave(reason);
8815
+ }
8816
+ else {
8817
+ this.logger.debug('[leaveAndClose] join not completed within grace period, skipping notifyLeave');
8818
+ }
8689
8819
  }
8690
8820
  catch (err) {
8691
8821
  this.logger.debug('Error notifying SFU about leaving call', err);
@@ -8754,9 +8884,9 @@ class StreamSfuClient {
8754
8884
  this.joinResponseTask.isRejected()) {
8755
8885
  // we need to lock the RPC requests until we receive a JoinResponse.
8756
8886
  // that's why we have this primitive lock mechanism.
8757
- // the client starts with already initialized joinResponseTask,
8887
+ // the client starts with an already initialized joinResponseTask,
8758
8888
  // and this code creates a new one for the next join request.
8759
- this.joinResponseTask = promiseWithResolvers();
8889
+ this.joinResponseTask = makeJoinResponseTask();
8760
8890
  }
8761
8891
  // capture a reference to the current joinResponseTask as it might
8762
8892
  // be replaced with a new one in case a second join request is made
@@ -8929,6 +9059,12 @@ StreamSfuClient.DISPOSE_OLD_SOCKET = 4100;
8929
9059
  * The close code used when the client fails to join the call (on the SFU).
8930
9060
  */
8931
9061
  StreamSfuClient.JOIN_FAILED = 4101;
9062
+ /**
9063
+ * Best-effort grace period in `leaveAndClose` for an in-flight join to
9064
+ * complete before we give up and close without sending `leaveCallRequest`.
9065
+ * Bounded so a stuck join can never hang the leave path.
9066
+ */
9067
+ StreamSfuClient.LEAVE_NOTIFY_GRACE_MS = 1000;
8932
9068
 
8933
9069
  /**
8934
9070
  * Event handler that watched the delivery of `call.accepted`.
@@ -10324,6 +10460,50 @@ const CallTypes = new CallTypesRegistry([
10324
10460
  }),
10325
10461
  ]);
10326
10462
 
10463
+ /**
10464
+ * A generic sliding-window rate limiter.
10465
+ *
10466
+ * Allows at most `maxAttempts` registrations inside a rolling `windowMs`.
10467
+ * Attempts spaced further apart than `windowMs` are always allowed.
10468
+ */
10469
+ class SlidingWindowRateLimiter {
10470
+ constructor(maxAttempts, windowMs) {
10471
+ this.timestamps = [];
10472
+ /**
10473
+ * Attempts to register a new event at `now`. Returns `true` if the attempt
10474
+ * fits inside the budget (and records it), or `false` if the budget is
10475
+ * exhausted (in which case no timestamp is recorded).
10476
+ */
10477
+ this.tryRegister = (now = Date.now()) => {
10478
+ this.prune(now);
10479
+ if (this.timestamps.length >= this.maxAttempts)
10480
+ return false;
10481
+ this.timestamps.push(now);
10482
+ return true;
10483
+ };
10484
+ /**
10485
+ * Clears the attempt history.
10486
+ */
10487
+ this.reset = () => {
10488
+ this.timestamps = [];
10489
+ };
10490
+ /**
10491
+ * Updates the budget and window size. Existing timestamps are kept; they
10492
+ * will be pruned by the next `tryRegister` call.
10493
+ */
10494
+ this.setLimits = (maxAttempts, windowMs) => {
10495
+ this.maxAttempts = maxAttempts;
10496
+ this.windowMs = windowMs;
10497
+ };
10498
+ this.prune = (now) => {
10499
+ const cutoff = now - this.windowMs;
10500
+ this.timestamps = this.timestamps.filter((t) => t >= cutoff);
10501
+ };
10502
+ this.maxAttempts = maxAttempts;
10503
+ this.windowMs = windowMs;
10504
+ }
10505
+ }
10506
+
10327
10507
  /**
10328
10508
  * Deactivates MediaStream (stops and removes tracks) to be later garbage collected
10329
10509
  *
@@ -10705,7 +10885,6 @@ const getScreenShareStream = async (options, tracer) => {
10705
10885
  const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
10706
10886
  try {
10707
10887
  const constraints = {
10708
- // @ts-expect-error - not present in types yet
10709
10888
  systemAudio: 'include',
10710
10889
  ...options,
10711
10890
  video: typeof options?.video === 'boolean'
@@ -10720,6 +10899,8 @@ const getScreenShareStream = async (options, tracer) => {
10720
10899
  ? options.audio
10721
10900
  : {
10722
10901
  channelCount: { ideal: 2 },
10902
+ // @ts-expect-error not yet present in the types
10903
+ restrictOwnAudio: true,
10723
10904
  echoCancellation: false,
10724
10905
  autoGainControl: false,
10725
10906
  noiseSuppression: false,
@@ -11364,6 +11545,7 @@ class DeviceManagerState {
11364
11545
  this.statusSubject = new BehaviorSubject(undefined);
11365
11546
  this.optimisticStatusSubject = new BehaviorSubject(undefined);
11366
11547
  this.mediaStreamSubject = new BehaviorSubject(undefined);
11548
+ this.rootMediaStreamSubject = new BehaviorSubject(undefined);
11367
11549
  this.selectedDeviceSubject = new BehaviorSubject(undefined);
11368
11550
  this.defaultConstraintsSubject = new BehaviorSubject(undefined);
11369
11551
  /**
@@ -11371,6 +11553,12 @@ class DeviceManagerState {
11371
11553
  *
11372
11554
  */
11373
11555
  this.mediaStream$ = this.mediaStreamSubject.asObservable();
11556
+ /**
11557
+ * An Observable that emits the raw device media stream (before any filters are applied),
11558
+ * or `undefined` if the device is currently disabled. When no filters are active, this
11559
+ * emits the same stream as `mediaStream$`.
11560
+ */
11561
+ this.rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
11374
11562
  /**
11375
11563
  * An Observable that emits the currently selected device
11376
11564
  */
@@ -11426,6 +11614,14 @@ class DeviceManagerState {
11426
11614
  get mediaStream() {
11427
11615
  return getCurrentValue(this.mediaStream$);
11428
11616
  }
11617
+ /**
11618
+ * The raw device media stream (before any filters are applied), or `undefined`
11619
+ * if the device is currently disabled. When no filters are active, this is the
11620
+ * same as `mediaStream`.
11621
+ */
11622
+ get rootMediaStream() {
11623
+ return getCurrentValue(this.rootMediaStream$);
11624
+ }
11429
11625
  /**
11430
11626
  * @internal
11431
11627
  * @param status
@@ -11450,6 +11646,7 @@ class DeviceManagerState {
11450
11646
  */
11451
11647
  setMediaStream(stream, rootStream) {
11452
11648
  setCurrentValue(this.mediaStreamSubject, stream);
11649
+ setCurrentValue(this.rootMediaStreamSubject, rootStream);
11453
11650
  if (rootStream) {
11454
11651
  this.setDevice(this.getDeviceIdFromStream(rootStream));
11455
11652
  }
@@ -11932,192 +12129,6 @@ const createNoAudioDetector = (audioStream, options) => {
11932
12129
  return stop;
11933
12130
  };
11934
12131
 
11935
- class RNSpeechDetector {
11936
- constructor(externalAudioStream) {
11937
- this.pc1 = new RTCPeerConnection({});
11938
- this.pc2 = new RTCPeerConnection({});
11939
- this.isStopped = false;
11940
- this.externalAudioStream = externalAudioStream;
11941
- }
11942
- /**
11943
- * Starts the speech detection.
11944
- */
11945
- async start(onSoundDetectedStateChanged) {
11946
- let detachListeners;
11947
- let unsubscribe;
11948
- try {
11949
- this.isStopped = false;
11950
- const audioStream = this.externalAudioStream != null
11951
- ? this.externalAudioStream
11952
- : await navigator.mediaDevices.getUserMedia({ audio: true });
11953
- this.audioStream = audioStream;
11954
- const onPc1IceCandidate = (e) => {
11955
- this.forwardIceCandidate(this.pc2, e.candidate);
11956
- };
11957
- const onPc2IceCandidate = (e) => {
11958
- this.forwardIceCandidate(this.pc1, e.candidate);
11959
- };
11960
- const onTrackPc2 = (e) => {
11961
- e.streams[0].getTracks().forEach((track) => {
11962
- // In RN, the remote track is automatically added to the audio output device
11963
- // so we need to mute it to avoid hearing the audio back
11964
- // @ts-expect-error _setVolume is a private method in react-native-webrtc
11965
- track._setVolume(0);
11966
- });
11967
- };
11968
- this.pc1.addEventListener('icecandidate', onPc1IceCandidate);
11969
- this.pc2.addEventListener('icecandidate', onPc2IceCandidate);
11970
- this.pc2.addEventListener('track', onTrackPc2);
11971
- detachListeners = () => {
11972
- this.pc1.removeEventListener('icecandidate', onPc1IceCandidate);
11973
- this.pc2.removeEventListener('icecandidate', onPc2IceCandidate);
11974
- this.pc2.removeEventListener('track', onTrackPc2);
11975
- };
11976
- audioStream
11977
- .getTracks()
11978
- .forEach((track) => this.pc1.addTrack(track, audioStream));
11979
- const offer = await this.pc1.createOffer({});
11980
- await this.pc2.setRemoteDescription(offer);
11981
- await this.pc1.setLocalDescription(offer);
11982
- const answer = await this.pc2.createAnswer();
11983
- await this.pc1.setRemoteDescription(answer);
11984
- await this.pc2.setLocalDescription(answer);
11985
- unsubscribe = this.onSpeakingDetectedStateChange(onSoundDetectedStateChanged);
11986
- return () => {
11987
- detachListeners?.();
11988
- unsubscribe?.();
11989
- this.stop();
11990
- };
11991
- }
11992
- catch (error) {
11993
- detachListeners?.();
11994
- unsubscribe?.();
11995
- this.stop();
11996
- const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
11997
- logger.error('error handling permissions: ', error);
11998
- return () => { };
11999
- }
12000
- }
12001
- /**
12002
- * Stops the speech detection and releases all allocated resources.
12003
- */
12004
- stop() {
12005
- if (this.isStopped)
12006
- return;
12007
- this.isStopped = true;
12008
- this.pc1.close();
12009
- this.pc2.close();
12010
- if (this.externalAudioStream != null) {
12011
- this.externalAudioStream = undefined;
12012
- }
12013
- else {
12014
- this.cleanupAudioStream();
12015
- }
12016
- }
12017
- /**
12018
- * Public method that detects the audio levels and returns the status.
12019
- */
12020
- onSpeakingDetectedStateChange(onSoundDetectedStateChanged) {
12021
- const initialBaselineNoiseLevel = 0.13;
12022
- let baselineNoiseLevel = initialBaselineNoiseLevel;
12023
- let speechDetected = false;
12024
- let speechTimer;
12025
- let silenceTimer;
12026
- const audioLevelHistory = []; // Store recent audio levels for smoother detection
12027
- const historyLength = 10;
12028
- const silenceThreshold = 1.1;
12029
- const resetThreshold = 0.9;
12030
- const speechTimeout = 500; // Speech is set to true after 500ms of audio detection
12031
- const silenceTimeout = 5000; // Reset baseline after 5 seconds of silence
12032
- const checkAudioLevel = async () => {
12033
- try {
12034
- const stats = await this.pc1.getStats();
12035
- const report = flatten(stats);
12036
- // Audio levels are present inside stats of type `media-source` and of kind `audio`
12037
- const audioMediaSourceStats = report.find((stat) => stat.type === 'media-source' &&
12038
- stat.kind === 'audio');
12039
- if (audioMediaSourceStats) {
12040
- const { audioLevel } = audioMediaSourceStats;
12041
- if (audioLevel) {
12042
- // Update audio level history (with max historyLength sized array)
12043
- audioLevelHistory.push(audioLevel);
12044
- if (audioLevelHistory.length > historyLength) {
12045
- audioLevelHistory.shift();
12046
- }
12047
- // Calculate average audio level
12048
- const avgAudioLevel = audioLevelHistory.reduce((a, b) => a + b, 0) /
12049
- audioLevelHistory.length;
12050
- // Update baseline (if necessary) based on silence detection
12051
- if (avgAudioLevel < baselineNoiseLevel * silenceThreshold) {
12052
- if (!silenceTimer) {
12053
- silenceTimer = setTimeout(() => {
12054
- baselineNoiseLevel = Math.min(avgAudioLevel * resetThreshold, initialBaselineNoiseLevel);
12055
- }, silenceTimeout);
12056
- }
12057
- }
12058
- else {
12059
- clearTimeout(silenceTimer);
12060
- silenceTimer = undefined;
12061
- }
12062
- // Speech detection with hysteresis
12063
- if (avgAudioLevel > baselineNoiseLevel * 1.5) {
12064
- if (!speechDetected) {
12065
- speechDetected = true;
12066
- onSoundDetectedStateChanged({
12067
- isSoundDetected: true,
12068
- audioLevel,
12069
- });
12070
- }
12071
- clearTimeout(speechTimer);
12072
- speechTimer = setTimeout(() => {
12073
- speechDetected = false;
12074
- onSoundDetectedStateChanged({
12075
- isSoundDetected: false,
12076
- audioLevel: 0,
12077
- });
12078
- }, speechTimeout);
12079
- }
12080
- }
12081
- }
12082
- }
12083
- catch (error) {
12084
- const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
12085
- logger.error('error checking audio level from stats', error);
12086
- }
12087
- };
12088
- const intervalId = setInterval(checkAudioLevel, 250);
12089
- return () => {
12090
- clearInterval(intervalId);
12091
- clearTimeout(speechTimer);
12092
- clearTimeout(silenceTimer);
12093
- };
12094
- }
12095
- cleanupAudioStream() {
12096
- if (!this.audioStream) {
12097
- return;
12098
- }
12099
- this.audioStream.getTracks().forEach((track) => track.stop());
12100
- if (
12101
- // @ts-expect-error release() is present in react-native-webrtc
12102
- typeof this.audioStream.release === 'function') {
12103
- // @ts-expect-error called to dispose the stream in RN
12104
- this.audioStream.release();
12105
- }
12106
- }
12107
- forwardIceCandidate(destination, candidate) {
12108
- if (this.isStopped ||
12109
- !candidate ||
12110
- destination.signalingState === 'closed') {
12111
- return;
12112
- }
12113
- destination.addIceCandidate(candidate).catch(() => {
12114
- // silently ignore the error
12115
- const logger = videoLoggerSystem.getLogger('RNSpeechDetector');
12116
- logger.info('cannot add ice candidate - ignoring');
12117
- });
12118
- }
12119
- }
12120
-
12121
12132
  class MicrophoneManager extends AudioDeviceManager {
12122
12133
  constructor(call, devicePersistence, disableMode = 'stop-tracks') {
12123
12134
  super(call, new MicrophoneManagerState(disableMode, call.tracer), TrackType.AUDIO, devicePersistence);
@@ -12430,13 +12441,16 @@ class MicrophoneManager extends AudioDeviceManager {
12430
12441
  return;
12431
12442
  await this.teardownSpeakingWhileMutedDetection();
12432
12443
  if (isReactNative()) {
12433
- this.rnSpeechDetector = new RNSpeechDetector();
12434
- const unsubscribe = await this.rnSpeechDetector.start((event) => {
12444
+ const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
12445
+ if (!speechActivity) {
12446
+ this.logger.warn('Native speech activity not available, make sure the "@stream-io/react-native-webrtc" peer dependency version is satisfied');
12447
+ return;
12448
+ }
12449
+ const unsubscribe = speechActivity.subscribe((event) => {
12435
12450
  this.state.setSpeakingWhileMuted(event.isSoundDetected);
12436
12451
  });
12437
12452
  this.soundDetectorCleanup = async () => {
12438
12453
  unsubscribe();
12439
- this.rnSpeechDetector = undefined;
12440
12454
  };
12441
12455
  }
12442
12456
  else {
@@ -12884,6 +12898,16 @@ class Call {
12884
12898
  this.fastReconnectDeadlineSeconds = 0;
12885
12899
  this.disconnectionTimeoutSeconds = 0;
12886
12900
  this.lastOfflineTimestamp = 0;
12901
+ // (10 attempts per rolling 120 s window).
12902
+ this.rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
12903
+ // "Network doesn't support WebRTC" detector: counts peer-connection
12904
+ // failures where ICE never reached `connected`/`completed`.
12905
+ this.maxIceFailuresWithoutConnect = 2;
12906
+ this.iceFailuresWithoutConnect = 0;
12907
+ // Consecutive-negotiation-failure detector: stops the reconnect loop when
12908
+ // the SFU keeps failing to negotiate SDP for us.
12909
+ this.maxConsecutiveNegotiationFailures = 3;
12910
+ this.consecutiveNegotiationFailures = 0;
12887
12911
  // maintain the order of publishing tracks to restore them after a reconnection
12888
12912
  // it shouldn't contain duplicates
12889
12913
  this.trackPublishOrder = [];
@@ -13186,6 +13210,19 @@ class Call {
13186
13210
  this.state.setCallingState(CallingState.LEFT);
13187
13211
  this.state.setParticipants([]);
13188
13212
  this.state.dispose();
13213
+ // Reset reconnect-related accumulators so a future `call.join()` on the
13214
+ // same instance starts with a fresh budget. The `Call` may be reused
13215
+ // (see `Call.test.ts` "can reuse call instance") so this is required.
13216
+ // Strategy/reason/attempts must also be cleared: when `leave()` is
13217
+ // reached via `giveUpAndLeave()` the success-path reset at the end of
13218
+ // `joinFlow` never runs, leaving stale values that would make the next
13219
+ // fresh `join()` send a stale `ReconnectDetails` to the SFU.
13220
+ this.rejoinRateLimiter.reset();
13221
+ this.iceFailuresWithoutConnect = 0;
13222
+ this.consecutiveNegotiationFailures = 0;
13223
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
13224
+ this.reconnectReason = '';
13225
+ this.reconnectAttempts = 0;
13189
13226
  // Call all leave call hooks, e.g. to clean up global event handlers
13190
13227
  this.leaveCallHooks.forEach((hook) => hook());
13191
13228
  this.initialized = false;
@@ -13536,9 +13573,20 @@ class Call {
13536
13573
  // when performing fast reconnect, or when we reuse the same SFU client,
13537
13574
  // (ws remained healthy), we just need to restore the ICE connection
13538
13575
  if (performingFastReconnect) {
13539
- // the SFU automatically issues an ICE restart on the subscriber
13540
- // we don't have to do it ourselves
13541
- await this.restoreICE(sfuClient, { includeSubscriber: false });
13576
+ // The SFU automatically issues an ICE restart on the subscriber,
13577
+ // so we only need to decide about the publisher. If the publisher's
13578
+ // peer connection is still stable (ICE still connected end-to-end),
13579
+ // the signal WebSocket drop was the only problem — the new WS alone
13580
+ // is enough, and restarting ICE would add unnecessary SDP/ICE churn.
13581
+ const publisherIsStable = this.publisher?.isStable() ?? true;
13582
+ const includePublisher = !!this.publisher?.isPublishing() && !publisherIsStable;
13583
+ if (!includePublisher && this.publisher?.isPublishing()) {
13584
+ this.logger.info('[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable');
13585
+ }
13586
+ await this.restoreICE(sfuClient, {
13587
+ includeSubscriber: false,
13588
+ includePublisher,
13589
+ });
13542
13590
  }
13543
13591
  else {
13544
13592
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
@@ -13581,6 +13629,15 @@ class Call {
13581
13629
  // reset the reconnect strategy to unspecified after a successful reconnection
13582
13630
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
13583
13631
  this.reconnectReason = '';
13632
+ // A successful SFU join handshake resets the consecutive-negotiation
13633
+ // counter (negotiation just succeeded). It does NOT reset
13634
+ // `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
13635
+ // those track WebRTC-level health and rejoin frequency, which are not
13636
+ // proven by the SFU handshake alone. ICE-failures-without-connect is
13637
+ // cleared via the `onIceConnected` callback when the peer connection
13638
+ // actually reaches `connected`/`completed` end-to-end. The rejoin
13639
+ // rolling window decays naturally as old timestamps age out.
13640
+ this.consecutiveNegotiationFailures = 0;
13584
13641
  this.logger.info(`Joined call ${this.cid}`);
13585
13642
  };
13586
13643
  /**
@@ -13698,6 +13755,12 @@ class Call {
13698
13755
  this.logger.warn(message, err);
13699
13756
  });
13700
13757
  },
13758
+ onIceConnected: () => {
13759
+ // ICE has reached `connected`/`completed` end-to-end on at least
13760
+ // one peer connection, WebRTC is actually working, so the
13761
+ // "ICE never connected" failure budget can be cleared.
13762
+ this.iceFailuresWithoutConnect = 0;
13763
+ },
13701
13764
  };
13702
13765
  this.subscriber = new Subscriber(basePeerConnectionOptions);
13703
13766
  // anonymous users can't publish anything hence, there is no need
@@ -13803,7 +13866,9 @@ class Call {
13803
13866
  * @internal
13804
13867
  *
13805
13868
  * @param strategy the reconnection strategy to use.
13806
- * @param reason the reason for the reconnection.
13869
+ * @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
13870
+ * constant when the SDK should react to it (e.g.
13871
+ * `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
13807
13872
  */
13808
13873
  this.reconnect = async (strategy, reason) => {
13809
13874
  if (this.state.callingState === CallingState.RECONNECTING ||
@@ -13824,6 +13889,30 @@ class Call {
13824
13889
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
13825
13890
  }
13826
13891
  };
13892
+ const giveUpAndLeave = async (message) => {
13893
+ this.logger.warn(`[Reconnect] Giving up: ${message}. Leaving the call.`);
13894
+ // If we're mid-iteration, the state can be JOINING; `Call.leave` would
13895
+ // then wait for JOINED before proceeding, but no more attempts will run
13896
+ // so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
13897
+ if (this.state.callingState === CallingState.JOINING) {
13898
+ this.state.setCallingState(CallingState.RECONNECTING);
13899
+ }
13900
+ try {
13901
+ await this.leave({ message });
13902
+ }
13903
+ catch (err) {
13904
+ this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
13905
+ }
13906
+ };
13907
+ // Count this entry into reconnect if it was triggered by a peer
13908
+ // connection that never reached `connected`/`completed`.
13909
+ if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
13910
+ this.iceFailuresWithoutConnect++;
13911
+ if (this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect) {
13912
+ await giveUpAndLeave('webrtc_unsupported_network');
13913
+ return;
13914
+ }
13915
+ }
13827
13916
  let attempt = 0;
13828
13917
  do {
13829
13918
  const reconnectingTime = Date.now() - reconnectStartTime;
@@ -13834,6 +13923,16 @@ class Call {
13834
13923
  await markAsReconnectingFailed();
13835
13924
  return;
13836
13925
  }
13926
+ // Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
13927
+ // transitions inside a rolling window. FAST is not counted because
13928
+ // it does not issue a new backend `joinCall`.
13929
+ if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
13930
+ this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE) {
13931
+ if (!this.rejoinRateLimiter.tryRegister()) {
13932
+ await giveUpAndLeave('rejoin_attempt_limit_exceeded');
13933
+ return;
13934
+ }
13935
+ }
13837
13936
  // we don't increment reconnect attempts for the FAST strategy.
13838
13937
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
13839
13938
  this.reconnectAttempts++;
@@ -13861,6 +13960,8 @@ class Call {
13861
13960
  ensureExhausted(this.reconnectStrategy, 'Unknown reconnection strategy');
13862
13961
  break;
13863
13962
  }
13963
+ // reconnection worked — reset the negotiation-failure streak.
13964
+ this.consecutiveNegotiationFailures = 0;
13864
13965
  break; // do-while loop, reconnection worked, exit the loop
13865
13966
  }
13866
13967
  catch (error) {
@@ -13875,7 +13976,16 @@ class Call {
13875
13976
  await markAsReconnectingFailed();
13876
13977
  return;
13877
13978
  }
13878
- await sleep(500);
13979
+ if (error instanceof NegotiationError) {
13980
+ this.consecutiveNegotiationFailures++;
13981
+ if (this.consecutiveNegotiationFailures >=
13982
+ this.maxConsecutiveNegotiationFailures) {
13983
+ await giveUpAndLeave('repeated_negotiation_failures');
13984
+ return;
13985
+ }
13986
+ }
13987
+ // exponential backoff with jitter, capped at 5 s
13988
+ await sleep(retryInterval(attempt));
13879
13989
  const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
13880
13990
  const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
13881
13991
  this.fastReconnectDeadlineSeconds;
@@ -13984,7 +14094,7 @@ class Call {
13984
14094
  this.registerReconnectHandlers = () => {
13985
14095
  // handles the legacy "goAway" event
13986
14096
  const unregisterGoAway = this.on('goAway', () => {
13987
- this.reconnect(WebsocketReconnectStrategy.MIGRATE, 'goAway').catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
14097
+ this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
13988
14098
  });
13989
14099
  // handles the "error" event, through which the SFU can request a reconnect
13990
14100
  const unregisterOnError = this.on('error', (e) => {
@@ -14003,7 +14113,7 @@ class Call {
14003
14113
  });
14004
14114
  }
14005
14115
  else {
14006
- this.reconnect(strategy, error?.message || 'SFU Error').catch((err) => {
14116
+ this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
14007
14117
  this.logger.warn('[Reconnect] Error reconnecting', err);
14008
14118
  });
14009
14119
  }
@@ -14027,7 +14137,7 @@ class Call {
14027
14137
  strategy = WebsocketReconnectStrategy.REJOIN;
14028
14138
  }
14029
14139
  }
14030
- this.reconnect(strategy, 'Going online').catch((err) => {
14140
+ this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch((err) => {
14031
14141
  this.logger.warn('[Reconnect] Error reconnecting after going online', err);
14032
14142
  });
14033
14143
  });
@@ -14170,10 +14280,12 @@ class Call {
14170
14280
  * @param trackTypes the track types to update the call state with.
14171
14281
  */
14172
14282
  this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
14173
- if (!this.sfuClient || !this.sfuClient.sessionId)
14283
+ const sessionId = this.sfuClient?.sessionId;
14284
+ if (!sessionId)
14174
14285
  return;
14175
14286
  await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
14176
- const { sessionId } = this.sfuClient;
14287
+ if (this.sfuClient?.sessionId !== sessionId)
14288
+ return;
14177
14289
  for (const trackType of trackTypes) {
14178
14290
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
14179
14291
  if (!streamStateProp)
@@ -14969,6 +15081,39 @@ class Call {
14969
15081
  this.setDisconnectionTimeout = (timeoutSeconds) => {
14970
15082
  this.disconnectionTimeoutSeconds = timeoutSeconds;
14971
15083
  };
15084
+ /**
15085
+ * Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
15086
+ * `maxAttempts` rejoins have been registered inside `windowSeconds`, the
15087
+ * SDK stops retrying and transitions the call to `LEFT` with the
15088
+ * `rejoin_attempt_limit_exceeded` leave message.
15089
+ *
15090
+ * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
15091
+ * Both arguments are clamped to a minimum of 1.
15092
+ */
15093
+ this.setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
15094
+ this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
15095
+ };
15096
+ /**
15097
+ * Configures how many peer-connection failures where ICE never reached
15098
+ * `connected`/`completed` are tolerated before the SDK concludes that the
15099
+ * current network cannot support WebRTC and transitions the call to
15100
+ * `LEFT` with the `webrtc_unsupported_network` leave message.
15101
+ *
15102
+ * Default: 2. Clamped to a minimum of 1.
15103
+ */
15104
+ this.setMaxIceFailuresWithoutConnect = (n) => {
15105
+ this.maxIceFailuresWithoutConnect = Math.max(1, n);
15106
+ };
15107
+ /**
15108
+ * Configures how many consecutive SDP `NegotiationError`s are tolerated
15109
+ * before the SDK stops retrying and transitions the call to `LEFT` with
15110
+ * the `repeated_negotiation_failures` leave message.
15111
+ *
15112
+ * Default: 3. Clamped to a minimum of 1.
15113
+ */
15114
+ this.setMaxConsecutiveNegotiationFailures = (n) => {
15115
+ this.maxConsecutiveNegotiationFailures = Math.max(1, n);
15116
+ };
14972
15117
  /**
14973
15118
  * Enables the provided client capabilities.
14974
15119
  */
@@ -16151,7 +16296,7 @@ class StreamClient {
16151
16296
  this.getUserAgent = () => {
16152
16297
  if (!this.cachedUserAgent) {
16153
16298
  const { clientAppIdentifier = {} } = this.options;
16154
- const { sdkName = 'js', sdkVersion = "1.47.0", ...extras } = clientAppIdentifier;
16299
+ const { sdkName = 'js', sdkVersion = "1.49.0", ...extras } = clientAppIdentifier;
16155
16300
  this.cachedUserAgent = [
16156
16301
  `stream-video-${sdkName}-v${sdkVersion}`,
16157
16302
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -16787,5 +16932,5 @@ const humanize = (n) => {
16787
16932
  return String(n);
16788
16933
  };
16789
16934
 
16790
- export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LayoutSettingsRequestNameEnum, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RNSpeechDetector, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, checkIfAudioOutputChangeSupported, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getClientDetails, getDeviceState, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, withParticipantSource };
16935
+ export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LayoutSettingsRequestNameEnum, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, checkIfAudioOutputChangeSupported, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getClientDetails, getDeviceState, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, withParticipantSource };
16791
16936
  //# sourceMappingURL=index.browser.es.js.map