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