@stream-io/video-client 1.60.0 → 1.61.1

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.
@@ -5081,7 +5081,7 @@ const toIceCandidate = (iceTrickle) => {
5081
5081
  }
5082
5082
  };
5083
5083
 
5084
- const version = "1.60.0";
5084
+ const version = "1.61.1";
5085
5085
  const [major, minor, patch] = version.split('.');
5086
5086
  let sdkInfo = {
5087
5087
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -8754,7 +8754,12 @@ class BasePeerConnection {
8754
8754
  }
8755
8755
  // we can't recover from a failed connection state (contrary to ICE)
8756
8756
  if (state === 'failed') {
8757
- this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
8757
+ const reason = this.iceHasEverConnected
8758
+ ? ReconnectReason.CONNECTION_FAILED
8759
+ : ReconnectReason.ICE_NEVER_CONNECTED;
8760
+ clearTimeout(this.preConnectStuckTimeout);
8761
+ this.preConnectStuckTimeout = undefined;
8762
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, reason, this.peerType);
8758
8763
  return;
8759
8764
  }
8760
8765
  this.handleConnectionStateUpdate(state);
@@ -9146,6 +9151,25 @@ class TransceiverCache {
9146
9151
  this.cache.push(bundle);
9147
9152
  this.transceiverOrder.push(bundle.transceiver);
9148
9153
  };
9154
+ /**
9155
+ * Retires a failed sender from the reusable cache.
9156
+ *
9157
+ * A transceiver that never reached an m-section also leaves the creation
9158
+ * history: once stopped, it is left out of every later offer, so counting
9159
+ * it would shift the `mid` index of every transceiver added after it. One
9160
+ * that is already associated keeps its slot, since its m-section survives
9161
+ * as a rejected one.
9162
+ */
9163
+ remove = (transceiver) => {
9164
+ const index = this.cache.findIndex((b) => b.transceiver === transceiver);
9165
+ if (index !== -1)
9166
+ this.cache.splice(index, 1);
9167
+ if (transceiver.mid)
9168
+ return;
9169
+ const orderIndex = this.transceiverOrder.indexOf(transceiver);
9170
+ if (orderIndex !== -1)
9171
+ this.transceiverOrder.splice(orderIndex, 1);
9172
+ };
9149
9173
  /**
9150
9174
  * Gets the transceiver for the given publish option.
9151
9175
  */
