@stream-io/video-client 1.61.2 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -5056,7 +5056,7 @@ const toIceCandidate = (iceTrickle) => {
5056
5056
  }
5057
5057
  };
5058
5058
 
5059
- const version = "1.61.2";
5059
+ const version = "1.62.0";
5060
5060
  const [major, minor, patch] = version.split('.');
5061
5061
  let sdkInfo = {
5062
5062
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -8451,10 +8451,12 @@ class BasePeerConnection {
8451
8451
  sfuClient;
8452
8452
  onReconnectionNeeded;
8453
8453
  onIceConnected;
8454
+ onNoLocalCandidates;
8454
8455
  onPeerConnectionStateChange;
8455
8456
  onRemoteTrackUnmute;
8456
8457
  iceRestartDelay;
8457
8458
  iceHasEverConnected = false;
8459
+ localCandidatesGathered = 0;
8458
8460
  iceRestartTimeout;
8459
8461
  preConnectStuckTimeout;
8460
8462
  isIceRestarting = false;
@@ -8468,7 +8470,7 @@ class BasePeerConnection {
8468
8470
  /**
8469
8471
  * Constructs a new `BasePeerConnection` instance.
8470
8472
  */
8471
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8473
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onNoLocalCandidates, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8472
8474
  this.peerType = peerType;
8473
8475
  this.sfuClient = sfuClient;
8474
8476
  this.state = state;
@@ -8479,6 +8481,7 @@ class BasePeerConnection {
8479
8481
  this.tag = tag;
8480
8482
  this.onReconnectionNeeded = onReconnectionNeeded;
8481
8483
  this.onIceConnected = onIceConnected;
8484
+ this.onNoLocalCandidates = onNoLocalCandidates;
8482
8485
  this.onPeerConnectionStateChange = onPeerConnectionStateChange;
8483
8486
  this.onRemoteTrackUnmute = onRemoteTrackUnmute;
8484
8487
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
@@ -8519,6 +8522,7 @@ class BasePeerConnection {
8519
8522
  this.preConnectStuckTimeout = undefined;
8520
8523
  this.onReconnectionNeeded = undefined;
8521
8524
  this.onIceConnected = undefined;
8525
+ this.onNoLocalCandidates = undefined;
8522
8526
  this.onPeerConnectionStateChange = undefined;
8523
8527
  this.onRemoteTrackUnmute = undefined;
8524
8528
  this.isDisposed = true;
@@ -8622,6 +8626,17 @@ class BasePeerConnection {
8622
8626
  getTrackType = (trackId) => {
8623
8627
  return this.trackIdToTrackType.get(trackId);
8624
8628
  };
8629
+ /**
8630
+ * Returns `true` once ICE has reached `connected`/`completed` at least
8631
+ * once during the lifetime of this peer connection.
8632
+ */
8633
+ hasIceEverConnected = () => this.iceHasEverConnected;
8634
+ /**
8635
+ * Returns `true` when the latest ICE gathering round completed without
8636
+ * producing a single local candidate.
8637
+ */
8638
+ hasNoLocalCandidates = () => this.pc.iceGatheringState === 'complete' &&
8639
+ this.localCandidatesGathered === 0;
8625
8640
  /**
8626
8641
  * Checks if the `RTCPeerConnection` is healthy.
8627
8642
  * It checks the ICE connection state and the peer connection state.
@@ -8644,6 +8659,8 @@ class BasePeerConnection {
8644
8659
  this.logger.debug('null ice candidate');
8645
8660
  return;
8646
8661
  }
8662
+ if (candidate.candidate)
8663
+ this.localCandidatesGathered++;
8647
8664
  const iceCandidate = toJSON(candidate);
8648
8665
  this.sfuClient
8649
8666
  .iceTrickle({ peerType: this.peerType, iceCandidate })
@@ -8708,6 +8725,17 @@ class BasePeerConnection {
8708
8725
  }
8709
8726
  };
8710
8727
  handleConnectionStateUpdate = (state) => {
8728
+ // Fire `onIceConnected` exactly once per peer-connection lifetime, the
8729
+ // first time ICE reaches `connected`/`completed` end-to-end. Recorded
8730
+ // ahead of the guards below so a connection established while offline,
8731
+ // reconnecting or restarting ICE is not missed. Used by `Call` to reset
8732
+ // the unsupported-network failure counter only after WebRTC has actually
8733
+ // recovered, not merely on SFU join.
8734
+ if ((state === 'connected' || state === 'completed') &&
8735
+ !this.iceHasEverConnected) {
8736
+ this.iceHasEverConnected = true;
8737
+ this.onIceConnected?.(this.peerType);
8738
+ }
8711
8739
  const { callingState } = this.state;
8712
8740
  if (callingState === CallingState.OFFLINE)
8713
8741
  return;
@@ -8771,14 +8799,6 @@ class BasePeerConnection {
8771
8799
  break;
8772
8800
  case 'connected':
8773
8801
  case 'completed':
8774
- // Fire `onIceConnected` exactly once per peer-connection lifetime —
8775
- // the first time ICE reaches `connected`/`completed` end-to-end.
8776
- // Used by `Call` to reset the unsupported-network failure counter
8777
- // only after WebRTC has actually recovered, not merely on SFU join.
8778
- if (!this.iceHasEverConnected) {
8779
- this.iceHasEverConnected = true;
8780
- this.onIceConnected?.(this.peerType);
8781
- }
8782
8802
  // clear any scheduled restartICE since the connection is healthy
8783
8803
  if (this.iceRestartTimeout) {
8784
8804
  this.logger.info('connected connection, canceling restartICE');
@@ -8804,7 +8824,17 @@ class BasePeerConnection {
8804
8824
  * Handles the ICE gathering state change event.
8805
8825
  */
8806
8826
  onIceGatherChange = () => {
8807
- this.logger.debug(`ICE Gathering State`, this.pc.iceGatheringState);
8827
+ const state = this.pc.iceGatheringState;
8828
+ this.logger.debug(`ICE Gathering State`, state);
8829
+ if (state === 'gathering') {
8830
+ this.localCandidatesGathered = 0;
8831
+ }
8832
+ else if (state === 'complete' &&
8833
+ this.localCandidatesGathered === 0 &&
8834
+ !this.iceHasEverConnected) {
8835
+ this.logger.info('ICE gathering completed without local candidates');
8836
+ this.onNoLocalCandidates?.(this.peerType);
8837
+ }
8808
8838
  };
8809
8839
  /**
8810
8840
  * Handles the signaling state change event.
@@ -12320,7 +12350,7 @@ class DynascaleManager {
12320
12350
  this.traceAudioSessionState();
12321
12351
  const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
12322
12352
  const updateSinkId = (deviceId, audioContext) => {
12323
- if (!deviceId)
12353
+ if (!deviceId || !this.speaker.state.isDeviceSelectionSupported)
12324
12354
  return;
12325
12355
  if ('setSinkId' in audioElement) {
12326
12356
  audioElement.setSinkId(deviceId).catch((e) => {
@@ -12415,7 +12445,7 @@ class DynascaleManager {
12415
12445
  updateSinkId(selectedDevice, audioContext);
12416
12446
  });
12417
12447
  });
12418
- const sinkIdSubscription = !('setSinkId' in audioElement)
12448
+ const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
12419
12449
  ? null
12420
12450
  : this.speaker.state.selectedDevice$.subscribe((deviceId) => {
12421
12451
  const audioContext = this.getOrCreateAudioContext();
@@ -13078,10 +13108,17 @@ const getDevices = (permission, kind, tracer) => {
13078
13108
  /**
13079
13109
  * Tells if the browser supports audio output change on 'audio' elements,
13080
13110
  * see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
13111
+ *
13112
+ * WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
13113
+ * setSinkId requires a user gesture, a per-device output can run at a
13114
+ * different sample rate than the remote audio mixer (pitched/slowed audio),
13115
+ * and audio goes silent when the selected device disconnects.
13081
13116
  */
13082
13117
  const checkIfAudioOutputChangeSupported = () => {
13083
13118
  if (typeof document === 'undefined')
13084
13119
  return false;
13120
+ if (isWebKit())
13121
+ return false;
13085
13122
  const element = document.createElement('audio');
13086
13123
  return 'setSinkId' in element;
13087
13124
  };
@@ -13587,6 +13624,7 @@ class DeviceManager {
13587
13624
  ? forceStopOrOptions
13588
13625
  : (forceStopOrOptions?.forceStop ?? false);
13589
13626
  this.state.prevStatus = this.state.optimisticStatus;
13627
+ this.state.setError(undefined);
13590
13628
  if (!forceStop && this.state.optimisticStatus === 'disabled') {
13591
13629
  return;
13592
13630
  }
@@ -13596,6 +13634,7 @@ class DeviceManager {
13596
13634
  const stopTracks = forceStop || this.state.disableMode === 'stop-tracks';
13597
13635
  await this.muteStream(stopTracks);
13598
13636
  this.state.setStatus('disabled');
13637
+ this.state.setError(undefined);
13599
13638
  }
13600
13639
  finally {
13601
13640
  if (!signal.aborted) {
@@ -13891,7 +13930,11 @@ class DeviceManager {
13891
13930
  };
13892
13931
  // the rootStream represents the stream coming from the actual device
13893
13932
  // e.g. camera or microphone stream
13933
+ this.state.setError(undefined);
13894
13934
  rootStreamPromise = this.getSelectedStream(constraints);
13935
+ rootStreamPromise.catch((error) => {
13936
+ this.state.setError(error);
13937
+ });
13895
13938
  // we publish the last MediaStream of the chain
13896
13939
  stream = await this.filters.reduce((parent, entry) => parent
13897
13940
  .then((inputStream) => {
@@ -14129,6 +14172,7 @@ class DeviceManagerState {
14129
14172
  rootMediaStreamSubject = new BehaviorSubject(undefined);
14130
14173
  selectedDeviceSubject = new BehaviorSubject(undefined);
14131
14174
  defaultConstraintsSubject = new BehaviorSubject(undefined);
14175
+ errorSubject = new BehaviorSubject(undefined);
14132
14176
  /**
14133
14177
  * @internal
14134
14178
  */
@@ -14163,6 +14207,13 @@ class DeviceManagerState {
14163
14207
  * The default constraints for the device.
14164
14208
  */
14165
14209
  defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
14210
+ /**
14211
+ * An Observable that emits the error of the last failed attempt to start
14212
+ * the device (e.g. the device is in use by another app), or `undefined`.
14213
+ * For screen share, this includes the user dismissing the picker.
14214
+ * Cleared when the device is started again or disabled.
14215
+ */
14216
+ error$ = this.errorSubject.asObservable().pipe(distinctUntilChanged());
14166
14217
  /**
14167
14218
  * An observable that will emit `true` if browser/system permission
14168
14219
  * is granted (or at least hasn't been denied), `false` otherwise.
@@ -14230,6 +14281,19 @@ class DeviceManagerState {
14230
14281
  get rootMediaStream() {
14231
14282
  return getCurrentValue(this.rootMediaStream$);
14232
14283
  }
14284
+ /**
14285
+ * The error of the last failed attempt to start the device, or `undefined`.
14286
+ */
14287
+ get error() {
14288
+ return getCurrentValue(this.error$);
14289
+ }
14290
+ /**
14291
+ * @internal
14292
+ * @param error the error to set.
14293
+ */
14294
+ setError(error) {
14295
+ setCurrentValue(this.errorSubject, error);
14296
+ }
14233
14297
  /**
14234
14298
  * @internal
14235
14299
  * @param status
@@ -14811,9 +14875,11 @@ class MicrophoneManager extends AudioDeviceManager {
14811
14875
  this.call.state.callingState$,
14812
14876
  this.call.state.ownCapabilities$,
14813
14877
  this.state.selectedDevice$,
14814
- this.state.status$,
14815
14878
  this.state.browserPermissionState$,
14816
- ]), async ([callingState, ownCapabilities, deviceId, status, permissionState,]) => {
14879
+ // only a trigger: queued handlers can run after the status changed
14880
+ // again, so the live status is read below
14881
+ this.state.status$,
14882
+ ]), async ([callingState, ownCapabilities, deviceId, permissionState]) => {
14817
14883
  try {
14818
14884
  if (callingState === CallingState.LEFT) {
14819
14885
  // The muted-recording-prepared mode is reset in `callManager.stop()`
@@ -14828,7 +14894,7 @@ class MicrophoneManager extends AudioDeviceManager {
14828
14894
  return;
14829
14895
  if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
14830
14896
  const hasPermission = await this.hasPermission(permissionState);
14831
- if (hasPermission && status !== 'enabled') {
14897
+ if (hasPermission && this.state.status !== 'enabled') {
14832
14898
  this.setMutedRecordingPrepared(true);
14833
14899
  await this.startSpeakingWhileMutedDetection(deviceId);
14834
14900
  }
@@ -15128,6 +15194,52 @@ class MicrophoneManager extends AudioDeviceManager {
15128
15194
  await this.call.ensureMediaFactory();
15129
15195
  return getAudioStream(constraints, this.call.tracer);
15130
15196
  }
15197
+ /**
15198
+ * On mute, hands a clone of the live mic track to the speaking while muted
15199
+ * detection, so the device stays captured after the mic tracks are stopped.
15200
+ * Releasing and re-acquiring the mic flips Bluetooth headsets between audio
15201
+ * profiles.
15202
+ */
15203
+ async muteStream(stopTracks = true) {
15204
+ const [track] = this.state.rootMediaStream?.getAudioTracks() ?? [];
15205
+ const clone = track?.readyState === 'live' &&
15206
+ this.state.optimisticStatus === 'disabled' &&
15207
+ this.canHandOffMic()
15208
+ ? track.clone()
15209
+ : undefined;
15210
+ try {
15211
+ await super.muteStream(stopTracks);
15212
+ }
15213
+ catch (err) {
15214
+ clone?.stop();
15215
+ throw err;
15216
+ }
15217
+ if (!clone)
15218
+ return;
15219
+ // the mic, the call, or the selected device may have changed while
15220
+ // unpublishing
15221
+ const deviceId = resolveDeviceId(clone.getSettings().deviceId, 'audioinput');
15222
+ if (clone.readyState !== 'live' ||
15223
+ !this.canHandOffMic() ||
15224
+ deviceId !== this.state.selectedDevice) {
15225
+ clone.stop();
15226
+ return;
15227
+ }
15228
+ this.startSpeakingWhileMutedDetection(deviceId, clone).catch((err) => {
15229
+ clone.stop();
15230
+ this.logger.warn('Could not hand off mic to speaking while muted', err);
15231
+ });
15232
+ }
15233
+ /**
15234
+ * Whether a mute can hand the mic over to the speaking while muted detection.
15235
+ */
15236
+ canHandOffMic = () => {
15237
+ const { callingState, ownCapabilities } = this.call.state;
15238
+ return (!isReactNative() &&
15239
+ this.speakingWhileMutedNotificationEnabled &&
15240
+ callingState === CallingState.JOINED &&
15241
+ ownCapabilities.includes(OwnCapability.SEND_AUDIO));
15242
+ };
15131
15243
  doSetAudioBitrateProfile(profile) {
15132
15244
  this.setDefaultConstraints({
15133
15245
  ...this.state.defaultConstraints,
@@ -15147,10 +15259,13 @@ class MicrophoneManager extends AudioDeviceManager {
15147
15259
  }
15148
15260
  }
15149
15261
  }
15150
- async startSpeakingWhileMutedDetection(deviceId) {
15262
+ async startSpeakingWhileMutedDetection(deviceId, track) {
15151
15263
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
15152
- if (this.soundDetectorCleanup && this.soundDetectorDeviceId === deviceId)
15264
+ if (this.soundDetectorCleanup &&
15265
+ this.soundDetectorDeviceId === deviceId) {
15266
+ track?.stop();
15153
15267
  return;
15268
+ }
15154
15269
  await this.teardownSpeakingWhileMutedDetection();
15155
15270
  if (isReactNative()) {
15156
15271
  const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
@@ -15166,11 +15281,14 @@ class MicrophoneManager extends AudioDeviceManager {
15166
15281
  };
15167
15282
  }
15168
15283
  else {
15169
- // Need to start a new stream that's not connected to publisher
15170
- const stream = await this.getStream({
15171
- ...this.state.defaultConstraints,
15172
- deviceId: { exact: deviceId },
15173
- });
15284
+ // Use the handed-off mic track, or start a new stream that's not
15285
+ // connected to publisher
15286
+ const stream = track
15287
+ ? new MediaStream([track])
15288
+ : await this.getStream({
15289
+ ...this.state.defaultConstraints,
15290
+ deviceId: { exact: deviceId },
15291
+ });
15174
15292
  this.soundDetectorCleanup = createSoundDetector(stream, (event) => {
15175
15293
  this.state.setSpeakingWhileMuted(event.isSoundDetected);
15176
15294
  });
@@ -15450,7 +15568,7 @@ class SpeakerManager {
15450
15568
  }
15451
15569
  async applyWeb() {
15452
15570
  const { enabled, storageKey } = this.devicePersistence;
15453
- if (!enabled)
15571
+ if (!enabled || !this.state.isDeviceSelectionSupported)
15454
15572
  return;
15455
15573
  const preferences = readPreferences(storageKey);
15456
15574
  const preferenceList = toPreferenceList(preferences.speaker);
@@ -15726,6 +15844,14 @@ class Call {
15726
15844
  networkAvailableTask;
15727
15845
  // (10 attempts per rolling 120 s window).
15728
15846
  rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
15847
+ // Backstop for slow REJOIN loops that stay under the short window
15848
+ // (20 attempts per rolling 30 min window). Dropped once the integrator
15849
+ // takes over the budget through `setRejoinAttemptLimit`.
15850
+ rejoinLongWindowRateLimiter = new SlidingWindowRateLimiter(20, 30 * 60 * 1000);
15851
+ // In-flight `doJoin` runs, awaited before acting on peer connections that
15852
+ // gathered no local ICE candidates.
15853
+ joinTasks = new Set();
15854
+ noLocalCandidatesCheckScheduled = false;
15729
15855
  // "Network doesn't support WebRTC" detector: counts peer-connection
15730
15856
  // failures where ICE never reached `connected`/`completed`.
15731
15857
  maxIceFailuresWithoutConnect = 2;
@@ -16119,6 +16245,7 @@ class Call {
16119
16245
  // `joinFlow` never runs, leaving stale values that would make the next
16120
16246
  // fresh `join()` send a stale `ReconnectDetails` to the SFU.
16121
16247
  this.rejoinRateLimiter.reset();
16248
+ this.rejoinLongWindowRateLimiter?.reset();
16122
16249
  this.iceFailuresWithoutConnect = 0;
16123
16250
  this.consecutiveNegotiationFailures = 0;
16124
16251
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
@@ -16514,6 +16641,16 @@ class Call {
16514
16641
  * @returns a promise which resolves once the call join-flow has finished.
16515
16642
  */
16516
16643
  doJoin = async (data) => {
16644
+ const task = this.performJoin(data);
16645
+ this.joinTasks.add(task);
16646
+ try {
16647
+ await task;
16648
+ }
16649
+ finally {
16650
+ this.joinTasks.delete(task);
16651
+ }
16652
+ };
16653
+ performJoin = async (data) => {
16517
16654
  const connectStartTime = Date.now();
16518
16655
  const callingState = this.state.callingState;
16519
16656
  const joinLeaveGeneration = this.leaveGeneration;
@@ -16871,6 +17008,9 @@ class Call {
16871
17008
  // "ICE never connected" failure budget can be cleared.
16872
17009
  this.iceFailuresWithoutConnect = 0;
16873
17010
  },
17011
+ onNoLocalCandidates: () => {
17012
+ this.checkNoLocalCandidates();
17013
+ },
16874
17014
  onPeerConnectionStateChange: (event) => {
16875
17015
  this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
16876
17016
  },
@@ -16919,6 +17059,59 @@ class Call {
16919
17059
  this.sfuStatsReporter.start();
16920
17060
  }
16921
17061
  };
17062
+ /**
17063
+ * Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
17064
+ * connections never connected and one of them completed ICE gathering
17065
+ * without a single local candidate, which means WebRTC networking is
17066
+ * blocked on this device.
17067
+ *
17068
+ * `reconnect` ignores requests while a join or another reconnect is in
17069
+ * flight, so the check is postponed until those settle and then re-run
17070
+ * against the peer connections current at that time.
17071
+ */
17072
+ checkNoLocalCandidates = () => {
17073
+ if (this.noLocalCandidatesCheckScheduled)
17074
+ return;
17075
+ if (this.joinTasks.size > 0 || hasPending(this.reconnectConcurrencyTag)) {
17076
+ this.noLocalCandidatesCheckScheduled = true;
17077
+ const leaveGeneration = this.leaveGeneration;
17078
+ this.waitForJoinsAndReconnects()
17079
+ .then(() => {
17080
+ this.noLocalCandidatesCheckScheduled = false;
17081
+ if (this.leaveGeneration !== leaveGeneration)
17082
+ return;
17083
+ this.checkNoLocalCandidates();
17084
+ })
17085
+ .catch((err) => {
17086
+ this.noLocalCandidatesCheckScheduled = false;
17087
+ this.logger.warn('[Reconnect] No-candidates check failed', err);
17088
+ });
17089
+ return;
17090
+ }
17091
+ if (this.state.callingState !== CallingState.JOINED)
17092
+ return;
17093
+ // without network, gathering legitimately yields no candidates
17094
+ if (typeof navigator !== 'undefined' && navigator.onLine === false)
17095
+ return;
17096
+ const peerConnections = [this.publisher, this.subscriber];
17097
+ if (peerConnections.some((pc) => pc?.hasIceEverConnected()))
17098
+ return;
17099
+ if (!peerConnections.some((pc) => pc?.hasNoLocalCandidates()))
17100
+ return;
17101
+ this.reconnect(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED).catch((err) => {
17102
+ this.logger.warn('[Reconnect] Error reconnecting', err);
17103
+ });
17104
+ };
17105
+ /**
17106
+ * Resolves once no `doJoin` run and no reconnect is in flight.
17107
+ */
17108
+ waitForJoinsAndReconnects = async () => {
17109
+ while (this.joinTasks.size > 0 ||
17110
+ hasPending(this.reconnectConcurrencyTag)) {
17111
+ await Promise.allSettled(this.joinTasks);
17112
+ await settled(this.reconnectConcurrencyTag);
17113
+ }
17114
+ };
16922
17115
  /**
16923
17116
  * Retrieves credentials for joining the call.
16924
17117
  *
@@ -17117,6 +17310,14 @@ class Call {
17117
17310
  return;
17118
17311
  }
17119
17312
  }
17313
+ // MIGRATE is server-driven (e.g. SFU drains), so it doesn't count
17314
+ // toward the long-window backstop.
17315
+ if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN &&
17316
+ this.rejoinLongWindowRateLimiter &&
17317
+ !this.rejoinLongWindowRateLimiter.tryRegister()) {
17318
+ await giveUpAndLeave('rejoin_attempt_limit_exceeded');
17319
+ return;
17320
+ }
17120
17321
  // we don't increment reconnect attempts for the FAST strategy.
17121
17322
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
17122
17323
  this.reconnectAttempts++;
@@ -17335,7 +17536,13 @@ class Call {
17335
17536
  this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
17336
17537
  });
17337
17538
  // handles the "error" event, through which the SFU can request a reconnect
17338
- const unregisterOnError = this.on('error', (e) => {
17539
+ // Only the active SFU client may drive a reconnect: a superseded one can
17540
+ // still report errors about its own (replaced) session, and none may
17541
+ // while `leave()` tears the call down.
17542
+ const activeSfuTag = () => this.sfuClient?.tag ?? '*';
17543
+ const unregisterOnError = this.dispatcher.on('error', activeSfuTag, (e) => {
17544
+ if (!this.sfuClient)
17545
+ return;
17339
17546
  const { reconnectStrategy: strategy, error } = e;
17340
17547
  if (!SfuJoinError.isJoinErrorCode(e)) {
17341
17548
  const code = error?.code ? ErrorCode[error.code] : undefined;
@@ -17358,7 +17565,16 @@ class Call {
17358
17565
  });
17359
17566
  }
17360
17567
  else {
17361
- this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
17568
+ // A media transport failure on a session whose peer connections
17569
+ // never connected is an "ICE never connected" failure, and must
17570
+ // count toward the unsupported-network budget.
17571
+ const neverConnected = error?.code === ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE &&
17572
+ !this.publisher?.hasIceEverConnected() &&
17573
+ !this.subscriber?.hasIceEverConnected();
17574
+ const reason = neverConnected
17575
+ ? ReconnectReason.ICE_NEVER_CONNECTED
17576
+ : error?.message || ReconnectReason.SFU_ERROR;
17577
+ this.reconnect(strategy, reason).catch((err) => {
17362
17578
  this.logger.warn('[Reconnect] Error reconnecting', err);
17363
17579
  });
17364
17580
  }
@@ -18422,9 +18638,11 @@ class Call {
18422
18638
  * `rejoin_attempt_limit_exceeded` leave message.
18423
18639
  *
18424
18640
  * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
18425
- * Both arguments are clamped to a minimum of 1.
18641
+ * Both arguments are clamped to a minimum of 1. Unless this method is
18642
+ * called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
18426
18643
  */
18427
18644
  setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
18645
+ this.rejoinLongWindowRateLimiter = undefined;
18428
18646
  this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
18429
18647
  };
18430
18648
  /**
@@ -19762,11 +19980,11 @@ class StreamClient {
19762
19980
  return await this.wsConnection.connect(this.defaultWSTimeout);
19763
19981
  };
19764
19982
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19765
- "1.61.2";
19983
+ "1.62.0";
19766
19984
  getUserAgent = () => {
19767
19985
  if (!this.cachedUserAgent) {
19768
19986
  const { clientAppIdentifier = {} } = this.options;
19769
- const { sdkName = 'js', sdkVersion = "1.61.2", ...extras } = clientAppIdentifier;
19987
+ const { sdkName = 'js', sdkVersion = "1.62.0", ...extras } = clientAppIdentifier;
19770
19988
  this.cachedUserAgent = [
19771
19989
  `stream-video-${sdkName}-v${sdkVersion}`,
19772
19990
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),