@stream-io/video-client 1.48.0 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/index.browser.es.js +1497 -677
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1497 -677
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1497 -677
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +77 -4
  9. package/dist/src/StreamSfuClient.d.ts +8 -1
  10. package/dist/src/coordinator/connection/client.d.ts +1 -1
  11. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  12. package/dist/src/coordinator/connection/types.d.ts +14 -0
  13. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +3 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +13 -1
  16. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  17. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  18. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  19. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  20. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  21. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  22. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  23. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  24. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  25. package/dist/src/helpers/browsers.d.ts +13 -0
  26. package/dist/src/helpers/concurrency.d.ts +6 -4
  27. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  28. package/dist/src/rtc/Publisher.d.ts +17 -0
  29. package/dist/src/rtc/Subscriber.d.ts +1 -0
  30. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/types.d.ts +33 -1
  33. package/dist/src/stats/rtc/types.d.ts +1 -1
  34. package/dist/src/store/rxUtils.d.ts +9 -0
  35. package/dist/src/types.d.ts +18 -0
  36. package/package.json +2 -2
  37. package/src/Call.ts +268 -40
  38. package/src/StreamSfuClient.ts +75 -12
  39. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  40. package/src/__tests__/Call.publishing.test.ts +103 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  42. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  43. package/src/coordinator/connection/client.ts +1 -1
  44. package/src/coordinator/connection/connection.ts +149 -96
  45. package/src/coordinator/connection/types.ts +15 -0
  46. package/src/coordinator/connection/utils.ts +15 -0
  47. package/src/devices/DeviceManager.ts +92 -32
  48. package/src/devices/DeviceManagerState.ts +20 -1
  49. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  50. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  51. package/src/devices/__tests__/mocks.ts +2 -0
  52. package/src/devices/devices.ts +2 -1
  53. package/src/gen/video/sfu/event/events.ts +15 -0
  54. package/src/gen/video/sfu/models/models.ts +44 -0
  55. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  56. package/src/helpers/BlockedAudioTracker.ts +74 -0
  57. package/src/helpers/DynascaleManager.ts +46 -337
  58. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  59. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  60. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  61. package/src/helpers/ViewportTracker.ts +74 -19
  62. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  63. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  64. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  65. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rpc/retryable.ts +0 -1
  72. package/src/rtc/BasePeerConnection.ts +96 -6
  73. package/src/rtc/Publisher.ts +49 -2
  74. package/src/rtc/Subscriber.ts +42 -14
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  76. package/src/rtc/__tests__/Publisher.test.ts +332 -10
  77. package/src/rtc/__tests__/Subscriber.test.ts +202 -1
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  80. package/src/rtc/helpers/degradationPreference.ts +22 -0
  81. package/src/rtc/index.ts +1 -0
  82. package/src/rtc/types.ts +38 -1
  83. package/src/stats/rtc/types.ts +1 -0
  84. package/src/store/__tests__/rxUtils.test.ts +276 -0
  85. package/src/store/rxUtils.ts +19 -0
  86. package/src/types.ts +19 -0
@@ -1398,6 +1398,35 @@ var ClientCapability;
1398
1398
  */
1399
1399
  ClientCapability[ClientCapability["SUBSCRIBER_VIDEO_PAUSE"] = 1] = "SUBSCRIBER_VIDEO_PAUSE";
1400
1400
  })(ClientCapability || (ClientCapability = {}));
1401
+ /**
1402
+ * DegradationPreference represents the RTCDegradationPreference from WebRTC.
1403
+ * See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
1404
+ *
1405
+ * @generated from protobuf enum stream.video.sfu.models.DegradationPreference
1406
+ */
1407
+ var DegradationPreference;
1408
+ (function (DegradationPreference) {
1409
+ /**
1410
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
1411
+ */
1412
+ DegradationPreference[DegradationPreference["UNSPECIFIED"] = 0] = "UNSPECIFIED";
1413
+ /**
1414
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
1415
+ */
1416
+ DegradationPreference[DegradationPreference["BALANCED"] = 1] = "BALANCED";
1417
+ /**
1418
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
1419
+ */
1420
+ DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE"] = 2] = "MAINTAIN_FRAMERATE";
1421
+ /**
1422
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
1423
+ */
1424
+ DegradationPreference[DegradationPreference["MAINTAIN_RESOLUTION"] = 3] = "MAINTAIN_RESOLUTION";
1425
+ /**
1426
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
1427
+ */
1428
+ DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE_AND_RESOLUTION"] = 4] = "MAINTAIN_FRAMERATE_AND_RESOLUTION";
1429
+ })(DegradationPreference || (DegradationPreference = {}));
1401
1430
  // @generated message type with reflection information, may provide speed optimized methods
1402
1431
  class CallState$Type extends MessageType {
1403
1432
  constructor() {
@@ -1667,6 +1696,16 @@ class PublishOption$Type extends MessageType {
1667
1696
  repeat: 2 /*RepeatType.UNPACKED*/,
1668
1697
  T: () => AudioBitrate,
1669
1698
  },
1699
+ {
1700
+ no: 11,
1701
+ name: 'degradation_preference',
1702
+ kind: 'enum',
1703
+ T: () => [
1704
+ 'stream.video.sfu.models.DegradationPreference',
1705
+ DegradationPreference,
1706
+ 'DEGRADATION_PREFERENCE_',
1707
+ ],
1708
+ },
1670
1709
  ]);
1671
1710
  }
1672
1711
  }
@@ -2113,6 +2152,7 @@ var models = /*#__PURE__*/Object.freeze({
2113
2152
  ClientDetails: ClientDetails,
2114
2153
  Codec: Codec,
2115
2154
  get ConnectionQuality () { return ConnectionQuality; },
2155
+ get DegradationPreference () { return DegradationPreference; },
2116
2156
  Device: Device,
2117
2157
  Error: Error$2,
2118
2158
  get ErrorCode () { return ErrorCode; },
@@ -3500,6 +3540,16 @@ class VideoSender$Type extends MessageType {
3500
3540
  kind: 'scalar',
3501
3541
  T: 5 /*ScalarType.INT32*/,
3502
3542
  },
3543
+ {
3544
+ no: 6,
3545
+ name: 'degradation_preference',
3546
+ kind: 'enum',
3547
+ T: () => [
3548
+ 'stream.video.sfu.models.DegradationPreference',
3549
+ DegradationPreference,
3550
+ 'DEGRADATION_PREFERENCE_',
3551
+ ],
3552
+ },
3503
3553
  ]);
3504
3554
  }
3505
3555
  }
@@ -3865,6 +3915,18 @@ const createSignalClient = (options) => {
3865
3915
  };
3866
3916
 
3867
3917
  const sleep = (m) => new Promise((r) => setTimeout(r, m));
