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