@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.cjs.js CHANGED
@@ -5075,7 +5075,7 @@ const toIceCandidate = (iceTrickle) => {
5075
5075
  }
5076
5076
  };
5077
5077
 
5078
- const version = "1.61.2";
5078
+ const version = "1.62.0";
5079
5079
  const [major, minor, patch] = version.split('.');
5080
5080
  let sdkInfo = {
5081
5081
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -8470,10 +8470,12 @@ class BasePeerConnection {
8470
8470
  sfuClient;
8471
8471
  onReconnectionNeeded;
8472
8472
  onIceConnected;
8473
+ onNoLocalCandidates;
8473
8474
  onPeerConnectionStateChange;
8474
8475
  onRemoteTrackUnmute;
8475
8476
  iceRestartDelay;
8476
8477
  iceHasEverConnected = false;
8478
+ localCandidatesGathered = 0;
8477
8479
  iceRestartTimeout;
8478
8480
  preConnectStuckTimeout;
8479
8481
  isIceRestarting = false;
@@ -8487,7 +8489,7 @@ class BasePeerConnection {
8487
8489
  /**
8488
8490
  * Constructs a new `BasePeerConnection` instance.
8489
8491
  */
8490
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8492
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onNoLocalCandidates, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
8491
8493
  this.peerType = peerType;
8492
8494
  this.sfuClient = sfuClient;
8493
8495
  this.state = state;
@@ -8498,6 +8500,7 @@ class BasePeerConnection {
8498
8500
  this.tag = tag;
8499
8501
  this.onReconnectionNeeded = onReconnectionNeeded;
8500
8502
  this.onIceConnected = onIceConnected;
8503
+ this.onNoLocalCandidates = onNoLocalCandidates;
8501
8504
  this.onPeerConnectionStateChange = onPeerConnectionStateChange;
8502
8505
  this.onRemoteTrackUnmute = onRemoteTrackUnmute;
8503
8506
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
@@ -8538,6 +8541,7 @@ class BasePeerConnection {
8538
8541
  this.preConnectStuckTimeout = undefined;
8539
8542
  this.onReconnectionNeeded = undefined;
8540
8543
  this.onIceConnected = undefined;
8544
+ this.onNoLocalCandidates = undefined;
8541
8545
  this.onPeerConnectionStateChange = undefined;
8542
8546
  this.onRemoteTrackUnmute = undefined;
8543
8547
  this.isDisposed = true;
@@ -8641,6 +8645,17 @@ class BasePeerConnection {
8641
8645
  getTrackType = (trackId) => {
8642
8646
  return this.trackIdToTrackType.get(trackId);
8643
8647
  };
8648
+ /**
8649
+ * Returns `true` once ICE has reached `connected`/`completed` at least
8650
+ * once during the lifetime of this peer connection.
8651
+ */
8652
+ hasIceEverConnected = () => this.iceHasEverConnected;
8653
+ /**
8654
+ * Returns `true` when the latest ICE gathering round completed without
8655
+ * producing a single local candidate.
8656
+ */
8657
+ hasNoLocalCandidates = () => this.pc.iceGatheringState === 'complete' &&
8658
+ this.localCandidatesGathered === 0;
8644
8659
  /**
8645
8660
  * Checks if the `RTCPeerConnection` is healthy.
8646
8661
  * It checks the ICE connection state and the peer connection state.
@@ -8663,6 +8678,8 @@ class BasePeerConnection {
8663
8678
  this.logger.debug('null ice candidate');
8664
8679
  return;
8665
8680
  }
8681
+ if (candidate.candidate)
8682
+ this.localCandidatesGathered++;
8666
8683
  const iceCandidate = toJSON(candidate);
8667
8684
  this.sfuClient
8668
8685
  .iceTrickle({ peerType: this.peerType, iceCandidate })
@@ -8727,6 +8744,17 @@ class BasePeerConnection {
8727
8744
  }
8728
8745
  };
8729
8746
  handleConnectionStateUpdate = (state) => {
8747
+ // Fire `onIceConnected` exactly once per peer-connection lifetime, the
8748
+ // first time ICE reaches `connected`/`completed` end-to-end. Recorded
8749
+ // ahead of the guards below so a connection established while offline,
8750
+ // reconnecting or restarting ICE is not missed. Used by `Call` to reset
8751
+ // the unsupported-network failure counter only after WebRTC has actually
8752
+ // recovered, not merely on SFU join.
8753
+ if ((state === 'connected' || state === 'completed') &&
8754
+ !this.iceHasEverConnected) {
8755
+ this.iceHasEverConnected = true;
8756
+ this.onIceConnected?.(this.peerType);
8757
+ }
8730
8758
  const { callingState } = this.state;
8731
8759
  if (callingState === exports.CallingState.OFFLINE)
8732
8760
  return;
@@ -8790,14 +8818,6 @@ class BasePeerConnection {
8790
8818
  break;
8791
8819
  case 'connected':
8792
8820
  case 'completed':
8793
- // Fire `onIceConnected` exactly once per peer-connection lifetime —
8794
- // the first time ICE reaches `connected`/`completed` end-to-end.
8795
- // Used by `Call` to reset the unsupported-network failure counter
8796
- // only after WebRTC has actually recovered, not merely on SFU join.
8797
- if (!this.iceHasEverConnected) {
8798
- this.iceHasEverConnected = true;
8799
- this.onIceConnected?.(this.peerType);
8800
- }
8801
8821
  // clear any scheduled restartICE since the connection is healthy
8802
8822
  if (this.iceRestartTimeout) {
8803
8823
  this.logger.info('connected connection, canceling restartICE');
@@ -8823,7 +8843,17 @@ class BasePeerConnection {
8823
8843
  * Handles the ICE gathering state change event.
8824
8844
  */
8825
8845
  onIceGatherChange = () => {
8826
- this.logger.debug(`ICE Gathering State`, this.pc.iceGatheringState);
8846
+ const state = this.pc.iceGatheringState;
8847
+ this.logger.debug(`ICE Gathering State`, state);
8848
+ if (state === 'gathering') {
8849
+ this.localCandidatesGathered = 0;
8850
+ }
8851
+ else if (state === 'complete' &&
8852
+ this.localCandidatesGathered === 0 &&
8853
+ !this.iceHasEverConnected) {
8854
+ this.logger.info('ICE gathering completed without local candidates');
8855
+ this.onNoLocalCandidates?.(this.peerType);
8856
+ }
8827
8857
  };
8828
8858
  /**
8829
8859
  * Handles the signaling state change event.
@@ -12339,7 +12369,7 @@ class DynascaleManager {
12339
12369
  this.traceAudioSessionState();
12340
12370
  const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((p) => !!p), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
12341
12371
  const updateSinkId = (deviceId, audioContext) => {
12342
- if (!deviceId)
12372
+ if (!deviceId || !this.speaker.state.isDeviceSelectionSupported)
12343
12373
  return;
12344
12374
  if ('setSinkId' in audioElement) {
12345
12375
  audioElement.setSinkId(deviceId).catch((e) => {
@@ -12434,7 +12464,7 @@ class DynascaleManager {
12434
12464
  updateSinkId(selectedDevice, audioContext);
12435
12465
  });
12436
12466
  });
12437
- const sinkIdSubscription = !('setSinkId' in audioElement)
12467
+ const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
12438
12468
  ? null
12439
12469
  : this.speaker.state.selectedDevice$.subscribe((deviceId) => {
12440
12470
  const audioContext = this.getOrCreateAudioContext();
@@ -13097,10 +13127,17 @@ const getDevices = (permission, kind, tracer) => {
13097
13127
  /**
13098
13128
  * Tells if the browser supports audio output change on 'audio' elements,
13099
13129
  * see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
13130
+ *
13131
+ * WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
13132
+ * setSinkId requires a user gesture, a per-device output can run at a
13133
+ * different sample rate than the remote audio mixer (pitched/slowed audio),
13134
+ * and audio goes silent when the selected device disconnects.
13100
13135
  */
13101
13136
  const checkIfAudioOutputChangeSupported = () => {
13102
13137
  if (typeof document === 'undefined')
13103
13138
  return false;
13139
+ if (isWebKit())
13140
+ return false;
13104
13141
  const element = document.createElement('audio');
13105
13142
  return 'setSinkId' in element;
13106
13143
  };
@@ -13606,6 +13643,7 @@ class DeviceManager {
13606
13643
  ? forceStopOrOptions
13607
13644
  : (forceStopOrOptions?.forceStop ?? false);
13608
13645
  this.state.prevStatus = this.state.optimisticStatus;
13646
+ this.state.setError(undefined);
13609
13647
  if (!forceStop && this.state.optimisticStatus === 'disabled') {
13610
13648
  return;
13611
13649
  }
@@ -13615,6 +13653,7 @@ class DeviceManager {
13615
13653
  const stopTracks = forceStop || this.state.disableMode === 'stop-tracks';
13616
13654
  await this.muteStream(stopTracks);
13617
13655
  this.state.setStatus('disabled');
13656
+ this.state.setError(undefined);
13618
13657
  }
13619
13658
  finally {
13620
13659
  if (!signal.aborted) {
@@ -13910,7 +13949,11 @@ class DeviceManager {
13910
13949
  };
13911
13950
  // the rootStream represents the stream coming from the actual device
13912
13951
  // e.g. camera or microphone stream
13952
+ this.state.setError(undefined);
13913
13953
  rootStreamPromise = this.getSelectedStream(constraints);
13954
+ rootStreamPromise.catch((error) => {
13955
+ this.state.setError(error);
13956
+ });
13914
13957
  // we publish the last MediaStream of the chain
13915
13958
  stream = await this.filters.reduce((parent, entry) => parent
13916
13959
  .then((inputStream) => {
@@ -14148,6 +14191,7 @@ class DeviceManagerState {
14148
14191
  rootMediaStreamSubject = new rxjs.BehaviorSubject(undefined);
14149
14192
  selectedDeviceSubject = new rxjs.BehaviorSubject(undefined);
14150
14193
  defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
14194
+ errorSubject = new rxjs.BehaviorSubject(undefined);
14151
14195
  /**
14152
14196
  * @internal
14153
14197
  */
@@ -14182,6 +14226,13 @@ class DeviceManagerState {
14182
14226
  * The default constraints for the device.
14183
14227
  */
14184
14228
  defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
14229
+ /**
14230
+ * An Observable that emits the error of the last failed attempt to start
14231
+ * the device (e.g. the device is in use by another app), or `undefined`.
14232
+ * For screen share, this includes the user dismissing the picker.
14233
+ * Cleared when the device is started again or disabled.
14234
+ */
14235
+ error$ = this.errorSubject.asObservable().pipe(rxjs.distinctUntilChanged());
14185
14236
  /**
14186
14237
  * An observable that will emit `true` if browser/system permission
14187
14238
  * is granted (or at least hasn't been denied), `false` otherwise.
@@ -14249,6 +14300,19 @@ class DeviceManagerState {
14249
14300
  get rootMediaStream() {
14250
14301
  return getCurrentValue(this.rootMediaStream$);
14251
14302
  }
14303
+ /**
14304
+ * The error of the last failed attempt to start the device, or `undefined`.
14305
+ */
14306
+ get error() {
14307
+ return getCurrentValue(this.error$);
14308
+ }
14309
+ /**
14310
+ * @internal
14311
+ * @param error the error to set.
14312
+ */
14313
+ setError(error) {
14314
+ setCurrentValue(this.errorSubject, error);
14315
+ }
14252
14316
  /**
14253
14317
  * @internal
14254
14318
  * @param status
@@ -14830,9 +14894,11 @@ class MicrophoneManager extends AudioDeviceManager {
14830
14894
  this.call.state.callingState$,
14831
14895
  this.call.state.ownCapabilities$,
14832
14896
  this.state.selectedDevice$,
14833
- this.state.status$,
14834
14897
  this.state.browserPermissionState$,
14835
- ]), async ([callingState, ownCapabilities, deviceId, status, permissionState,]) => {
14898
+ // only a trigger: queued handlers can run after the status changed
14899
+ // again, so the live status is read below
14900
+ this.state.status$,
14901
+ ]), async ([callingState, ownCapabilities, deviceId, permissionState]) => {
14836
14902
  try {
14837
14903
  if (callingState === exports.CallingState.LEFT) {
14838
14904
  // The muted-recording-prepared mode is reset in `callManager.stop()`
@@ -14847,7 +14913,7 @@ class MicrophoneManager extends AudioDeviceManager {
14847
14913
  return;
14848
14914
  if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
14849
14915
  const hasPermission = await this.hasPermission(permissionState);
14850
- if (hasPermission && status !== 'enabled') {
14916
+ if (hasPermission && this.state.status !== 'enabled') {
14851
14917
  this.setMutedRecordingPrepared(true);
14852
14918
  await this.startSpeakingWhileMutedDetection(deviceId);
14853
14919
  }
@@ -15147,6 +15213,52 @@ class MicrophoneManager extends AudioDeviceManager {
15147
15213
  await this.call.ensureMediaFactory();
15148
15214
  return getAudioStream(constraints, this.call.tracer);
15149
15215
  }
15216
+ /**
15217
+ * On mute, hands a clone of the live mic track to the speaking while muted
15218
+ * detection, so the device stays captured after the mic tracks are stopped.
15219
+ * Releasing and re-acquiring the mic flips Bluetooth headsets between audio
15220
+ * profiles.
15221
+ */
15222
+ async muteStream(stopTracks = true) {
15223
+ const [track] = this.state.rootMediaStream?.getAudioTracks() ?? [];
15224
+ const clone = track?.readyState === 'live' &&
15225
+ this.state.optimisticStatus === 'disabled' &&
15226
+ this.canHandOffMic()
15227
+ ? track.clone()
15228
+ : undefined;
15229
+ try {
15230
+ await super.muteStream(stopTracks);
15231
+ }
15232
+ catch (err) {
15233
+ clone?.stop();
15234
+ throw err;
15235
+ }
15236
+ if (!clone)
15237
+ return;
15238
+ // the mic, the call, or the selected device may have changed while
15239
+ // unpublishing
15240
+ const deviceId = resolveDeviceId(clone.getSettings().deviceId, 'audioinput');
15241
+ if (clone.readyState !== 'live' ||
15242
+ !this.canHandOffMic() ||
15243
+ deviceId !== this.state.selectedDevice) {
15244
+ clone.stop();
15245
+ return;
15246
+ }
15247
+ this.startSpeakingWhileMutedDetection(deviceId, clone).catch((err) => {
15248
+ clone.stop();
15249
+ this.logger.warn('Could not hand off mic to speaking while muted', err);
15250
+ });
15251
+ }
15252
+ /**
15253
+ * Whether a mute can hand the mic over to the speaking while muted detection.
15254
+ */
15255
+ canHandOffMic = () => {
15256
+ const { callingState, ownCapabilities } = this.call.state;
15257
+ return (!isReactNative() &&
15258
+ this.speakingWhileMutedNotificationEnabled &&
15259
+ callingState === exports.CallingState.JOINED &&
15260
+ ownCapabilities.includes(OwnCapability.SEND_AUDIO));
15261
+ };
15150
15262
  doSetAudioBitrateProfile(profile) {
15151
15263
  this.setDefaultConstraints({
15152
15264
  ...this.state.defaultConstraints,
@@ -15166,10 +15278,13 @@ class MicrophoneManager extends AudioDeviceManager {
15166
15278
  }
15167
15279
  }
15168
15280
  }
15169
- async startSpeakingWhileMutedDetection(deviceId) {
15281
+ async startSpeakingWhileMutedDetection(deviceId, track) {
15170
15282
  await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
15171
- if (this.soundDetectorCleanup && this.soundDetectorDeviceId === deviceId)
15283
+ if (this.soundDetectorCleanup &&
15284
+ this.soundDetectorDeviceId === deviceId) {
15285
+ track?.stop();
15172
15286
  return;
15287
+ }
15173
15288
  await this.teardownSpeakingWhileMutedDetection();
15174
15289
  if (isReactNative()) {
15175
15290
  const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
@@ -15185,11 +15300,14 @@ class MicrophoneManager extends AudioDeviceManager {
15185
15300
  };
15186
15301
  }
15187
15302
  else {
15188
- // Need to start a new stream that's not connected to publisher
15189
- const stream = await this.getStream({
15190
- ...this.state.defaultConstraints,
15191
- deviceId: { exact: deviceId },
15192
- });
15303
+ // Use the handed-off mic track, or start a new stream that's not
15304
+ // connected to publisher
15305
+ const stream = track
15306
+ ? new MediaStream([track])
15307
+ : await this.getStream({
15308
+ ...this.state.defaultConstraints,
15309
+ deviceId: { exact: deviceId },
15310
+ });
15193
15311
  this.soundDetectorCleanup = createSoundDetector(stream, (event) => {
15194
15312
  this.state.setSpeakingWhileMuted(event.isSoundDetected);
15195
15313
  });
@@ -15469,7 +15587,7 @@ class SpeakerManager {
15469
15587
  }
15470
15588
  async applyWeb() {
15471
15589
  const { enabled, storageKey } = this.devicePersistence;
15472
- if (!enabled)
15590
+ if (!enabled || !this.state.isDeviceSelectionSupported)
15473
15591
  return;
15474
15592
  const preferences = readPreferences(storageKey);
15475
15593
  const preferenceList = toPreferenceList(preferences.speaker);
@@ -15745,6 +15863,14 @@ class Call {
15745
15863
  networkAvailableTask;
15746
15864
  // (10 attempts per rolling 120 s window).
15747
15865
  rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
15866
+ // Backstop for slow REJOIN loops that stay under the short window
15867
+ // (20 attempts per rolling 30 min window). Dropped once the integrator
15868
+ // takes over the budget through `setRejoinAttemptLimit`.
15869
+ rejoinLongWindowRateLimiter = new SlidingWindowRateLimiter(20, 30 * 60 * 1000);
15870
+ // In-flight `doJoin` runs, awaited before acting on peer connections that
15871
+ // gathered no local ICE candidates.
15872
+ joinTasks = new Set();
15873
+ noLocalCandidatesCheckScheduled = false;
15748
15874
  // "Network doesn't support WebRTC" detector: counts peer-connection
15749
15875
  // failures where ICE never reached `connected`/`completed`.
15750
15876
  maxIceFailuresWithoutConnect = 2;
@@ -16138,6 +16264,7 @@ class Call {
16138
16264
  // `joinFlow` never runs, leaving stale values that would make the next
16139
16265
  // fresh `join()` send a stale `ReconnectDetails` to the SFU.
16140
16266
  this.rejoinRateLimiter.reset();
16267
+ this.rejoinLongWindowRateLimiter?.reset();
16141
16268
  this.iceFailuresWithoutConnect = 0;
16142
16269
  this.consecutiveNegotiationFailures = 0;
16143
16270
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
@@ -16533,6 +16660,16 @@ class Call {
16533
16660
  * @returns a promise which resolves once the call join-flow has finished.
16534
16661
  */
16535
16662
  doJoin = async (data) => {
16663
+ const task = this.performJoin(data);
16664
+ this.joinTasks.add(task);
16665
+ try {
16666
+ await task;
16667
+ }
16668
+ finally {
16669
+ this.joinTasks.delete(task);
16670
+ }
16671
+ };
16672
+ performJoin = async (data) => {
16536
16673
  const connectStartTime = Date.now();
16537
16674
  const callingState = this.state.callingState;
16538
16675
  const joinLeaveGeneration = this.leaveGeneration;
@@ -16890,6 +17027,9 @@ class Call {
16890
17027
  // "ICE never connected" failure budget can be cleared.
16891
17028
  this.iceFailuresWithoutConnect = 0;
16892
17029
  },
17030
+ onNoLocalCandidates: () => {
17031
+ this.checkNoLocalCandidates();
17032
+ },
16893
17033
  onPeerConnectionStateChange: (event) => {
16894
17034
  this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
16895
17035
  },
@@ -16938,6 +17078,59 @@ class Call {
16938
17078
  this.sfuStatsReporter.start();
16939
17079
  }
16940
17080
  };
17081
+ /**
17082
+ * Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
17083
+ * connections never connected and one of them completed ICE gathering
17084
+ * without a single local candidate, which means WebRTC networking is
17085
+ * blocked on this device.
17086
+ *
17087
+ * `reconnect` ignores requests while a join or another reconnect is in
17088
+ * flight, so the check is postponed until those settle and then re-run
17089
+ * against the peer connections current at that time.
17090
+ */
17091
+ checkNoLocalCandidates = () => {
17092
+ if (this.noLocalCandidatesCheckScheduled)
17093
+ return;
17094
+ if (this.joinTasks.size > 0 || hasPending(this.reconnectConcurrencyTag)) {
17095
+ this.noLocalCandidatesCheckScheduled = true;
17096
+ const leaveGeneration = this.leaveGeneration;
17097
+ this.waitForJoinsAndReconnects()
17098
+ .then(() => {
17099
+ this.noLocalCandidatesCheckScheduled = false;
17100
+ if (this.leaveGeneration !== leaveGeneration)
17101
+ return;
17102
+ this.checkNoLocalCandidates();
17103
+ })
17104
+ .catch((err) => {
17105
+ this.noLocalCandidatesCheckScheduled = false;
17106
+ this.logger.warn('[Reconnect] No-candidates check failed', err);
17107
+ });
17108
+ return;
17109
+ }
17110
+ if (this.state.callingState !== exports.CallingState.JOINED)
17111
+ return;
17112
+ // without network, gathering legitimately yields no candidates
17113
+ if (typeof navigator !== 'undefined' && navigator.onLine === false)
17114
+ return;
17115
+ const peerConnections = [this.publisher, this.subscriber];
17116
+ if (peerConnections.some((pc) => pc?.hasIceEverConnected()))
17117
+ return;
17118
+ if (!peerConnections.some((pc) => pc?.hasNoLocalCandidates()))
17119
+ return;
17120
+ this.reconnect(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED).catch((err) => {
17121
+ this.logger.warn('[Reconnect] Error reconnecting', err);
17122
+ });
17123
+ };
17124
+ /**
17125
+ * Resolves once no `doJoin` run and no reconnect is in flight.
17126
+ */
17127
+ waitForJoinsAndReconnects = async () => {
17128
+ while (this.joinTasks.size > 0 ||
17129
+ hasPending(this.reconnectConcurrencyTag)) {
17130
+ await Promise.allSettled(this.joinTasks);
17131
+ await settled(this.reconnectConcurrencyTag);
17132
+ }
17133
+ };
16941
17134
  /**
16942
17135
  * Retrieves credentials for joining the call.
16943
17136
  *
@@ -17136,6 +17329,14 @@ class Call {
17136
17329
  return;
17137
17330
  }
17138
17331
  }
17332
+ // MIGRATE is server-driven (e.g. SFU drains), so it doesn't count
17333
+ // toward the long-window backstop.
17334
+ if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN &&
17335
+ this.rejoinLongWindowRateLimiter &&
17336
+ !this.rejoinLongWindowRateLimiter.tryRegister()) {
17337
+ await giveUpAndLeave('rejoin_attempt_limit_exceeded');
17338
+ return;
17339
+ }
17139
17340
  // we don't increment reconnect attempts for the FAST strategy.
17140
17341
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
17141
17342
  this.reconnectAttempts++;
@@ -17354,7 +17555,13 @@ class Call {
17354
17555
  this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
17355
17556
  });
17356
17557
  // handles the "error" event, through which the SFU can request a reconnect
17357
- const unregisterOnError = this.on('error', (e) => {
17558
+ // Only the active SFU client may drive a reconnect: a superseded one can
17559
+ // still report errors about its own (replaced) session, and none may
17560
+ // while `leave()` tears the call down.
17561
+ const activeSfuTag = () => this.sfuClient?.tag ?? '*';
17562
+ const unregisterOnError = this.dispatcher.on('error', activeSfuTag, (e) => {
17563
+ if (!this.sfuClient)
17564
+ return;
17358
17565
  const { reconnectStrategy: strategy, error } = e;
17359
17566
  if (!SfuJoinError.isJoinErrorCode(e)) {
17360
17567
  const code = error?.code ? ErrorCode[error.code] : undefined;
@@ -17377,7 +17584,16 @@ class Call {
17377
17584
  });
17378
17585
  }
17379
17586
  else {
17380
- this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
17587
+ // A media transport failure on a session whose peer connections
17588
+ // never connected is an "ICE never connected" failure, and must
17589
+ // count toward the unsupported-network budget.
17590
+ const neverConnected = error?.code === ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE &&
17591
+ !this.publisher?.hasIceEverConnected() &&
17592
+ !this.subscriber?.hasIceEverConnected();
17593
+ const reason = neverConnected
17594
+ ? ReconnectReason.ICE_NEVER_CONNECTED
17595
+ : error?.message || ReconnectReason.SFU_ERROR;
17596
+ this.reconnect(strategy, reason).catch((err) => {
17381
17597
  this.logger.warn('[Reconnect] Error reconnecting', err);
17382
17598
  });
17383
17599
  }
@@ -18441,9 +18657,11 @@ class Call {
18441
18657
  * `rejoin_attempt_limit_exceeded` leave message.
18442
18658
  *
18443
18659
  * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
18444
- * Both arguments are clamped to a minimum of 1.
18660
+ * Both arguments are clamped to a minimum of 1. Unless this method is
18661
+ * called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
18445
18662
  */
18446
18663
  setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
18664
+ this.rejoinLongWindowRateLimiter = undefined;
18447
18665
  this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
18448
18666
  };
18449
18667
  /**
@@ -19781,11 +19999,11 @@ class StreamClient {
19781
19999
  return await this.wsConnection.connect(this.defaultWSTimeout);
19782
20000
  };
19783
20001
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19784
- "1.61.2";
20002
+ "1.62.0";
19785
20003
  getUserAgent = () => {
19786
20004
  if (!this.cachedUserAgent) {
19787
20005
  const { clientAppIdentifier = {} } = this.options;
19788
- const { sdkName = 'js', sdkVersion = "1.61.2", ...extras } = clientAppIdentifier;
20006
+ const { sdkName = 'js', sdkVersion = "1.62.0", ...extras } = clientAppIdentifier;
19789
20007
  this.cachedUserAgent = [
19790
20008
  `stream-video-${sdkName}-v${sdkVersion}`,
19791
20009
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),