3918
+ const timeboxed = async (promises, ms) => {
3919
+ let timerId;
3920
+ const timeout = new Promise((_, reject) => {
3921
+ timerId = setTimeout(() => reject(new Error('timebox error')), ms);
3922
+ });
3923
+ try {
3924
+ return await Promise.race([Promise.all(promises), timeout]);
3925
+ }
3926
+ finally {
3927
+ clearTimeout(timerId);
3928
+ }
3929
+ };
3868
3930
  function isFunction(value) {
3869
3931
  return (value &&
3870
3932
  (Object.prototype.toString.call(value) === '[object Function]' ||
@@ -4006,8 +4068,6 @@ const retryable = async (rpc, signal, maxRetries = Number.POSITIVE_INFINITY) =>
4006
4068
  do {
4007
4069
  if (attempt > 0)
4008
4070
  await sleep(retryInterval(attempt));
4009
- if (signal?.aborted)
4010
- throw new Error(signal.reason);
4011
4071
  try {
4012
4072
  result = await rpc({ attempt });
4013
4073
  }
@@ -4405,6 +4465,21 @@ class Dispatcher {
4405
4465
  }
4406
4466
  }
4407
4467
 
4468
+ /**
4469
+ * NegotiationError is thrown when there is an error during the negotiation process.
4470
+ * It extends the built-in Error class and includes an SfuError object for more details.
4471
+ */
4472
+ class NegotiationError extends Error {
4473
+ /**
4474
+ * Creates an instance of NegotiationError.
4475
+ */
4476
+ constructor(error) {
4477
+ super(error.message);
4478
+ this.name = 'NegotiationError';
4479
+ this.error = error;
4480
+ }
4481
+ }
4482
+
4408
4483
  /**
4409
4484
  * A buffer for ICE Candidates. Used for ICE Trickle:
4410
4485
  * - https://bloggeek.me/webrtcglossary/trickle-ice/
@@ -4591,6 +4666,20 @@ const setCurrentValue = (subject, update) => {
4591
4666
  subject.next(next);
4592
4667
  return next;
4593
4668
  };
4669
+ /**
4670
+ * Updates the value of the provided Subject asynchronously.
4671
+ * Locks the subject to prevent concurrent updates.
4672
+ *
4673
+ * @param subject the subject to update.
4674
+ * @param update the update to apply to the subject.
4675
+ */
4676
+ const setCurrentValueAsync = async (subject, update) => {
4677
+ return withoutConcurrency(subject, async () => {
4678
+ const next = await update(getCurrentValue(subject));
4679
+ subject.next(next);
4680
+ return next;
4681
+ });
4682
+ };
4594
4683
  /**
4595
4684
  * Updates the value of the provided Subject and returns the previous value
4596
4685
  * and a function to roll back the update.
@@ -4645,6 +4734,7 @@ var rxUtils = /*#__PURE__*/Object.freeze({
4645
4734
  createSubscription: createSubscription,
4646
4735
  getCurrentValue: getCurrentValue,
4647
4736
  setCurrentValue: setCurrentValue,
4737
+ setCurrentValueAsync: setCurrentValueAsync,
4648
4738
  updateValue: updateValue
4649
4739
  });
4650
4740
 
@@ -6222,21 +6312,6 @@ class CallState {
6222
6312
  }
6223
6313
  }
6224
6314
 
6225
- /**
6226
- * NegotiationError is thrown when there is an error during the negotiation process.
6227
- * It extends the built-in Error class and includes an SfuError object for more details.
6228
- */
6229
- class NegotiationError extends Error {
6230
- /**
6231
- * Creates an instance of NegotiationError.
6232
- */
6233
- constructor(error) {
6234
- super(error.message);
6235
- this.name = 'NegotiationError';
6236
- this.error = error;
6237
- }
6238
- }
6239
-
6240
6315
  /**
6241
6316
  * Flatten the stats report into an array of stats objects.
6242
6317
  *
@@ -6284,7 +6359,7 @@ const getSdkVersion = (sdk) => {
6284
6359
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6285
6360
  };
6286
6361
 
6287
- const version = "1.48.0";
6362
+ const version = "1.50.0";
6288
6363
  const [major, minor, patch] = version.split('.');
6289
6364
  let sdkInfo = {
6290
6365
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -6437,6 +6512,31 @@ const isSafari = () => {
6437
6512
  return false;
6438
6513
  return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
6439
6514
  };
6515
+ /**
6516
+ * Checks whether the current runtime is a WebKit-engine browser.
6517
+ *
6518
+ * Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
6519
+ * (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
6520
+ * Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
6521
+ * `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
6522
+ * share the underlying WebKit quirks.
6523
+ *
6524
+ * Returns false for desktop Chromium-based browsers (which reuse the
6525
+ * `AppleWebKit/` token in their UA) and Android.
6526
+ */
6527
+ const isWebKit = () => {
6528
+ if (typeof navigator === 'undefined')
6529
+ return false;
6530
+ const ua = navigator.userAgent || '';
6531
+ if (!/AppleWebKit\//.test(ua))
6532
+ return false;
6533
+ // Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
6534
+ // `Chromium/` markers are only present on desktop Chromium builds
6535
+ // (their iOS counterparts use `CriOS/` instead). `Android` rules out
6536
+ // the mobile Blink stack.
6537
+ const regExp = /Chrome\/|Chromium\/|Android/;
6538
+ return !regExp.test(ua);
6539
+ };
6440
6540
  /**
6441
6541
  * Checks whether the current browser is Firefox.
6442
6542
  */
@@ -6480,7 +6580,8 @@ var browsers = /*#__PURE__*/Object.freeze({
6480
6580
  isChrome: isChrome,
6481
6581
  isFirefox: isFirefox,
6482
6582
  isSafari: isSafari,
6483
- isSupportedBrowser: isSupportedBrowser
6583
+ isSupportedBrowser: isSupportedBrowser,
6584
+ isWebKit: isWebKit
6484
6585
  });
6485
6586
 
6486
6587
  /**
@@ -7306,6 +7407,30 @@ class Tracer {
7306
7407
  }
7307
7408
  }
7308
7409
 
7410
+ /**
7411
+ * Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
7412
+ * are still accepted at the callback boundary (e.g. when forwarding an SFU
7413
+ * error message), but only the members below influence reconnect-loop
7414
+ * behavior. In particular, `Call.reconnect` programmatically inspects
7415
+ * `ICE_NEVER_CONNECTED` to drive the unsupported-network detector — pass a
7416
+ * canonical member when you want the SDK to react to the reason; pass a
7417
+ * free-form string when the value is purely diagnostic.
7418
+ */
7419
+ const ReconnectReason = {
7420
+ /** ICE never reached `connected`/`completed`, escalate to REJOIN. */
7421
+ ICE_NEVER_CONNECTED: 'ice_never_connected',
7422
+ /** RTCPeerConnection.connectionState became `failed`. */
7423
+ CONNECTION_FAILED: 'connection_failed',
7424
+ /** `restartIce()` rejected. */
7425
+ RESTART_ICE_FAILED: 'restart_ice_failed',
7426
+ /** SFU `goAway` event, migrate to a new SFU. */
7427
+ GO_AWAY: 'go_away',
7428
+ /** Network came back online after going offline. */
7429
+ NETWORK_BACK_ONLINE: 'network_back_online',
7430
+ /** SFU error event with no descriptive message. */
7431
+ SFU_ERROR: 'sfu_error',
7432
+ };
7433
+
7309
7434
  /**
7310
7435
  * A base class for the `Publisher` and `Subscriber` classes.
7311
7436
  * @internal
@@ -7314,7 +7439,8 @@ class BasePeerConnection {
7314
7439
  /**
7315
7440
  * Constructs a new `BasePeerConnection` instance.
7316
7441
  */
7317
- constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
7442
+ constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, tag, enableTracing, clientPublishOptions, iceRestartDelay = 2500, }) {
7443
+ this.iceHasEverConnected = false;
7318
7444
  this.isIceRestarting = false;
7319
7445
  this.isDisposed = false;
7320
7446
  this.trackIdToTrackType = new Map();
@@ -7337,13 +7463,12 @@ class BasePeerConnection {
7337
7463
  */
7338
7464
  this.tryRestartIce = () => {
7339
7465
  this.restartIce().catch((e) => {
7340
- const reason = 'restartICE() failed, initiating reconnect';
7341
- this.logger.error(reason, e);
7466
+ this.logger.error('restartICE() failed, initiating reconnect', e);
7342
7467
  const strategy = e instanceof NegotiationError &&
7343
7468
  e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
7344
7469
  ? WebsocketReconnectStrategy.FAST
7345
7470
  : WebsocketReconnectStrategy.REJOIN;
7346
- this.onReconnectionNeeded?.(strategy, reason, this.peerType);
7471
+ this.onReconnectionNeeded?.(strategy, ReconnectReason.RESTART_ICE_FAILED, this.peerType);
7347
7472
  });
7348
7473
  };
7349
7474
  /**
@@ -7412,6 +7537,17 @@ class BasePeerConnection {
7412
7537
  const connectionState = this.pc.connectionState;
7413
7538
  return !failedStates.has(iceState) && !failedStates.has(connectionState);
7414
7539
  };
7540
+ /**
7541
+ * Returns true only when the peer connection is currently fully established
7542
+ * (ICE `connected`/`completed` AND connection state `connected`).
7543
+ * Transient states like `disconnected`, `checking`, or `new` return false.
7544
+ */
7545
+ this.isStable = () => {
7546
+ const iceState = this.pc.iceConnectionState;
7547
+ const connectionState = this.pc.connectionState;
7548
+ return ((iceState === 'connected' || iceState === 'completed') &&
7549
+ connectionState === 'connected');
7550
+ };
7415
7551
  /**
7416
7552
  * Handles the ICECandidate event and
7417
7553
  * Initiates an ICE Trickle process with the SFU.
@@ -7461,7 +7597,7 @@ class BasePeerConnection {
7461
7597
  }
7462
7598
  // we can't recover from a failed connection state (contrary to ICE)
7463
7599
  if (state === 'failed') {
7464
- this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, 'Connection failed', this.peerType);
7600
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.CONNECTION_FAILED, this.peerType);
7465
7601
  return;
7466
7602
  }
7467
7603
  this.handleConnectionStateUpdate(state);
@@ -7483,6 +7619,41 @@ class BasePeerConnection {
7483
7619
  // do nothing when ICE is restarting
7484
7620
  if (this.isIceRestarting)
7485
7621
  return;
7622
+ // Pre-connect handling: ICE has never reached `connected`/`completed`.
7623
+ // Restart is futile here (the data plane was never established), but
7624
+ // these two terminal-ish states need different treatment:
7625
+ // - `failed` is terminal, escalate to REJOIN so a new SFU/credentials
7626
+ // /PC configuration gets a chance, and let `Call.reconnect` count
7627
+ // this toward the unsupported-network budget.
7628
+ // - `disconnected` is transient, the browser may yet move back to
7629
+ // `checking`/`connected`. Don't restart, don't escalate; wait it
7630
+ // out. If it ultimately fails, ICE will transition to `failed` and
7631
+ // the branch above will take over.
7632
+ if (!this.iceHasEverConnected) {
7633
+ if (state === 'failed') {
7634
+ this.logger.info('ICE failed before connected, escalating to REJOIN');
7635
+ clearTimeout(this.preConnectStuckTimeout);
7636
+ this.preConnectStuckTimeout = undefined;
7637
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
7638
+ return;
7639
+ }
7640
+ if (state === 'disconnected') {
7641
+ this.logger.info('ICE disconnected before connected, wait to recover');
7642
+ // Watchdog: if the browser stays in `disconnected` without ever
7643
+ // reaching `connected` or transitioning to `failed`, escalate to
7644
+ // REJOIN ourselves so we don't wait silently forever. Rare but
7645
+ // observed on flaky mobile networks.
7646
+ clearTimeout(this.preConnectStuckTimeout);
7647
+ this.preConnectStuckTimeout = setTimeout(() => {
7648
+ if (!this.iceHasEverConnected &&
7649
+ this.pc.iceConnectionState === 'disconnected') {
7650
+ this.logger.info('ICE stuck in pre-connect disconnected, escalating to REJOIN');
7651
+ this.onReconnectionNeeded?.(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED, this.peerType);
7652
+ }
7653
+ }, this.iceRestartDelay * 2);
7654
+ return;
7655
+ }
7656
+ }
7486
7657
  switch (state) {
7487
7658
  case 'failed':
7488
7659
  // in the `failed` state, we try to restart ICE immediately
@@ -7502,12 +7673,24 @@ class BasePeerConnection {
7502
7673
  }, this.iceRestartDelay);
7503
7674
  break;
7504
7675
  case 'connected':
7505
- // in the `connected` state, we clear the ice restart timeout if it exists
7676
+ case 'completed':
7677
+ // Fire `onIceConnected` exactly once per peer-connection lifetime —
7678
+ // the first time ICE reaches `connected`/`completed` end-to-end.
7679
+ // Used by `Call` to reset the unsupported-network failure counter
7680
+ // only after WebRTC has actually recovered, not merely on SFU join.
7681
+ if (!this.iceHasEverConnected) {
7682
+ this.iceHasEverConnected = true;
7683
+ this.onIceConnected?.(this.peerType);
7684
+ }
7685
+ // clear any scheduled restartICE since the connection is healthy
7506
7686
  if (this.iceRestartTimeout) {
7507
7687
  this.logger.info('connected connection, canceling restartICE');
7508
7688
  clearTimeout(this.iceRestartTimeout);
7509
7689
  this.iceRestartTimeout = undefined;
7510
7690
  }
7691
+ // clear the pre-connect watchdog if it was armed
7692
+ clearTimeout(this.preConnectStuckTimeout);
7693
+ this.preConnectStuckTimeout = undefined;
7511
7694
  break;
7512
7695
  }
7513
7696
  };
@@ -7540,6 +7723,7 @@ class BasePeerConnection {
7540
7723
  this.clientPublishOptions = clientPublishOptions;
7541
7724
  this.tag = tag;
7542
7725
  this.onReconnectionNeeded = onReconnectionNeeded;
7726
+ this.onIceConnected = onIceConnected;
7543
7727
  this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
7544
7728
  this.pc = this.createPeerConnection(connectionConfig);
7545
7729
  this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
@@ -7558,7 +7742,10 @@ class BasePeerConnection {
7558
7742
  dispose() {
7559
7743
  clearTimeout(this.iceRestartTimeout);
7560
7744
  this.iceRestartTimeout = undefined;
7745
+ clearTimeout(this.preConnectStuckTimeout);
7746
+ this.preConnectStuckTimeout = undefined;
7561
7747
  this.onReconnectionNeeded = undefined;
7748
+ this.onIceConnected = undefined;
7562
7749
  this.isDisposed = true;
7563
7750
  this.detachEventHandlers();
7564
7751
  this.pc.close();
@@ -7870,6 +8057,24 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
7870
8057
  }));
7871
8058
  };
7872
8059
 
8060
+ const toRTCDegradationPreference = (preference) => {
8061
+ switch (preference) {
8062
+ case DegradationPreference.BALANCED:
8063
+ return 'balanced';
8064
+ case DegradationPreference.MAINTAIN_FRAMERATE:
8065
+ return 'maintain-framerate';
8066
+ case DegradationPreference.MAINTAIN_RESOLUTION:
8067
+ return 'maintain-resolution';
8068
+ case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
8069
+ // @ts-expect-error not in the typedefs yet
8070
+ return 'maintain-framerate-and-resolution';
8071
+ case DegradationPreference.UNSPECIFIED:
8072
+ return undefined;
8073
+ default:
8074
+ ensureExhausted(preference, 'Unknown degradation preference');
8075
+ }
8076
+ };
8077
+
7873
8078
  /**
7874
8079
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
7875
8080
  *
@@ -7931,7 +8136,9 @@ class Publisher extends BasePeerConnection {
7931
8136
  sendEncodings,
7932
8137
  });
7933
8138
  const params = transceiver.sender.getParameters();
7934
- params.degradationPreference = 'maintain-framerate';
8139
+ params.degradationPreference =
8140
+ toRTCDegradationPreference(publishOption.degradationPreference) ??
8141
+ 'maintain-framerate';
7935
8142
  await transceiver.sender.setParameters(params);
7936
8143
  const trackType = publishOption.trackType;
7937
8144
  this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
@@ -8028,6 +8235,40 @@ class Publisher extends BasePeerConnection {
8028
8235
  }
8029
8236
  return false;
8030
8237
  };
8238
+ /**
8239
+ * Re-arms the encoder for the given track type by detaching and
8240
+ * reattaching the currently published track on each matching sender.
8241
+ *
8242
+ * Workaround for a WebKit / iOS Safari quirk: after a system audio
8243
+ * session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
8244
+ * can stop producing RTP packets even though the underlying
8245
+ * `MediaStreamTrack` is `live` and `track.muted === false`.
8246
+ * `replaceTrack(null)` followed by `replaceTrack(track)` resets the
8247
+ * sender's encoder pipeline without renegotiation, restoring packet
8248
+ * flow with the same SSRC.
8249
+ *
8250
+ * No-op when nothing is published for the given track type.
8251
+ *
8252
+ * @param trackType the track type to refresh.
8253
+ */
8254
+ this.refreshTrack = async (trackType) => {
8255
+ for (const item of this.transceiverCache.items()) {
8256
+ if (item.publishOption.trackType !== trackType)
8257
+ continue;
8258
+ const { sender } = item.transceiver;
8259
+ const track = sender.track;
8260
+ if (!track || track.readyState !== 'live')
8261
+ continue;
8262
+ try {
8263
+ await sender.replaceTrack(null);
8264
+ await sender.replaceTrack(track);
8265
+ this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
8266
+ }
8267
+ catch (err) {
8268
+ this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
8269
+ }
8270
+ }
8271
+ };
8031
8272
  /**
8032
8273
  * Stops the cloned track that is being published to the SFU.
8033
8274
  */
@@ -8105,6 +8346,12 @@ class Publisher extends BasePeerConnection {
8105
8346
  changed = true;
8106
8347
  }
8107
8348
  }
8349
+ const degradationPreference = toRTCDegradationPreference(videoSender.degradationPreference);
8350
+ if (degradationPreference &&
8351
+ params.degradationPreference !== degradationPreference) {
8352
+ params.degradationPreference = degradationPreference;
8353
+ changed = true;
8354
+ }
8108
8355
  const activeEncoders = params.encodings.filter((e) => e.active);
8109
8356
  if (!changed) {
8110
8357
  return this.logger.info(`${tag} no change:`, activeEncoders);
@@ -8212,7 +8459,8 @@ class Publisher extends BasePeerConnection {
8212
8459
  const trackInfos = [];
8213
8460
  for (const publishOption of this.publishOptions) {
8214
8461
  const bundle = this.transceiverCache.get(publishOption);
8215
- if (!bundle || !bundle.transceiver.sender.track)
8462
+ const track = bundle?.transceiver.sender.track;
8463
+ if (!bundle || !track || track.readyState !== 'live')
8216
8464
  continue;
8217
8465
  trackInfos.push(this.toTrackInfo(bundle, sdp));
8218
8466
  }
@@ -8284,6 +8532,36 @@ class Publisher extends BasePeerConnection {
8284
8532
  }
8285
8533
  }
8286
8534
 
8535
+ /**
8536
+ * Adds unique values to an array.
8537
+ *
8538
+ * @param arr the array to add to.
8539
+ * @param values the values to add.
8540
+ */
8541
+ const pushToIfMissing = (arr, ...values) => {
8542
+ for (const v of values) {
8543
+ if (!arr.includes(v)) {
8544
+ arr.push(v);
8545
+ }
8546
+ }
8547
+ return arr;
8548
+ };
8549
+ /**
8550
+ * Removes values from an array if they are present.
8551
+ *
8552
+ * @param arr the array to remove from.
8553
+ * @param values the values to remove.
8554
+ */
8555
+ const removeFromIfPresent = (arr, ...values) => {
8556
+ for (const v of values) {
8557
+ const index = arr.indexOf(v);
8558
+ if (index !== -1) {
8559
+ arr.splice(index, 1);
8560
+ }
8561
+ }
8562
+ return arr;
8563
+ };
8564
+
8287
8565
  /**
8288
8566
  * A wrapper around the `RTCPeerConnection` that handles the incoming
8289
8567
  * media streams from the SFU.
@@ -8325,27 +8603,34 @@ class Subscriber extends BasePeerConnection {
8325
8603
  }
8326
8604
  };
8327
8605
  this.handleOnTrack = (e) => {
8328
- const [primaryStream] = e.streams;
8606
+ const { streams, track } = e;
8607
+ const [primaryStream] = streams;
8329
8608
  // example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
8330
8609
  const [trackId, rawTrackType] = primaryStream.id.split(':');
8331
8610
  const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
8332
- this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, e.track.id, e.track);
8611
+ this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
8612
+ const trackType = toTrackType(rawTrackType);
8613
+ if (!trackType) {
8614
+ return this.logger.error(`Unknown track type: ${rawTrackType}`);
8615
+ }
8333
8616
  const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
8334
- e.track.addEventListener('mute', () => {
8617
+ track.addEventListener('mute', () => {
8335
8618
  this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
8619
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
8336
8620
  });
8337
- e.track.addEventListener('unmute', () => {
8621
+ track.addEventListener('unmute', () => {
8338
8622
  this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
8623
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
8339
8624
  });
8340
- e.track.addEventListener('ended', () => {
8625
+ track.addEventListener('ended', () => {
8341
8626
  this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
8627
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
8342
8628
  this.state.removeOrphanedTrack(primaryStream.id);
8343
8629
  });
8344
- const trackType = toTrackType(rawTrackType);
8345
- if (!trackType) {
8346
- return this.logger.error(`Unknown track type: ${rawTrackType}`);
8630
+ if (track.muted) {
8631
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
8347
8632
  }
8348
- this.trackIdToTrackType.set(e.track.id, trackType);
8633
+ this.trackIdToTrackType.set(track.id, trackType);
8349
8634
  if (!participantToUpdate) {
8350
8635
  this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
8351
8636
  this.state.registerOrphanedTrack({
@@ -8371,13 +8656,30 @@ class Subscriber extends BasePeerConnection {
8371
8656
  });
8372
8657
  // now, dispose the previous stream if it exists
8373
8658
  if (previousStream) {
8374
- this.logger.info(`[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`);
8659
+ this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
8375
8660
  previousStream.getTracks().forEach((t) => {
8376
8661
  t.stop();
8377
8662
  previousStream.removeTrack(t);
8378
8663
  });
8379
8664
  }
8380
8665
  };
8666
+ this.setRemoteTrackInterrupted = (trackId, trackType, interrupted) => {
8667
+ if (trackType !== TrackType.AUDIO)
8668
+ return;
8669
+ const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
8670
+ if (!target)
8671
+ return;
8672
+ this.state.updateParticipant(target.sessionId, (p) => {
8673
+ const current = p.interruptedTracks ?? [];
8674
+ const has = current.includes(trackType);
8675
+ if (interrupted === has)
8676
+ return {};
8677
+ const next = interrupted
8678
+ ? pushToIfMissing([...current], trackType)
8679
+ : removeFromIfPresent([...current], trackType);
8680
+ return { interruptedTracks: next };
8681
+ });
8682
+ };
8381
8683
  this.negotiate = async (subscriberOffer) => {
8382
8684
  await this.pc.setRemoteDescription({
8383
8685
  type: 'offer',
@@ -8557,6 +8859,20 @@ class SfuJoinError extends Error {
8557
8859
  }
8558
8860
  }
8559
8861
 
8862
+ /**
8863
+ * Creates a fresh `joinResponseTask` with a no-op rejection handler attached
8864
+ * to the underlying promise. The handler marks the rejection path as handled
8865
+ * so a teardown-time reject (e.g., from `close()` during disposal) does not
8866
+ * surface as an `UnhandledPromiseRejection`. Explicit awaiters of
8867
+ * `StreamSfuClient.joinTask` still observe the rejection through their own
8868
+ * `then`/`catch` chain. `.catch()` returns a new promise; the original is
8869
+ * unchanged.
8870
+ */
8871
+ const makeJoinResponseTask = () => {
8872
+ const task = promiseWithResolvers();
8873
+ task.promise.catch(() => { }); // see the comment above
8874
+ return task;
8875
+ };
8560
8876
  /**
8561
8877
  * The client used for exchanging information with the SFU.
8562
8878
  */
@@ -8588,9 +8904,10 @@ class StreamSfuClient {
8588
8904
  this.subscriptionsConcurrencyTag = Symbol('subscriptionsConcurrencyTag');
8589
8905
  /**
8590
8906
  * Promise that resolves when the JoinResponse is received.
8591
- * Rejects after a certain threshold if the response is not received.
8907
+ * Rejects after a certain threshold if the response is not received,
8908
+ * or when the SFU client is disposed before a join completes.
8592
8909
  */
8593
- this.joinResponseTask = promiseWithResolvers();
8910
+ this.joinResponseTask = makeJoinResponseTask();
8594
8911
  /**
8595
8912
  * A controller to abort the current requests.
8596
8913
  */
@@ -8660,14 +8977,21 @@ class StreamSfuClient {
8660
8977
  };
8661
8978
  this.close = (code = StreamSfuClient.NORMAL_CLOSURE, reason) => {
8662
8979
  this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
8663
- if (this.signalWs.readyState === WebSocket.OPEN) {
8980
+ // Close the WebSocket whether it has fully opened (`OPEN`) or is still
8981
+ // mid-handshake (`CONNECTING`). The WebSocket spec aborts the handshake
8982
+ // when `close()` is called on a CONNECTING socket. Without this, an
8983
+ // SFU socket that opens just after teardown would dispatch events into
8984
+ // a Call instance that has already moved on.
8985
+ const ws = this.signalWs;
8986
+ if (ws.readyState === WebSocket.OPEN ||
8987
+ ws.readyState === WebSocket.CONNECTING) {
8664
8988
  this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
8665
- this.signalWs.close(code, `js-client: ${reason}`);
8666
- this.signalWs.removeEventListener('close', this.handleWebSocketClose);
8989
+ ws.close(code, `js-client: ${reason}`);
8990
+ ws.removeEventListener('close', this.handleWebSocketClose);
8667
8991
  }
8668
- this.dispose();
8992
+ this.dispose(reason);
8669
8993
  };
8670
- this.dispose = () => {
8994
+ this.dispose = (reason) => {
8671
8995
  this.logger.debug('Disposing SFU client');
8672
8996
  this.unsubscribeIceTrickle();
8673
8997
  this.unsubscribeNetworkChanged();
@@ -8676,6 +9000,13 @@ class StreamSfuClient {
8676
9000
  clearTimeout(this.migrateAwayTimeout);
8677
9001
  this.abortController.abort();
8678
9002
  this.migrationTask?.resolve();
9003
+ // Settle a pending `joinResponseTask` so `leaveAndClose`, `join()`, and
9004
+ // any other awaiters (`await this.joinTask`) don't hang indefinitely
9005
+ // when the SFU client is torn down before the SFU sent a JoinResponse.
9006
+ if (!this.joinResponseTask.isResolved() &&
9007
+ !this.joinResponseTask.isRejected()) {
9008
+ this.joinResponseTask.reject(new Error(`SFU client disposed before join completed${reason ? `: ${reason}` : ''}`));
9009
+ }
8679
9010
  this.iceTrickleBuffer.dispose();
8680
9011
  };
8681
9012
  this.getTrace = () => {
@@ -8684,8 +9015,24 @@ class StreamSfuClient {
8684
9015
  this.leaveAndClose = async (reason) => {
8685
9016
  try {
8686
9017
  this.isLeaving = true;
8687
- await this.joinTask;
8688
- await this.notifyLeave(reason);
9018
+ // Best-effort: give an in-flight join a short grace period to complete
9019
+ // so we can send a graceful `leaveCallRequest`. Bounded so we never hang
9020
+ // here if the SFU is unresponsive. If the task settles either way during
9021
+ // the wait, the re-check below decides whether to notify.
9022
+ if (!this.joinResponseTask.isResolved() &&
9023
+ !this.joinResponseTask.isRejected()) {
9024
+ await Promise.race([
9025
+ // swallow rejection — we re-check `isResolved()` below to decide
9026
+ this.joinResponseTask.promise.catch(() => { }),
9027
+ sleep(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS),
9028
+ ]);
9029
+ }
9030
+ if (this.joinResponseTask.isResolved()) {
9031
+ await this.notifyLeave(reason);
9032
+ }
9033
+ else {
9034
+ this.logger.debug('[leaveAndClose] join not completed within grace period, skipping notifyLeave');
9035
+ }
8689
9036
  }
8690
9037
  catch (err) {
8691
9038
  this.logger.debug('Error notifying SFU about leaving call', err);
@@ -8754,9 +9101,9 @@ class StreamSfuClient {
8754
9101
  this.joinResponseTask.isRejected()) {
8755
9102
  // we need to lock the RPC requests until we receive a JoinResponse.
8756
9103
  // that's why we have this primitive lock mechanism.
8757
- // the client starts with already initialized joinResponseTask,
9104
+ // the client starts with an already initialized joinResponseTask,
8758
9105
  // and this code creates a new one for the next join request.
8759
- this.joinResponseTask = promiseWithResolvers();
9106
+ this.joinResponseTask = makeJoinResponseTask();
8760
9107
  }
8761
9108
  // capture a reference to the current joinResponseTask as it might
8762
9109
  // be replaced with a new one in case a second join request is made
@@ -8929,6 +9276,12 @@ StreamSfuClient.DISPOSE_OLD_SOCKET = 4100;
8929
9276
  * The close code used when the client fails to join the call (on the SFU).
8930
9277
  */
8931
9278
  StreamSfuClient.JOIN_FAILED = 4101;
9279
+ /**
9280
+ * Best-effort grace period in `leaveAndClose` for an in-flight join to
9281
+ * complete before we give up and close without sending `leaveCallRequest`.
9282
+ * Bounded so a stuck join can never hang the leave path.
9283
+ */
9284
+ StreamSfuClient.LEAVE_NOTIFY_GRACE_MS = 1000;
8932
9285
 
8933
9286
  /**
8934
9287
  * Event handler that watched the delivery of `call.accepted`.
@@ -9049,36 +9402,6 @@ const watchCallGrantsUpdated = (state) => {
9049
9402
  };
9050
9403
  };
9051
9404
 
9052
- /**
9053
- * Adds unique values to an array.
9054
- *
9055
- * @param arr the array to add to.
9056
- * @param values the values to add.
9057
- */
9058
- const pushToIfMissing = (arr, ...values) => {
9059
- for (const v of values) {
9060
- if (!arr.includes(v)) {
9061
- arr.push(v);
9062
- }
9063
- }
9064
- return arr;
9065
- };
9066
- /**
9067
- * Removes values from an array if they are present.
9068
- *
9069
- * @param arr the array to remove from.
9070
- * @param values the values to remove.
9071
- */
9072
- const removeFromIfPresent = (arr, ...values) => {
9073
- for (const v of values) {
9074
- const index = arr.indexOf(v);
9075
- if (index !== -1) {
9076
- arr.splice(index, 1);
9077
- }
9078
- }
9079
- return arr;
9080
- };
9081
-
9082
9405
  const watchConnectionQualityChanged = (dispatcher, state) => {
9083
9406
  return dispatcher.on('connectionQualityChanged', '*', (e) => {
9084
9407
  const { connectionQualityUpdates } = e;
@@ -9411,140 +9734,54 @@ const registerRingingCallEventHandlers = (call) => {
9411
9734
  };
9412
9735
  };
9413
9736
 
9414
- const DEFAULT_THRESHOLD = 0.35;
9415
- class ViewportTracker {
9416
- constructor() {
9737
+ const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
9738
+ /**
9739
+ * Tracks audio element bindings and periodically warns about
9740
+ * remote participants whose audio streams have no bound element.
9741
+ */
9742
+ class AudioBindingsWatchdog {
9743
+ constructor(state, tracer) {
9744
+ this.bindings = new Map();
9745
+ this.enabled = true;
9746
+ this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
9417
9747
  /**
9418
- * @private
9748
+ * Registers an audio element binding for the given session and track type.
9749
+ * Warns if a different element is already bound to the same key.
9419
9750
  */
9420
- this.elementHandlerMap = new Map();
9751
+ this.register = (element, sessionId, trackType) => {
9752
+ const key = toBindingKey(sessionId, trackType);
9753
+ const existing = this.bindings.get(key);
9754
+ if (existing && existing !== element) {
9755
+ this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
9756
+ this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
9757
+ }
9758
+ this.bindings.set(key, element);
9759
+ };
9421
9760
  /**
9422
- * @private
9761
+ * Removes the audio element binding for the given session and track type.
9423
9762
  */
9424
- this.observer = null;
9425
- // in React children render before viewport is set, add
9426
- // them to the queue and observe them once the observer is ready
9763
+ this.unregister = (sessionId, trackType) => {
9764
+ this.bindings.delete(toBindingKey(sessionId, trackType));
9765
+ };
9427
9766
  /**
9428
- * @private
9767
+ * Enables or disables the watchdog.
9768
+ * When disabled, the periodic check stops but bindings are still tracked.
9429
9769
  */
9430
- this.queueSet = new Set();
9770
+ this.setEnabled = (enabled) => {
9771
+ this.enabled = enabled;
9772
+ if (enabled) {
9773
+ this.start();
9774
+ }
9775
+ else {
9776
+ this.stop();
9777
+ }
9778
+ };
9431
9779
  /**
9432
- * Method to set scrollable viewport as root for the IntersectionObserver, returns
9433
- * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
9434
- *
9435
- * @param viewportElement
9436
- * @param options
9437
- * @returns Unobserve
9438
- */
9439
- this.setViewport = (viewportElement, options) => {
9440
- const cleanup = () => {
9441
- this.observer?.disconnect();
9442
- this.observer = null;
9443
- this.elementHandlerMap.clear();
9444
- };
9445
- this.observer = new IntersectionObserver((entries) => {
9446
- entries.forEach((entry) => {
9447
- const handler = this.elementHandlerMap.get(entry.target);
9448
- handler?.(entry);
9449
- });
9450
- }, {
9451
- root: viewportElement,
9452
- ...options,
9453
- threshold: options?.threshold ?? DEFAULT_THRESHOLD,
9454
- });
9455
- if (this.queueSet.size) {
9456
- this.queueSet.forEach(([queueElement, queueHandler]) => {
9457
- // check if element which requested observation is
9458
- // a child of a viewport element, skip if isn't
9459
- if (!viewportElement.contains(queueElement))
9460
- return;
9461
- this.observer.observe(queueElement);
9462
- this.elementHandlerMap.set(queueElement, queueHandler);
9463
- });
9464
- this.queueSet.clear();
9465
- }
9466
- return cleanup;
9467
- };
9468
- /**
9469
- * Method to set element to observe and handler to be triggered whenever IntersectionObserver
9470
- * detects a possible change in element's visibility within specified viewport, returns
9471
- * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
9472
- *
9473
- * @param element
9474
- * @param handler
9475
- * @returns Unobserve
9476
- */
9477
- this.observe = (element, handler) => {
9478
- const queueItem = [element, handler];
9479
- const cleanup = () => {
9480
- this.elementHandlerMap.delete(element);
9481
- this.observer?.unobserve(element);
9482
- this.queueSet.delete(queueItem);
9483
- };
9484
- if (this.elementHandlerMap.has(element))
9485
- return cleanup;
9486
- if (!this.observer) {
9487
- this.queueSet.add(queueItem);
9488
- return cleanup;
9489
- }
9490
- if (this.observer.root.contains(element)) {
9491
- this.elementHandlerMap.set(element, handler);
9492
- this.observer.observe(element);
9493
- }
9494
- return cleanup;
9495
- };
9496
- }
9497
- }
9498
-
9499
- const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
9500
- /**
9501
- * Tracks audio element bindings and periodically warns about
9502
- * remote participants whose audio streams have no bound element.
9503
- */
9504
- class AudioBindingsWatchdog {
9505
- constructor(state, tracer) {
9506
- this.state = state;
9507
- this.tracer = tracer;
9508
- this.bindings = new Map();
9509
- this.enabled = true;
9510
- this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
9511
- /**
9512
- * Registers an audio element binding for the given session and track type.
9513
- * Warns if a different element is already bound to the same key.
9514
- */
9515
- this.register = (audioElement, sessionId, trackType) => {
9516
- const key = toBindingKey(sessionId, trackType);
9517
- const existing = this.bindings.get(key);
9518
- if (existing && existing !== audioElement) {
9519
- this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
9520
- this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
9521
- }
9522
- this.bindings.set(key, audioElement);
9523
- };
9524
- /**
9525
- * Removes the audio element binding for the given session and track type.
9526
- */
9527
- this.unregister = (sessionId, trackType) => {
9528
- this.bindings.delete(toBindingKey(sessionId, trackType));
9529
- };
9530
- /**
9531
- * Enables or disables the watchdog.
9532
- * When disabled, the periodic check stops but bindings are still tracked.
9533
- */
9534
- this.setEnabled = (enabled) => {
9535
- this.enabled = enabled;
9536
- if (enabled) {
9537
- this.start();
9538
- }
9539
- else {
9540
- this.stop();
9541
- }
9542
- };
9543
- /**
9544
- * Stops the watchdog and unsubscribes from callingState changes.
9780
+ * Stops the watchdog and unsubscribes from callingState changes.
9545
9781
  */
9546
9782
  this.dispose = () => {
9547
9783
  this.stop();
9784
+ this.bindings.clear();
9548
9785
  this.unsubscribeCallingState();
9549
9786
  };
9550
9787
  this.start = () => {
@@ -9576,6 +9813,8 @@ class AudioBindingsWatchdog {
9576
9813
  this.stop = () => {
9577
9814
  clearInterval(this.watchdogInterval);
9578
9815
  };
9816
+ this.tracer = tracer;
9817
+ this.state = state;
9579
9818
  this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
9580
9819
  if (!this.enabled)
9581
9820
  return;
@@ -9589,61 +9828,97 @@ class AudioBindingsWatchdog {
9589
9828
  }
9590
9829
  }
9591
9830
 
9592
- const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
9593
- videoTrack: VisibilityState.UNKNOWN,
9594
- screenShareTrack: VisibilityState.UNKNOWN,
9595
- };
9596
- const globalOverrideKey = Symbol('globalOverrideKey');
9597
9831
  /**
9598
- * A manager class that handles dynascale related tasks like:
9599
- *
9600
- * - binding video elements to session ids
9601
- * - binding audio elements to session ids
9602
- * - tracking element visibility
9603
- * - updating subscriptions based on viewport visibility
9604
- * - updating subscriptions based on video element dimensions
9605
- * - updating subscriptions based on published tracks
9832
+ * Tracks audio elements that the browser's autoplay policy has blocked.
9606
9833
  */
9607
- class DynascaleManager {
9608
- /**
9609
- * Creates a new DynascaleManager instance.
9610
- */
9611
- constructor(callState, speaker, tracer) {
9834
+ class BlockedAudioTracker {
9835
+ constructor(tracer) {
9836
+ this.logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
9837
+ this.blockedElementsSubject = new BehaviorSubject(new Set());
9612
9838
  /**
9613
- * The viewport tracker instance.
9839
+ * Whether the browser's autoplay policy is blocking audio playback.
9840
+ * Will be `true` when at least one audio element is currently blocked.
9841
+ * Use {@link resumeAudio} within a user gesture to unblock.
9614
9842
  */
9615
- this.viewportTracker = new ViewportTracker();
9616
- this.logger = videoLoggerSystem.getLogger('DynascaleManager');
9617
- this.useWebAudio = false;
9618
- this.pendingSubscriptionsUpdate = null;
9843
+ this.autoplayBlocked$ = this.blockedElementsSubject.pipe(map((elements) => elements.size > 0), distinctUntilChanged());
9619
9844
  /**
9620
- * Audio elements that were blocked by the browser's autoplay policy.
9621
- * These can be retried by calling `resumeAudio()` from a user gesture.
9845
+ * Registers an audio element as blocked by the browser's autoplay policy.
9622
9846
  */
9623
- this.blockedAudioElementsSubject = new BehaviorSubject(new Set());
9624
- /**
9625
- * Whether the browser's autoplay policy is blocking audio playback.
9626
- * Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
9627
- * Use `resumeAudio()` within a user gesture to unblock.
9628
- */
9629
- this.autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(map((elements) => elements.size > 0), distinctUntilChanged());
9630
- this.addBlockedAudioElement = (audioElement) => {
9631
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9632
- const next = new Set(elements);
9633
- next.add(audioElement);
9634
- return next;
9847
+ this.markBlocked = (audioElement, blocked) => {
9848
+ setCurrentValue(this.blockedElementsSubject, (elements) => {
9849
+ if (blocked)
9850
+ elements.add(audioElement);
9851
+ else
9852
+ elements.delete(audioElement);
9853
+ return elements;
9635
9854
  });
9636
9855
  };
9637
- this.removeBlockedAudioElement = (audioElement) => {
9638
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9639
- const nextElements = new Set(elements);
9640
- nextElements.delete(audioElement);
9641
- return nextElements;
9856
+ /**
9857
+ * Returns whether the given audio element is currently flagged as blocked
9858
+ * by the browser's autoplay policy.
9859
+ */
9860
+ this.isBlocked = (audioElement) => {
9861
+ return this.blockedElementsSubject.getValue().has(audioElement);
9862
+ };
9863
+ /**
9864
+ * Plays all audio elements blocked by the browser's autoplay policy.
9865
+ * Must be called from within a user gesture (e.g., click handler).
9866
+ */
9867
+ this.resumeAudio = async () => {
9868
+ this.tracer.trace('resumeAudio', null);
9869
+ await setCurrentValueAsync(this.blockedElementsSubject, async (elements) => {
9870
+ await Promise.all(Array.from(elements, async (element) => {
9871
+ try {
9872
+ if (element.srcObject)
9873
+ await timeboxed([element.play()], 2000);
9874
+ elements.delete(element);
9875
+ }
9876
+ catch (err) {
9877
+ this.logger.warn(`Can't resume audio for element`, element, err);
9878
+ }
9879
+ }));
9880
+ return elements;
9642
9881
  });
9643
9882
  };
9644
- this.videoTrackSubscriptionOverridesSubject = new BehaviorSubject({});
9645
- this.videoTrackSubscriptionOverrides$ = this.videoTrackSubscriptionOverridesSubject.asObservable();
9646
- this.incomingVideoSettings$ = this.videoTrackSubscriptionOverrides$.pipe(map((overrides) => {
9883
+ this.tracer = tracer;
9884
+ }
9885
+ }
9886
+
9887
+ /** Symbol key for the "applies to all participants" override slot. */
9888
+ const globalOverrideKey = Symbol('globalOverrideKey');
9889
+ /**
9890
+ * Owns the SFU-side video-subscription machinery for a `Call`:
9891
+ *
9892
+ * - Holds the per-session / global override state in a
9893
+ * `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
9894
+ * - Derives the SFU subscription list from `CallState` participants +
9895
+ * current overrides via the `subscriptions` getter.
9896
+ * - Debounces and pushes the list to the SFU through
9897
+ * `sfuClient.updateSubscriptions`.
9898
+ * - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
9899
+ * the override state for React hooks.
9900
+ *
9901
+ * Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
9902
+ * `DynascaleManager.bindVideoElement` triggers `apply()` on every
9903
+ * dimension / visibility change.
9904
+ */
9905
+ class TrackSubscriptionManager {
9906
+ /**
9907
+ * Constructs new TrackSubscriptionManager instance.
9908
+ *
9909
+ * @param callState the call state.
9910
+ * @param tracer the tracer to use.
9911
+ */
9912
+ constructor(callState, tracer) {
9913
+ this.logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
9914
+ this.pendingUpdate = null;
9915
+ this.overridesSubject = new BehaviorSubject({});
9916
+ this.overrides$ = this.overridesSubject.asObservable();
9917
+ /**
9918
+ * Consumer-friendly projection of the override state. Used by the
9919
+ * `useIncomingVideoSettings()` React hook.
9920
+ */
9921
+ this.incomingVideoSettings$ = this.overrides$.pipe(map((overrides) => {
9647
9922
  const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
9648
9923
  return {
9649
9924
  enabled: globalSettings?.enabled !== false,
@@ -9665,106 +9940,255 @@ class DynascaleManager {
9665
9940
  };
9666
9941
  }), shareReplay(1));
9667
9942
  /**
9668
- * Disposes the allocated resources and closes the audio context if it was created.
9943
+ * Sets the SFU client used by `apply()` to push subscription updates.
9944
+ * Called by the owner on call join; cleared on leave.
9669
9945
  */
9670
- this.dispose = async () => {
9671
- if (this.pendingSubscriptionsUpdate) {
9672
- clearTimeout(this.pendingSubscriptionsUpdate);
9673
- }
9674
- this.audioBindingsWatchdog?.dispose();
9675
- setCurrentValue(this.blockedAudioElementsSubject, new Set());
9676
- const context = this.audioContext;
9677
- if (context && context.state !== 'closed') {
9678
- document.removeEventListener('click', this.resumeAudioContext);
9679
- await context.close();
9680
- this.audioContext = undefined;
9946
+ this.setSfuClient = (sfuClient) => {
9947
+ this.sfuClient = sfuClient;
9948
+ };
9949
+ /**
9950
+ * Cancels any pending debounced subscription push. Idempotent.
9951
+ */
9952
+ this.dispose = () => {
9953
+ if (this.pendingUpdate) {
9954
+ clearTimeout(this.pendingUpdate);
9955
+ this.pendingUpdate = null;
9681
9956
  }
9682
9957
  };
9683
- this.setVideoTrackSubscriptionOverrides = (override, sessionIds) => {
9684
- this.tracer.trace('setVideoTrackSubscriptionOverrides', [
9685
- override,
9686
- sessionIds,
9687
- ]);
9958
+ /**
9959
+ * Sets video-subscription overrides. Called by
9960
+ * `Call.setIncomingVideoEnabled` and
9961
+ * `Call.setPreferredIncomingVideoResolution`.
9962
+ *
9963
+ * - `sessionIds` omitted → applies `override` globally (or clears the
9964
+ * global override if `override` is `undefined`).
9965
+ * - `sessionIds` provided → applies `override` to each listed session.
9966
+ */
9967
+ this.setOverrides = (override, sessionIds) => {
9968
+ this.tracer.trace('setOverrides', [override, sessionIds]);
9688
9969
  if (!sessionIds) {
9689
- return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, override ? { [globalOverrideKey]: override } : {});
9970
+ return setCurrentValue(this.overridesSubject, override ? { [globalOverrideKey]: override } : {});
9690
9971
  }
9691
- return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, (overrides) => ({
9972
+ return setCurrentValue(this.overridesSubject, (overrides) => ({
9692
9973
  ...overrides,
9693
9974
  ...Object.fromEntries(sessionIds.map((id) => [id, override])),
9694
9975
  }));
9695
9976
  };
9696
- this.applyTrackSubscriptions = (debounceType = DebounceType.SLOW) => {
9697
- if (this.pendingSubscriptionsUpdate) {
9698
- clearTimeout(this.pendingSubscriptionsUpdate);
9977
+ /**
9978
+ * Pushes `subscriptions` to the SFU. Debounced by `debounceType`
9979
+ * (SLOW by default). Multiple rapid calls coalesce into one RPC.
9980
+ * Passing `0` fires synchronously.
9981
+ */
9982
+ this.apply = (debounceType = DebounceType.SLOW) => {
9983
+ if (this.pendingUpdate) {
9984
+ clearTimeout(this.pendingUpdate);
9699
9985
  }
9700
9986
  const updateSubscriptions = () => {
9701
- this.pendingSubscriptionsUpdate = null;
9987
+ this.pendingUpdate = null;
9702
9988
  this.sfuClient
9703
- ?.updateSubscriptions(this.trackSubscriptions)
9989
+ ?.updateSubscriptions(this.subscriptions)
9704
9990
  .catch((err) => {
9705
9991
  this.logger.debug(`Failed to update track subscriptions`, err);
9706
9992
  });
9707
9993
  };
9708
9994
  if (debounceType) {
9709
- this.pendingSubscriptionsUpdate = setTimeout(updateSubscriptions, debounceType);
9995
+ this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
9710
9996
  }
9711
9997
  else {
9712
9998
  updateSubscriptions();
9713
9999
  }
9714
10000
  };
9715
- /**
9716
- * Will begin tracking the given element for visibility changes within the
9717
- * configured viewport element (`call.setViewport`).
9718
- *
9719
- * @param element the element to track.
9720
- * @param sessionId the session id.
9721
- * @param trackType the kind of video.
9722
- * @returns Untrack.
9723
- */
9724
- this.trackElementVisibility = (element, sessionId, trackType) => {
9725
- const cleanup = this.viewportTracker.observe(element, (entry) => {
9726
- this.callState.updateParticipant(sessionId, (participant) => {
9727
- const previousVisibilityState = participant.viewportVisibilityState ??
9728
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
9729
- // observer triggers when the element is "moved" to be a fullscreen element
9730
- // keep it VISIBLE if that happens to prevent fullscreen with placeholder
9731
- const isVisible = entry.isIntersecting || document.fullscreenElement === element
9732
- ? VisibilityState.VISIBLE
9733
- : VisibilityState.INVISIBLE;
9734
- return {
9735
- ...participant,
9736
- viewportVisibilityState: {
9737
- ...previousVisibilityState,
9738
- [trackType]: isVisible,
9739
- },
9740
- };
10001
+ this.tracer = tracer;
10002
+ this.callState = callState;
10003
+ }
10004
+ /**
10005
+ * The current SFU subscription list, computed from `CallState`
10006
+ * participants and the override state. Used by:
10007
+ *
10008
+ * - `apply()` to push to the SFU each time the set changes.
10009
+ * - `Call.getReconnectDetails` to include the subscription list in
10010
+ * the reconnect payload.
10011
+ */
10012
+ get subscriptions() {
10013
+ const subscriptions = [];
10014
+ // Use getParticipantsSnapshot() to bypass the observable pipeline
10015
+ // and avoid stale data caused by shareReplay with no active subscribers
10016
+ const participants = this.callState.getParticipantsSnapshot();
10017
+ const overrides = this.overridesSubject.getValue();
10018
+ for (const p of participants) {
10019
+ if (p.isLocalParticipant)
10020
+ continue;
10021
+ // NOTE: audio tracks don't have to be requested explicitly
10022
+ // as the SFU will implicitly subscribe us to all of them,
10023
+ // once they become available.
10024
+ if (p.videoDimension && hasVideo(p)) {
10025
+ const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
10026
+ if (override?.enabled !== false) {
10027
+ subscriptions.push({
10028
+ userId: p.userId,
10029
+ sessionId: p.sessionId,
10030
+ trackType: TrackType.VIDEO,
10031
+ dimension: override?.dimension ?? p.videoDimension,
10032
+ });
10033
+ }
10034
+ }
10035
+ if (p.screenShareDimension && hasScreenShare(p)) {
10036
+ subscriptions.push({
10037
+ userId: p.userId,
10038
+ sessionId: p.sessionId,
10039
+ trackType: TrackType.SCREEN_SHARE,
10040
+ dimension: p.screenShareDimension,
10041
+ });
10042
+ }
10043
+ if (hasScreenShareAudio(p)) {
10044
+ subscriptions.push({
10045
+ userId: p.userId,
10046
+ sessionId: p.sessionId,
10047
+ trackType: TrackType.SCREEN_SHARE_AUDIO,
9741
10048
  });
10049
+ }
10050
+ }
10051
+ return subscriptions;
10052
+ }
10053
+ get overrides() {
10054
+ return getCurrentValue(this.overrides$);
10055
+ }
10056
+ }
10057
+
10058
+ /**
10059
+ * Watches a single audio or video element and attempts to recover playback
10060
+ * after the element transitions to a paused or suspended state unexpectedly.
10061
+ */
10062
+ class MediaPlaybackWatchdog {
10063
+ constructor(opts) {
10064
+ this.logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
10065
+ this.controller = new AbortController();
10066
+ this.attempt = 0;
10067
+ this.disposed = false;
10068
+ this.attach = () => {
10069
+ if (this.disposed)
10070
+ return;
10071
+ const { signal } = this.controller;
10072
+ this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
10073
+ this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
10074
+ this.element.addEventListener('playing', this.onPlaying, { signal });
10075
+ };
10076
+ this.dispose = () => {
10077
+ if (this.disposed)
10078
+ return;
10079
+ this.disposed = true;
10080
+ this.controller.abort();
10081
+ if (this.pendingTimer)
10082
+ clearTimeout(this.pendingTimer);
10083
+ this.pendingTimer = undefined;
10084
+ };
10085
+ this.onPlaying = () => {
10086
+ if (this.attempt > 0) {
10087
+ this.tracer.trace('mediaPlayback.recover.success', {
10088
+ kind: this.kind,
10089
+ attempts: this.attempt,
10090
+ });
10091
+ }
10092
+ this.attempt = 0;
10093
+ if (this.pendingTimer)
10094
+ clearTimeout(this.pendingTimer);
10095
+ this.pendingTimer = undefined;
10096
+ };
10097
+ this.onPauseOrSuspend = (event) => {
10098
+ if (this.disposed)
10099
+ return;
10100
+ this.tracer.trace('mediaPlayback.paused', {
10101
+ kind: this.kind,
10102
+ reason: event.type,
9742
10103
  });
9743
- return () => {
9744
- cleanup();
9745
- // reset visibility state to UNKNOWN upon cleanup
9746
- // so that the layouts that are not actively observed
9747
- // can still function normally (runtime layout switching)
9748
- this.callState.updateParticipant(sessionId, (participant) => {
9749
- const previousVisibilityState = participant.viewportVisibilityState ??
9750
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
9751
- return {
9752
- ...participant,
9753
- viewportVisibilityState: {
9754
- ...previousVisibilityState,
9755
- [trackType]: VisibilityState.UNKNOWN,
9756
- },
9757
- };
10104
+ this.scheduleRecovery();
10105
+ };
10106
+ this.scheduleRecovery = () => {
10107
+ if (this.disposed || this.pendingTimer)
10108
+ return;
10109
+ const skipReason = this.computeSkipReason();
10110
+ if (skipReason) {
10111
+ this.tracer.trace('mediaPlayback.recover.skipped', {
10112
+ kind: this.kind,
10113
+ reason: skipReason,
9758
10114
  });
9759
- };
10115
+ return;
10116
+ }
10117
+ const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
10118
+ this.pendingTimer = setTimeout(this.attemptPlay, delay);
10119
+ };
10120
+ this.computeSkipReason = () => {
10121
+ if (this.disposed)
10122
+ return 'disposed';
10123
+ if (!this.element.srcObject)
10124
+ return 'noSrc';
10125
+ if (this.element.ended)
10126
+ return 'ended';
10127
+ if (this.isBlocked())
10128
+ return 'blocked';
10129
+ const HAVE_CURRENT_DATA = 2;
10130
+ if (this.element.readyState < HAVE_CURRENT_DATA)
10131
+ return 'notReady';
10132
+ if (!this.element.paused)
10133
+ return 'notPaused';
10134
+ };
10135
+ this.attemptPlay = async () => {
10136
+ this.pendingTimer = undefined;
10137
+ if (this.disposed)
10138
+ return;
10139
+ this.attempt += 1;
10140
+ this.tracer.trace('mediaPlayback.recover.attempt', {
10141
+ kind: this.kind,
10142
+ attempt: this.attempt,
10143
+ });
10144
+ try {
10145
+ await timeboxed([this.element.play()], 2000);
10146
+ }
10147
+ catch (err) {
10148
+ if (this.disposed)
10149
+ return;
10150
+ this.logger.warn(`Failed to recover ${this.kind} playback`, err);
10151
+ if (this.attempt >= 10) {
10152
+ this.tracer.trace('mediaPlayback.recover.giveUp', {
10153
+ kind: this.kind,
10154
+ attempts: this.attempt,
10155
+ });
10156
+ return;
10157
+ }
10158
+ this.scheduleRecovery();
10159
+ }
9760
10160
  };
10161
+ this.element = opts.element;
10162
+ this.kind = opts.kind;
10163
+ this.tracer = opts.tracer;
10164
+ this.isBlocked = opts.isBlocked ?? (() => false);
10165
+ this.attach();
10166
+ }
10167
+ }
10168
+
10169
+ /**
10170
+ * A manager class that handles dynascale related tasks like:
10171
+ *
10172
+ * - binding video elements to session ids
10173
+ * - binding audio elements to session ids
10174
+ */
10175
+ class DynascaleManager {
10176
+ /**
10177
+ * Creates a new DynascaleManager instance.
10178
+ */
10179
+ constructor(callState, speaker, tracer, trackSubscriptionManager, blockedAudioTracker) {
10180
+ this.logger = videoLoggerSystem.getLogger('DynascaleManager');
10181
+ this.useWebAudio = false;
9761
10182
  /**
9762
- * Sets the viewport element to track bound video elements for visibility.
9763
- *
9764
- * @param element the viewport element.
10183
+ * Closes the audio context if it was created.
9765
10184
  */
9766
- this.setViewport = (element) => {
9767
- return this.viewportTracker.setViewport(element);
10185
+ this.dispose = async () => {
10186
+ const context = this.audioContext;
10187
+ if (context && context.state !== 'closed') {
10188
+ document.removeEventListener('click', this.resumeAudioContext);
10189
+ await context.close();
10190
+ this.audioContext = undefined;
10191
+ }
9768
10192
  };
9769
10193
  /**
9770
10194
  * Sets whether to use WebAudio API for audio playback.
@@ -9809,7 +10233,7 @@ class DynascaleManager {
9809
10233
  this.callState.updateParticipantTracks(trackType, {
9810
10234
  [sessionId]: { dimension },
9811
10235
  });
9812
- this.applyTrackSubscriptions(debounceType);
10236
+ this.trackSubscriptionManager.apply(debounceType);
9813
10237
  };
9814
10238
  const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((participant) => !!participant), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
9815
10239
  /**
@@ -9898,6 +10322,11 @@ class DynascaleManager {
9898
10322
  // without prior user interaction:
9899
10323
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
9900
10324
  videoElement.muted = true;
10325
+ const playbackWatchdog = new MediaPlaybackWatchdog({
10326
+ element: videoElement,
10327
+ kind: 'video',
10328
+ tracer: this.tracer,
10329
+ });
9901
10330
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
9902
10331
  const streamSubscription = participant$
9903
10332
  .pipe(distinctUntilKeyChanged(trackKey))
@@ -9907,14 +10336,14 @@ class DynascaleManager {
9907
10336
  return;
9908
10337
  videoElement.srcObject = source ?? null;
9909
10338
  if (isSafari() || isFirefox()) {
9910
- setTimeout(() => {
10339
+ setTimeout(async () => {
9911
10340
  videoElement.srcObject = source ?? null;
9912
- videoElement.play().catch((e) => {
10341
+ try {
10342
+ await timeboxed([videoElement.play()], 2000);
10343
+ }
10344
+ catch (e) {
9913
10345
  this.logger.warn(`Failed to play stream`, e);
9914
- });
9915
- // we add extra delay until we attempt to force-play
9916
- // the participant's media stream in Firefox and Safari,
9917
- // as they seem to have some timing issues
10346
+ }
9918
10347
  }, 25);
9919
10348
  }
9920
10349
  });
@@ -9924,6 +10353,7 @@ class DynascaleManager {
9924
10353
  publishedTracksSubscription?.unsubscribe();
9925
10354
  streamSubscription.unsubscribe();
9926
10355
  resizeObserver?.disconnect();
10356
+ playbackWatchdog.dispose();
9927
10357
  };
9928
10358
  };
9929
10359
  /**
@@ -9941,7 +10371,6 @@ class DynascaleManager {
9941
10371
  const participant = this.callState.findParticipantBySessionId(sessionId);
9942
10372
  if (!participant || participant.isLocalParticipant)
9943
10373
  return;
9944
- this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
9945
10374
  const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
9946
10375
  const updateSinkId = (deviceId, audioContext) => {
9947
10376
  if (!deviceId)
@@ -9960,6 +10389,7 @@ class DynascaleManager {
9960
10389
  };
9961
10390
  let sourceNode = undefined;
9962
10391
  let gainNode = undefined;
10392
+ let audioWatchdog = undefined;
9963
10393
  const isAudioTrack = trackType === 'audioTrack';
9964
10394
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
9965
10395
  const updateMediaStreamSubscription = participant$
@@ -9970,8 +10400,10 @@ class DynascaleManager {
9970
10400
  return;
9971
10401
  setTimeout(() => {
9972
10402
  audioElement.srcObject = source ?? null;
10403
+ audioWatchdog?.dispose();
10404
+ audioWatchdog = undefined;
9973
10405
  if (!source) {
9974
- this.removeBlockedAudioElement(audioElement);
10406
+ this.blockedAudioTracker.markBlocked(audioElement, false);
9975
10407
  return;
9976
10408
  }
9977
10409
  // Safari has a special quirk that prevents playing audio until the user
@@ -9999,10 +10431,16 @@ class DynascaleManager {
9999
10431
  this.tracer.trace('audioPlaybackError', e.message);
10000
10432
  if (e.name === 'NotAllowedError') {
10001
10433
  this.tracer.trace('audioPlaybackBlocked', null);
10002
- this.addBlockedAudioElement(audioElement);
10434
+ this.blockedAudioTracker.markBlocked(audioElement, true);
10003
10435
  }
10004
10436
  this.logger.warn(`Failed to play audio stream`, e);
10005
10437
  });
10438
+ audioWatchdog = new MediaPlaybackWatchdog({
10439
+ element: audioElement,
10440
+ kind: 'audio',
10441
+ tracer: this.tracer,
10442
+ isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
10443
+ });
10006
10444
  }
10007
10445
  const { selectedDevice } = this.speaker.state;
10008
10446
  if (selectedDevice)
@@ -10026,38 +10464,17 @@ class DynascaleManager {
10026
10464
  });
10027
10465
  audioElement.autoplay = true;
10028
10466
  return () => {
10029
- this.audioBindingsWatchdog?.unregister(sessionId, trackType);
10030
- this.removeBlockedAudioElement(audioElement);
10467
+ this.blockedAudioTracker.markBlocked(audioElement, false);
10031
10468
  sinkIdSubscription?.unsubscribe();
10032
10469
  volumeSubscription.unsubscribe();
10033
10470
  updateMediaStreamSubscription.unsubscribe();
10034
10471
  audioElement.srcObject = null;
10035
10472
  sourceNode?.disconnect();
10036
10473
  gainNode?.disconnect();
10474
+ audioWatchdog?.dispose();
10475
+ audioWatchdog = undefined;
10037
10476
  };
10038
10477
  };
10039
- /**
10040
- * Plays all audio elements blocked by the browser's autoplay policy.
10041
- * Must be called from within a user gesture (e.g., click handler).
10042
- *
10043
- * @returns a promise that resolves when all blocked elements have been retried.
10044
- */
10045
- this.resumeAudio = async () => {
10046
- this.tracer.trace('resumeAudio', null);
10047
- const blocked = new Set();
10048
- await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
10049
- try {
10050
- if (el.srcObject) {
10051
- await el.play();
10052
- }
10053
- }
10054
- catch {
10055
- this.logger.warn(`Can't resume audio for element: `, el);
10056
- blocked.add(el);
10057
- }
10058
- }));
10059
- setCurrentValue(this.blockedAudioElementsSubject, blocked);
10060
- };
10061
10478
  this.getOrCreateAudioContext = () => {
10062
10479
  if (!this.useWebAudio)
10063
10480
  return;
@@ -10110,57 +10527,124 @@ class DynascaleManager {
10110
10527
  this.callState = callState;
10111
10528
  this.speaker = speaker;
10112
10529
  this.tracer = tracer;
10113
- if (!isReactNative()) {
10114
- this.audioBindingsWatchdog = new AudioBindingsWatchdog(callState, tracer);
10115
- }
10116
- }
10117
- setSfuClient(sfuClient) {
10118
- this.sfuClient = sfuClient;
10530
+ this.trackSubscriptionManager = trackSubscriptionManager;
10531
+ this.blockedAudioTracker = blockedAudioTracker;
10119
10532
  }
10120
- get trackSubscriptions() {
10121
- const subscriptions = [];
10122
- // Use getParticipantsSnapshot() to bypass the observable pipeline
10123
- // and avoid stale data caused by shareReplay with no active subscribers
10124
- const participants = this.callState.getParticipantsSnapshot();
10125
- const videoTrackSubscriptionOverrides = this.videoTrackSubscriptionOverridesSubject.getValue();
10126
- for (const p of participants) {
10127
- if (p.isLocalParticipant)
10128
- continue;
10129
- // NOTE: audio tracks don't have to be requested explicitly
10130
- // as the SFU will implicitly subscribe us to all of them,
10131
- // once they become available.
10132
- if (p.videoDimension && hasVideo(p)) {
10133
- const override = videoTrackSubscriptionOverrides[p.sessionId] ??
10134
- videoTrackSubscriptionOverrides[globalOverrideKey];
10135
- if (override?.enabled !== false) {
10136
- subscriptions.push({
10137
- userId: p.userId,
10138
- sessionId: p.sessionId,
10139
- trackType: TrackType.VIDEO,
10140
- dimension: override?.dimension ?? p.videoDimension,
10141
- });
10142
- }
10143
- }
10144
- if (p.screenShareDimension && hasScreenShare(p)) {
10145
- subscriptions.push({
10146
- userId: p.userId,
10147
- sessionId: p.sessionId,
10148
- trackType: TrackType.SCREEN_SHARE,
10149
- dimension: p.screenShareDimension,
10533
+ }
10534
+
10535
+ const DEFAULT_THRESHOLD = 0.35;
10536
+ const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
10537
+ videoTrack: VisibilityState.UNKNOWN,
10538
+ screenShareTrack: VisibilityState.UNKNOWN,
10539
+ };
10540
+ class ViewportTracker {
10541
+ constructor(callState) {
10542
+ this.elementHandlerMap = new Map();
10543
+ this.observer = null;
10544
+ // in React children render before viewport is set, add
10545
+ // them to the queue and observe them once the observer is ready
10546
+ this.queueSet = new Set();
10547
+ /**
10548
+ * Method to set scrollable viewport as root for the IntersectionObserver, returns
10549
+ * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
10550
+ */
10551
+ this.setViewport = (viewportElement, options) => {
10552
+ const cleanup = () => {
10553
+ this.observer?.disconnect();
10554
+ this.observer = null;
10555
+ this.elementHandlerMap.clear();
10556
+ };
10557
+ this.observer = new IntersectionObserver((entries) => {
10558
+ entries.forEach((entry) => {
10559
+ const handler = this.elementHandlerMap.get(entry.target);
10560
+ handler?.(entry);
10150
10561
  });
10151
- }
10152
- if (hasScreenShareAudio(p)) {
10153
- subscriptions.push({
10154
- userId: p.userId,
10155
- sessionId: p.sessionId,
10156
- trackType: TrackType.SCREEN_SHARE_AUDIO,
10562
+ }, {
10563
+ root: viewportElement,
10564
+ ...options,
10565
+ threshold: options?.threshold ?? DEFAULT_THRESHOLD,
10566
+ });
10567
+ if (this.queueSet.size) {
10568
+ this.queueSet.forEach(([queueElement, queueHandler]) => {
10569
+ // check if element which requested observation is
10570
+ // a child of a viewport element, skip if isn't
10571
+ if (!viewportElement.contains(queueElement))
10572
+ return;
10573
+ this.observer.observe(queueElement);
10574
+ this.elementHandlerMap.set(queueElement, queueHandler);
10157
10575
  });
10576
+ this.queueSet.clear();
10158
10577
  }
10159
- }
10160
- return subscriptions;
10161
- }
10162
- get videoTrackSubscriptionOverrides() {
10163
- return getCurrentValue(this.videoTrackSubscriptionOverrides$);
10578
+ return cleanup;
10579
+ };
10580
+ /**
10581
+ * Method to set element to observe and handler to be triggered whenever IntersectionObserver
10582
+ * detects a possible change in element's visibility within specified viewport, returns
10583
+ * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
10584
+ */
10585
+ this.observe = (element, handler) => {
10586
+ const queueItem = [element, handler];
10587
+ const cleanup = () => {
10588
+ this.elementHandlerMap.delete(element);
10589
+ this.observer?.unobserve(element);
10590
+ this.queueSet.delete(queueItem);
10591
+ };
10592
+ if (this.elementHandlerMap.has(element))
10593
+ return cleanup;
10594
+ if (!this.observer) {
10595
+ this.queueSet.add(queueItem);
10596
+ return cleanup;
10597
+ }
10598
+ if (this.observer.root.contains(element)) {
10599
+ this.elementHandlerMap.set(element, handler);
10600
+ this.observer.observe(element);
10601
+ }
10602
+ return cleanup;
10603
+ };
10604
+ /**
10605
+ * Tracks the given element for visibility changes and mirrors the result
10606
+ * into `participant.viewportVisibilityState[trackType]` in `CallState`.
10607
+ * Returns a function that unobserves the element and resets the visibility
10608
+ * state back to `UNKNOWN`.
10609
+ */
10610
+ this.trackElementVisibility = (element, sessionId, trackType) => {
10611
+ const cleanup = this.observe(element, (entry) => {
10612
+ this.callState.updateParticipant(sessionId, (participant) => {
10613
+ const previousVisibilityState = participant.viewportVisibilityState ??
10614
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
10615
+ // observer triggers when the element is "moved" to be a fullscreen element
10616
+ // keep it VISIBLE if that happens to prevent fullscreen with placeholder
10617
+ const isVisible = entry.isIntersecting || document.fullscreenElement === element
10618
+ ? VisibilityState.VISIBLE
10619
+ : VisibilityState.INVISIBLE;
10620
+ return {
10621
+ ...participant,
10622
+ viewportVisibilityState: {
10623
+ ...previousVisibilityState,
10624
+ [trackType]: isVisible,
10625
+ },
10626
+ };
10627
+ });
10628
+ });
10629
+ return () => {
10630
+ cleanup();
10631
+ // reset visibility state to UNKNOWN upon cleanup
10632
+ // so that the layouts that are not actively observed
10633
+ // can still function normally (runtime layout switching)
10634
+ this.callState.updateParticipant(sessionId, (participant) => {
10635
+ const previousVisibilityState = participant.viewportVisibilityState ??
10636
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
10637
+ return {
10638
+ ...participant,
10639
+ viewportVisibilityState: {
10640
+ ...previousVisibilityState,
10641
+ [trackType]: VisibilityState.UNKNOWN,
10642
+ },
10643
+ };
10644
+ });
10645
+ };
10646
+ };
10647
+ this.callState = callState;
10164
10648
  }
10165
10649
  }
10166
10650
 
@@ -10324,6 +10808,50 @@ const CallTypes = new CallTypesRegistry([
10324
10808
  }),
10325
10809
  ]);
10326
10810
 
10811
+ /**
10812
+ * A generic sliding-window rate limiter.
10813
+ *
10814
+ * Allows at most `maxAttempts` registrations inside a rolling `windowMs`.
10815
+ * Attempts spaced further apart than `windowMs` are always allowed.
10816
+ */
10817
+ class SlidingWindowRateLimiter {
10818
+ constructor(maxAttempts, windowMs) {
10819
+ this.timestamps = [];
10820
+ /**
10821
+ * Attempts to register a new event at `now`. Returns `true` if the attempt
10822
+ * fits inside the budget (and records it), or `false` if the budget is
10823
+ * exhausted (in which case no timestamp is recorded).
10824
+ */
10825
+ this.tryRegister = (now = Date.now()) => {
10826
+ this.prune(now);
10827
+ if (this.timestamps.length >= this.maxAttempts)
10828
+ return false;
10829
+ this.timestamps.push(now);
10830
+ return true;
10831
+ };
10832
+ /**
10833
+ * Clears the attempt history.
10834
+ */
10835
+ this.reset = () => {
10836
+ this.timestamps = [];
10837
+ };
10838
+ /**
10839
+ * Updates the budget and window size. Existing timestamps are kept; they
10840
+ * will be pruned by the next `tryRegister` call.
10841
+ */
10842
+ this.setLimits = (maxAttempts, windowMs) => {
10843
+ this.maxAttempts = maxAttempts;
10844
+ this.windowMs = windowMs;
10845
+ };
10846
+ this.prune = (now) => {
10847
+ const cutoff = now - this.windowMs;
10848
+ this.timestamps = this.timestamps.filter((t) => t >= cutoff);
10849
+ };
10850
+ this.maxAttempts = maxAttempts;
10851
+ this.windowMs = windowMs;
10852
+ }
10853
+ }
10854
+
10327
10855
  /**
10328
10856
  * Deactivates MediaStream (stops and removes tracks) to be later garbage collected
10329
10857
  *
@@ -10705,7 +11233,6 @@ const getScreenShareStream = async (options, tracer) => {
10705
11233
  const tag = `navigator.mediaDevices.getDisplayMedia.${getDisplayMediaExecId++}.`;
10706
11234
  try {
10707
11235
  const constraints = {
10708
- // @ts-expect-error - not present in types yet
10709
11236
  systemAudio: 'include',
10710
11237
  ...options,
10711
11238
  video: typeof options?.video === 'boolean'
@@ -10720,6 +11247,8 @@ const getScreenShareStream = async (options, tracer) => {
10720
11247
  ? options.audio
10721
11248
  : {
10722
11249
  channelCount: { ideal: 2 },
11250
+ // @ts-expect-error not yet present in the types
11251
+ restrictOwnAudio: true,
10723
11252
  echoCancellation: false,
10724
11253
  autoGainControl: false,
10725
11254
  noiseSuppression: false,
@@ -10833,6 +11362,7 @@ class DeviceManager {
10833
11362
  */
10834
11363
  this.stopOnLeave = true;
10835
11364
  this.subscriptions = [];
11365
+ this.currentStreamCleanups = [];
10836
11366
  this.areSubscriptionsSetUp = false;
10837
11367
  this.isTrackStoppedDueToTrackEnd = false;
10838
11368
  this.filters = [];
@@ -10844,10 +11374,30 @@ class DeviceManager {
10844
11374
  * @internal
10845
11375
  */
10846
11376
  this.dispose = () => {
11377
+ this.runCurrentStreamCleanups();
10847
11378
  this.subscriptions.forEach((s) => s());
10848
11379
  this.subscriptions = [];
10849
11380
  this.areSubscriptionsSetUp = false;
10850
11381
  };
11382
+ this.runCurrentStreamCleanups = () => {
11383
+ this.currentStreamCleanups.forEach((c) => c());
11384
+ this.currentStreamCleanups = [];
11385
+ };
11386
+ this.setLocalInterrupted = (interrupted) => {
11387
+ const localParticipant = this.call.state.localParticipant;
11388
+ if (!localParticipant)
11389
+ return;
11390
+ this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
11391
+ const current = p.interruptedTracks ?? [];
11392
+ const has = current.includes(this.trackType);
11393
+ if (interrupted === has)
11394
+ return {};
11395
+ const next = interrupted
11396
+ ? pushToIfMissing([...current], this.trackType)
11397
+ : removeFromIfPresent([...current], this.trackType);
11398
+ return { interruptedTracks: next };
11399
+ });
11400
+ };
10851
11401
  this.call = call;
10852
11402
  this.state = state;
10853
11403
  this.trackType = trackType;
@@ -11071,7 +11621,9 @@ class DeviceManager {
11071
11621
  // @ts-expect-error called to dispose the stream in RN
11072
11622
  mediaStream.release();
11073
11623
  }
11624
+ this.runCurrentStreamCleanups();
11074
11625
  this.state.setMediaStream(undefined, undefined);
11626
+ this.setLocalInterrupted(false);
11075
11627
  this.filters.forEach((entry) => entry.stop?.());
11076
11628
  }
11077
11629
  }
@@ -11107,13 +11659,17 @@ class DeviceManager {
11107
11659
  async unmuteStream() {
11108
11660
  this.logger.debug('Starting stream');
11109
11661
  let stream;
11110
- let rootStream;
11662
+ let rootStreamPromise;
11111
11663
  if (this.state.mediaStream &&
11112
11664
  this.getTracks().every((t) => t.readyState === 'live')) {
11113
11665
  stream = this.state.mediaStream;
11114
11666
  this.enableTracks();
11115
11667
  }
11116
11668
  else {
11669
+ // We are about to compose a fresh filter chain and acquire a new
11670
+ // root stream. Drop any listeners bound to the previous root stream
11671
+ // before chainWith below registers new ones for the new chain.
11672
+ this.runCurrentStreamCleanups();
11117
11673
  const defaultConstraints = this.state.defaultConstraints;
11118
11674
  const constraints = {
11119
11675
  ...defaultConstraints,
@@ -11169,7 +11725,7 @@ class DeviceManager {
11169
11725
  });
11170
11726
  };
11171
11727
  parentTrack.addEventListener('ended', handleParentTrackEnded);
11172
- this.subscriptions.push(() => {
11728
+ this.currentStreamCleanups.push(() => {
11173
11729
  parentTrack.removeEventListener('ended', handleParentTrackEnded);
11174
11730
  });
11175
11731
  });
@@ -11177,7 +11733,7 @@ class DeviceManager {
11177
11733
  };
11178
11734
  // the rootStream represents the stream coming from the actual device
11179
11735
  // e.g. camera or microphone stream
11180
- rootStream = this.getStream(constraints);
11736
+ rootStreamPromise = this.getStream(constraints);
11181
11737
  // we publish the last MediaStream of the chain
11182
11738
  stream = await this.filters.reduce((parent, entry) => parent
11183
11739
  .then((inputStream) => {
@@ -11187,43 +11743,71 @@ class DeviceManager {
11187
11743
  })
11188
11744
  .then(chainWith(parent), (error) => {
11189
11745
  this.logger.warn('Filter failed to start and will be ignored', error);
11190
- return parent;
11191
- }), rootStream);
11192
- }
11193
- if (this.call.state.callingState === CallingState.JOINED) {
11194
- await this.publishStream(stream);
11195
- }
11196
- if (this.state.mediaStream !== stream) {
11197
- this.state.setMediaStream(stream, await rootStream);
11198
- const handleTrackEnded = async () => {
11199
- await this.statusChangeSettled();
11200
- if (this.enabled) {
11201
- this.isTrackStoppedDueToTrackEnd = true;
11202
- setTimeout(() => {
11203
- this.isTrackStoppedDueToTrackEnd = false;
11204
- }, 2000);
11205
- await this.disable();
11206
- }
11207
- };
11208
- const createTrackMuteHandler = (muted) => () => {
11209
- if (!isMobile() || this.trackType !== TrackType.VIDEO)
11210
- return;
11211
- this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
11212
- this.logger.warn('Error while notifying track mute state', err);
11213
- });
11214
- };
11215
- stream.getTracks().forEach((track) => {
11216
- const muteHandler = createTrackMuteHandler(true);
11217
- const unmuteHandler = createTrackMuteHandler(false);
11218
- track.addEventListener('mute', muteHandler);
11219
- track.addEventListener('unmute', unmuteHandler);
11220
- track.addEventListener('ended', handleTrackEnded);
11221
- this.subscriptions.push(() => {
11222
- track.removeEventListener('mute', muteHandler);
11223
- track.removeEventListener('unmute', unmuteHandler);
11224
- track.removeEventListener('ended', handleTrackEnded);
11746
+ return parent;
11747
+ }), rootStreamPromise);
11748
+ }
11749
+ if (this.call.state.callingState === CallingState.JOINED) {
11750
+ await this.publishStream(stream);
11751
+ }
11752
+ if (this.state.mediaStream !== stream) {
11753
+ const rootStream = await rootStreamPromise;
11754
+ this.state.setMediaStream(stream, rootStream);
11755
+ if (rootStream) {
11756
+ const handleTrackEnded = async () => {
11757
+ this.setLocalInterrupted(false);
11758
+ await this.statusChangeSettled();
11759
+ if (this.enabled) {
11760
+ this.isTrackStoppedDueToTrackEnd = true;
11761
+ setTimeout(() => {
11762
+ this.isTrackStoppedDueToTrackEnd = false;
11763
+ }, 2000);
11764
+ await this.disable();
11765
+ }
11766
+ };
11767
+ const createTrackMuteHandler = (muted) => () => {
11768
+ this.setLocalInterrupted(muted);
11769
+ // WebKit's RTCRtpSender encoder can stay stalled after an iOS /
11770
+ // macOS audio session interruption even though the track is
11771
+ // unmuted. Re-arm the sender on every unmute for any WebKit
11772
+ // runtime (Safari + plain iOS WKWebViews). Skipped when the
11773
+ // page is hidden because the encoder won't resume until
11774
+ // foreground anyway.
11775
+ if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
11776
+ this.call.refreshPublishedTrack(this.trackType).catch((err) => {
11777
+ this.logger.warn('Failed to refresh track on system unmute', err);
11778
+ });
11779
+ }
11780
+ // report all tracks on mobile, and only Video on desktop browsers
11781
+ if (isMobile() || this.trackType == TrackType.VIDEO) {
11782
+ this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
11783
+ trackType: TrackType[this.trackType],
11784
+ muted,
11785
+ });
11786
+ this.call
11787
+ .notifyTrackMuteState(muted, this.trackType)
11788
+ .catch((err) => {
11789
+ this.logger.warn('Error while notifying track mute state', err);
11790
+ });
11791
+ }
11792
+ };
11793
+ rootStream.getTracks().forEach((track) => {
11794
+ const muteHandler = createTrackMuteHandler(true);
11795
+ const unmuteHandler = createTrackMuteHandler(false);
11796
+ track.addEventListener('mute', muteHandler);
11797
+ track.addEventListener('unmute', unmuteHandler);
11798
+ track.addEventListener('ended', handleTrackEnded);
11799
+ this.currentStreamCleanups.push(() => {
11800
+ track.removeEventListener('mute', muteHandler);
11801
+ track.removeEventListener('unmute', unmuteHandler);
11802
+ track.removeEventListener('ended', handleTrackEnded);
11803
+ });
11225
11804
  });
11226
- });
11805
+ const initialMuted = rootStream.getTracks().some((t) => t.muted);
11806
+ this.setLocalInterrupted(initialMuted);
11807
+ }
11808
+ else {
11809
+ this.setLocalInterrupted(false);
11810
+ }
11227
11811
  }
11228
11812
  }
11229
11813
  get mediaDeviceKind() {
@@ -11364,13 +11948,19 @@ class DeviceManagerState {
11364
11948
  this.statusSubject = new BehaviorSubject(undefined);
11365
11949
  this.optimisticStatusSubject = new BehaviorSubject(undefined);
11366
11950
  this.mediaStreamSubject = new BehaviorSubject(undefined);
11951
+ this.rootMediaStreamSubject = new BehaviorSubject(undefined);
11367
11952
  this.selectedDeviceSubject = new BehaviorSubject(undefined);
11368
11953
  this.defaultConstraintsSubject = new BehaviorSubject(undefined);
11369
11954
  /**
11370
11955
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
11371
- *
11372
11956
  */
11373
11957
  this.mediaStream$ = this.mediaStreamSubject.asObservable();
11958
+ /**
11959
+ * An Observable that emits the raw device media stream (before any filters are applied),
11960
+ * or `undefined` if the device is currently disabled. When no filters are active, this
11961
+ * emits the same stream as `mediaStream$`.
11962
+ */
11963
+ this.rootMediaStream$ = this.rootMediaStreamSubject.asObservable();
11374
11964
  /**
11375
11965
  * An Observable that emits the currently selected device
11376
11966
  */
@@ -11426,6 +12016,14 @@ class DeviceManagerState {
11426
12016
  get mediaStream() {
11427
12017
  return getCurrentValue(this.mediaStream$);
11428
12018
  }
12019
+ /**
12020
+ * The raw device media stream (before any filters are applied), or `undefined`
12021
+ * if the device is currently disabled. When no filters are active, this is the
12022
+ * same as `mediaStream`.
12023
+ */
12024
+ get rootMediaStream() {
12025
+ return getCurrentValue(this.rootMediaStream$);
12026
+ }
11429
12027
  /**
11430
12028
  * @internal
11431
12029
  * @param status
@@ -11450,6 +12048,7 @@ class DeviceManagerState {
11450
12048
  */
11451
12049
  setMediaStream(stream, rootStream) {
11452
12050
  setCurrentValue(this.mediaStreamSubject, stream);
12051
+ setCurrentValue(this.rootMediaStreamSubject, rootStream);
11453
12052
  if (rootStream) {
11454
12053
  this.setDevice(this.getDeviceIdFromStream(rootStream));
11455
12054
  }
@@ -12701,6 +13300,16 @@ class Call {
12701
13300
  this.fastReconnectDeadlineSeconds = 0;
12702
13301
  this.disconnectionTimeoutSeconds = 0;
12703
13302
  this.lastOfflineTimestamp = 0;
13303
+ // (10 attempts per rolling 120 s window).
13304
+ this.rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
13305
+ // "Network doesn't support WebRTC" detector: counts peer-connection
13306
+ // failures where ICE never reached `connected`/`completed`.
13307
+ this.maxIceFailuresWithoutConnect = 2;
13308
+ this.iceFailuresWithoutConnect = 0;
13309
+ // Consecutive-negotiation-failure detector: stops the reconnect loop when
13310
+ // the SFU keeps failing to negotiate SDP for us.
13311
+ this.maxConsecutiveNegotiationFailures = 3;
13312
+ this.consecutiveNegotiationFailures = 0;
12704
13313
  // maintain the order of publishing tracks to restore them after a reconnection
12705
13314
  // it shouldn't contain duplicates
12706
13315
  this.trackPublishOrder = [];
@@ -12998,11 +13607,26 @@ class Call {
12998
13607
  this.publisher = undefined;
12999
13608
  await this.sfuClient?.leaveAndClose(leaveReason);
13000
13609
  this.sfuClient = undefined;
13001
- this.dynascaleManager.setSfuClient(undefined);
13002
- await this.dynascaleManager.dispose();
13610
+ this.trackSubscriptionManager.setSfuClient(undefined);
13611
+ this.trackSubscriptionManager.dispose();
13612
+ this.audioBindingsWatchdog?.dispose();
13613
+ await this.dynascaleManager?.dispose();
13003
13614
  this.state.setCallingState(CallingState.LEFT);
13004
13615
  this.state.setParticipants([]);
13005
13616
  this.state.dispose();
13617
+ // Reset reconnect-related accumulators so a future `call.join()` on the
13618
+ // same instance starts with a fresh budget. The `Call` may be reused
13619
+ // (see `Call.test.ts` "can reuse call instance") so this is required.
13620
+ // Strategy/reason/attempts must also be cleared: when `leave()` is
13621
+ // reached via `giveUpAndLeave()` the success-path reset at the end of
13622
+ // `joinFlow` never runs, leaving stale values that would make the next
13623
+ // fresh `join()` send a stale `ReconnectDetails` to the SFU.
13624
+ this.rejoinRateLimiter.reset();
13625
+ this.iceFailuresWithoutConnect = 0;
13626
+ this.consecutiveNegotiationFailures = 0;
13627
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
13628
+ this.reconnectReason = '';
13629
+ this.reconnectAttempts = 0;
13006
13630
  // Call all leave call hooks, e.g. to clean up global event handlers
13007
13631
  this.leaveCallHooks.forEach((hook) => hook());
13008
13632
  this.initialized = false;
@@ -13296,7 +13920,7 @@ class Call {
13296
13920
  : previousSfuClient;
13297
13921
  this.sfuClient = sfuClient;
13298
13922
  this.unifiedSessionId ?? (this.unifiedSessionId = sfuClient.sessionId);
13299
- this.dynascaleManager.setSfuClient(sfuClient);
13923
+ this.trackSubscriptionManager.setSfuClient(sfuClient);
13300
13924
  const clientDetails = await getClientDetails();
13301
13925
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
13302
13926
  if (previousSfuClient !== sfuClient) {
@@ -13353,9 +13977,20 @@ class Call {
13353
13977
  // when performing fast reconnect, or when we reuse the same SFU client,
13354
13978
  // (ws remained healthy), we just need to restore the ICE connection
13355
13979
  if (performingFastReconnect) {
13356
- // the SFU automatically issues an ICE restart on the subscriber
13357
- // we don't have to do it ourselves
13358
- await this.restoreICE(sfuClient, { includeSubscriber: false });
13980
+ // The SFU automatically issues an ICE restart on the subscriber,
13981
+ // so we only need to decide about the publisher. If the publisher's
13982
+ // peer connection is still stable (ICE still connected end-to-end),
13983
+ // the signal WebSocket drop was the only problem — the new WS alone
13984
+ // is enough, and restarting ICE would add unnecessary SDP/ICE churn.
13985
+ const publisherIsStable = this.publisher?.isStable() ?? true;
13986
+ const includePublisher = !!this.publisher?.isPublishing() && !publisherIsStable;
13987
+ if (!includePublisher && this.publisher?.isPublishing()) {
13988
+ this.logger.info('[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable');
13989
+ }
13990
+ await this.restoreICE(sfuClient, {
13991
+ includeSubscriber: false,
13992
+ includePublisher,
13993
+ });
13359
13994
  }
13360
13995
  else {
13361
13996
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
@@ -13398,6 +14033,15 @@ class Call {
13398
14033
  // reset the reconnect strategy to unspecified after a successful reconnection
13399
14034
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
13400
14035
  this.reconnectReason = '';
14036
+ // A successful SFU join handshake resets the consecutive-negotiation
14037
+ // counter (negotiation just succeeded). It does NOT reset
14038
+ // `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
14039
+ // those track WebRTC-level health and rejoin frequency, which are not
14040
+ // proven by the SFU handshake alone. ICE-failures-without-connect is
14041
+ // cleared via the `onIceConnected` callback when the peer connection
14042
+ // actually reaches `connected`/`completed` end-to-end. The rejoin
14043
+ // rolling window decays naturally as old timestamps age out.
14044
+ this.consecutiveNegotiationFailures = 0;
13401
14045
  this.logger.info(`Joined call ${this.cid}`);
13402
14046
  };
13403
14047
  /**
@@ -13411,7 +14055,7 @@ class Call {
13411
14055
  return {
13412
14056
  strategy,
13413
14057
  announcedTracks,
13414
- subscriptions: this.dynascaleManager.trackSubscriptions,
14058
+ subscriptions: this.trackSubscriptionManager.subscriptions,
13415
14059
  reconnectAttempt: this.reconnectAttempts,
13416
14060
  fromSfuId: migratingFromSfuId || '',
13417
14061
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
@@ -13515,6 +14159,12 @@ class Call {
13515
14159
  this.logger.warn(message, err);
13516
14160
  });
13517
14161
  },
14162
+ onIceConnected: () => {
14163
+ // ICE has reached `connected`/`completed` end-to-end on at least
14164
+ // one peer connection, WebRTC is actually working, so the
14165
+ // "ICE never connected" failure budget can be cleared.
14166
+ this.iceFailuresWithoutConnect = 0;
14167
+ },
13518
14168
  };
13519
14169
  this.subscriber = new Subscriber(basePeerConnectionOptions);
13520
14170
  // anonymous users can't publish anything hence, there is no need
@@ -13620,7 +14270,9 @@ class Call {
13620
14270
  * @internal
13621
14271
  *
13622
14272
  * @param strategy the reconnection strategy to use.
13623
- * @param reason the reason for the reconnection.
14273
+ * @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
14274
+ * constant when the SDK should react to it (e.g.
14275
+ * `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
13624
14276
  */
13625
14277
  this.reconnect = async (strategy, reason) => {
13626
14278
  if (this.state.callingState === CallingState.RECONNECTING ||
@@ -13641,6 +14293,30 @@ class Call {
13641
14293
  this.state.setCallingState(CallingState.RECONNECTING_FAILED);
13642
14294
  }
13643
14295
  };
14296
+ const giveUpAndLeave = async (message) => {
14297
+ this.logger.warn(`[Reconnect] Giving up: ${message}. Leaving the call.`);
14298
+ // If we're mid-iteration, the state can be JOINING; `Call.leave` would
14299
+ // then wait for JOINED before proceeding, but no more attempts will run
14300
+ // so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
14301
+ if (this.state.callingState === CallingState.JOINING) {
14302
+ this.state.setCallingState(CallingState.RECONNECTING);
14303
+ }
14304
+ try {
14305
+ await this.leave({ message });
14306
+ }
14307
+ catch (err) {
14308
+ this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
14309
+ }
14310
+ };
14311
+ // Count this entry into reconnect if it was triggered by a peer
14312
+ // connection that never reached `connected`/`completed`.
14313
+ if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
14314
+ this.iceFailuresWithoutConnect++;
14315
+ if (this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect) {
14316
+ await giveUpAndLeave('webrtc_unsupported_network');
14317
+ return;
14318
+ }
14319
+ }
13644
14320
  let attempt = 0;
13645
14321
  do {
13646
14322
  const reconnectingTime = Date.now() - reconnectStartTime;
@@ -13651,6 +14327,16 @@ class Call {
13651
14327
  await markAsReconnectingFailed();
13652
14328
  return;
13653
14329
  }
14330
+ // Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
14331
+ // transitions inside a rolling window. FAST is not counted because
14332
+ // it does not issue a new backend `joinCall`.
14333
+ if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
14334
+ this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE) {
14335
+ if (!this.rejoinRateLimiter.tryRegister()) {
14336
+ await giveUpAndLeave('rejoin_attempt_limit_exceeded');
14337
+ return;
14338
+ }
14339
+ }
13654
14340
  // we don't increment reconnect attempts for the FAST strategy.
13655
14341
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
13656
14342
  this.reconnectAttempts++;
@@ -13678,6 +14364,8 @@ class Call {
13678
14364
  ensureExhausted(this.reconnectStrategy, 'Unknown reconnection strategy');
13679
14365
  break;
13680
14366
  }
14367
+ // reconnection worked — reset the negotiation-failure streak.
14368
+ this.consecutiveNegotiationFailures = 0;
13681
14369
  break; // do-while loop, reconnection worked, exit the loop
13682
14370
  }
13683
14371
  catch (error) {
@@ -13692,7 +14380,16 @@ class Call {
13692
14380
  await markAsReconnectingFailed();
13693
14381
  return;
13694
14382
  }
13695
- await sleep(500);
14383
+ if (error instanceof NegotiationError) {
14384
+ this.consecutiveNegotiationFailures++;
14385
+ if (this.consecutiveNegotiationFailures >=
14386
+ this.maxConsecutiveNegotiationFailures) {
14387
+ await giveUpAndLeave('repeated_negotiation_failures');
14388
+ return;
14389
+ }
14390
+ }
14391
+ // exponential backoff with jitter, capped at 5 s
14392
+ await sleep(retryInterval(attempt));
13696
14393
  const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
13697
14394
  const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
13698
14395
  this.fastReconnectDeadlineSeconds;
@@ -13801,7 +14498,7 @@ class Call {
13801
14498
  this.registerReconnectHandlers = () => {
13802
14499
  // handles the legacy "goAway" event
13803
14500
  const unregisterGoAway = this.on('goAway', () => {
13804
- this.reconnect(WebsocketReconnectStrategy.MIGRATE, 'goAway').catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
14501
+ this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
13805
14502
  });
13806
14503
  // handles the "error" event, through which the SFU can request a reconnect
13807
14504
  const unregisterOnError = this.on('error', (e) => {
@@ -13820,7 +14517,7 @@ class Call {
13820
14517
  });
13821
14518
  }
13822
14519
  else {
13823
- this.reconnect(strategy, error?.message || 'SFU Error').catch((err) => {
14520
+ this.reconnect(strategy, error?.message || ReconnectReason.SFU_ERROR).catch((err) => {
13824
14521
  this.logger.warn('[Reconnect] Error reconnecting', err);
13825
14522
  });
13826
14523
  }
@@ -13844,7 +14541,7 @@ class Call {
13844
14541
  strategy = WebsocketReconnectStrategy.REJOIN;
13845
14542
  }
13846
14543
  }
13847
- this.reconnect(strategy, 'Going online').catch((err) => {
14544
+ this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch((err) => {
13848
14545
  this.logger.warn('[Reconnect] Error reconnecting after going online', err);
13849
14546
  });
13850
14547
  });
@@ -13905,7 +14602,7 @@ class Call {
13905
14602
  const { remoteParticipants } = this.state;
13906
14603
  if (remoteParticipants.length <= 0)
13907
14604
  return;
13908
- this.dynascaleManager.applyTrackSubscriptions(undefined);
14605
+ this.trackSubscriptionManager.apply(undefined);
13909
14606
  };
13910
14607
  /**
13911
14608
  * Starts publishing the given video stream to the call.
@@ -13987,10 +14684,12 @@ class Call {
13987
14684
  * @param trackTypes the track types to update the call state with.
13988
14685
  */
13989
14686
  this.updateLocalStreamState = async (mediaStream, ...trackTypes) => {
13990
- if (!this.sfuClient || !this.sfuClient.sessionId)
14687
+ const sessionId = this.sfuClient?.sessionId;
14688
+ if (!sessionId)
13991
14689
  return;
13992
14690
  await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
13993
- const { sessionId } = this.sfuClient;
14691
+ if (this.sfuClient?.sessionId !== sessionId)
14692
+ return;
13994
14693
  for (const trackType of trackTypes) {
13995
14694
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
13996
14695
  if (!streamStateProp)
@@ -14003,6 +14702,20 @@ class Call {
14003
14702
  }));
14004
14703
  }
14005
14704
  };
14705
+ /**
14706
+ * Re-arms the encoder for a currently published track type. Useful for
14707
+ * working around WebKit's stalled sender bug after an iOS audio session
14708
+ * interruption (Siri, PSTN call).
14709
+ *
14710
+ * @internal
14711
+ *
14712
+ * @param trackType the track type to refresh.
14713
+ */
14714
+ this.refreshPublishedTrack = async (trackType) => {
14715
+ if (!this.publisher)
14716
+ return;
14717
+ await this.publisher.refreshTrack(trackType);
14718
+ };
14006
14719
  /**
14007
14720
  * Updates the preferred publishing options
14008
14721
  *
@@ -14664,7 +15377,7 @@ class Call {
14664
15377
  * @param trackType the video mode.
14665
15378
  */
14666
15379
  this.trackElementVisibility = (element, sessionId, trackType) => {
14667
- return this.dynascaleManager.trackElementVisibility(element, sessionId, trackType);
15380
+ return this.viewportTracker?.trackElementVisibility(element, sessionId, trackType);
14668
15381
  };
14669
15382
  /**
14670
15383
  * Sets the viewport element to track bound video elements for visibility.
@@ -14672,7 +15385,7 @@ class Call {
14672
15385
  * @param element the viewport element.
14673
15386
  */
14674
15387
  this.setViewport = (element) => {
14675
- return this.dynascaleManager.setViewport(element);
15388
+ return this.viewportTracker?.setViewport(element);
14676
15389
  };
14677
15390
  /**
14678
15391
  * Binds a DOM <video> element to the given session id.
@@ -14690,7 +15403,7 @@ class Call {
14690
15403
  * @param trackType the kind of video.
14691
15404
  */
14692
15405
  this.bindVideoElement = (videoElement, sessionId, trackType) => {
14693
- const unbind = this.dynascaleManager.bindVideoElement(videoElement, sessionId, trackType);
15406
+ const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
14694
15407
  if (!unbind)
14695
15408
  return;
14696
15409
  this.leaveCallHooks.add(unbind);
@@ -14710,21 +15423,28 @@ class Call {
14710
15423
  * @param trackType the kind of audio.
14711
15424
  */
14712
15425
  this.bindAudioElement = (audioElement, sessionId, trackType = 'audioTrack') => {
14713
- const unbind = this.dynascaleManager.bindAudioElement(audioElement, sessionId, trackType);
15426
+ const unbind = this.dynascaleManager?.bindAudioElement(audioElement, sessionId, trackType);
14714
15427
  if (!unbind)
14715
15428
  return;
14716
- this.leaveCallHooks.add(unbind);
14717
- return () => {
14718
- this.leaveCallHooks.delete(unbind);
15429
+ this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
15430
+ const cleanup = () => {
14719
15431
  unbind();
15432
+ this.audioBindingsWatchdog?.unregister(sessionId, trackType);
15433
+ };
15434
+ this.leaveCallHooks.add(cleanup);
15435
+ return () => {
15436
+ this.leaveCallHooks.delete(cleanup);
15437
+ cleanup();
14720
15438
  };
14721
15439
  };
14722
15440
  /**
14723
15441
  * Plays all audio elements blocked by the browser's autoplay policy.
15442
+ * Must be called from within a user gesture (e.g., click handler).
15443
+ *
15444
+ * Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
15445
+ * gesture is required.
14724
15446
  */
14725
- this.resumeAudio = () => {
14726
- return this.dynascaleManager.resumeAudio();
14727
- };
15447
+ this.resumeAudio = () => this.blockedAudioTracker.resumeAudio();
14728
15448
  /**
14729
15449
  * Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
14730
15450
  *
@@ -14762,21 +15482,21 @@ class Call {
14762
15482
  * preference has effect on. Affects all participants by default.
14763
15483
  */
14764
15484
  this.setPreferredIncomingVideoResolution = (resolution, sessionIds) => {
14765
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(resolution
15485
+ this.trackSubscriptionManager.setOverrides(resolution
14766
15486
  ? {
14767
15487
  enabled: true,
14768
15488
  dimension: resolution,
14769
15489
  }
14770
15490
  : undefined, sessionIds);
14771
- this.dynascaleManager.applyTrackSubscriptions();
15491
+ this.trackSubscriptionManager.apply();
14772
15492
  };
14773
15493
  /**
14774
15494
  * Enables or disables incoming video from all remote call participants,
14775
15495
  * and removes any preference for preferred resolution.
14776
15496
  */
14777
15497
  this.setIncomingVideoEnabled = (enabled) => {
14778
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(enabled ? undefined : { enabled: false });
14779
- this.dynascaleManager.applyTrackSubscriptions();
15498
+ this.trackSubscriptionManager.setOverrides(enabled ? undefined : { enabled: false });
15499
+ this.trackSubscriptionManager.apply();
14780
15500
  };
14781
15501
  /**
14782
15502
  * Sets the maximum amount of time a user can remain waiting for a reconnect
@@ -14786,6 +15506,39 @@ class Call {
14786
15506
  this.setDisconnectionTimeout = (timeoutSeconds) => {
14787
15507
  this.disconnectionTimeoutSeconds = timeoutSeconds;
14788
15508
  };
15509
+ /**
15510
+ * Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
15511
+ * `maxAttempts` rejoins have been registered inside `windowSeconds`, the
15512
+ * SDK stops retrying and transitions the call to `LEFT` with the
15513
+ * `rejoin_attempt_limit_exceeded` leave message.
15514
+ *
15515
+ * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
15516
+ * Both arguments are clamped to a minimum of 1.
15517
+ */
15518
+ this.setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
15519
+ this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
15520
+ };
15521
+ /**
15522
+ * Configures how many peer-connection failures where ICE never reached
15523
+ * `connected`/`completed` are tolerated before the SDK concludes that the
15524
+ * current network cannot support WebRTC and transitions the call to
15525
+ * `LEFT` with the `webrtc_unsupported_network` leave message.
15526
+ *
15527
+ * Default: 2. Clamped to a minimum of 1.
15528
+ */
15529
+ this.setMaxIceFailuresWithoutConnect = (n) => {
15530
+ this.maxIceFailuresWithoutConnect = Math.max(1, n);
15531
+ };
15532
+ /**
15533
+ * Configures how many consecutive SDP `NegotiationError`s are tolerated
15534
+ * before the SDK stops retrying and transitions the call to `LEFT` with
15535
+ * the `repeated_negotiation_failures` leave message.
15536
+ *
15537
+ * Default: 3. Clamped to a minimum of 1.
15538
+ */
15539
+ this.setMaxConsecutiveNegotiationFailures = (n) => {
15540
+ this.maxConsecutiveNegotiationFailures = Math.max(1, n);
15541
+ };
14789
15542
  /**
14790
15543
  * Enables the provided client capabilities.
14791
15544
  */
@@ -14824,7 +15577,13 @@ class Call {
14824
15577
  this.microphone = new MicrophoneManager(this, preferences);
14825
15578
  this.speaker = new SpeakerManager(this, preferences);
14826
15579
  this.screenShare = new ScreenShareManager(this);
14827
- this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer);
15580
+ this.trackSubscriptionManager = new TrackSubscriptionManager(this.state, this.tracer);
15581
+ this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
15582
+ if (typeof document !== 'undefined') {
15583
+ this.audioBindingsWatchdog = new AudioBindingsWatchdog(this.state, this.tracer);
15584
+ this.viewportTracker = new ViewportTracker(this.state);
15585
+ this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer, this.trackSubscriptionManager, this.blockedAudioTracker);
15586
+ }
14828
15587
  }
14829
15588
  /**
14830
15589
  * A flag indicating whether the call is "ringing" type of call.
@@ -14899,12 +15658,118 @@ const APIErrorCodes = {
14899
15658
  */
14900
15659
  class StableWSConnection {
14901
15660
  constructor(client) {
15661
+ /** Incremented when a new WS connection is made */
15662
+ this.wsID = 1;
15663
+ // Connection lifecycle flags.
15664
+ /** We only make 1 attempt to reconnect at the same time.. */
15665
+ this.isConnecting = false;
15666
+ /** To avoid reconnect if client is disconnected */
15667
+ this.isDisconnected = false;
15668
+ /** Boolean that indicates if we have a working connection to the server */
15669
+ this.isHealthy = false;
15670
+ /** Boolean that indicates if the connection promise is resolved */
15671
+ this.isConnectionOpenResolved = false;
15672
+ // Failure counters (drive retry/backoff scheduling).
15673
+ /** consecutive failures influence the duration of the timeout */
15674
+ this.consecutiveFailures = 0;
15675
+ /** keep track of the total number of failures */
15676
+ this.totalFailures = 0;
15677
+ // Health-check pings + connection-staleness check.
15678
+ /** Send a health check message every 25 seconds */
15679
+ this.pingInterval = 25 * 1000;
15680
+ this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
15681
+ /** Store the last event time for health checks */
15682
+ this.lastEvent = null;
14902
15683
  this._log = (msg, extra = {}, level = 'info') => {
14903
15684
  this.client.logger[level](`connection:${msg}`, extra);
14904
15685
  };
14905
15686
  this.setClient = (client) => {
14906
15687
  this.client = client;
14907
15688
  };
15689
+ /**
15690
+ * connect - Connect to the WS URL
15691
+ * the default 15s timeout allows between 2~3 tries
15692
+ * @return Promise that completes once the first health check message is received
15693
+ */
15694
+ this.connect = async (timeout = 15000) => {
15695
+ if (this.isConnecting) {
15696
+ throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
15697
+ }
15698
+ this.isDisconnected = false;
15699
+ try {
15700
+ const healthCheck = await this._connect(timeout);
15701
+ this.consecutiveFailures = 0;
15702
+ this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
15703
+ }
15704
+ catch (caught) {
15705
+ const error = caught;
15706
+ this.isHealthy = false;
15707
+ this.consecutiveFailures += 1;
15708
+ if (error.code === KnownCodes.TOKEN_EXPIRED &&
15709
+ !this.client.tokenManager.isStatic()) {
15710
+ this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
15711
+ this._reconnect({ refreshToken: true });
15712
+ }
15713
+ else if (!error.isWSFailure) {
15714
+ // API rejected the connection and we should not retry
15715
+ throw new Error(JSON.stringify({
15716
+ code: error.code,
15717
+ StatusCode: error.StatusCode,
15718
+ message: error.message,
15719
+ isWSFailure: error.isWSFailure,
15720
+ }));
15721
+ }
15722
+ else {
15723
+ // Transient WS failure (e.g., handshake watchdog). Kick off a
15724
+ // reconnect chain so _waitForHealthy(timeout) below has something
15725
+ // to poll for. Owning the trigger here (rather than inside
15726
+ // _connect()'s catch) keeps a single failure from spawning two
15727
+ // parallel chains - one from this catch and one from _reconnect's
15728
+ // own catch when _connect was called from there.
15729
+ this._reconnect();
15730
+ }
15731
+ }
15732
+ return await this._waitForHealthy(timeout);
15733
+ };
15734
+ /**
15735
+ * _waitForHealthy polls the promise connection to see if its resolved until it times out
15736
+ * the default 15s timeout allows between 2~3 tries
15737
+ * @param timeout duration(ms)
15738
+ */
15739
+ this._waitForHealthy = async (timeout = 15000) => {
15740
+ return Promise.race([
15741
+ (async () => {
15742
+ const interval = 50; // ms
15743
+ for (let i = 0; i <= timeout; i += interval) {
15744
+ try {
15745
+ return await this.connectionOpen;
15746
+ }
15747
+ catch (caught) {
15748
+ const error = caught;
15749
+ if (i === timeout) {
15750
+ throw new Error(JSON.stringify({
15751
+ code: error.code,
15752
+ StatusCode: error.StatusCode,
15753
+ message: error.message,
15754
+ isWSFailure: error.isWSFailure,
15755
+ }));
15756
+ }
15757
+ await sleep(interval);
15758
+ }
15759
+ }
15760
+ })(),
15761
+ (async () => {
15762
+ await sleep(timeout);
15763
+ this.isConnecting = false;
15764
+ throw new Error(JSON.stringify({
15765
+ code: '',
15766
+ StatusCode: '',
15767
+ message: 'initial WS connection could not be established',
15768
+ isWSFailure: true,
15769
+ }));
15770
+ })(),
15771
+ ]);
15772
+ };
14908
15773
  /**
14909
15774
  * Builds and returns the url for websocket.
14910
15775
  * @private
@@ -14917,11 +15782,166 @@ class StableWSConnection {
14917
15782
  params.set('X-Stream-Client', this.client.getUserAgent());
14918
15783
  return `${this.client.wsBaseURL}/connect?${params.toString()}`;
14919
15784
  };
15785
+ /**
15786
+ * disconnect - Disconnect the connection and doesn't recover...
15787
+ */
15788
+ this.disconnect = (timeout) => {
15789
+ this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
15790
+ this.wsID += 1;
15791
+ this.isConnecting = false;
15792
+ this.isDisconnected = true;
15793
+ // start by removing all the listeners
15794
+ if (this.healthCheckTimeoutRef) {
15795
+ getTimers().clearInterval(this.healthCheckTimeoutRef);
15796
+ }
15797
+ if (this.connectionCheckTimeoutRef) {
15798
+ clearInterval(this.connectionCheckTimeoutRef);
15799
+ }
15800
+ removeConnectionEventListeners(this.onlineStatusChanged);
15801
+ this.isHealthy = false;
15802
+ let isClosedPromise;
15803
+ // and finally close...
15804
+ // Assigning to local here because we will remove it from this before the
15805
+ // promise resolves.
15806
+ const { ws } = this;
15807
+ if (ws && ws.close && ws.readyState === ws.OPEN) {
15808
+ isClosedPromise = new Promise((resolve) => {
15809
+ const onclose = (event) => {
15810
+ this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
15811
+ resolve();
15812
+ };
15813
+ ws.onclose = onclose;
15814
+ // In case we don't receive close frame websocket server in time,
15815
+ // lets not wait for more than 1 second.
15816
+ setTimeout(onclose, timeout != null ? timeout : 1000);
15817
+ });
15818
+ this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
15819
+ ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
15820
+ }
15821
+ else {
15822
+ this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
15823
+ isClosedPromise = Promise.resolve();
15824
+ }
15825
+ delete this.ws;
15826
+ return isClosedPromise;
15827
+ };
15828
+ /**
15829
+ * _connect - Connect to the WS endpoint
15830
+ *
15831
+ * @param timeoutMs handshake watchdog deadline in ms. Defaults to
15832
+ * `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
15833
+ * passes its own timeout through so caller-supplied deadlines are honored.
15834
+ * @return Promise that completes once the first health check message is received
15835
+ */
15836
+ this._connect = async (timeoutMs) => {
15837
+ if (this.isConnecting)
15838
+ return; // ignore _connect if it's currently trying to connect
15839
+ this.isConnecting = true;
15840
+ // Snapshot of the connection-id reject closure owned by THIS attempt.
15841
+ // Captured at function entry so that even early failures (e.g.,
15842
+ // tokenManager.loadToken throwing before we reach the WS phase) can
15843
+ // settle the promise the caller is awaiting. Re-captured below if
15844
+ // _connect itself sets up a fresh promise. If a concurrent
15845
+ // openConnection() rotates `client.rejectConnectionId` later, our
15846
+ // captured closure still settles only the original promise (P1) and
15847
+ // never poisons the newer one (P2).
15848
+ let ownRejectConnectionId = this.client.rejectConnectionId;
15849
+ let isTokenReady = false;
15850
+ try {
15851
+ this._log(`_connect() - waiting for token`);
15852
+ await this.client.tokenManager.tokenReady();
15853
+ isTokenReady = true;
15854
+ }
15855
+ catch {
15856
+ // token provider has failed before, so try again
15857
+ }
15858
+ try {
15859
+ if (!isTokenReady) {
15860
+ this._log(`_connect() - tokenProvider failed before, so going to retry`);
15861
+ await this.client.tokenManager.loadToken();
15862
+ }
15863
+ if (!this.client.isConnectionIdPromisePending) {
15864
+ this.client._setupConnectionIdPromise();
15865
+ // recapture: we just rotated the resolver ourselves, the new
15866
+ // closure is the one bound to the promise this attempt owns.
15867
+ ownRejectConnectionId = this.client.rejectConnectionId;
15868
+ }
15869
+ this._setupConnectionPromise();
15870
+ const wsURL = this._buildUrl();
15871
+ this._log(`_connect() - Connecting to ${wsURL}`);
15872
+ const WS = this.client.options.WebSocketImpl ?? WebSocket;
15873
+ this.ws = new WS(wsURL);
15874
+ this.ws.onopen = this.onopen.bind(this, this.wsID);
15875
+ this.ws.onclose = this.onclose.bind(this, this.wsID);
15876
+ this.ws.onerror = this.onerror.bind(this, this.wsID);
15877
+ this.ws.onmessage = this.onmessage.bind(this, this.wsID);
15878
+ // race the WS handshake against an explicit deadline so a silent
15879
+ // network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
15880
+ const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
15881
+ const timers = getTimers();
15882
+ let handshakeTimeoutId;
15883
+ let response;
15884
+ try {
15885
+ response = await Promise.race([
15886
+ this.connectionOpen,
15887
+ new Promise((_, reject) => {
15888
+ handshakeTimeoutId = timers.setTimeout(() => {
15889
+ const err = new Error(`WS handshake timed out after ${handshakeTimeout}ms`);
15890
+ err.isWSFailure = true;
15891
+ reject(err);
15892
+ }, handshakeTimeout);
15893
+ }),
15894
+ ]);
15895
+ }
15896
+ finally {
15897
+ timers.clearTimeout(handshakeTimeoutId);
15898
+ }
15899
+ this.isConnecting = false;
15900
+ // If we were disconnected during the handshake (e.g. closeConnection()
15901
+ // ran while a background _reconnect's _connect was in flight), tear
15902
+ // down the new WS and throw so the caller of connect() does not get
15903
+ // a misleading "success" for a connection that has already been
15904
+ // aborted. We must NOT skip the throw and just return undefined: the
15905
+ // outer connect() would otherwise fall through to _waitForHealthy(),
15906
+ // which would observe the already-resolved connectionOpen promise
15907
+ // and resolve with a ConnectedEvent for a torn-down connection.
15908
+ if (this.isDisconnected) {
15909
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
15910
+ this._destroyCurrentWSConnection();
15911
+ }
15912
+ throw new Error('WS handshake aborted: disconnect() ran while connecting');
15913
+ }
15914
+ if (response) {
15915
+ this.connectionID = response.connection_id;
15916
+ this.client.resolveConnectionId?.(this.connectionID);
15917
+ return response;
15918
+ }
15919
+ }
15920
+ catch (caught) {
15921
+ const err = caught;
15922
+ this.isConnecting = false;
15923
+ this._log(`_connect() - Error - `, err);
15924
+ // Reject THIS attempt's connection-id promise (P1) directly via the
15925
+ // captured closure. Whether or not a concurrent openConnection() has
15926
+ // since rotated client.rejectConnectionId to a newer promise (P2),
15927
+ // calling ownRejectConnectionId only settles P1 - P2 is untouched.
15928
+ // P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
15929
+ // therefore fail fast instead of being orphaned.
15930
+ ownRejectConnectionId?.(err);
15931
+ // connectionOpen is per-instance and not subject to rotation, so
15932
+ // calling it unconditionally is safe (and a no-op if already settled).
15933
+ this.rejectConnectionOpen?.(err);
15934
+ // tear down a half-open WS so it does not linger and fire a stale wsID later
15935
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
15936
+ this._destroyCurrentWSConnection();
15937
+ }
15938
+ throw err;
15939
+ }
15940
+ };
14920
15941
  /**
14921
15942
  * onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
14922
15943
  *
14923
15944
  * @param {Event} event Event with type online or offline
14924
- *
14925
15945
  */
14926
15946
  this.onlineStatusChanged = (event) => {
14927
15947
  if (event.type === 'offline') {
@@ -15019,16 +16039,12 @@ class StableWSConnection {
15019
16039
  return;
15020
16040
  this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
15021
16041
  if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
15022
- // this is a permanent error raised by stream..
16042
+ // this is a permanent error raised by stream.
15023
16043
  // usually caused by invalid auth details
15024
16044
  const error = new Error(`WS connection reject with error ${event.reason}`);
15025
- // @ts-expect-error type issue
15026
16045
  error.reason = event.reason;
15027
- // @ts-expect-error type issue
15028
16046
  error.code = event.code;
15029
- // @ts-expect-error type issue
15030
16047
  error.wasClean = event.wasClean;
15031
- // @ts-expect-error type issue
15032
16048
  error.target = event.target;
15033
16049
  this.rejectConnectionOpen?.(error);
15034
16050
  this._log(`onclose() - WS connection reject with error ${event.reason}`, {
@@ -15166,205 +16182,8 @@ class StableWSConnection {
15166
16182
  }, this.connectionCheckTimeout);
15167
16183
  };
15168
16184
  this.client = client;
15169
- /** consecutive failures influence the duration of the timeout */
15170
- this.consecutiveFailures = 0;
15171
- /** keep track of the total number of failures */
15172
- this.totalFailures = 0;
15173
- /** We only make 1 attempt to reconnect at the same time.. */
15174
- this.isConnecting = false;
15175
- /** To avoid reconnect if client is disconnected */
15176
- this.isDisconnected = false;
15177
- /** Boolean that indicates if the connection promise is resolved */
15178
- this.isConnectionOpenResolved = false;
15179
- /** Boolean that indicates if we have a working connection to the server */
15180
- this.isHealthy = false;
15181
- /** Incremented when a new WS connection is made */
15182
- this.wsID = 1;
15183
- /** Store the last event time for health checks */
15184
- this.lastEvent = null;
15185
- /** Send a health check message every 25 seconds */
15186
- this.pingInterval = 25 * 1000;
15187
- this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
15188
16185
  addConnectionEventListeners(this.onlineStatusChanged);
15189
16186
  }
15190
- /**
15191
- * connect - Connect to the WS URL
15192
- * the default 15s timeout allows between 2~3 tries
15193
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
15194
- */
15195
- async connect(timeout = 15000) {
15196
- if (this.isConnecting) {
15197
- throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
15198
- }
15199
- this.isDisconnected = false;
15200
- try {
15201
- const healthCheck = await this._connect();
15202
- this.consecutiveFailures = 0;
15203
- this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
15204
- }
15205
- catch (error) {
15206
- this.isHealthy = false;
15207
- this.consecutiveFailures += 1;
15208
- if (
15209
- // @ts-expect-error type issue
15210
- error.code === KnownCodes.TOKEN_EXPIRED &&
15211
- !this.client.tokenManager.isStatic()) {
15212
- this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
15213
- this._reconnect({ refreshToken: true });
15214
- }
15215
- else {
15216
- // @ts-expect-error type issue
15217
- if (!error.isWSFailure) {
15218
- // API rejected the connection and we should not retry
15219
- throw new Error(JSON.stringify({
15220
- // @ts-expect-error type issue
15221
- code: error.code,
15222
- // @ts-expect-error type issue
15223
- StatusCode: error.StatusCode,
15224
- // @ts-expect-error type issue
15225
- message: error.message,
15226
- // @ts-expect-error type issue
15227
- isWSFailure: error.isWSFailure,
15228
- }));
15229
- }
15230
- }
15231
- }
15232
- return await this._waitForHealthy(timeout);
15233
- }
15234
- /**
15235
- * _waitForHealthy polls the promise connection to see if its resolved until it times out
15236
- * the default 15s timeout allows between 2~3 tries
15237
- * @param timeout duration(ms)
15238
- */
15239
- async _waitForHealthy(timeout = 15000) {
15240
- return Promise.race([
15241
- (async () => {
15242
- const interval = 50; // ms
15243
- for (let i = 0; i <= timeout; i += interval) {
15244
- try {
15245
- return await this.connectionOpen;
15246
- }
15247
- catch (error) {
15248
- if (i === timeout) {
15249
- throw new Error(JSON.stringify({
15250
- code: error.code,
15251
- StatusCode: error.StatusCode,
15252
- message: error.message,
15253
- isWSFailure: error.isWSFailure,
15254
- }));
15255
- }
15256
- await sleep(interval);
15257
- }
15258
- }
15259
- })(),
15260
- (async () => {
15261
- await sleep(timeout);
15262
- this.isConnecting = false;
15263
- throw new Error(JSON.stringify({
15264
- code: '',
15265
- StatusCode: '',
15266
- message: 'initial WS connection could not be established',
15267
- isWSFailure: true,
15268
- }));
15269
- })(),
15270
- ]);
15271
- }
15272
- /**
15273
- * disconnect - Disconnect the connection and doesn't recover...
15274
- *
15275
- */
15276
- disconnect(timeout) {
15277
- this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
15278
- this.wsID += 1;
15279
- this.isConnecting = false;
15280
- this.isDisconnected = true;
15281
- // start by removing all the listeners
15282
- if (this.healthCheckTimeoutRef) {
15283
- getTimers().clearInterval(this.healthCheckTimeoutRef);
15284
- }
15285
- if (this.connectionCheckTimeoutRef) {
15286
- clearInterval(this.connectionCheckTimeoutRef);
15287
- }
15288
- removeConnectionEventListeners(this.onlineStatusChanged);
15289
- this.isHealthy = false;
15290
- let isClosedPromise;
15291
- // and finally close...
15292
- // Assigning to local here because we will remove it from this before the
15293
- // promise resolves.
15294
- const { ws } = this;
15295
- if (ws && ws.close && ws.readyState === ws.OPEN) {
15296
- isClosedPromise = new Promise((resolve) => {
15297
- const onclose = (event) => {
15298
- this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
15299
- resolve();
15300
- };
15301
- ws.onclose = onclose;
15302
- // In case we don't receive close frame websocket server in time,
15303
- // lets not wait for more than 1 second.
15304
- setTimeout(onclose, timeout != null ? timeout : 1000);
15305
- });
15306
- this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
15307
- ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
15308
- }
15309
- else {
15310
- this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
15311
- isClosedPromise = Promise.resolve();
15312
- }
15313
- delete this.ws;
15314
- return isClosedPromise;
15315
- }
15316
- /**
15317
- * _connect - Connect to the WS endpoint
15318
- *
15319
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
15320
- */
15321
- async _connect() {
15322
- if (this.isConnecting)
15323
- return; // ignore _connect if it's currently trying to connect
15324
- this.isConnecting = true;
15325
- let isTokenReady = false;
15326
- try {
15327
- this._log(`_connect() - waiting for token`);
15328
- await this.client.tokenManager.tokenReady();
15329
- isTokenReady = true;
15330
- }
15331
- catch {
15332
- // token provider has failed before, so try again
15333
- }
15334
- try {
15335
- if (!isTokenReady) {
15336
- this._log(`_connect() - tokenProvider failed before, so going to retry`);
15337
- await this.client.tokenManager.loadToken();
15338
- }
15339
- if (!this.client.isConnectionIsPromisePending) {
15340
- this.client._setupConnectionIdPromise();
15341
- }
15342
- this._setupConnectionPromise();
15343
- const wsURL = this._buildUrl();
15344
- this._log(`_connect() - Connecting to ${wsURL}`);
15345
- const WS = this.client.options.WebSocketImpl ?? WebSocket;
15346
- this.ws = new WS(wsURL);
15347
- this.ws.onopen = this.onopen.bind(this, this.wsID);
15348
- this.ws.onclose = this.onclose.bind(this, this.wsID);
15349
- this.ws.onerror = this.onerror.bind(this, this.wsID);
15350
- this.ws.onmessage = this.onmessage.bind(this, this.wsID);
15351
- const response = await this.connectionOpen;
15352
- this.isConnecting = false;
15353
- if (response) {
15354
- this.connectionID = response.connection_id;
15355
- this.client.resolveConnectionId?.(this.connectionID);
15356
- return response;
15357
- }
15358
- }
15359
- catch (err) {
15360
- this.client._setupConnectionIdPromise();
15361
- this.isConnecting = false;
15362
- // @ts-expect-error type issue
15363
- this._log(`_connect() - Error - `, err);
15364
- this.client.rejectConnectionId?.(err);
15365
- throw err;
15366
- }
15367
- }
15368
16187
  /**
15369
16188
  * _reconnect - Retry the connection to WS endpoint
15370
16189
  *
@@ -15411,7 +16230,8 @@ class StableWSConnection {
15411
16230
  this._log('_reconnect() - Finished recoverCallBack');
15412
16231
  this.consecutiveFailures = 0;
15413
16232
  }
15414
- catch (error) {
16233
+ catch (caught) {
16234
+ const error = caught;
15415
16235
  this.isHealthy = false;
15416
16236
  this.consecutiveFailures += 1;
15417
16237
  if (error.code === KnownCodes.TOKEN_EXPIRED &&
@@ -15968,7 +16788,7 @@ class StreamClient {
15968
16788
  this.getUserAgent = () => {
15969
16789
  if (!this.cachedUserAgent) {
15970
16790
  const { clientAppIdentifier = {} } = this.options;
15971
- const { sdkName = 'js', sdkVersion = "1.48.0", ...extras } = clientAppIdentifier;
16791
+ const { sdkName = 'js', sdkVersion = "1.50.0", ...extras } = clientAppIdentifier;
15972
16792
  this.cachedUserAgent = [
15973
16793
  `stream-video-${sdkName}-v${sdkVersion}`,
15974
16794
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -16076,7 +16896,7 @@ class StreamClient {
16076
16896
  get connectionIdPromise() {
16077
16897
  return this.connectionIdPromiseSafe?.();
16078
16898
  }
16079
- get isConnectionIsPromisePending() {
16899
+ get isConnectionIdPromisePending() {
16080
16900
  return this.connectionIdPromiseSafe?.checkPending() ?? false;
16081
16901
  }
16082
16902
  get wsPromise() {