@@ -9317,7 +9341,9 @@ class Publisher extends BasePeerConnection {
9317
9341
  const sendEncodings = isSvcCodec(publishOption.codec?.name)
9318
9342
  ? toSvcEncodings(encodings)
9319
9343
  : encodings;
9320
- const transceiver = this.pc.addTransceiver(track, {
9344
+ // An E2EE sender must never have media before its transform is attached,
9345
+ // including when another track or an ICE restart negotiates this PC.
9346
+ const transceiver = this.pc.addTransceiver(this.e2ee ? track.kind : track, {
9321
9347
  direction: 'sendonly',
9322
9348
  sendEncodings,
9323
9349
  });
@@ -9327,16 +9353,63 @@ class Publisher extends BasePeerConnection {
9327
9353
  this.transceiverCache.add({ publishOption, transceiver, options });
9328
9354
  this.trackIdToTrackType.set(track.id, trackType);
9329
9355
  this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
9330
- const params = transceiver.sender.getParameters();
9356
+ if (this.e2ee) {
9357
+ await this.initEncryptedSender(transceiver, track, publishOption);
9358
+ }
9359
+ else {
9360
+ await this.setDegradationPreference(transceiver.sender, publishOption);
9361
+ }
9362
+ await this.negotiate();
9363
+ };
9364
+ /**
9365
+ * Attaches the encryptor, applies sender parameters, then attaches the track.
9366
+ *
9367
+ * `encrypt` must run before the first await: Chrome short-circuits a sender
9368
+ * whose encoded streams aren't created in the task that created it, and the
9369
+ * track goes on last so the sender has no media until both steps succeed.
9370
+ */
9371
+ initEncryptedSender = async (transceiver, track, publishOption) => {
9372
+ try {
9373
+ const { sender } = transceiver;
9374
+ const codec = publishOption.codec?.name.toLowerCase();
9375
+ this.e2ee.encrypt(sender, codec, TrackType[publishOption.trackType]);
9376
+ this.logger.debug('E2EE encryptor attached to sender');
9377
+ await this.setDegradationPreference(sender, publishOption);
9378
+ await sender.replaceTrack(track);
9379
+ }
9380
+ catch (err) {
9381
+ this.retireTransceiver(transceiver, track);
9382
+ throw err;
9383
+ }
9384
+ };
9385
+ setDegradationPreference = async (sender, publishOption) => {
9386
+ const params = sender.getParameters();
9331
9387
  params.degradationPreference =
9332
9388
  toRTCDegradationPreference(publishOption.degradationPreference) ??
9333
9389
  'maintain-framerate';
9334
- await transceiver.sender.setParameters(params);
9335
- if (this.e2ee) {
9336
- this.e2ee.encrypt(transceiver.sender, publishOption.codec?.name.toLowerCase(), TrackType[publishOption.trackType]);
9337
- this.logger.debug('E2EE encryptor attached to sender');
9390
+ await sender.setParameters(params);
9391
+ };
9392
+ /**
9393
+ * Discards a sender whose E2EE initialization failed.
9394
+ *
9395
+ * A partially initialized sender may carry no transform, so publish() must
9396
+ * not reuse it: retiring it from the cache is what keeps a later publish or
9397
+ * ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
9398
+ * the initialization failure is what reaches the caller.
9399
+ */
9400
+ retireTransceiver = (transceiver, track) => {
9401
+ this.transceiverCache.remove(transceiver);
9402
+ this.trackIdToTrackType.delete(track.id);
9403
+ // on React Native a clone shares its native source, so stopping it here
9404
+ // would stop the track the caller passed in. dispose() releases it.
9405
+ if (!isReactNative())
9406
+ this.stopTrack(track);
9407
+ try {
9408
+ transceiver.stop();
9409
+ }
9410
+ catch (err) {
9411
+ this.logger.debug('Failed to stop a retired transceiver', err);
9338
9412
  }
9339
- await this.negotiate();
9340
9413
  };
9341
9414
  /**
9342
9415
  * Updates the transceiver with the given track and track type.
@@ -15902,6 +15975,11 @@ class Call {
15902
15975
  this.audioBindingsWatchdog?.dispose();
15903
15976
  await this.dynascaleManager?.dispose();
15904
15977
  this.state.setCallingState(CallingState.LEFT);
15978
+ // `ringingSubject` is cleared further down, so this still reads true for
15979
+ // a call that was ringing.
15980
+ if (this.ringing) {
15981
+ globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this);
15982
+ }
15905
15983
  this.state.setParticipants([]);
15906
15984
  this.state.dispose();
15907
15985
  // Reset reconnect-related accumulators so a future `call.join()` on the
@@ -16070,8 +16148,11 @@ class Call {
16070
16148
  * @param params.video if set to true, in a ringing scenario, mobile SDKs will show "incoming video call", audio only otherwise.
16071
16149
  */
16072
16150
  get = async (params) => {
16151
+ const getLeaveGeneration = this.leaveGeneration;
16073
16152
  await this.setup();
16074
16153
  const response = await this.streamClient.get(this.streamClientBasePath, params);
16154
+ if (this.leaveGeneration !== getLeaveGeneration)
16155
+ return response;
16075
16156
  this.updateFromCallStateResponse(response);
16076
16157
  if (params?.ring) {
16077
16158
  this.ringingSubject.next(true);
@@ -16176,42 +16257,80 @@ class Call {
16176
16257
  /**
16177
16258
  * Will start to watch for call related WebSocket events and initiate a call session with the server.
16178
16259
  *
16260
+ * One instance is one call flow: discard it after leaving, cancelling, or a
16261
+ * failed join, and create a fresh one for a later flow. Reconnection inside a
16262
+ * live call is handled here and needs no new instance.
16263
+ *
16179
16264
  * @returns a promise which resolves once the call join-flow has finished.
16180
16265
  */
16181
16266
  join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, joinSource, ...data } = {}) => {
16182
16267
  const callingState = this.state.callingState;
16268
+ // Ahead of the failure boundary below on purpose: a duplicate join on a
16269
+ // live call is refused without tearing that call down.
16183
16270
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
16184
16271
  throw new Error(`Illegal State: call.join() shall be called only once`);
16185
16272
  }
16273
+ // snapshot before the first await: a leave() landing at any point from
16274
+ // here on supersedes this join, and the retry loop must bail out instead
16275
+ // of resurrecting a call that leave() already tore down.
16276
+ const joinLeaveGeneration = this.leaveGeneration;
16277
+ const supersededByLeave = () => this.leaveGeneration !== joinLeaveGeneration;
16186
16278
  // we need this to be set before the callingx.joinCall() is
16187
16279
  // called to avoid registering the test call in the CallKit/Telecom
16188
16280
  this.allowOwnTracksLoopback = allowOwnTracksLoopback;
16189
16281
  if (data?.ring) {
16190
16282
  this.ringingSubject.next(true);
16191
16283
  }
16284
+ // A ringing call is joined by the SDK rather than by app code, so React
16285
+ // Native prepares it from in here - there is no earlier point at which the
16286
+ // app holds the call. Read after `data.ring` above, which is what makes an
16287
+ // outgoing call ringing in the first place.
16288
+ const ringingLifecycle = this.ringing
16289
+ ? globalThis.streamRNVideoSDK?.ringingCallLifecycle
16290
+ : undefined;
16192
16291
  const callingX = globalThis.streamRNVideoSDK?.callingX;
16193
- if (callingX) {
16194
- // for Android/iOS, we need to start the call in the callingx library as soon as possible
16195
- await callingX.joinCall(this, this.clientStore.calls);
16196
- }
16197
- await this.setup();
16198
- this.clientEventReporter.registerCall(this.cid, {
16199
- callType: this.type,
16200
- callId: this.id,
16201
- getCallSessionId: () => this.state.session?.id ?? '',
16202
- getSfuId: () => this.credentials?.server.edge_name ?? '',
16203
- });
16204
- this.joinResponseTimeout = joinResponseTimeout;
16205
- this.rpcRequestTimeout = rpcRequestTimeout;
16206
- // we will count the number of join failures per SFU.
16207
- // once the number of failures reaches 2, we will piggyback on the `migrating_from`
16208
- // field to force the coordinator to provide us another SFU
16209
- const sfuJoinFailures = new Map();
16210
- const joinData = data;
16211
- maxJoinRetries = Math.max(maxJoinRetries, 1);
16212
16292
  try {
16293
+ if (ringingLifecycle) {
16294
+ await ringingLifecycle.beforeJoin(this);
16295
+ if (supersededByLeave()) {
16296
+ this.logger.debug('Join superseded by leave; not joining');
16297
+ return;
16298
+ }
16299
+ }
16300
+ if (callingX) {
16301
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
16302
+ await callingX.joinCall(this, this.clientStore.calls, supersededByLeave);
16303
+ if (supersededByLeave()) {
16304
+ this.logger.debug('Join superseded by leave; not setting up');
16305
+ return;
16306
+ }
16307
+ }
16308
+ await this.setup();
16309
+ if (supersededByLeave()) {
16310
+ this.logger.debug('Join superseded by leave; not registering');
16311
+ return;
16312
+ }
16313
+ this.clientEventReporter.registerCall(this.cid, {
16314
+ callType: this.type,
16315
+ callId: this.id,
16316
+ getCallSessionId: () => this.state.session?.id ?? '',
16317
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
16318
+ });
16319
+ this.joinResponseTimeout = joinResponseTimeout;
16320
+ this.rpcRequestTimeout = rpcRequestTimeout;
16321
+ // we will count the number of join failures per SFU.
16322
+ // once the number of failures reaches 2, we will piggyback on the `migrating_from`
16323
+ // field to force the coordinator to provide us another SFU
16324
+ const sfuJoinFailures = new Map();
16325
+ const joinData = data;
16326
+ maxJoinRetries = Math.max(maxJoinRetries, 1);
16213
16327
  await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'first-attempt', joinSource }, async () => {
16214
16328
  for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
16329
+ // Also covers a leave that lands during the backoff below.
16330
+ if (supersededByLeave()) {
16331
+ this.logger.debug('Join superseded by leave; not attempting');
16332
+ return;
16333
+ }
16215
16334
  try {
16216
16335
  this.logger.trace(`Joining call (${attempt})`, this.cid);
16217
16336
  await this.doJoin(data);
@@ -16220,6 +16339,10 @@ class Call {
16220
16339
  return;
16221
16340
  }
16222
16341
  catch (err) {
16342
+ if (supersededByLeave()) {
16343
+ this.logger.debug('Join superseded by leave; not retrying');
16344
+ return;
16345
+ }
16223
16346
  this.logger.warn(`Failed to join call (${attempt})`, this.cid);
16224
16347
  if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
16225
16348
  (err instanceof SfuJoinError && err.unrecoverable)) {
@@ -16249,6 +16372,9 @@ class Call {
16249
16372
  }
16250
16373
  catch (error) {
16251
16374
  callingX?.endCall(this, 'error');
16375
+ // Ends the failed ringing flow and releases what its setup installed.
16376
+ // Never rejects, so `error` is what the caller sees.
16377
+ await ringingLifecycle?.onJoinFailed(this);
16252
16378
  throw error;
16253
16379
  }
16254
16380
  });
@@ -16271,6 +16397,10 @@ class Call {
16271
16397
  // globals resolve to the call's factory. Idempotent across
16272
16398
  // reconnect/migration attempts.
16273
16399
  await this.ensureMediaFactory();
16400
+ if (supersededByLeave()) {
16401
+ this.logger.debug('Join superseded by leave; not wiring media');
16402
+ return;
16403
+ }
16274
16404
  const callingX = globalThis.streamRNVideoSDK?.callingX;
16275
16405
  if (callingX) {
16276
16406
  callingX.wireAudioEngineSubscription();
@@ -16286,11 +16416,17 @@ class Call {
16286
16416
  data?.migrating_from) {
16287
16417
  try {
16288
16418
  const joinResponse = await this.clientEventReporter.track(this.cid, 'CoordinatorJoin', () => this.doJoinRequest(data));
16419
+ if (!joinResponse || supersededByLeave()) {
16420
+ this.logger.debug('Join superseded by leave; not creating SFU client');
16421
+ return;
16422
+ }
16289
16423
  this.credentials = joinResponse.credentials;
16290
16424
  statsOptions = joinResponse.stats_options;
16291
16425
  this.lastStatsOptions = statsOptions;
16292
16426
  }
16293
16427
  catch (error) {
16428
+ if (supersededByLeave())
16429
+ return;
16294
16430
  // prevent triggering reconnect flow if the state is OFFLINE
16295
16431
  const avoidRestoreState = this.state.callingState === CallingState.OFFLINE;
16296
16432
  if (!avoidRestoreState) {
@@ -16338,6 +16474,10 @@ class Call {
16338
16474
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
16339
16475
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
16340
16476
  ]);
16477
+ if (supersededByLeave()) {
16478
+ this.logger.debug('Join superseded by leave; not joining SFU');
16479
+ return;
16480
+ }
16341
16481
  const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
16342
16482
  const reconnectDetails = isReconnecting
16343
16483
  ? this.getReconnectDetails(previousSfuClient?.edgeName, previousSessionId)
@@ -16363,6 +16503,10 @@ class Call {
16363
16503
  capabilities,
16364
16504
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
16365
16505
  }));
16506
+ if (supersededByLeave()) {
16507
+ this.logger.debug('Join superseded by leave; not applying SFU response');
16508
+ return;
16509
+ }
16366
16510
  this.currentPublishOptions = publishOptions;
16367
16511
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
16368
16512
  if (callState) {
@@ -16370,6 +16514,10 @@ class Call {
16370
16514
  }
16371
16515
  }
16372
16516
  catch (error) {
16517
+ if (supersededByLeave()) {
16518
+ this.logger.debug('Join superseded by leave; ignoring SFU failure');
16519
+ return;
16520
+ }
16373
16521
  this.logger.warn('Join SFU request failed', error);
16374
16522
  sfuClient.close(StreamSfuClient.JOIN_FAILED, 'Join request failed, connection considered unhealthy');
16375
16523
  // restore the previous call state if the join-flow fails
@@ -16377,8 +16525,8 @@ class Call {
16377
16525
  throw error;
16378
16526
  }
16379
16527
  }
16380
- // If the user left while this join was in flight, bail before re-setting JOINED and before
16381
- // peer-connection setup below (both run synchronously after this, so one check covers them).
16528
+ // If the user left while this join was in flight, bail before re-setting JOINED
16529
+ // or starting peer-connection setup below.
16382
16530
  if (supersededByLeave()) {
16383
16531
  this.logger.debug('Join superseded by leave; aborting join flow');
16384
16532
  return;
@@ -16407,6 +16555,10 @@ class Call {
16407
16555
  unifiedSessionId: this.unifiedSessionId,
16408
16556
  });
16409
16557
  }
16558
+ if (supersededByLeave()) {
16559
+ this.logger.debug('Join superseded by leave; not completing join flow');
16560
+ return;
16561
+ }
16410
16562
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
16411
16563
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
16412
16564
  this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
@@ -16552,15 +16704,20 @@ class Call {
16552
16704
  * @internal
16553
16705
  */
16554
16706
  initPublisherAndSubscriber = async (opts) => {
16707
+ const joinLeaveGeneration = this.leaveGeneration;
16555
16708
  const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
16556
16709
  const { enable_rtc_stats: enableTracing, reporting_interval_ms: reportingIntervalMs, } = statsOptions;
16557
16710
  // Flush the previous reporter's final sample while its peer connections are
16558
16711
  // still alive, before we dispose them below. Awaits only the sampling step.
16559
16712
  await this.sfuStatsReporter?.flush();
16713
+ if (this.leaveGeneration !== joinLeaveGeneration)
16714
+ return;
16560
16715
  this.sfuStatsReporter?.stop();
16561
16716
  this.sfuStatsReporter = undefined;
16562
16717
  if (closePreviousInstances && this.subscriber) {
16563
16718
  await this.subscriber.dispose();
16719
+ if (this.leaveGeneration !== joinLeaveGeneration)
16720
+ return;
16564
16721
  this.state.removeAllOrphanedTracks();
16565
16722
  }
16566
16723
  const basePeerConnectionOptions = {
@@ -16603,6 +16760,8 @@ class Call {
16603
16760
  if (!isAnonymous) {
16604
16761
  if (closePreviousInstances && this.publisher) {
16605
16762
  await this.publisher.dispose();
16763
+ if (this.leaveGeneration !== joinLeaveGeneration)
16764
+ return;
16606
16765
  }
16607
16766
  this.publisher = new Publisher(basePeerConnectionOptions, publishOptions, {
16608
16767
  selfSubEnabled: this.allowOwnTracksLoopback,
@@ -16639,12 +16798,22 @@ class Call {
16639
16798
  *
16640
16799
  * @internal
16641
16800
  * @param data the join call data.
16801
+ * @returns The coordinator response, or undefined if leave superseded the request before it was sent.
16642
16802
  */
16643
16803
  doJoinRequest = async (data) => {
16804
+ const joinLeaveGeneration = this.leaveGeneration;
16644
16805
  const location = await this.streamClient.getLocationHint();
16806
+ if (this.leaveGeneration !== joinLeaveGeneration) {
16807
+ this.logger.debug('Join superseded by leave; not sending coordinator request');
16808
+ return;
16809
+ }
16645
16810
  const e2ee = !!this.e2eeManager;
16646
16811
  const request = { ...data, location, e2ee };
16647
16812
  const joinResponse = await this.streamClient.post(`${this.streamClientBasePath}/join`, request);
16813
+ if (this.leaveGeneration !== joinLeaveGeneration) {
16814
+ this.logger.debug('Join superseded by leave; not applying coordinator response');
16815
+ return joinResponse;
16816
+ }
16648
16817
  this.state.updateFromCallResponse(joinResponse.call);
16649
16818
  this.state.setMembers(joinResponse.members);
16650
16819
  this.state.setOwnCapabilities(joinResponse.own_capabilities);
@@ -16655,6 +16824,8 @@ class Call {
16655
16824
  if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
16656
16825
  // signals other users that I have accepted the incoming call.
16657
16826
  await this.accept();
16827
+ if (this.leaveGeneration !== joinLeaveGeneration)
16828
+ return joinResponse;
16658
16829
  }
16659
16830
  if (this.streamClient._hasConnectionID()) {
16660
16831
  this.watching = true;
@@ -16744,6 +16915,7 @@ class Call {
16744
16915
  if (callingState === CallingState.JOINING ||
16745
16916
  callingState === CallingState.RECONNECTING ||
16746
16917
  callingState === CallingState.MIGRATING ||
16918
+ callingState === CallingState.LEFT ||
16747
16919
  callingState === CallingState.RECONNECTING_FAILED)
16748
16920
  return;
16749
16921
  // Drop redundant reconnect calls. If a reconnect is already queued or
@@ -16752,19 +16924,27 @@ class Call {
16752
16924
  // `POST /join` per entry) once the call is already healthy again.
16753
16925
  if (hasPending(this.reconnectConcurrencyTag))
16754
16926
  return;
16927
+ const reconnectLeaveGeneration = this.leaveGeneration;
16928
+ const supersededByLeave = () => this.leaveGeneration !== reconnectLeaveGeneration;
16755
16929
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
16930
+ if (supersededByLeave())
16931
+ return;
16756
16932
  const reconnectStartTime = Date.now();
16757
16933
  this.reconnectStrategy = strategy;
16758
16934
  this.reconnectReason = reason;
16759
16935
  const sfuRejoinFailures = new Map();
16760
16936
  const markAsReconnectingFailed = async () => {
16937
+ if (supersededByLeave())
16938
+ return;
16761
16939
  try {
16762
16940
  // attempt to fetch the call data from the server, as the call
16763
16941
  // state might have changed while we were reconnecting or were offline
16764
16942
  await this.get();
16765
16943
  }
16766
16944
  finally {
16767
- this.state.setCallingState(CallingState.RECONNECTING_FAILED);
16945
+ if (!supersededByLeave()) {
16946
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
16947
+ }
16768
16948
  }
16769
16949
  };
16770
16950
  const giveUpAndLeave = async (message) => {
@@ -16820,6 +17000,8 @@ class Call {
16820
17000
  try {
16821
17001
  // wait until the network is available
16822
17002
  await this.networkAvailableTask?.promise;
17003
+ if (supersededByLeave())
17004
+ return;
16823
17005
  this.logger.info(`[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[this.reconnectStrategy]}`);
16824
17006
  switch (this.reconnectStrategy) {
16825
17007
  case WebsocketReconnectStrategy.UNSPECIFIED:
@@ -16859,6 +17041,8 @@ class Call {
16859
17041
  break; // do-while loop, reconnection worked, exit the loop
16860
17042
  }
16861
17043
  catch (error) {
17044
+ if (supersededByLeave())
17045
+ return;
16862
17046
  if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
16863
17047
  const failedSfu = this.credentials?.server.edge_name;
16864
17048
  if (failedSfu) {
@@ -16889,6 +17073,8 @@ class Call {
16889
17073
  }
16890
17074
  // exponential backoff with jitter, capped at 5 s
16891
17075
  await sleep(retryInterval(attempt));
17076
+ if (supersededByLeave())
17077
+ return;
16892
17078
  const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
16893
17079
  const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
16894
17080
  this.fastReconnectDeadlineSeconds;
@@ -16907,7 +17093,8 @@ class Call {
16907
17093
  this.reconnectStrategy = nextStrategy;
16908
17094
  this.logger.info(`[Reconnect] ${currentStrategy} (${this.reconnectAttempts}) failed. Attempting with ${WebsocketReconnectStrategy[nextStrategy]}`, error);
16909
17095
  }
16910
- } while (this.state.callingState !== CallingState.JOINED &&
17096
+ } while (!supersededByLeave() &&
17097
+ this.state.callingState !== CallingState.JOINED &&
16911
17098
  this.state.callingState !== CallingState.RECONNECTING_FAILED &&
16912
17099
  this.state.callingState !== CallingState.LEFT);
16913
17100
  this.logger.info('[Reconnect] Reconnection flow finished');
@@ -16918,10 +17105,13 @@ class Call {
16918
17105
  * @internal
16919
17106
  */
16920
17107
  reconnectFast = async () => {
17108
+ const reconnectLeaveGeneration = this.leaveGeneration;
16921
17109
  const reconnectStartTime = Date.now();
16922
17110
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
16923
17111
  this.state.setCallingState(CallingState.RECONNECTING);
16924
17112
  await this.doJoin(this.joinCallData);
17113
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17114
+ return;
16925
17115
  await this.get(); // fetch the latest call state, as it might have changed
16926
17116
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
16927
17117
  };
@@ -16930,6 +17120,7 @@ class Call {
16930
17120
  * @internal
16931
17121
  */
16932
17122
  reconnectRejoin = async () => {
17123
+ const reconnectLeaveGeneration = this.leaveGeneration;
16933
17124
  const reconnectStartTime = Date.now();
16934
17125
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
16935
17126
  this.state.setCallingState(CallingState.RECONNECTING);
@@ -16937,7 +17128,11 @@ class Call {
16937
17128
  ? 'network-available'
16938
17129
  : 'full-rejoin';
16939
17130
  await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason }, () => this.doJoin(this.joinCallData));
17131
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17132
+ return;
16940
17133
  await this.restorePublishedTracks();
17134
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17135
+ return;
16941
17136
  this.restoreSubscribedTracks();
16942
17137
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
16943
17138
  };
@@ -16946,6 +17141,7 @@ class Call {
16946
17141
  * @internal
16947
17142
  */
16948
17143
  reconnectMigrate = async () => {
17144
+ const reconnectLeaveGeneration = this.leaveGeneration;
16949
17145
  const reconnectStartTime = Date.now();
16950
17146
  const currentSfuClient = this.sfuClient;
16951
17147
  if (!currentSfuClient) {
@@ -16959,26 +17155,32 @@ class Call {
16959
17155
  currentPublisher?.detachEventHandlers();
16960
17156
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
16961
17157
  try {
16962
- const currentSfu = currentSfuClient.edgeName;
16963
- await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'migration' }, () => this.doJoin({
16964
- ...this.joinCallData,
16965
- migrating_from: currentSfu,
16966
- migrating_from_list: [currentSfu],
16967
- }));
16968
- }
16969
- finally {
16970
- // cleanup the migration_from field after the migration is complete or failed
16971
- // as we don't want to keep dirty data in the join call data
16972
- delete this.joinCallData?.migrating_from;
16973
- delete this.joinCallData?.migrating_from_list;
16974
- }
16975
- await this.restorePublishedTracks();
16976
- this.restoreSubscribedTracks();
16977
- try {
17158
+ try {
17159
+ const currentSfu = currentSfuClient.edgeName;
17160
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'migration' }, () => this.doJoin({
17161
+ ...this.joinCallData,
17162
+ migrating_from: currentSfu,
17163
+ migrating_from_list: [currentSfu],
17164
+ }));
17165
+ }
17166
+ finally {
17167
+ // cleanup the migration_from field after the migration is complete or failed
17168
+ // as we don't want to keep dirty data in the join call data
17169
+ delete this.joinCallData?.migrating_from;
17170
+ delete this.joinCallData?.migrating_from_list;
17171
+ }
17172
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17173
+ return;
17174
+ await this.restorePublishedTracks();
17175
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17176
+ return;
17177
+ this.restoreSubscribedTracks();
16978
17178
  // Wait for the migration to complete, then close the previous SFU client
16979
17179
  // and the peer connection instances. In case of failure, the migration
16980
17180
  // task would throw an error and REJOIN would be attempted.
16981
17181
  await migrationTask();
17182
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17183
+ return;
16982
17184
  // in MIGRATE, we can consider the call as joined only after
16983
17185
  // `participantMigrationComplete` event is received, signaled by
16984
17186
  // the `migrationTask`
@@ -17263,10 +17465,11 @@ class Call {
17263
17465
  * Must be called before {@link join} so the RTCPeerConnection can be
17264
17466
  * configured for E2EE.
17265
17467
  *
17266
- * The manager is kept across {@link leave} so a rejoin of this same instance
17267
- * stays encrypted: do not dispose it while this call may be joined again.
17268
- * A disposed manager throws from `encrypt`/`decrypt` rather than silently
17269
- * publishing nothing, so re-attach a fresh one instead of reusing it.
17468
+ * The application owns the manager's lifetime - the SDK never disposes it, and
17469
+ * closing the peer connections does not release it. Discard the manager
17470
+ * together with this call instance once its flow has ended, and create a fresh
17471
+ * pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
17472
+ * rather than silently publishing nothing.
17270
17473
  *
17271
17474
  * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
17272
17475
  * built-in AES-GCM scheme, or pass your own implementation.
@@ -19435,11 +19638,11 @@ class StreamClient {
19435
19638
  return await this.wsConnection.connect(this.defaultWSTimeout);
19436
19639
  };
19437
19640
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19438
- "1.60.0";
19641
+ "1.61.1";
19439
19642
  getUserAgent = () => {
19440
19643
  if (!this.cachedUserAgent) {
19441
19644
  const { clientAppIdentifier = {} } = this.options;
19442
- const { sdkName = 'js', sdkVersion = "1.60.0", ...extras } = clientAppIdentifier;
19645
+ const { sdkName = 'js', sdkVersion = "1.61.1", ...extras } = clientAppIdentifier;
19443
19646
  this.cachedUserAgent = [
19444
19647
  `stream-video-${sdkName}-v${sdkVersion}`,
19445
19648
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -19974,7 +20177,11 @@ class ClientEventReporter {
19974
20177
  return;
19975
20178
  }
19976
20179
  if (event.state === 'failed') {
19977
- this.emitPeerConnectionFailure(cid, role, 'DTLS_CONNECTIVITY_FAILED', 'DTLS connectivity checks failed');
20180
+ const iceConnected = event.iceConnectionState === 'connected' ||
20181
+ event.iceConnectionState === 'completed';
20182
+ this.emitPeerConnectionFailure(cid, role, iceConnected ? 'DTLS_CONNECTIVITY_FAILED' : 'ICE_CONNECTIVITY_FAILED', iceConnected
20183
+ ? 'DTLS connectivity checks failed'
20184
+ : 'ICE connectivity checks failed');
19978
20185
  return;
19979
20186
  }
19980
20187
  switch (event.state) {
@@ -21148,9 +21355,9 @@ class EncryptionManager extends TypedEventEmitter {
21148
21355
  }
21149
21356
  if ((this.piped ??= new WeakSet()).has(target))
21150
21357
  return;
21151
- this.piped.add(target);
21152
21358
  // @ts-expect-error createEncodedStreams is not in the standard typedefs
21153
21359
  const { readable, writable } = target.createEncodedStreams();
21360
+ this.piped.add(target);
21154
21361
  this.worker.postMessage({ type: 'cmd.setup_transform', ...options, readable, writable }, [readable, writable]);
21155
21362
  };
21156
21363
  /**
@@ -21206,5 +21413,5 @@ class EncryptionManager extends TypedEventEmitter {
21206
21413
  };
21207
21414
  }
21208
21415
 
21209
- export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DEFAULT_LOOPBACK_RECORDING_DURATION_MS, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, EncryptionManager, EncryptionSettingsRequestModeEnum, EncryptionSettingsResponseModeEnum, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, HLSSettingsRequestQualityTracksEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsRequestOutputTypesEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, LayoutSettingsRequestNameEnum, LoopbackStreamsTimeoutError, MAX_LOOPBACK_RECORDING_DURATION_MS, MIN_LOOPBACK_RECORDING_DURATION_MS, MediaStreamRecorder, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, RequestPermissionRequestPermissionsEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SfuTimeoutError, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, TranslationSettingsLanguagesEnum, UpdateUserPermissionsRequestGrantPermissionsEnum, UpdateUserPermissionsRequestRevokePermissionsEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, WSAuthMessageProductsEnum, checkIfAudioOutputChangeSupported, clampLoopbackRecordingDuration, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, enableLoopbackAudio, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getCallMediaEngineProvider, getClientDetails, getDeviceState, getLoopbackStreams, getLoopbackTracks, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasInterruptedTrack, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishing, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setCallMediaEngineProvider, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, waitForLoopbackStreams, withLoopbackAudioEnabled, withParticipantSource };
21416
+ export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DEFAULT_LOOPBACK_RECORDING_DURATION_MS, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, EncryptionManager, EncryptionSettingsRequestModeEnum, EncryptionSettingsResponseModeEnum, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, HLSSettingsRequestQualityTracksEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsRequestOutputTypesEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, LayoutSettingsRequestNameEnum, LoopbackStreamsTimeoutError, MAX_LOOPBACK_RECORDING_DURATION_MS, MIN_LOOPBACK_RECORDING_DURATION_MS, MediaStreamRecorder, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, RequestPermissionRequestPermissionsEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SfuTimeoutError, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, TranslationSettingsLanguagesEnum, TypedEventEmitter, UpdateUserPermissionsRequestGrantPermissionsEnum, UpdateUserPermissionsRequestRevokePermissionsEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, WSAuthMessageProductsEnum, checkIfAudioOutputChangeSupported, clampLoopbackRecordingDuration, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, enableLoopbackAudio, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getCallMediaEngineProvider, getClientDetails, getDeviceState, getLoopbackStreams, getLoopbackTracks, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasInterruptedTrack, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishing, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setCallMediaEngineProvider, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, waitForLoopbackStreams, withLoopbackAudioEnabled, withParticipantSource };
21210
21417
  //# sourceMappingURL=index.browser.es.js.map