@stream-io/video-client 1.49.0 → 1.51.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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +1404 -682
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1404 -682
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1404 -682
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +43 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/CameraManager.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +23 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  16. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  17. package/dist/src/devices/devicePersistence.d.ts +1 -1
  18. package/dist/src/devices/index.d.ts +1 -0
  19. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  20. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  21. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  22. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  23. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  24. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  25. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  26. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  27. package/dist/src/helpers/browsers.d.ts +13 -0
  28. package/dist/src/helpers/concurrency.d.ts +6 -4
  29. package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
  30. package/dist/src/rtc/Publisher.d.ts +38 -3
  31. package/dist/src/rtc/Subscriber.d.ts +1 -0
  32. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  33. package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
  34. package/dist/src/rtc/types.d.ts +2 -0
  35. package/dist/src/stats/rtc/types.d.ts +1 -1
  36. package/dist/src/store/rxUtils.d.ts +9 -0
  37. package/dist/src/types.d.ts +18 -0
  38. package/package.json +2 -2
  39. package/src/Call.ts +111 -33
  40. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  41. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  42. package/src/coordinator/connection/client.ts +1 -1
  43. package/src/coordinator/connection/connection.ts +149 -96
  44. package/src/coordinator/connection/types.ts +15 -0
  45. package/src/coordinator/connection/utils.ts +15 -0
  46. package/src/devices/CameraManager.ts +9 -2
  47. package/src/devices/DeviceManager.ts +239 -39
  48. package/src/devices/DeviceManagerState.ts +4 -2
  49. package/src/devices/VirtualDevice.ts +69 -0
  50. package/src/devices/__tests__/CameraManager.test.ts +19 -0
  51. package/src/devices/__tests__/DeviceManager.test.ts +404 -1
  52. package/src/devices/__tests__/mocks.ts +2 -0
  53. package/src/devices/devicePersistence.ts +2 -1
  54. package/src/devices/index.ts +1 -0
  55. package/src/gen/video/sfu/event/events.ts +15 -0
  56. package/src/gen/video/sfu/models/models.ts +44 -0
  57. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  58. package/src/helpers/BlockedAudioTracker.ts +74 -0
  59. package/src/helpers/DynascaleManager.ts +46 -337
  60. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  61. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  62. package/src/helpers/ViewportTracker.ts +74 -19
  63. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  64. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  65. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -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/rtc/BasePeerConnection.ts +15 -3
  72. package/src/rtc/Publisher.ts +185 -40
  73. package/src/rtc/Subscriber.ts +42 -14
  74. package/src/rtc/TransceiverCache.ts +10 -3
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  76. package/src/rtc/__tests__/Publisher.test.ts +747 -88
  77. package/src/rtc/__tests__/Subscriber.test.ts +148 -3
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
  80. package/src/rtc/helpers/degradationPreference.ts +40 -0
  81. package/src/rtc/types.ts +2 -0
  82. package/src/stats/rtc/types.ts +1 -0
  83. package/src/store/__tests__/rxUtils.test.ts +276 -0
  84. package/src/store/rxUtils.ts +19 -0
  85. package/src/types.ts +19 -0
package/dist/index.cjs.js CHANGED
@@ -1418,6 +1418,35 @@ var ClientCapability;
1418
1418
  */
1419
1419
  ClientCapability[ClientCapability["SUBSCRIBER_VIDEO_PAUSE"] = 1] = "SUBSCRIBER_VIDEO_PAUSE";
1420
1420
  })(ClientCapability || (ClientCapability = {}));
1421
+ /**
1422
+ * DegradationPreference represents the RTCDegradationPreference from WebRTC.
1423
+ * See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
1424
+ *
1425
+ * @generated from protobuf enum stream.video.sfu.models.DegradationPreference
1426
+ */
1427
+ var DegradationPreference;
1428
+ (function (DegradationPreference) {
1429
+ /**
1430
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
1431
+ */
1432
+ DegradationPreference[DegradationPreference["UNSPECIFIED"] = 0] = "UNSPECIFIED";
1433
+ /**
1434
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
1435
+ */
1436
+ DegradationPreference[DegradationPreference["BALANCED"] = 1] = "BALANCED";
1437
+ /**
1438
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
1439
+ */
1440
+ DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE"] = 2] = "MAINTAIN_FRAMERATE";
1441
+ /**
1442
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
1443
+ */
1444
+ DegradationPreference[DegradationPreference["MAINTAIN_RESOLUTION"] = 3] = "MAINTAIN_RESOLUTION";
1445
+ /**
1446
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
1447
+ */
1448
+ DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE_AND_RESOLUTION"] = 4] = "MAINTAIN_FRAMERATE_AND_RESOLUTION";
1449
+ })(DegradationPreference || (DegradationPreference = {}));
1421
1450
  // @generated message type with reflection information, may provide speed optimized methods
1422
1451
  class CallState$Type extends runtime.MessageType {
1423
1452
  constructor() {
@@ -1687,6 +1716,16 @@ class PublishOption$Type extends runtime.MessageType {
1687
1716
  repeat: 2 /*RepeatType.UNPACKED*/,
1688
1717
  T: () => AudioBitrate,
1689
1718
  },
1719
+ {
1720
+ no: 11,
1721
+ name: 'degradation_preference',
1722
+ kind: 'enum',
1723
+ T: () => [
1724
+ 'stream.video.sfu.models.DegradationPreference',
1725
+ DegradationPreference,
1726
+ 'DEGRADATION_PREFERENCE_',
1727
+ ],
1728
+ },
1690
1729
  ]);
1691
1730
  }
1692
1731
  }
@@ -2133,6 +2172,7 @@ var models = /*#__PURE__*/Object.freeze({
2133
2172
  ClientDetails: ClientDetails,
2134
2173
  Codec: Codec,
2135
2174
  get ConnectionQuality () { return ConnectionQuality; },
2175
+ get DegradationPreference () { return DegradationPreference; },
2136
2176
  Device: Device,
2137
2177
  Error: Error$2,
2138
2178
  get ErrorCode () { return ErrorCode; },
@@ -3520,6 +3560,16 @@ class VideoSender$Type extends runtime.MessageType {
3520
3560
  kind: 'scalar',
3521
3561
  T: 5 /*ScalarType.INT32*/,
3522
3562
  },
3563
+ {
3564
+ no: 6,
3565
+ name: 'degradation_preference',
3566
+ kind: 'enum',
3567
+ T: () => [
3568
+ 'stream.video.sfu.models.DegradationPreference',
3569
+ DegradationPreference,
3570
+ 'DEGRADATION_PREFERENCE_',
3571
+ ],
3572
+ },
3523
3573
  ]);
3524
3574
  }
3525
3575
  }
@@ -3885,6 +3935,18 @@ const createSignalClient = (options) => {
3885
3935
  };
3886
3936
 
3887
3937
  const sleep = (m) => new Promise((r) => setTimeout(r, m));
3938
+ const timeboxed = async (promises, ms) => {
3939
+ let timerId;
3940
+ const timeout = new Promise((_, reject) => {
3941
+ timerId = setTimeout(() => reject(new Error('timebox error')), ms);
3942
+ });
3943
+ try {
3944
+ return await Promise.race([Promise.all(promises), timeout]);
3945
+ }
3946
+ finally {
3947
+ clearTimeout(timerId);
3948
+ }
3949
+ };
3888
3950
  function isFunction(value) {
3889
3951
  return (value &&
3890
3952
  (Object.prototype.toString.call(value) === '[object Function]' ||
@@ -4624,6 +4686,20 @@ const setCurrentValue = (subject, update) => {
4624
4686
  subject.next(next);
4625
4687
  return next;
4626
4688
  };
4689
+ /**
4690
+ * Updates the value of the provided Subject asynchronously.
4691
+ * Locks the subject to prevent concurrent updates.
4692
+ *
4693
+ * @param subject the subject to update.
4694
+ * @param update the update to apply to the subject.
4695
+ */
4696
+ const setCurrentValueAsync = async (subject, update) => {
4697
+ return withoutConcurrency(subject, async () => {
4698
+ const next = await update(getCurrentValue(subject));
4699
+ subject.next(next);
4700
+ return next;
4701
+ });
4702
+ };
4627
4703
  /**
4628
4704
  * Updates the value of the provided Subject and returns the previous value
4629
4705
  * and a function to roll back the update.
@@ -4678,6 +4754,7 @@ var rxUtils = /*#__PURE__*/Object.freeze({
4678
4754
  createSubscription: createSubscription,
4679
4755
  getCurrentValue: getCurrentValue,
4680
4756
  setCurrentValue: setCurrentValue,
4757
+ setCurrentValueAsync: setCurrentValueAsync,
4681
4758
  updateValue: updateValue
4682
4759
  });
4683
4760
 
@@ -6302,7 +6379,7 @@ const getSdkVersion = (sdk) => {
6302
6379
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6303
6380
  };
6304
6381
 
6305
- const version = "1.49.0";
6382
+ const version = "1.51.0";
6306
6383
  const [major, minor, patch] = version.split('.');
6307
6384
  let sdkInfo = {
6308
6385
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -6455,6 +6532,31 @@ const isSafari = () => {
6455
6532
  return false;
6456
6533
  return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
6457
6534
  };
6535
+ /**
6536
+ * Checks whether the current runtime is a WebKit-engine browser.
6537
+ *
6538
+ * Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
6539
+ * (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
6540
+ * Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
6541
+ * `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
6542
+ * share the underlying WebKit quirks.
6543
+ *
6544
+ * Returns false for desktop Chromium-based browsers (which reuse the
6545
+ * `AppleWebKit/` token in their UA) and Android.
6546
+ */
6547
+ const isWebKit = () => {
6548
+ if (typeof navigator === 'undefined')
6549
+ return false;
6550
+ const ua = navigator.userAgent || '';
6551
+ if (!/AppleWebKit\//.test(ua))
6552
+ return false;
6553
+ // Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
6554
+ // `Chromium/` markers are only present on desktop Chromium builds
6555
+ // (their iOS counterparts use `CriOS/` instead). `Android` rules out
6556
+ // the mobile Blink stack.
6557
+ const regExp = /Chrome\/|Chromium\/|Android/;
6558
+ return !regExp.test(ua);
6559
+ };
6458
6560
  /**
6459
6561
  * Checks whether the current browser is Firefox.
6460
6562
  */
@@ -6498,7 +6600,8 @@ var browsers = /*#__PURE__*/Object.freeze({
6498
6600
  isChrome: isChrome,
6499
6601
  isFirefox: isFirefox,
6500
6602
  isSafari: isSafari,
6501
- isSupportedBrowser: isSupportedBrowser
6603
+ isSupportedBrowser: isSupportedBrowser,
6604
+ isWebKit: isWebKit
6502
6605
  });
6503
6606
 
6504
6607
  /**
@@ -7395,7 +7498,7 @@ class BasePeerConnection {
7395
7498
  this.on = (event, fn) => {
7396
7499
  const getTag = () => this.tag;
7397
7500
  this.subscriptions.push(this.dispatcher.on(event, getTag, (e) => {
7398
- const lockKey = `pc.${this.lock}.${event}`;
7501
+ const lockKey = this.eventLockKey(event);
7399
7502
  withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
7400
7503
  if (this.isDisposed)
7401
7504
  return;
@@ -7403,6 +7506,13 @@ class BasePeerConnection {
7403
7506
  });
7404
7507
  }));
7405
7508
  };
7509
+ /**
7510
+ * Returns the per-event `withoutConcurrency` tag used to serialize the
7511
+ * dispatcher handler for `event` on this peer connection.
7512
+ */
7513
+ this.eventLockKey = (event) => {
7514
+ return `pc.${this.lock}.${event}`;
7515
+ };
7406
7516
  /**
7407
7517
  * Appends the trickled ICE candidates to the `RTCPeerConnection`.
7408
7518
  */
@@ -7656,7 +7766,7 @@ class BasePeerConnection {
7656
7766
  /**
7657
7767
  * Disposes the `RTCPeerConnection` instance.
7658
7768
  */
7659
- dispose() {
7769
+ async dispose() {
7660
7770
  clearTimeout(this.iceRestartTimeout);
7661
7771
  this.iceRestartTimeout = undefined;
7662
7772
  clearTimeout(this.preConnectStuckTimeout);
@@ -7678,6 +7788,7 @@ class BasePeerConnection {
7678
7788
  pc.removeEventListener('signalingstatechange', this.onSignalingChange);
7679
7789
  pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
7680
7790
  pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
7791
+ pc.removeEventListener('connectionstatechange', this.onConnectionStateChange);
7681
7792
  this.unsubscribeIceTrickle?.();
7682
7793
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
7683
7794
  this.subscriptions = [];
@@ -7705,8 +7816,14 @@ class TransceiverCache {
7705
7816
  * Gets the transceiver for the given publish option.
7706
7817
  */
7707
7818
  this.get = (publishOption) => {
7708
- return this.cache.find((bundle) => bundle.publishOption.id === publishOption.id &&
7709
- bundle.publishOption.trackType === publishOption.trackType);
7819
+ return this.getBy(publishOption.id, publishOption.trackType);
7820
+ };
7821
+ /**
7822
+ * Gets the transceiver for the given publish option id and track type.
7823
+ */
7824
+ this.getBy = (publishOptionId, trackType) => {
7825
+ return this.cache.find((bundle) => bundle.publishOption.id === publishOptionId &&
7826
+ bundle.publishOption.trackType === trackType);
7710
7827
  };
7711
7828
  /**
7712
7829
  * Updates the cached bundle with the given patch.
@@ -7974,6 +8091,39 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
7974
8091
  }));
7975
8092
  };
7976
8093
 
8094
+ const toRTCDegradationPreference = (preference) => {
8095
+ switch (preference) {
8096
+ case DegradationPreference.BALANCED:
8097
+ return 'balanced';
8098
+ case DegradationPreference.MAINTAIN_FRAMERATE:
8099
+ return 'maintain-framerate';
8100
+ case DegradationPreference.MAINTAIN_RESOLUTION:
8101
+ return 'maintain-resolution';
8102
+ case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
8103
+ // @ts-expect-error not in the typedefs yet
8104
+ return 'maintain-framerate-and-resolution';
8105
+ case DegradationPreference.UNSPECIFIED:
8106
+ return undefined;
8107
+ default:
8108
+ ensureExhausted(preference, 'Unknown degradation preference');
8109
+ }
8110
+ };
8111
+ const fromRTCDegradationPreference = (preference) => {
8112
+ switch (preference) {
8113
+ case 'balanced':
8114
+ return DegradationPreference.BALANCED;
8115
+ case 'maintain-framerate':
8116
+ return DegradationPreference.MAINTAIN_FRAMERATE;
8117
+ case 'maintain-resolution':
8118
+ return DegradationPreference.MAINTAIN_RESOLUTION;
8119
+ // @ts-expect-error not in the typedefs yet
8120
+ case 'maintain-framerate-and-resolution':
8121
+ return DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION;
8122
+ default:
8123
+ return DegradationPreference.UNSPECIFIED;
8124
+ }
8125
+ };
8126
+
7977
8127
  /**
7978
8128
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
7979
8129
  *
@@ -8007,13 +8157,13 @@ class Publisher extends BasePeerConnection {
8007
8157
  // create a clone of the track as otherwise the same trackId will
8008
8158
  // appear in the SDP in multiple transceivers
8009
8159
  const trackToPublish = this.cloneTrack(track);
8010
- const { transceiver } = this.transceiverCache.get(publishOption) || {};
8011
- if (!transceiver) {
8160
+ const bundle = this.transceiverCache.get(publishOption);
8161
+ if (!bundle) {
8012
8162
  await this.addTransceiver(trackToPublish, publishOption, options);
8013
8163
  }
8014
8164
  else {
8015
- const previousTrack = transceiver.sender.track;
8016
- await this.updateTransceiver(transceiver, trackToPublish, trackType, options);
8165
+ const previousTrack = bundle.transceiver.sender.track;
8166
+ await this.updateTransceiver(bundle, trackToPublish, options);
8017
8167
  if (!isReactNative()) {
8018
8168
  this.stopTrack(previousTrack);
8019
8169
  }
@@ -8035,7 +8185,9 @@ class Publisher extends BasePeerConnection {
8035
8185
  sendEncodings,
8036
8186
  });
8037
8187
  const params = transceiver.sender.getParameters();
8038
- params.degradationPreference = 'maintain-framerate';
8188
+ params.degradationPreference =
8189
+ toRTCDegradationPreference(publishOption.degradationPreference) ??
8190
+ 'maintain-framerate';
8039
8191
  await transceiver.sender.setParameters(params);
8040
8192
  const trackType = publishOption.trackType;
8041
8193
  this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
@@ -8046,13 +8198,20 @@ class Publisher extends BasePeerConnection {
8046
8198
  /**
8047
8199
  * Updates the transceiver with the given track and track type.
8048
8200
  */
8049
- this.updateTransceiver = async (transceiver, track, trackType, options = {}) => {
8201
+ this.updateTransceiver = async (bundle, track, options = {}) => {
8202
+ const { transceiver, publishOption } = bundle;
8203
+ const trackType = publishOption.trackType;
8050
8204
  const sender = transceiver.sender;
8051
8205
  if (sender.track)
8052
8206
  this.trackIdToTrackType.delete(sender.track.id);
8053
8207
  await sender.replaceTrack(track);
8054
- if (track)
8208
+ if (track) {
8055
8209
  this.trackIdToTrackType.set(track.id, trackType);
8210
+ if (isFirefox() && bundle.videoSender) {
8211
+ // restore the encoding config from the cache, if any
8212
+ await this.changePublishQuality(bundle.videoSender, bundle);
8213
+ }
8214
+ }
8056
8215
  if (isAudioTrackType(trackType)) {
8057
8216
  await this.updateAudioPublishOptions(trackType, options);
8058
8217
  }
@@ -8112,7 +8271,7 @@ class Publisher extends BasePeerConnection {
8112
8271
  continue;
8113
8272
  // it is safe to stop the track here, it is a clone
8114
8273
  this.stopTrack(transceiver.sender.track);
8115
- await this.updateTransceiver(transceiver, null, publishOption.trackType);
8274
+ await this.updateTransceiver(item, null);
8116
8275
  }
8117
8276
  };
8118
8277
  /**
@@ -8133,35 +8292,74 @@ class Publisher extends BasePeerConnection {
8133
8292
  return false;
8134
8293
  };
8135
8294
  /**
8136
- * Stops the cloned track that is being published to the SFU.
8295
+ * Re-arms the encoder for the given track type by detaching and
8296
+ * reattaching the currently published track on each matching sender.
8297
+ *
8298
+ * Workaround for a WebKit / iOS Safari quirk: after a system audio
8299
+ * session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
8300
+ * can stop producing RTP packets even though the underlying
8301
+ * `MediaStreamTrack` is `live` and `track.muted === false`.
8302
+ * `replaceTrack(null)` followed by `replaceTrack(track)` resets the
8303
+ * sender's encoder pipeline without renegotiation, restoring packet
8304
+ * flow with the same SSRC.
8305
+ *
8306
+ * No-op when nothing is published for the given track type.
8307
+ *
8308
+ * @param trackType the track type to refresh.
8137
8309
  */
8138
- this.stopTracks = (...trackTypes) => {
8310
+ this.refreshTrack = async (trackType) => {
8139
8311
  for (const item of this.transceiverCache.items()) {
8140
- const { publishOption, transceiver } = item;
8141
- if (!trackTypes.includes(publishOption.trackType))
8312
+ if (item.publishOption.trackType !== trackType)
8142
8313
  continue;
8143
- this.stopTrack(transceiver.sender.track);
8314
+ const { sender } = item.transceiver;
8315
+ const track = sender.track;
8316
+ if (!track || track.readyState !== 'live')
8317
+ continue;
8318
+ try {
8319
+ await sender.replaceTrack(null);
8320
+ await sender.replaceTrack(track);
8321
+ this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
8322
+ }
8323
+ catch (err) {
8324
+ this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
8325
+ }
8144
8326
  }
8145
8327
  };
8328
+ /**
8329
+ * Stops the cloned track that is being published to the SFU.
8330
+ */
8331
+ this.stopTracks = async (...trackTypes) => {
8332
+ return withoutConcurrency(this.eventLockKey('changePublishQuality'), async () => {
8333
+ for (const item of this.transceiverCache.items()) {
8334
+ const { publishOption, transceiver } = item;
8335
+ if (!trackTypes.includes(publishOption.trackType))
8336
+ continue;
8337
+ const track = transceiver.sender.track;
8338
+ await this.silenceSenderOnFirefox(item);
8339
+ this.stopTrack(track);
8340
+ }
8341
+ });
8342
+ };
8146
8343
  /**
8147
8344
  * Stops all the cloned tracks that are being published to the SFU.
8148
8345
  */
8149
- this.stopAllTracks = () => {
8150
- for (const { transceiver } of this.transceiverCache.items()) {
8151
- this.stopTrack(transceiver.sender.track);
8152
- }
8153
- for (const track of this.clonedTracks) {
8154
- this.stopTrack(track);
8155
- }
8346
+ this.stopAllTracks = async () => {
8347
+ return withoutConcurrency(this.eventLockKey('changePublishQuality'), async () => {
8348
+ for (const item of this.transceiverCache.items()) {
8349
+ const track = item.transceiver.sender.track;
8350
+ await this.silenceSenderOnFirefox(item);
8351
+ this.stopTrack(track);
8352
+ }
8353
+ for (const track of this.clonedTracks) {
8354
+ this.stopTrack(track);
8355
+ }
8356
+ });
8156
8357
  };
8157
- this.changePublishQuality = async (videoSender) => {
8158
- const { trackType, layers, publishOptionId } = videoSender;
8159
- const enabledLayers = layers.filter((l) => l.active);
8358
+ this.changePublishQuality = async (videoSender, bundle) => {
8359
+ const enabledLayers = videoSender.layers.filter((l) => l.active);
8160
8360
  const tag = 'Update publish quality:';
8161
8361
  this.logger.info(`${tag} requested layers by SFU:`, enabledLayers);
8162
- const transceiverId = this.transceiverCache.find((t) => t.publishOption.id === publishOptionId &&
8163
- t.publishOption.trackType === trackType);
8164
- const sender = transceiverId?.transceiver.sender;
8362
+ const sender = bundle?.transceiver.sender;
8165
8363
  if (!sender) {
8166
8364
  return this.logger.warn(`${tag} no video sender found.`);
8167
8365
  }
@@ -8169,7 +8367,7 @@ class Publisher extends BasePeerConnection {
8169
8367
  if (params.encodings.length === 0) {
8170
8368
  return this.logger.warn(`${tag} there are no encodings set.`);
8171
8369
  }
8172
- const codecInUse = transceiverId?.publishOption.codec?.name;
8370
+ const codecInUse = bundle?.publishOption.codec?.name;
8173
8371
  const usesSvcCodec = codecInUse && isSvcCodec(codecInUse);
8174
8372
  let changed = false;
8175
8373
  for (const encoder of params.encodings) {
@@ -8209,6 +8407,12 @@ class Publisher extends BasePeerConnection {
8209
8407
  changed = true;
8210
8408
  }
8211
8409
  }
8410
+ const degradationPreference = toRTCDegradationPreference(videoSender.degradationPreference);
8411
+ if (degradationPreference &&
8412
+ params.degradationPreference !== degradationPreference) {
8413
+ params.degradationPreference = degradationPreference;
8414
+ changed = true;
8415
+ }
8212
8416
  const activeEncoders = params.encodings.filter((e) => e.active);
8213
8417
  if (!changed) {
8214
8418
  return this.logger.info(`${tag} no change:`, activeEncoders);
@@ -8363,6 +8567,72 @@ class Publisher extends BasePeerConnection {
8363
8567
  track.stop();
8364
8568
  this.clonedTracks.delete(track);
8365
8569
  };
8570
+ /**
8571
+ * Silences a Firefox sender on the wire during unpublish.
8572
+ *
8573
+ * Firefox keeps emitting RTP after track.stop(), but the right lever
8574
+ * differs by track type:
8575
+ * - audio: `replaceTrack(null)` is the only reliable silencer;
8576
+ * `setParameters({encodings:[...active:false]})` does NOT stop
8577
+ * the Opus encoder.
8578
+ * - video: `setParameters({encodings:[...active:false]})` pauses
8579
+ * the encoder; `replaceTrack(null)` does NOT reliably stop the
8580
+ * video encoder. The prior active=true configuration is captured
8581
+ * onto `bundle.videoSender` so `updateTransceiver` can restore
8582
+ * it on the next publish.
8583
+ *
8584
+ * No-op on non-Firefox browsers and during teardown.
8585
+ */
8586
+ this.silenceSenderOnFirefox = async (bundle) => {
8587
+ if (this.isDisposed || !isFirefox())
8588
+ return;
8589
+ const { transceiver, publishOption } = bundle;
8590
+ if (isAudioTrackType(publishOption.trackType)) {
8591
+ await transceiver.sender.replaceTrack(null).catch((err) => {
8592
+ this.logger.warn('Failed to clear audio sender track', err);
8593
+ });
8594
+ return;
8595
+ }
8596
+ await this.disableAllEncodings(bundle);
8597
+ };
8598
+ this.disableAllEncodings = async (bundle) => {
8599
+ const { transceiver, publishOption } = bundle;
8600
+ const sender = transceiver.sender;
8601
+ const params = sender.getParameters();
8602
+ if (!params.encodings || params.encodings.length === 0)
8603
+ return;
8604
+ if (!bundle.videoSender) {
8605
+ this.transceiverCache.update(publishOption, {
8606
+ videoSender: {
8607
+ trackType: publishOption.trackType,
8608
+ publishOptionId: publishOption.id,
8609
+ codec: publishOption.codec,
8610
+ degradationPreference: fromRTCDegradationPreference(params.degradationPreference),
8611
+ layers: params.encodings.map((e) => ({
8612
+ name: e.rid ?? 'q',
8613
+ active: e.active ?? true,
8614
+ maxBitrate: e.maxBitrate ?? 0,
8615
+ scaleResolutionDownBy: e.scaleResolutionDownBy ?? 0,
8616
+ maxFramerate: e.maxFramerate ?? 0,
8617
+ // @ts-expect-error scalabilityMode is not in the typedefs yet
8618
+ scalabilityMode: e.scalabilityMode ?? '',
8619
+ })),
8620
+ },
8621
+ });
8622
+ }
8623
+ let changed = false;
8624
+ for (const encoding of params.encodings) {
8625
+ if (encoding.active !== false) {
8626
+ encoding.active = false;
8627
+ changed = true;
8628
+ }
8629
+ }
8630
+ if (!changed)
8631
+ return;
8632
+ await sender.setParameters(params).catch((err) => {
8633
+ this.logger.error('Failed to disable video sender encodings:', err);
8634
+ });
8635
+ };
8366
8636
  this.publishOptions = publishOptions;
8367
8637
  this.on('iceRestart', (iceRestart) => {
8368
8638
  if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
@@ -8371,7 +8641,16 @@ class Publisher extends BasePeerConnection {
8371
8641
  });
8372
8642
  this.on('changePublishQuality', async (event) => {
8373
8643
  for (const videoSender of event.videoSenders) {
8374
- await this.changePublishQuality(videoSender);
8644
+ // if not publishing, update the encodingConfigCache and don't modify the state.
8645
+ // we'll apply this config on the next publish/unmute.
8646
+ const { trackType, publishOptionId } = videoSender;
8647
+ const bundle = this.transceiverCache.getBy(publishOptionId, trackType);
8648
+ if (bundle) {
8649
+ this.transceiverCache.update(bundle.publishOption, { videoSender });
8650
+ }
8651
+ if (isFirefox() && !this.isPublishing(trackType))
8652
+ continue;
8653
+ await this.changePublishQuality(videoSender, bundle);
8375
8654
  }
8376
8655
  });
8377
8656
  this.on('changePublishOptions', (event) => {
@@ -8382,13 +8661,48 @@ class Publisher extends BasePeerConnection {
8382
8661
  /**
8383
8662
  * Disposes this Publisher instance.
8384
8663
  */
8385
- dispose() {
8386
- super.dispose();
8387
- this.stopAllTracks();
8664
+ async dispose() {
8665
+ await super.dispose();
8666
+ try {
8667
+ await this.stopAllTracks();
8668
+ }
8669
+ catch (err) {
8670
+ this.logger.warn('Failed to stop tracks during dispose', err);
8671
+ }
8388
8672
  this.clonedTracks.clear();
8389
8673
  }
8390
8674
  }
8391
8675
 
8676
+ /**
8677
+ * Adds unique values to an array.
8678
+ *
8679
+ * @param arr the array to add to.
8680
+ * @param values the values to add.
8681
+ */
8682
+ const pushToIfMissing = (arr, ...values) => {
8683
+ for (const v of values) {
8684
+ if (!arr.includes(v)) {
8685
+ arr.push(v);
8686
+ }
8687
+ }
8688
+ return arr;
8689
+ };
8690
+ /**
8691
+ * Removes values from an array if they are present.
8692
+ *
8693
+ * @param arr the array to remove from.
8694
+ * @param values the values to remove.
8695
+ */
8696
+ const removeFromIfPresent = (arr, ...values) => {
8697
+ for (const v of values) {
8698
+ const index = arr.indexOf(v);
8699
+ if (index !== -1) {
8700
+ arr.splice(index, 1);
8701
+ }
8702
+ }
8703
+ return arr;
8704
+ };
8705
+
8392
8706
  /**
8393
8707
  * A wrapper around the `RTCPeerConnection` that handles the incoming
8394
8708
  * media streams from the SFU.
@@ -8430,27 +8744,34 @@ class Subscriber extends BasePeerConnection {
8430
8744
  }
8431
8745
  };
8432
8746
  this.handleOnTrack = (e) => {
8433
- const [primaryStream] = e.streams;
8747
+ const { streams, track } = e;
8748
+ const [primaryStream] = streams;
8434
8749
  // example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
8435
8750
  const [trackId, rawTrackType] = primaryStream.id.split(':');
8436
8751
  const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
8437
- this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, e.track.id, e.track);
8752
+ this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
8753
+ const trackType = toTrackType(rawTrackType);
8754
+ if (!trackType) {
8755
+ return this.logger.error(`Unknown track type: ${rawTrackType}`);
8756
+ }
8438
8757
  const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
8439
- e.track.addEventListener('mute', () => {
8758
+ track.addEventListener('mute', () => {
8440
8759
  this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
8760
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
8441
8761
  });
8442
- e.track.addEventListener('unmute', () => {
8762
+ track.addEventListener('unmute', () => {
8443
8763
  this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
8764
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
8444
8765
  });
8445
- e.track.addEventListener('ended', () => {
8766
+ track.addEventListener('ended', () => {
8446
8767
  this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
8768
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
8447
8769
  this.state.removeOrphanedTrack(primaryStream.id);
8448
8770
  });
8449
- const trackType = toTrackType(rawTrackType);
8450
- if (!trackType) {
8451
- return this.logger.error(`Unknown track type: ${rawTrackType}`);
8771
+ if (track.muted) {
8772
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
8452
8773
  }
8453
- this.trackIdToTrackType.set(e.track.id, trackType);
8774
+ this.trackIdToTrackType.set(track.id, trackType);
8454
8775
  if (!participantToUpdate) {
8455
8776
  this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
8456
8777
  this.state.registerOrphanedTrack({
@@ -8476,13 +8797,30 @@ class Subscriber extends BasePeerConnection {
8476
8797
  });
8477
8798
  // now, dispose the previous stream if it exists
8478
8799
  if (previousStream) {
8479
- this.logger.info(`[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`);
8800
+ this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
8480
8801
  previousStream.getTracks().forEach((t) => {
8481
8802
  t.stop();
8482
8803
  previousStream.removeTrack(t);
8483
8804
  });
8484
8805
  }
8485
8806
  };
8807
+ this.setRemoteTrackInterrupted = (trackId, trackType, interrupted) => {
8808
+ if (trackType !== TrackType.AUDIO)
8809
+ return;
8810
+ const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
8811
+ if (!target)
8812
+ return;
8813
+ this.state.updateParticipant(target.sessionId, (p) => {
8814
+ const current = p.interruptedTracks ?? [];
8815
+ const has = current.includes(trackType);
8816
+ if (interrupted === has)
8817
+ return {};
8818
+ const next = interrupted
8819
+ ? pushToIfMissing([...current], trackType)
8820
+ : removeFromIfPresent([...current], trackType);
8821
+ return { interruptedTracks: next };
8822
+ });
8823
+ };
8486
8824
  this.negotiate = async (subscriberOffer) => {
8487
8825
  await this.pc.setRemoteDescription({
8488
8826
  type: 'offer',
@@ -9205,36 +9543,6 @@ const watchCallGrantsUpdated = (state) => {
9205
9543
  };
9206
9544
  };
9207
9545
 
9208
- /**
9209
- * Adds unique values to an array.
9210
- *
9211
- * @param arr the array to add to.
9212
- * @param values the values to add.
9213
- */
9214
- const pushToIfMissing = (arr, ...values) => {
9215
- for (const v of values) {
9216
- if (!arr.includes(v)) {
9217
- arr.push(v);
9218
- }
9219
- }
9220
- return arr;
9221
- };
9222
- /**
9223
- * Removes values from an array if they are present.
9224
- *
9225
- * @param arr the array to remove from.
9226
- * @param values the values to remove.
9227
- */
9228
- const removeFromIfPresent = (arr, ...values) => {
9229
- for (const v of values) {
9230
- const index = arr.indexOf(v);
9231
- if (index !== -1) {
9232
- arr.splice(index, 1);
9233
- }
9234
- }
9235
- return arr;
9236
- };
9237
-
9238
9546
  const watchConnectionQualityChanged = (dispatcher, state) => {
9239
9547
  return dispatcher.on('connectionQualityChanged', '*', (e) => {
9240
9548
  const { connectionQualityUpdates } = e;
@@ -9567,140 +9875,54 @@ const registerRingingCallEventHandlers = (call) => {
9567
9875
  };
9568
9876
  };
9569
9877
 
9570
- const DEFAULT_THRESHOLD = 0.35;
9571
- class ViewportTracker {
9572
- constructor() {
9878
+ const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
9879
+ /**
9880
+ * Tracks audio element bindings and periodically warns about
9881
+ * remote participants whose audio streams have no bound element.
9882
+ */
9883
+ class AudioBindingsWatchdog {
9884
+ constructor(state, tracer) {
9885
+ this.bindings = new Map();
9886
+ this.enabled = true;
9887
+ this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
9573
9888
  /**
9574
- * @private
9889
+ * Registers an audio element binding for the given session and track type.
9890
+ * Warns if a different element is already bound to the same key.
9575
9891
  */
9576
- this.elementHandlerMap = new Map();
9892
+ this.register = (element, sessionId, trackType) => {
9893
+ const key = toBindingKey(sessionId, trackType);
9894
+ const existing = this.bindings.get(key);
9895
+ if (existing && existing !== element) {
9896
+ this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
9897
+ this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
9898
+ }
9899
+ this.bindings.set(key, element);
9900
+ };
9577
9901
  /**
9578
- * @private
9902
+ * Removes the audio element binding for the given session and track type.
9579
9903
  */
9580
- this.observer = null;
9581
- // in React children render before viewport is set, add
9582
- // them to the queue and observe them once the observer is ready
9904
+ this.unregister = (sessionId, trackType) => {
9905
+ this.bindings.delete(toBindingKey(sessionId, trackType));
9906
+ };
9583
9907
  /**
9584
- * @private
9908
+ * Enables or disables the watchdog.
9909
+ * When disabled, the periodic check stops but bindings are still tracked.
9585
9910
  */
9586
- this.queueSet = new Set();
9911
+ this.setEnabled = (enabled) => {
9912
+ this.enabled = enabled;
9913
+ if (enabled) {
9914
+ this.start();
9915
+ }
9916
+ else {
9917
+ this.stop();
9918
+ }
9919
+ };
9587
9920
  /**
9588
- * Method to set scrollable viewport as root for the IntersectionObserver, returns
9589
- * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
9590
- *
9591
- * @param viewportElement
9592
- * @param options
9593
- * @returns Unobserve
9594
- */
9595
- this.setViewport = (viewportElement, options) => {
9596
- const cleanup = () => {
9597
- this.observer?.disconnect();
9598
- this.observer = null;
9599
- this.elementHandlerMap.clear();
9600
- };
9601
- this.observer = new IntersectionObserver((entries) => {
9602
- entries.forEach((entry) => {
9603
- const handler = this.elementHandlerMap.get(entry.target);
9604
- handler?.(entry);
9605
- });
9606
- }, {
9607
- root: viewportElement,
9608
- ...options,
9609
- threshold: options?.threshold ?? DEFAULT_THRESHOLD,
9610
- });
9611
- if (this.queueSet.size) {
9612
- this.queueSet.forEach(([queueElement, queueHandler]) => {
9613
- // check if element which requested observation is
9614
- // a child of a viewport element, skip if isn't
9615
- if (!viewportElement.contains(queueElement))
9616
- return;
9617
- this.observer.observe(queueElement);
9618
- this.elementHandlerMap.set(queueElement, queueHandler);
9619
- });
9620
- this.queueSet.clear();
9621
- }
9622
- return cleanup;
9623
- };
9624
- /**
9625
- * Method to set element to observe and handler to be triggered whenever IntersectionObserver
9626
- * detects a possible change in element's visibility within specified viewport, returns
9627
- * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
9628
- *
9629
- * @param element
9630
- * @param handler
9631
- * @returns Unobserve
9632
- */
9633
- this.observe = (element, handler) => {
9634
- const queueItem = [element, handler];
9635
- const cleanup = () => {
9636
- this.elementHandlerMap.delete(element);
9637
- this.observer?.unobserve(element);
9638
- this.queueSet.delete(queueItem);
9639
- };
9640
- if (this.elementHandlerMap.has(element))
9641
- return cleanup;
9642
- if (!this.observer) {
9643
- this.queueSet.add(queueItem);
9644
- return cleanup;
9645
- }
9646
- if (this.observer.root.contains(element)) {
9647
- this.elementHandlerMap.set(element, handler);
9648
- this.observer.observe(element);
9649
- }
9650
- return cleanup;
9651
- };
9652
- }
9653
- }
9654
-
9655
- const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
9656
- /**
9657
- * Tracks audio element bindings and periodically warns about
9658
- * remote participants whose audio streams have no bound element.
9659
- */
9660
- class AudioBindingsWatchdog {
9661
- constructor(state, tracer) {
9662
- this.state = state;
9663
- this.tracer = tracer;
9664
- this.bindings = new Map();
9665
- this.enabled = true;
9666
- this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
9667
- /**
9668
- * Registers an audio element binding for the given session and track type.
9669
- * Warns if a different element is already bound to the same key.
9670
- */
9671
- this.register = (audioElement, sessionId, trackType) => {
9672
- const key = toBindingKey(sessionId, trackType);
9673
- const existing = this.bindings.get(key);
9674
- if (existing && existing !== audioElement) {
9675
- this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
9676
- this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
9677
- }
9678
- this.bindings.set(key, audioElement);
9679
- };
9680
- /**
9681
- * Removes the audio element binding for the given session and track type.
9682
- */
9683
- this.unregister = (sessionId, trackType) => {
9684
- this.bindings.delete(toBindingKey(sessionId, trackType));
9685
- };
9686
- /**
9687
- * Enables or disables the watchdog.
9688
- * When disabled, the periodic check stops but bindings are still tracked.
9689
- */
9690
- this.setEnabled = (enabled) => {
9691
- this.enabled = enabled;
9692
- if (enabled) {
9693
- this.start();
9694
- }
9695
- else {
9696
- this.stop();
9697
- }
9698
- };
9699
- /**
9700
- * Stops the watchdog and unsubscribes from callingState changes.
9921
+ * Stops the watchdog and unsubscribes from callingState changes.
9701
9922
  */
9702
9923
  this.dispose = () => {
9703
9924
  this.stop();
9925
+ this.bindings.clear();
9704
9926
  this.unsubscribeCallingState();
9705
9927
  };
9706
9928
  this.start = () => {
@@ -9732,6 +9954,8 @@ class AudioBindingsWatchdog {
9732
9954
  this.stop = () => {
9733
9955
  clearInterval(this.watchdogInterval);
9734
9956
  };
9957
+ this.tracer = tracer;
9958
+ this.state = state;
9735
9959
  this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
9736
9960
  if (!this.enabled)
9737
9961
  return;
@@ -9745,61 +9969,97 @@ class AudioBindingsWatchdog {
9745
9969
  }
9746
9970
  }
9747
9971
 
9748
- const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
9749
- videoTrack: exports.VisibilityState.UNKNOWN,
9750
- screenShareTrack: exports.VisibilityState.UNKNOWN,
9751
- };
9752
- const globalOverrideKey = Symbol('globalOverrideKey');
9753
9972
  /**
9754
- * A manager class that handles dynascale related tasks like:
9755
- *
9756
- * - binding video elements to session ids
9757
- * - binding audio elements to session ids
9758
- * - tracking element visibility
9759
- * - updating subscriptions based on viewport visibility
9760
- * - updating subscriptions based on video element dimensions
9761
- * - updating subscriptions based on published tracks
9973
+ * Tracks audio elements that the browser's autoplay policy has blocked.
9762
9974
  */
9763
- class DynascaleManager {
9764
- /**
9765
- * Creates a new DynascaleManager instance.
9766
- */
9767
- constructor(callState, speaker, tracer) {
9975
+ class BlockedAudioTracker {
9976
+ constructor(tracer) {
9977
+ this.logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
9978
+ this.blockedElementsSubject = new rxjs.BehaviorSubject(new Set());
9768
9979
  /**
9769
- * The viewport tracker instance.
9980
+ * Whether the browser's autoplay policy is blocking audio playback.
9981
+ * Will be `true` when at least one audio element is currently blocked.
9982
+ * Use {@link resumeAudio} within a user gesture to unblock.
9770
9983
  */
9771
- this.viewportTracker = new ViewportTracker();
9772
- this.logger = videoLoggerSystem.getLogger('DynascaleManager');
9773
- this.useWebAudio = false;
9774
- this.pendingSubscriptionsUpdate = null;
9984
+ this.autoplayBlocked$ = this.blockedElementsSubject.pipe(rxjs.map((elements) => elements.size > 0), rxjs.distinctUntilChanged());
9775
9985
  /**
9776
- * Audio elements that were blocked by the browser's autoplay policy.
9777
- * These can be retried by calling `resumeAudio()` from a user gesture.
9986
+ * Registers an audio element as blocked by the browser's autoplay policy.
9778
9987
  */
9779
- this.blockedAudioElementsSubject = new rxjs.BehaviorSubject(new Set());
9780
- /**
9781
- * Whether the browser's autoplay policy is blocking audio playback.
9782
- * Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
9783
- * Use `resumeAudio()` within a user gesture to unblock.
9784
- */
9785
- this.autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(rxjs.map((elements) => elements.size > 0), rxjs.distinctUntilChanged());
9786
- this.addBlockedAudioElement = (audioElement) => {
9787
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9788
- const next = new Set(elements);
9789
- next.add(audioElement);
9790
- return next;
9988
+ this.markBlocked = (audioElement, blocked) => {
9989
+ setCurrentValue(this.blockedElementsSubject, (elements) => {
9990
+ if (blocked)
9991
+ elements.add(audioElement);
9992
+ else
9993
+ elements.delete(audioElement);
9994
+ return elements;
9791
9995
  });
9792
9996
  };
9793
- this.removeBlockedAudioElement = (audioElement) => {
9794
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9795
- const nextElements = new Set(elements);
9796
- nextElements.delete(audioElement);
9797
- return nextElements;
9997
+ /**
9998
+ * Returns whether the given audio element is currently flagged as blocked
9999
+ * by the browser's autoplay policy.
10000
+ */
10001
+ this.isBlocked = (audioElement) => {
10002
+ return this.blockedElementsSubject.getValue().has(audioElement);
10003
+ };
10004
+ /**
10005
+ * Plays all audio elements blocked by the browser's autoplay policy.
10006
+ * Must be called from within a user gesture (e.g., click handler).
10007
+ */
10008
+ this.resumeAudio = async () => {
10009
+ this.tracer.trace('resumeAudio', null);
10010
+ await setCurrentValueAsync(this.blockedElementsSubject, async (elements) => {
10011
+ await Promise.all(Array.from(elements, async (element) => {
10012
+ try {
10013
+ if (element.srcObject)
10014
+ await timeboxed([element.play()], 2000);
10015
+ elements.delete(element);
10016
+ }
10017
+ catch (err) {
10018
+ this.logger.warn(`Can't resume audio for element`, element, err);
10019
+ }
10020
+ }));
10021
+ return elements;
9798
10022
  });
9799
10023
  };
9800
- this.videoTrackSubscriptionOverridesSubject = new rxjs.BehaviorSubject({});
9801
- this.videoTrackSubscriptionOverrides$ = this.videoTrackSubscriptionOverridesSubject.asObservable();
9802
- this.incomingVideoSettings$ = this.videoTrackSubscriptionOverrides$.pipe(rxjs.map((overrides) => {
10024
+ this.tracer = tracer;
10025
+ }
10026
+ }
10027
+
10028
+ /** Symbol key for the "applies to all participants" override slot. */
10029
+ const globalOverrideKey = Symbol('globalOverrideKey');
10030
+ /**
10031
+ * Owns the SFU-side video-subscription machinery for a `Call`:
10032
+ *
10033
+ * - Holds the per-session / global override state in a
10034
+ * `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
10035
+ * - Derives the SFU subscription list from `CallState` participants +
10036
+ * current overrides via the `subscriptions` getter.
10037
+ * - Debounces and pushes the list to the SFU through
10038
+ * `sfuClient.updateSubscriptions`.
10039
+ * - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
10040
+ * the override state for React hooks.
10041
+ *
10042
+ * Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
10043
+ * `DynascaleManager.bindVideoElement` triggers `apply()` on every
10044
+ * dimension / visibility change.
10045
+ */
10046
+ class TrackSubscriptionManager {
10047
+ /**
10048
+ * Constructs new TrackSubscriptionManager instance.
10049
+ *
10050
+ * @param callState the call state.
10051
+ * @param tracer the tracer to use.
10052
+ */
10053
+ constructor(callState, tracer) {
10054
+ this.logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
10055
+ this.pendingUpdate = null;
10056
+ this.overridesSubject = new rxjs.BehaviorSubject({});
10057
+ this.overrides$ = this.overridesSubject.asObservable();
10058
+ /**
10059
+ * Consumer-friendly projection of the override state. Used by the
10060
+ * `useIncomingVideoSettings()` React hook.
10061
+ */
10062
+ this.incomingVideoSettings$ = this.overrides$.pipe(rxjs.map((overrides) => {
9803
10063
  const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
9804
10064
  return {
9805
10065
  enabled: globalSettings?.enabled !== false,
@@ -9821,106 +10081,255 @@ class DynascaleManager {
9821
10081
  };
9822
10082
  }), rxjs.shareReplay(1));
9823
10083
  /**
9824
- * Disposes the allocated resources and closes the audio context if it was created.
10084
+ * Sets the SFU client used by `apply()` to push subscription updates.
10085
+ * Called by the owner on call join; cleared on leave.
9825
10086
  */
9826
- this.dispose = async () => {
9827
- if (this.pendingSubscriptionsUpdate) {
9828
- clearTimeout(this.pendingSubscriptionsUpdate);
9829
- }
9830
- this.audioBindingsWatchdog?.dispose();
9831
- setCurrentValue(this.blockedAudioElementsSubject, new Set());
9832
- const context = this.audioContext;
9833
- if (context && context.state !== 'closed') {
9834
- document.removeEventListener('click', this.resumeAudioContext);
9835
- await context.close();
9836
- this.audioContext = undefined;
10087
+ this.setSfuClient = (sfuClient) => {
10088
+ this.sfuClient = sfuClient;
10089
+ };
10090
+ /**
10091
+ * Cancels any pending debounced subscription push. Idempotent.
10092
+ */
10093
+ this.dispose = () => {
10094
+ if (this.pendingUpdate) {
10095
+ clearTimeout(this.pendingUpdate);
10096
+ this.pendingUpdate = null;
9837
10097
  }
9838
10098
  };
9839
- this.setVideoTrackSubscriptionOverrides = (override, sessionIds) => {
9840
- this.tracer.trace('setVideoTrackSubscriptionOverrides', [
9841
- override,
9842
- sessionIds,
9843
- ]);
10099
+ /**
10100
+ * Sets video-subscription overrides. Called by
10101
+ * `Call.setIncomingVideoEnabled` and
10102
+ * `Call.setPreferredIncomingVideoResolution`.
10103
+ *
10104
+ * - `sessionIds` omitted → applies `override` globally (or clears the
10105
+ * global override if `override` is `undefined`).
10106
+ * - `sessionIds` provided → applies `override` to each listed session.
10107
+ */
10108
+ this.setOverrides = (override, sessionIds) => {
10109
+ this.tracer.trace('setOverrides', [override, sessionIds]);
9844
10110
  if (!sessionIds) {
9845
- return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, override ? { [globalOverrideKey]: override } : {});
10111
+ return setCurrentValue(this.overridesSubject, override ? { [globalOverrideKey]: override } : {});
9846
10112
  }
9847
- return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, (overrides) => ({
10113
+ return setCurrentValue(this.overridesSubject, (overrides) => ({
9848
10114
  ...overrides,
9849
10115
  ...Object.fromEntries(sessionIds.map((id) => [id, override])),
9850
10116
  }));
9851
10117
  };
9852
- this.applyTrackSubscriptions = (debounceType = exports.DebounceType.SLOW) => {
9853
- if (this.pendingSubscriptionsUpdate) {
9854
- clearTimeout(this.pendingSubscriptionsUpdate);
10118
+ /**
10119
+ * Pushes `subscriptions` to the SFU. Debounced by `debounceType`
10120
+ * (SLOW by default). Multiple rapid calls coalesce into one RPC.
10121
+ * Passing `0` fires synchronously.
10122
+ */
10123
+ this.apply = (debounceType = exports.DebounceType.SLOW) => {
10124
+ if (this.pendingUpdate) {
10125
+ clearTimeout(this.pendingUpdate);
9855
10126
  }
9856
10127
  const updateSubscriptions = () => {
9857
- this.pendingSubscriptionsUpdate = null;
10128
+ this.pendingUpdate = null;
9858
10129
  this.sfuClient
9859
- ?.updateSubscriptions(this.trackSubscriptions)
10130
+ ?.updateSubscriptions(this.subscriptions)
9860
10131
  .catch((err) => {
9861
10132
  this.logger.debug(`Failed to update track subscriptions`, err);
9862
10133
  });
9863
10134
  };
9864
10135
  if (debounceType) {
9865
- this.pendingSubscriptionsUpdate = setTimeout(updateSubscriptions, debounceType);
10136
+ this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
9866
10137
  }
9867
10138
  else {
9868
10139
  updateSubscriptions();
9869
10140
  }
9870
10141
  };
9871
- /**
9872
- * Will begin tracking the given element for visibility changes within the
9873
- * configured viewport element (`call.setViewport`).
9874
- *
9875
- * @param element the element to track.
9876
- * @param sessionId the session id.
9877
- * @param trackType the kind of video.
9878
- * @returns Untrack.
9879
- */
9880
- this.trackElementVisibility = (element, sessionId, trackType) => {
9881
- const cleanup = this.viewportTracker.observe(element, (entry) => {
9882
- this.callState.updateParticipant(sessionId, (participant) => {
9883
- const previousVisibilityState = participant.viewportVisibilityState ??
9884
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
9885
- // observer triggers when the element is "moved" to be a fullscreen element
9886
- // keep it VISIBLE if that happens to prevent fullscreen with placeholder
9887
- const isVisible = entry.isIntersecting || document.fullscreenElement === element
9888
- ? exports.VisibilityState.VISIBLE
9889
- : exports.VisibilityState.INVISIBLE;
9890
- return {
9891
- ...participant,
9892
- viewportVisibilityState: {
9893
- ...previousVisibilityState,
9894
- [trackType]: isVisible,
9895
- },
9896
- };
10142
+ this.tracer = tracer;
10143
+ this.callState = callState;
10144
+ }
10145
+ /**
10146
+ * The current SFU subscription list, computed from `CallState`
10147
+ * participants and the override state. Used by:
10148
+ *
10149
+ * - `apply()` to push to the SFU each time the set changes.
10150
+ * - `Call.getReconnectDetails` to include the subscription list in
10151
+ * the reconnect payload.
10152
+ */
10153
+ get subscriptions() {
10154
+ const subscriptions = [];
10155
+ // Use getParticipantsSnapshot() to bypass the observable pipeline
10156
+ // and avoid stale data caused by shareReplay with no active subscribers
10157
+ const participants = this.callState.getParticipantsSnapshot();
10158
+ const overrides = this.overridesSubject.getValue();
10159
+ for (const p of participants) {
10160
+ if (p.isLocalParticipant)
10161
+ continue;
10162
+ // NOTE: audio tracks don't have to be requested explicitly
10163
+ // as the SFU will implicitly subscribe us to all of them,
10164
+ // once they become available.
10165
+ if (p.videoDimension && hasVideo(p)) {
10166
+ const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
10167
+ if (override?.enabled !== false) {
10168
+ subscriptions.push({
10169
+ userId: p.userId,
10170
+ sessionId: p.sessionId,
10171
+ trackType: TrackType.VIDEO,
10172
+ dimension: override?.dimension ?? p.videoDimension,
10173
+ });
10174
+ }
10175
+ }
10176
+ if (p.screenShareDimension && hasScreenShare(p)) {
10177
+ subscriptions.push({
10178
+ userId: p.userId,
10179
+ sessionId: p.sessionId,
10180
+ trackType: TrackType.SCREEN_SHARE,
10181
+ dimension: p.screenShareDimension,
9897
10182
  });
10183
+ }
10184
+ if (hasScreenShareAudio(p)) {
10185
+ subscriptions.push({
10186
+ userId: p.userId,
10187
+ sessionId: p.sessionId,
10188
+ trackType: TrackType.SCREEN_SHARE_AUDIO,
10189
+ });
10190
+ }
10191
+ }
10192
+ return subscriptions;
10193
+ }
10194
+ get overrides() {
10195
+ return getCurrentValue(this.overrides$);
10196
+ }
10197
+ }
10198
+
10199
+ /**
10200
+ * Watches a single audio or video element and attempts to recover playback
10201
+ * after the element transitions to a paused or suspended state unexpectedly.
10202
+ */
10203
+ class MediaPlaybackWatchdog {
10204
+ constructor(opts) {
10205
+ this.logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
10206
+ this.controller = new AbortController();
10207
+ this.attempt = 0;
10208
+ this.disposed = false;
10209
+ this.attach = () => {
10210
+ if (this.disposed)
10211
+ return;
10212
+ const { signal } = this.controller;
10213
+ this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
10214
+ this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
10215
+ this.element.addEventListener('playing', this.onPlaying, { signal });
10216
+ };
10217
+ this.dispose = () => {
10218
+ if (this.disposed)
10219
+ return;
10220
+ this.disposed = true;
10221
+ this.controller.abort();
10222
+ if (this.pendingTimer)
10223
+ clearTimeout(this.pendingTimer);
10224
+ this.pendingTimer = undefined;
10225
+ };
10226
+ this.onPlaying = () => {
10227
+ if (this.attempt > 0) {
10228
+ this.tracer.trace('mediaPlayback.recover.success', {
10229
+ kind: this.kind,
10230
+ attempts: this.attempt,
10231
+ });
10232
+ }
10233
+ this.attempt = 0;
10234
+ if (this.pendingTimer)
10235
+ clearTimeout(this.pendingTimer);
10236
+ this.pendingTimer = undefined;
10237
+ };
10238
+ this.onPauseOrSuspend = (event) => {
10239
+ if (this.disposed)
10240
+ return;
10241
+ this.tracer.trace('mediaPlayback.paused', {
10242
+ kind: this.kind,
10243
+ reason: event.type,
9898
10244
  });
9899
- return () => {
9900
- cleanup();
9901
- // reset visibility state to UNKNOWN upon cleanup
9902
- // so that the layouts that are not actively observed
9903
- // can still function normally (runtime layout switching)
9904
- this.callState.updateParticipant(sessionId, (participant) => {
9905
- const previousVisibilityState = participant.viewportVisibilityState ??
9906
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
9907
- return {
9908
- ...participant,
9909
- viewportVisibilityState: {
9910
- ...previousVisibilityState,
9911
- [trackType]: exports.VisibilityState.UNKNOWN,
9912
- },
9913
- };
10245
+ this.scheduleRecovery();
10246
+ };
10247
+ this.scheduleRecovery = () => {
10248
+ if (this.disposed || this.pendingTimer)
10249
+ return;
10250
+ const skipReason = this.computeSkipReason();
10251
+ if (skipReason) {
10252
+ this.tracer.trace('mediaPlayback.recover.skipped', {
10253
+ kind: this.kind,
10254
+ reason: skipReason,
9914
10255
  });
9915
- };
10256
+ return;
10257
+ }
10258
+ const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
10259
+ this.pendingTimer = setTimeout(this.attemptPlay, delay);
10260
+ };
10261
+ this.computeSkipReason = () => {
10262
+ if (this.disposed)
10263
+ return 'disposed';
10264
+ if (!this.element.srcObject)
10265
+ return 'noSrc';
10266
+ if (this.element.ended)
10267
+ return 'ended';
10268
+ if (this.isBlocked())
10269
+ return 'blocked';
10270
+ const HAVE_CURRENT_DATA = 2;
10271
+ if (this.element.readyState < HAVE_CURRENT_DATA)
10272
+ return 'notReady';
10273
+ if (!this.element.paused)
10274
+ return 'notPaused';
10275
+ };
10276
+ this.attemptPlay = async () => {
10277
+ this.pendingTimer = undefined;
10278
+ if (this.disposed)
10279
+ return;
10280
+ this.attempt += 1;
10281
+ this.tracer.trace('mediaPlayback.recover.attempt', {
10282
+ kind: this.kind,
10283
+ attempt: this.attempt,
10284
+ });
10285
+ try {
10286
+ await timeboxed([this.element.play()], 2000);
10287
+ }
10288
+ catch (err) {
10289
+ if (this.disposed)
10290
+ return;
10291
+ this.logger.warn(`Failed to recover ${this.kind} playback`, err);
10292
+ if (this.attempt >= 10) {
10293
+ this.tracer.trace('mediaPlayback.recover.giveUp', {
10294
+ kind: this.kind,
10295
+ attempts: this.attempt,
10296
+ });
10297
+ return;
10298
+ }
10299
+ this.scheduleRecovery();
10300
+ }
9916
10301
  };
10302
+ this.element = opts.element;
10303
+ this.kind = opts.kind;
10304
+ this.tracer = opts.tracer;
10305
+ this.isBlocked = opts.isBlocked ?? (() => false);
10306
+ this.attach();
10307
+ }
10308
+ }
10309
+
10310
+ /**
10311
+ * A manager class that handles dynascale related tasks like:
10312
+ *
10313
+ * - binding video elements to session ids
10314
+ * - binding audio elements to session ids
10315
+ */
10316
+ class DynascaleManager {
10317
+ /**
10318
+ * Creates a new DynascaleManager instance.
10319
+ */
10320
+ constructor(callState, speaker, tracer, trackSubscriptionManager, blockedAudioTracker) {
10321
+ this.logger = videoLoggerSystem.getLogger('DynascaleManager');
10322
+ this.useWebAudio = false;
9917
10323
  /**
9918
- * Sets the viewport element to track bound video elements for visibility.
9919
- *
9920
- * @param element the viewport element.
10324
+ * Closes the audio context if it was created.
9921
10325
  */
9922
- this.setViewport = (element) => {
9923
- return this.viewportTracker.setViewport(element);
10326
+ this.dispose = async () => {
10327
+ const context = this.audioContext;
10328
+ if (context && context.state !== 'closed') {
10329
+ document.removeEventListener('click', this.resumeAudioContext);
10330
+ await context.close();
10331
+ this.audioContext = undefined;
10332
+ }
9924
10333
  };
9925
10334
  /**
9926
10335
  * Sets whether to use WebAudio API for audio playback.
@@ -9965,7 +10374,7 @@ class DynascaleManager {
9965
10374
  this.callState.updateParticipantTracks(trackType, {
9966
10375
  [sessionId]: { dimension },
9967
10376
  });
9968
- this.applyTrackSubscriptions(debounceType);
10377
+ this.trackSubscriptionManager.apply(debounceType);
9969
10378
  };
9970
10379
  const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((participant) => !!participant), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
9971
10380
  /**
@@ -10054,6 +10463,11 @@ class DynascaleManager {
10054
10463
  // without prior user interaction:
10055
10464
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
10056
10465
  videoElement.muted = true;
10466
+ const playbackWatchdog = new MediaPlaybackWatchdog({
10467
+ element: videoElement,
10468
+ kind: 'video',
10469
+ tracer: this.tracer,
10470
+ });
10057
10471
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
10058
10472
  const streamSubscription = participant$
10059
10473
  .pipe(rxjs.distinctUntilKeyChanged(trackKey))
@@ -10063,14 +10477,14 @@ class DynascaleManager {
10063
10477
  return;
10064
10478
  videoElement.srcObject = source ?? null;
10065
10479
  if (isSafari() || isFirefox()) {
10066
- setTimeout(() => {
10480
+ setTimeout(async () => {
10067
10481
  videoElement.srcObject = source ?? null;
10068
- videoElement.play().catch((e) => {
10482
+ try {
10483
+ await timeboxed([videoElement.play()], 2000);
10484
+ }
10485
+ catch (e) {
10069
10486
  this.logger.warn(`Failed to play stream`, e);
10070
- });
10071
- // we add extra delay until we attempt to force-play
10072
- // the participant's media stream in Firefox and Safari,
10073
- // as they seem to have some timing issues
10487
+ }
10074
10488
  }, 25);
10075
10489
  }
10076
10490
  });
@@ -10080,6 +10494,7 @@ class DynascaleManager {
10080
10494
  publishedTracksSubscription?.unsubscribe();
10081
10495
  streamSubscription.unsubscribe();
10082
10496
  resizeObserver?.disconnect();
10497
+ playbackWatchdog.dispose();
10083
10498
  };
10084
10499
  };
10085
10500
  /**
@@ -10097,7 +10512,6 @@ class DynascaleManager {
10097
10512
  const participant = this.callState.findParticipantBySessionId(sessionId);
10098
10513
  if (!participant || participant.isLocalParticipant)
10099
10514
  return;
10100
- this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
10101
10515
  const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((p) => !!p), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
10102
10516
  const updateSinkId = (deviceId, audioContext) => {
10103
10517
  if (!deviceId)
@@ -10116,6 +10530,7 @@ class DynascaleManager {
10116
10530
  };
10117
10531
  let sourceNode = undefined;
10118
10532
  let gainNode = undefined;
10533
+ let audioWatchdog = undefined;
10119
10534
  const isAudioTrack = trackType === 'audioTrack';
10120
10535
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
10121
10536
  const updateMediaStreamSubscription = participant$
@@ -10126,8 +10541,10 @@ class DynascaleManager {
10126
10541
  return;
10127
10542
  setTimeout(() => {
10128
10543
  audioElement.srcObject = source ?? null;
10544
+ audioWatchdog?.dispose();
10545
+ audioWatchdog = undefined;
10129
10546
  if (!source) {
10130
- this.removeBlockedAudioElement(audioElement);
10547
+ this.blockedAudioTracker.markBlocked(audioElement, false);
10131
10548
  return;
10132
10549
  }
10133
10550
  // Safari has a special quirk that prevents playing audio until the user
@@ -10155,10 +10572,16 @@ class DynascaleManager {
10155
10572
  this.tracer.trace('audioPlaybackError', e.message);
10156
10573
  if (e.name === 'NotAllowedError') {
10157
10574
  this.tracer.trace('audioPlaybackBlocked', null);
10158
- this.addBlockedAudioElement(audioElement);
10575
+ this.blockedAudioTracker.markBlocked(audioElement, true);
10159
10576
  }
10160
10577
  this.logger.warn(`Failed to play audio stream`, e);
10161
10578
  });
10579
+ audioWatchdog = new MediaPlaybackWatchdog({
10580
+ element: audioElement,
10581
+ kind: 'audio',
10582
+ tracer: this.tracer,
10583
+ isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
10584
+ });
10162
10585
  }
10163
10586
  const { selectedDevice } = this.speaker.state;
10164
10587
  if (selectedDevice)
@@ -10182,38 +10605,17 @@ class DynascaleManager {
10182
10605
  });
10183
10606
  audioElement.autoplay = true;
10184
10607
  return () => {
10185
- this.audioBindingsWatchdog?.unregister(sessionId, trackType);
10186
- this.removeBlockedAudioElement(audioElement);
10608
+ this.blockedAudioTracker.markBlocked(audioElement, false);
10187
10609
  sinkIdSubscription?.unsubscribe();
10188
10610
  volumeSubscription.unsubscribe();
10189
10611
  updateMediaStreamSubscription.unsubscribe();
10190
10612
  audioElement.srcObject = null;
10191
10613
  sourceNode?.disconnect();
10192
10614
  gainNode?.disconnect();
10615
+ audioWatchdog?.dispose();
10616
+ audioWatchdog = undefined;
10193
10617
  };
10194
10618
  };
10195
- /**
10196
- * Plays all audio elements blocked by the browser's autoplay policy.
10197
- * Must be called from within a user gesture (e.g., click handler).
10198
- *
10199
- * @returns a promise that resolves when all blocked elements have been retried.
10200
- */
10201
- this.resumeAudio = async () => {
10202
- this.tracer.trace('resumeAudio', null);
10203
- const blocked = new Set();
10204
- await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
10205
- try {
10206
- if (el.srcObject) {
10207
- await el.play();
10208
- }
10209
- }
10210
- catch {
10211
- this.logger.warn(`Can't resume audio for element: `, el);
10212
- blocked.add(el);
10213
- }
10214
- }));
10215
- setCurrentValue(this.blockedAudioElementsSubject, blocked);
10216
- };
10217
10619
  this.getOrCreateAudioContext = () => {
10218
10620
  if (!this.useWebAudio)
10219
10621
  return;
@@ -10266,57 +10668,124 @@ class DynascaleManager {
10266
10668
  this.callState = callState;
10267
10669
  this.speaker = speaker;
10268
10670
  this.tracer = tracer;
10269
- if (!isReactNative()) {
10270
- this.audioBindingsWatchdog = new AudioBindingsWatchdog(callState, tracer);
10271
- }
10272
- }
10273
- setSfuClient(sfuClient) {
10274
- this.sfuClient = sfuClient;
10671
+ this.trackSubscriptionManager = trackSubscriptionManager;
10672
+ this.blockedAudioTracker = blockedAudioTracker;
10275
10673
  }
10276
- get trackSubscriptions() {
10277
- const subscriptions = [];
10278
- // Use getParticipantsSnapshot() to bypass the observable pipeline
10279
- // and avoid stale data caused by shareReplay with no active subscribers
10280
- const participants = this.callState.getParticipantsSnapshot();
10281
- const videoTrackSubscriptionOverrides = this.videoTrackSubscriptionOverridesSubject.getValue();
10282
- for (const p of participants) {
10283
- if (p.isLocalParticipant)
10284
- continue;
10285
- // NOTE: audio tracks don't have to be requested explicitly
10286
- // as the SFU will implicitly subscribe us to all of them,
10287
- // once they become available.
10288
- if (p.videoDimension && hasVideo(p)) {
10289
- const override = videoTrackSubscriptionOverrides[p.sessionId] ??
10290
- videoTrackSubscriptionOverrides[globalOverrideKey];
10291
- if (override?.enabled !== false) {
10292
- subscriptions.push({
10293
- userId: p.userId,
10294
- sessionId: p.sessionId,
10295
- trackType: TrackType.VIDEO,
10296
- dimension: override?.dimension ?? p.videoDimension,
10297
- });
10298
- }
10299
- }
10300
- if (p.screenShareDimension && hasScreenShare(p)) {
10301
- subscriptions.push({
10302
- userId: p.userId,
10303
- sessionId: p.sessionId,
10304
- trackType: TrackType.SCREEN_SHARE,
10305
- dimension: p.screenShareDimension,
10674
+ }
10675
+
10676
+ const DEFAULT_THRESHOLD = 0.35;
10677
+ const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
10678
+ videoTrack: exports.VisibilityState.UNKNOWN,
10679
+ screenShareTrack: exports.VisibilityState.UNKNOWN,
10680
+ };
10681
+ class ViewportTracker {
10682
+ constructor(callState) {
10683
+ this.elementHandlerMap = new Map();
10684
+ this.observer = null;
10685
+ // in React children render before viewport is set, add
10686
+ // them to the queue and observe them once the observer is ready
10687
+ this.queueSet = new Set();
10688
+ /**
10689
+ * Method to set scrollable viewport as root for the IntersectionObserver, returns
10690
+ * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
10691
+ */
10692
+ this.setViewport = (viewportElement, options) => {
10693
+ const cleanup = () => {
10694
+ this.observer?.disconnect();
10695
+ this.observer = null;
10696
+ this.elementHandlerMap.clear();
10697
+ };
10698
+ this.observer = new IntersectionObserver((entries) => {
10699
+ entries.forEach((entry) => {
10700
+ const handler = this.elementHandlerMap.get(entry.target);
10701
+ handler?.(entry);
10306
10702
  });
10307
- }
10308
- if (hasScreenShareAudio(p)) {
10309
- subscriptions.push({
10310
- userId: p.userId,
10311
- sessionId: p.sessionId,
10312
- trackType: TrackType.SCREEN_SHARE_AUDIO,
10703
+ }, {
10704
+ root: viewportElement,
10705
+ ...options,
10706
+ threshold: options?.threshold ?? DEFAULT_THRESHOLD,
10707
+ });
10708
+ if (this.queueSet.size) {
10709
+ this.queueSet.forEach(([queueElement, queueHandler]) => {
10710
+ // check if element which requested observation is
10711
+ // a child of a viewport element, skip if isn't
10712
+ if (!viewportElement.contains(queueElement))
10713
+ return;
10714
+ this.observer.observe(queueElement);
10715
+ this.elementHandlerMap.set(queueElement, queueHandler);
10313
10716
  });
10717
+ this.queueSet.clear();
10314
10718
  }
10315
- }
10316
- return subscriptions;
10317
- }
10318
- get videoTrackSubscriptionOverrides() {
10319
- return getCurrentValue(this.videoTrackSubscriptionOverrides$);
10719
+ return cleanup;
10720
+ };
10721
+ /**
10722
+ * Method to set element to observe and handler to be triggered whenever IntersectionObserver
10723
+ * detects a possible change in element's visibility within specified viewport, returns
10724
+ * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
10725
+ */
10726
+ this.observe = (element, handler) => {
10727
+ const queueItem = [element, handler];
10728
+ const cleanup = () => {
10729
+ this.elementHandlerMap.delete(element);
10730
+ this.observer?.unobserve(element);
10731
+ this.queueSet.delete(queueItem);
10732
+ };
10733
+ if (this.elementHandlerMap.has(element))
10734
+ return cleanup;
10735
+ if (!this.observer) {
10736
+ this.queueSet.add(queueItem);
10737
+ return cleanup;
10738
+ }
10739
+ if (this.observer.root.contains(element)) {
10740
+ this.elementHandlerMap.set(element, handler);
10741
+ this.observer.observe(element);
10742
+ }
10743
+ return cleanup;
10744
+ };
10745
+ /**
10746
+ * Tracks the given element for visibility changes and mirrors the result
10747
+ * into `participant.viewportVisibilityState[trackType]` in `CallState`.
10748
+ * Returns a function that unobserves the element and resets the visibility
10749
+ * state back to `UNKNOWN`.
10750
+ */
10751
+ this.trackElementVisibility = (element, sessionId, trackType) => {
10752
+ const cleanup = this.observe(element, (entry) => {
10753
+ this.callState.updateParticipant(sessionId, (participant) => {
10754
+ const previousVisibilityState = participant.viewportVisibilityState ??
10755
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
10756
+ // observer triggers when the element is "moved" to be a fullscreen element
10757
+ // keep it VISIBLE if that happens to prevent fullscreen with placeholder
10758
+ const isVisible = entry.isIntersecting || document.fullscreenElement === element
10759
+ ? exports.VisibilityState.VISIBLE
10760
+ : exports.VisibilityState.INVISIBLE;
10761
+ return {
10762
+ ...participant,
10763
+ viewportVisibilityState: {
10764
+ ...previousVisibilityState,
10765
+ [trackType]: isVisible,
10766
+ },
10767
+ };
10768
+ });
10769
+ });
10770
+ return () => {
10771
+ cleanup();
10772
+ // reset visibility state to UNKNOWN upon cleanup
10773
+ // so that the layouts that are not actively observed
10774
+ // can still function normally (runtime layout switching)
10775
+ this.callState.updateParticipant(sessionId, (participant) => {
10776
+ const previousVisibilityState = participant.viewportVisibilityState ??
10777
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
10778
+ return {
10779
+ ...participant,
10780
+ viewportVisibilityState: {
10781
+ ...previousVisibilityState,
10782
+ [trackType]: exports.VisibilityState.UNKNOWN,
10783
+ },
10784
+ };
10785
+ });
10786
+ };
10787
+ };
10788
+ this.callState = callState;
10320
10789
  }
10321
10790
  }
10322
10791
 
@@ -10983,8 +11452,8 @@ const normalize = (options) => {
10983
11452
  : false,
10984
11453
  };
10985
11454
  };
10986
- const createSyntheticDevice = (deviceId, kind) => {
10987
- return { deviceId, kind, label: '', groupId: '' };
11455
+ const createSyntheticDevice = (deviceId, kind, label = '') => {
11456
+ return { deviceId, kind, label, groupId: '' };
10988
11457
  };
10989
11458
  const readPreferences = (storageKey) => {
10990
11459
  try {
@@ -11034,9 +11503,12 @@ class DeviceManager {
11034
11503
  */
11035
11504
  this.stopOnLeave = true;
11036
11505
  this.subscriptions = [];
11506
+ this.currentStreamCleanups = [];
11037
11507
  this.areSubscriptionsSetUp = false;
11038
11508
  this.isTrackStoppedDueToTrackEnd = false;
11039
11509
  this.filters = [];
11510
+ this.virtualDevicesSubject = new rxjs.BehaviorSubject([]);
11511
+ this.virtualDeviceConcurrencyTag = Symbol('virtualDeviceConcurrencyTag');
11040
11512
  this.statusChangeConcurrencyTag = Symbol('statusChangeConcurrencyTag');
11041
11513
  this.filterRegistrationConcurrencyTag = Symbol('filterRegistrationConcurrencyTag');
11042
11514
  /**
@@ -11045,9 +11517,30 @@ class DeviceManager {
11045
11517
  * @internal
11046
11518
  */
11047
11519
  this.dispose = () => {
11520
+ this.runCurrentStreamCleanups();
11048
11521
  this.subscriptions.forEach((s) => s());
11049
11522
  this.subscriptions = [];
11050
11523
  this.areSubscriptionsSetUp = false;
11524
+ this.virtualDevicesSubject.next([]);
11525
+ };
11526
+ this.runCurrentStreamCleanups = () => {
11527
+ this.currentStreamCleanups.forEach((c) => c());
11528
+ this.currentStreamCleanups = [];
11529
+ };
11530
+ this.setLocalInterrupted = (interrupted) => {
11531
+ const localParticipant = this.call.state.localParticipant;
11532
+ if (!localParticipant)
11533
+ return;
11534
+ this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
11535
+ const current = p.interruptedTracks ?? [];
11536
+ const has = current.includes(this.trackType);
11537
+ if (interrupted === has)
11538
+ return {};
11539
+ const next = interrupted
11540
+ ? pushToIfMissing([...current], this.trackType)
11541
+ : removeFromIfPresent([...current], this.trackType);
11542
+ return { interruptedTracks: next };
11543
+ });
11051
11544
  };
11052
11545
  this.call = call;
11053
11546
  this.state = state;
@@ -11080,14 +11573,100 @@ class DeviceManager {
11080
11573
  }
11081
11574
  }
11082
11575
  /**
11083
- * Lists the available audio/video devices
11576
+ * Lists the available audio/video devices
11577
+ *
11578
+ * Note: It prompts the user for a permission to use devices (if not already granted)
11579
+ *
11580
+ * @returns an Observable that will be updated if a device is connected or disconnected
11581
+ */
11582
+ listDevices() {
11583
+ return rxjs.combineLatest([this.getDevices(), this.virtualDevicesSubject]).pipe(rxjs.map(([real, virtual]) => [
11584
+ ...real,
11585
+ ...virtual.map((d) => createSyntheticDevice(d.deviceId, d.kind, d.label)),
11586
+ ]));
11587
+ }
11588
+ /**
11589
+ * Registers a virtual camera or microphone backed by a caller-supplied
11590
+ * stream factory. The device appears in `listDevices()` and can be selected
11591
+ * via `select()` like any real device.
11084
11592
  *
11085
- * Note: It prompts the user for a permission to use devices (if not already granted)
11593
+ * Web only. React Native is not supported.
11086
11594
  *
11087
- * @returns an Observable that will be updated if a device is connected or disconnected
11595
+ * Only supported for camera and microphone managers; calling on any other
11596
+ * manager throws.
11088
11597
  */
11089
- listDevices() {
11090
- return this.getDevices();
11598
+ registerVirtualDevice(virtualDevice) {
11599
+ if (isReactNative()) {
11600
+ throw new Error('Virtual devices are not supported on React Native.');
11601
+ }
11602
+ if (this.trackType !== TrackType.AUDIO &&
11603
+ this.trackType !== TrackType.VIDEO) {
11604
+ throw new Error('Virtual devices are only supported for camera and microphone.');
11605
+ }
11606
+ const deviceId = `stream-virtual:${generateUUIDv4()}`;
11607
+ const entry = {
11608
+ deviceId,
11609
+ kind: this.mediaDeviceKind,
11610
+ ...virtualDevice,
11611
+ };
11612
+ setCurrentValue(this.virtualDevicesSubject, (current) => [
11613
+ ...current,
11614
+ entry,
11615
+ ]);
11616
+ return {
11617
+ deviceId: entry.deviceId,
11618
+ unregister: async () => {
11619
+ await withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
11620
+ setCurrentValue(this.virtualDevicesSubject, (current) => current.filter((d) => d !== entry));
11621
+ if (this.activeVirtualSession?.deviceId === deviceId) {
11622
+ await this.stopActiveVirtualSession();
11623
+ }
11624
+ });
11625
+ if (this.state.selectedDevice === deviceId) {
11626
+ await this.statusChangeSettled();
11627
+ await this.disable({ forceStop: true });
11628
+ await this.select(undefined);
11629
+ }
11630
+ },
11631
+ };
11632
+ }
11633
+ sanitizeVirtualStream(stream) {
11634
+ stream.getTracks().forEach((track) => {
11635
+ const originalGetSettings = track.getSettings.bind(track);
11636
+ track.getSettings = () => {
11637
+ const settings = originalGetSettings();
11638
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
11639
+ const { deviceId, ...rest } = settings;
11640
+ return rest;
11641
+ };
11642
+ });
11643
+ return stream;
11644
+ }
11645
+ findVirtualDevice(deviceId) {
11646
+ if (!deviceId)
11647
+ return undefined;
11648
+ return getCurrentValue(this.virtualDevicesSubject).find((d) => d.deviceId === deviceId);
11649
+ }
11650
+ async stopActiveVirtualSession() {
11651
+ const session = this.activeVirtualSession;
11652
+ this.activeVirtualSession = undefined;
11653
+ await session?.stop?.();
11654
+ }
11655
+ async getSelectedStream(constraints) {
11656
+ const deviceId = this.state.selectedDevice;
11657
+ if (!deviceId?.startsWith('stream-virtual')) {
11658
+ return this.getStream(constraints);
11659
+ }
11660
+ return withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
11661
+ const virtualDevice = this.findVirtualDevice(deviceId);
11662
+ if (!virtualDevice) {
11663
+ throw new Error(`Virtual device is not registered: ${deviceId}`);
11664
+ }
11665
+ await this.stopActiveVirtualSession();
11666
+ const { stream, stop } = await virtualDevice.getUserMedia(constraints);
11667
+ this.activeVirtualSession = { deviceId, stop };
11668
+ return this.sanitizeVirtualStream(stream);
11669
+ });
11091
11670
  }
11092
11671
  /**
11093
11672
  * Returns `true` when this device is in enabled state.
@@ -11247,6 +11826,9 @@ class DeviceManager {
11247
11826
  }
11248
11827
  });
11249
11828
  }
11829
+ getResolvedConstraints(constraints) {
11830
+ return constraints;
11831
+ }
11250
11832
  publishStream(stream, options) {
11251
11833
  return this.call.publish(stream, this.trackType, options);
11252
11834
  }
@@ -11267,12 +11849,15 @@ class DeviceManager {
11267
11849
  this.muteLocalStream(stopTracks);
11268
11850
  const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
11269
11851
  if (allEnded) {
11852
+ await this.stopActiveVirtualSession();
11270
11853
  // @ts-expect-error release() is present in react-native-webrtc
11271
11854
  if (typeof mediaStream.release === 'function') {
11272
11855
  // @ts-expect-error called to dispose the stream in RN
11273
11856
  mediaStream.release();
11274
11857
  }
11858
+ this.runCurrentStreamCleanups();
11275
11859
  this.state.setMediaStream(undefined, undefined);
11860
+ this.setLocalInterrupted(false);
11276
11861
  this.filters.forEach((entry) => entry.stop?.());
11277
11862
  }
11278
11863
  }
@@ -11308,20 +11893,24 @@ class DeviceManager {
11308
11893
  async unmuteStream() {
11309
11894
  this.logger.debug('Starting stream');
11310
11895
  let stream;
11311
- let rootStream;
11896
+ let rootStreamPromise;
11312
11897
  if (this.state.mediaStream &&
11313
11898
  this.getTracks().every((t) => t.readyState === 'live')) {
11314
11899
  stream = this.state.mediaStream;
11315
11900
  this.enableTracks();
11316
11901
  }
11317
11902
  else {
11903
+ // We are about to compose a fresh filter chain and acquire a new
11904
+ // root stream. Drop any listeners bound to the previous root stream
11905
+ // before chainWith below registers new ones for the new chain.
11906
+ this.runCurrentStreamCleanups();
11318
11907
  const defaultConstraints = this.state.defaultConstraints;
11319
- const constraints = {
11908
+ const constraints = this.getResolvedConstraints({
11320
11909
  ...defaultConstraints,
11321
11910
  deviceId: this.state.selectedDevice
11322
11911
  ? { exact: this.state.selectedDevice }
11323
11912
  : undefined,
11324
- };
11913
+ });
11325
11914
  /**
11326
11915
  * Chains two media streams together.
11327
11916
  *
@@ -11370,7 +11959,7 @@ class DeviceManager {
11370
11959
  });
11371
11960
  };
11372
11961
  parentTrack.addEventListener('ended', handleParentTrackEnded);
11373
- this.subscriptions.push(() => {
11962
+ this.currentStreamCleanups.push(() => {
11374
11963
  parentTrack.removeEventListener('ended', handleParentTrackEnded);
11375
11964
  });
11376
11965
  });
@@ -11378,7 +11967,7 @@ class DeviceManager {
11378
11967
  };
11379
11968
  // the rootStream represents the stream coming from the actual device
11380
11969
  // e.g. camera or microphone stream
11381
- rootStream = this.getStream(constraints);
11970
+ rootStreamPromise = this.getSelectedStream(constraints);
11382
11971
  // we publish the last MediaStream of the chain
11383
11972
  stream = await this.filters.reduce((parent, entry) => parent
11384
11973
  .then((inputStream) => {
@@ -11389,42 +11978,70 @@ class DeviceManager {
11389
11978
  .then(chainWith(parent), (error) => {
11390
11979
  this.logger.warn('Filter failed to start and will be ignored', error);
11391
11980
  return parent;
11392
- }), rootStream);
11981
+ }), rootStreamPromise);
11393
11982
  }
11394
11983
  if (this.call.state.callingState === exports.CallingState.JOINED) {
11395
11984
  await this.publishStream(stream);
11396
11985
  }
11397
11986
  if (this.state.mediaStream !== stream) {
11398
- this.state.setMediaStream(stream, await rootStream);
11399
- const handleTrackEnded = async () => {
11400
- await this.statusChangeSettled();
11401
- if (this.enabled) {
11402
- this.isTrackStoppedDueToTrackEnd = true;
11403
- setTimeout(() => {
11404
- this.isTrackStoppedDueToTrackEnd = false;
11405
- }, 2000);
11406
- await this.disable();
11407
- }
11408
- };
11409
- const createTrackMuteHandler = (muted) => () => {
11410
- if (!isMobile() || this.trackType !== TrackType.VIDEO)
11411
- return;
11412
- this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
11413
- this.logger.warn('Error while notifying track mute state', err);
11414
- });
11415
- };
11416
- stream.getTracks().forEach((track) => {
11417
- const muteHandler = createTrackMuteHandler(true);
11418
- const unmuteHandler = createTrackMuteHandler(false);
11419
- track.addEventListener('mute', muteHandler);
11420
- track.addEventListener('unmute', unmuteHandler);
11421
- track.addEventListener('ended', handleTrackEnded);
11422
- this.subscriptions.push(() => {
11423
- track.removeEventListener('mute', muteHandler);
11424
- track.removeEventListener('unmute', unmuteHandler);
11425
- track.removeEventListener('ended', handleTrackEnded);
11987
+ const rootStream = await rootStreamPromise;
11988
+ this.state.setMediaStream(stream, rootStream);
11989
+ if (rootStream) {
11990
+ const handleTrackEnded = async () => {
11991
+ this.setLocalInterrupted(false);
11992
+ await this.statusChangeSettled();
11993
+ if (this.enabled) {
11994
+ this.isTrackStoppedDueToTrackEnd = true;
11995
+ setTimeout(() => {
11996
+ this.isTrackStoppedDueToTrackEnd = false;
11997
+ }, 2000);
11998
+ await this.disable();
11999
+ }
12000
+ };
12001
+ const createTrackMuteHandler = (muted) => () => {
12002
+ this.setLocalInterrupted(muted);
12003
+ // WebKit's RTCRtpSender encoder can stay stalled after an iOS /
12004
+ // macOS audio session interruption even though the track is
12005
+ // unmuted. Re-arm the sender on every unmute for any WebKit
12006
+ // runtime (Safari + plain iOS WKWebViews). Skipped when the
12007
+ // page is hidden because the encoder won't resume until
12008
+ // foreground anyway.
12009
+ if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
12010
+ this.call.refreshPublishedTrack(this.trackType).catch((err) => {
12011
+ this.logger.warn('Failed to refresh track on system unmute', err);
12012
+ });
12013
+ }
12014
+ // report all tracks on mobile, and only Video on desktop browsers
12015
+ if (isMobile() || this.trackType == TrackType.VIDEO) {
12016
+ this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
12017
+ trackType: TrackType[this.trackType],
12018
+ muted,
12019
+ });
12020
+ this.call
12021
+ .notifyTrackMuteState(muted, this.trackType)
12022
+ .catch((err) => {
12023
+ this.logger.warn('Error while notifying track mute state', err);
12024
+ });
12025
+ }
12026
+ };
12027
+ rootStream.getTracks().forEach((track) => {
12028
+ const muteHandler = createTrackMuteHandler(true);
12029
+ const unmuteHandler = createTrackMuteHandler(false);
12030
+ track.addEventListener('mute', muteHandler);
12031
+ track.addEventListener('unmute', unmuteHandler);
12032
+ track.addEventListener('ended', handleTrackEnded);
12033
+ this.currentStreamCleanups.push(() => {
12034
+ track.removeEventListener('mute', muteHandler);
12035
+ track.removeEventListener('unmute', unmuteHandler);
12036
+ track.removeEventListener('ended', handleTrackEnded);
12037
+ });
11426
12038
  });
11427
- });
12039
+ const initialMuted = rootStream.getTracks().some((t) => t.muted);
12040
+ this.setLocalInterrupted(initialMuted);
12041
+ }
12042
+ else {
12043
+ this.setLocalInterrupted(false);
12044
+ }
11428
12045
  }
11429
12046
  }
11430
12047
  get mediaDeviceKind() {
@@ -11570,7 +12187,6 @@ class DeviceManagerState {
11570
12187
  this.defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
11571
12188
  /**
11572
12189
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
11573
- *
11574
12190
  */
11575
12191
  this.mediaStream$ = this.mediaStreamSubject.asObservable();
11576
12192
  /**
@@ -11668,7 +12284,10 @@ class DeviceManagerState {
11668
12284
  setCurrentValue(this.mediaStreamSubject, stream);
11669
12285
  setCurrentValue(this.rootMediaStreamSubject, rootStream);
11670
12286
  if (rootStream) {
11671
- this.setDevice(this.getDeviceIdFromStream(rootStream));
12287
+ const derived = this.getDeviceIdFromStream(rootStream);
12288
+ if (derived) {
12289
+ this.setDevice(derived);
12290
+ }
11672
12291
  }
11673
12292
  }
11674
12293
  /**
@@ -11881,7 +12500,7 @@ class CameraManager extends DeviceManager {
11881
12500
  getDevices() {
11882
12501
  return getVideoDevices(this.call.tracer);
11883
12502
  }
11884
- getStream(constraints) {
12503
+ getResolvedConstraints(constraints) {
11885
12504
  constraints.width = this.targetResolution.width;
11886
12505
  constraints.height = this.targetResolution.height;
11887
12506
  // We can't set both device id and facing mode
@@ -11892,6 +12511,9 @@ class CameraManager extends DeviceManager {
11892
12511
  constraints.facingMode =
11893
12512
  this.state.direction === 'front' ? 'user' : 'environment';
11894
12513
  }
12514
+ return constraints;
12515
+ }
12516
+ getStream(constraints) {
11895
12517
  return getVideoStream(constraints, this.call.tracer);
11896
12518
  }
11897
12519
  }
@@ -13219,14 +13841,17 @@ class Call {
13219
13841
  this.sfuStatsReporter?.flush();
13220
13842
  this.sfuStatsReporter?.stop();
13221
13843
  this.sfuStatsReporter = undefined;
13222
- this.subscriber?.dispose();
13844
+ this.lastStatsOptions = undefined;
13845
+ await this.subscriber?.dispose();
13223
13846
  this.subscriber = undefined;
13224
- this.publisher?.dispose();
13847
+ await this.publisher?.dispose();
13225
13848
  this.publisher = undefined;
13226
13849
  await this.sfuClient?.leaveAndClose(leaveReason);
13227
13850
  this.sfuClient = undefined;
13228
- this.dynascaleManager.setSfuClient(undefined);
13229
- await this.dynascaleManager.dispose();
13851
+ this.trackSubscriptionManager.setSfuClient(undefined);
13852
+ this.trackSubscriptionManager.dispose();
13853
+ this.audioBindingsWatchdog?.dispose();
13854
+ await this.dynascaleManager?.dispose();
13230
13855
  this.state.setCallingState(exports.CallingState.LEFT);
13231
13856
  this.state.setParticipants([]);
13232
13857
  this.state.dispose();
@@ -13495,15 +14120,17 @@ class Call {
13495
14120
  const performingMigration = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
13496
14121
  const performingRejoin = this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN;
13497
14122
  const performingFastReconnect = this.reconnectStrategy === WebsocketReconnectStrategy.FAST;
13498
- let statsOptions = this.sfuStatsReporter?.options;
14123
+ let statsOptions = this.lastStatsOptions;
13499
14124
  if (!this.credentials ||
13500
14125
  !statsOptions ||
13501
14126
  performingRejoin ||
13502
- performingMigration) {
14127
+ performingMigration ||
14128
+ data?.migrating_from) {
13503
14129
  try {
13504
14130
  const joinResponse = await this.doJoinRequest(data);
13505
14131
  this.credentials = joinResponse.credentials;
13506
14132
  statsOptions = joinResponse.stats_options;
14133
+ this.lastStatsOptions = statsOptions;
13507
14134
  }
13508
14135
  catch (error) {
13509
14136
  // prevent triggering reconnect flow if the state is OFFLINE
@@ -13536,7 +14163,7 @@ class Call {
13536
14163
  : previousSfuClient;
13537
14164
  this.sfuClient = sfuClient;
13538
14165
  this.unifiedSessionId ?? (this.unifiedSessionId = sfuClient.sessionId);
13539
- this.dynascaleManager.setSfuClient(sfuClient);
14166
+ this.trackSubscriptionManager.setSfuClient(sfuClient);
13540
14167
  const clientDetails = await getClientDetails();
13541
14168
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
13542
14169
  if (previousSfuClient !== sfuClient) {
@@ -13610,7 +14237,7 @@ class Call {
13610
14237
  }
13611
14238
  else {
13612
14239
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
13613
- this.initPublisherAndSubscriber({
14240
+ await this.initPublisherAndSubscriber({
13614
14241
  sfuClient,
13615
14242
  connectionConfig,
13616
14243
  clientDetails,
@@ -13671,7 +14298,7 @@ class Call {
13671
14298
  return {
13672
14299
  strategy,
13673
14300
  announcedTracks,
13674
- subscriptions: this.dynascaleManager.trackSubscriptions,
14301
+ subscriptions: this.trackSubscriptionManager.subscriptions,
13675
14302
  reconnectAttempt: this.reconnectAttempts,
13676
14303
  fromSfuId: migratingFromSfuId || '',
13677
14304
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
@@ -13755,11 +14382,11 @@ class Call {
13755
14382
  * Initializes the Publisher and Subscriber Peer Connections.
13756
14383
  * @internal
13757
14384
  */
13758
- this.initPublisherAndSubscriber = (opts) => {
14385
+ this.initPublisherAndSubscriber = async (opts) => {
13759
14386
  const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
13760
14387
  const { enable_rtc_stats: enableTracing } = statsOptions;
13761
14388
  if (closePreviousInstances && this.subscriber) {
13762
- this.subscriber.dispose();
14389
+ await this.subscriber.dispose();
13763
14390
  }
13764
14391
  const basePeerConnectionOptions = {
13765
14392
  sfuClient,
@@ -13788,7 +14415,7 @@ class Call {
13788
14415
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
13789
14416
  if (!isAnonymous) {
13790
14417
  if (closePreviousInstances && this.publisher) {
13791
- this.publisher.dispose();
14418
+ await this.publisher.dispose();
13792
14419
  }
13793
14420
  this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
13794
14421
  }
@@ -13891,10 +14518,17 @@ class Call {
13891
14518
  * `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
13892
14519
  */
13893
14520
  this.reconnect = async (strategy, reason) => {
13894
- if (this.state.callingState === exports.CallingState.RECONNECTING ||
14521
+ if (this.state.callingState === exports.CallingState.JOINING ||
14522
+ this.state.callingState === exports.CallingState.RECONNECTING ||
13895
14523
  this.state.callingState === exports.CallingState.MIGRATING ||
13896
14524
  this.state.callingState === exports.CallingState.RECONNECTING_FAILED)
13897
14525
  return;
14526
+ // Drop redundant reconnect calls. If a reconnect is already queued or
14527
+ // running for this Call, that entry will resolve whatever broke;
14528
+ // queueing more entries just replays the full REJOIN cycle (one extra
14529
+ // `POST /join` per entry) once the call is already healthy again.
14530
+ if (hasPending(this.reconnectConcurrencyTag))
14531
+ return;
13898
14532
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
13899
14533
  const reconnectStartTime = Date.now();
13900
14534
  this.reconnectStrategy = strategy;
@@ -14099,8 +14733,8 @@ class Call {
14099
14733
  this.state.setCallingState(exports.CallingState.JOINED);
14100
14734
  }
14101
14735
  finally {
14102
- currentSubscriber?.dispose();
14103
- currentPublisher?.dispose();
14736
+ await currentSubscriber?.dispose();
14737
+ await currentPublisher?.dispose();
14104
14738
  // and close the previous SFU client, without specifying close code
14105
14739
  currentSfuClient.close(StreamSfuClient.NORMAL_CLOSURE, 'Migrating away');
14106
14740
  }
@@ -14218,7 +14852,7 @@ class Call {
14218
14852
  const { remoteParticipants } = this.state;
14219
14853
  if (remoteParticipants.length <= 0)
14220
14854
  return;
14221
- this.dynascaleManager.applyTrackSubscriptions(undefined);
14855
+ this.trackSubscriptionManager.apply(undefined);
14222
14856
  };
14223
14857
  /**
14224
14858
  * Starts publishing the given video stream to the call.
@@ -14289,7 +14923,7 @@ class Call {
14289
14923
  this.stopPublish = async (...trackTypes) => {
14290
14924
  if (!this.sfuClient || !this.publisher)
14291
14925
  return;
14292
- this.publisher.stopTracks(...trackTypes);
14926
+ await this.publisher.stopTracks(...trackTypes);
14293
14927
  await this.updateLocalStreamState(undefined, ...trackTypes);
14294
14928
  };
14295
14929
  /**
@@ -14318,6 +14952,20 @@ class Call {
14318
14952
  }));
14319
14953
  }
14320
14954
  };
14955
+ /**
14956
+ * Re-arms the encoder for a currently published track type. Useful for
14957
+ * working around WebKit's stalled sender bug after an iOS audio session
14958
+ * interruption (Siri, PSTN call).
14959
+ *
14960
+ * @internal
14961
+ *
14962
+ * @param trackType the track type to refresh.
14963
+ */
14964
+ this.refreshPublishedTrack = async (trackType) => {
14965
+ if (!this.publisher)
14966
+ return;
14967
+ await this.publisher.refreshTrack(trackType);
14968
+ };
14321
14969
  /**
14322
14970
  * Updates the preferred publishing options
14323
14971
  *
@@ -14979,7 +15627,7 @@ class Call {
14979
15627
  * @param trackType the video mode.
14980
15628
  */
14981
15629
  this.trackElementVisibility = (element, sessionId, trackType) => {
14982
- return this.dynascaleManager.trackElementVisibility(element, sessionId, trackType);
15630
+ return this.viewportTracker?.trackElementVisibility(element, sessionId, trackType);
14983
15631
  };
14984
15632
  /**
14985
15633
  * Sets the viewport element to track bound video elements for visibility.
@@ -14987,7 +15635,7 @@ class Call {
14987
15635
  * @param element the viewport element.
14988
15636
  */
14989
15637
  this.setViewport = (element) => {
14990
- return this.dynascaleManager.setViewport(element);
15638
+ return this.viewportTracker?.setViewport(element);
14991
15639
  };
14992
15640
  /**
14993
15641
  * Binds a DOM <video> element to the given session id.
@@ -15005,7 +15653,7 @@ class Call {
15005
15653
  * @param trackType the kind of video.
15006
15654
  */
15007
15655
  this.bindVideoElement = (videoElement, sessionId, trackType) => {
15008
- const unbind = this.dynascaleManager.bindVideoElement(videoElement, sessionId, trackType);
15656
+ const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
15009
15657
  if (!unbind)
15010
15658
  return;
15011
15659
  this.leaveCallHooks.add(unbind);
@@ -15025,21 +15673,28 @@ class Call {
15025
15673
  * @param trackType the kind of audio.
15026
15674
  */
15027
15675
  this.bindAudioElement = (audioElement, sessionId, trackType = 'audioTrack') => {
15028
- const unbind = this.dynascaleManager.bindAudioElement(audioElement, sessionId, trackType);
15676
+ const unbind = this.dynascaleManager?.bindAudioElement(audioElement, sessionId, trackType);
15029
15677
  if (!unbind)
15030
15678
  return;
15031
- this.leaveCallHooks.add(unbind);
15032
- return () => {
15033
- this.leaveCallHooks.delete(unbind);
15679
+ this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
15680
+ const cleanup = () => {
15034
15681
  unbind();
15682
+ this.audioBindingsWatchdog?.unregister(sessionId, trackType);
15683
+ };
15684
+ this.leaveCallHooks.add(cleanup);
15685
+ return () => {
15686
+ this.leaveCallHooks.delete(cleanup);
15687
+ cleanup();
15035
15688
  };
15036
15689
  };
15037
15690
  /**
15038
15691
  * Plays all audio elements blocked by the browser's autoplay policy.
15692
+ * Must be called from within a user gesture (e.g., click handler).
15693
+ *
15694
+ * Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
15695
+ * gesture is required.
15039
15696
  */
15040
- this.resumeAudio = () => {
15041
- return this.dynascaleManager.resumeAudio();
15042
- };
15697
+ this.resumeAudio = () => this.blockedAudioTracker.resumeAudio();
15043
15698
  /**
15044
15699
  * Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
15045
15700
  *
@@ -15077,21 +15732,21 @@ class Call {
15077
15732
  * preference has effect on. Affects all participants by default.
15078
15733
  */
15079
15734
  this.setPreferredIncomingVideoResolution = (resolution, sessionIds) => {
15080
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(resolution
15735
+ this.trackSubscriptionManager.setOverrides(resolution
15081
15736
  ? {
15082
15737
  enabled: true,
15083
15738
  dimension: resolution,
15084
15739
  }
15085
15740
  : undefined, sessionIds);
15086
- this.dynascaleManager.applyTrackSubscriptions();
15741
+ this.trackSubscriptionManager.apply();
15087
15742
  };
15088
15743
  /**
15089
15744
  * Enables or disables incoming video from all remote call participants,
15090
15745
  * and removes any preference for preferred resolution.
15091
15746
  */
15092
15747
  this.setIncomingVideoEnabled = (enabled) => {
15093
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(enabled ? undefined : { enabled: false });
15094
- this.dynascaleManager.applyTrackSubscriptions();
15748
+ this.trackSubscriptionManager.setOverrides(enabled ? undefined : { enabled: false });
15749
+ this.trackSubscriptionManager.apply();
15095
15750
  };
15096
15751
  /**
15097
15752
  * Sets the maximum amount of time a user can remain waiting for a reconnect
@@ -15172,7 +15827,13 @@ class Call {
15172
15827
  this.microphone = new MicrophoneManager(this, preferences);
15173
15828
  this.speaker = new SpeakerManager(this, preferences);
15174
15829
  this.screenShare = new ScreenShareManager(this);
15175
- this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer);
15830
+ this.trackSubscriptionManager = new TrackSubscriptionManager(this.state, this.tracer);
15831
+ this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
15832
+ if (typeof document !== 'undefined') {
15833
+ this.audioBindingsWatchdog = new AudioBindingsWatchdog(this.state, this.tracer);
15834
+ this.viewportTracker = new ViewportTracker(this.state);
15835
+ this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer, this.trackSubscriptionManager, this.blockedAudioTracker);
15836
+ }
15176
15837
  }
15177
15838
  /**
15178
15839
  * A flag indicating whether the call is "ringing" type of call.
@@ -15245,12 +15906,118 @@ const APIErrorCodes = {
15245
15906
  */
15246
15907
  class StableWSConnection {
15247
15908
  constructor(client) {
15909
+ /** Incremented when a new WS connection is made */
15910
+ this.wsID = 1;
15911
+ // Connection lifecycle flags.
15912
+ /** We only make 1 attempt to reconnect at the same time.. */
15913
+ this.isConnecting = false;
15914
+ /** To avoid reconnect if client is disconnected */
15915
+ this.isDisconnected = false;
15916
+ /** Boolean that indicates if we have a working connection to the server */
15917
+ this.isHealthy = false;
15918
+ /** Boolean that indicates if the connection promise is resolved */
15919
+ this.isConnectionOpenResolved = false;
15920
+ // Failure counters (drive retry/backoff scheduling).
15921
+ /** consecutive failures influence the duration of the timeout */
15922
+ this.consecutiveFailures = 0;
15923
+ /** keep track of the total number of failures */
15924
+ this.totalFailures = 0;
15925
+ // Health-check pings + connection-staleness check.
15926
+ /** Send a health check message every 25 seconds */
15927
+ this.pingInterval = 25 * 1000;
15928
+ this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
15929
+ /** Store the last event time for health checks */
15930
+ this.lastEvent = null;
15248
15931
  this._log = (msg, extra = {}, level = 'info') => {
15249
15932
  this.client.logger[level](`connection:${msg}`, extra);
15250
15933
  };
15251
15934
  this.setClient = (client) => {
15252
15935
  this.client = client;
15253
15936
  };
15937
+ /**
15938
+ * connect - Connect to the WS URL
15939
+ * the default 15s timeout allows between 2~3 tries
15940
+ * @return Promise that completes once the first health check message is received
15941
+ */
15942
+ this.connect = async (timeout = 15000) => {
15943
+ if (this.isConnecting) {
15944
+ throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
15945
+ }
15946
+ this.isDisconnected = false;
15947
+ try {
15948
+ const healthCheck = await this._connect(timeout);
15949
+ this.consecutiveFailures = 0;
15950
+ this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
15951
+ }
15952
+ catch (caught) {
15953
+ const error = caught;
15954
+ this.isHealthy = false;
15955
+ this.consecutiveFailures += 1;
15956
+ if (error.code === KnownCodes.TOKEN_EXPIRED &&
15957
+ !this.client.tokenManager.isStatic()) {
15958
+ this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
15959
+ this._reconnect({ refreshToken: true });
15960
+ }
15961
+ else if (!error.isWSFailure) {
15962
+ // API rejected the connection and we should not retry
15963
+ throw new Error(JSON.stringify({
15964
+ code: error.code,
15965
+ StatusCode: error.StatusCode,
15966
+ message: error.message,
15967
+ isWSFailure: error.isWSFailure,
15968
+ }));
15969
+ }
15970
+ else {
15971
+ // Transient WS failure (e.g., handshake watchdog). Kick off a
15972
+ // reconnect chain so _waitForHealthy(timeout) below has something
15973
+ // to poll for. Owning the trigger here (rather than inside
15974
+ // _connect()'s catch) keeps a single failure from spawning two
15975
+ // parallel chains - one from this catch and one from _reconnect's
15976
+ // own catch when _connect was called from there.
15977
+ this._reconnect();
15978
+ }
15979
+ }
15980
+ return await this._waitForHealthy(timeout);
15981
+ };
15982
+ /**
15983
+ * _waitForHealthy polls the promise connection to see if its resolved until it times out
15984
+ * the default 15s timeout allows between 2~3 tries
15985
+ * @param timeout duration(ms)
15986
+ */
15987
+ this._waitForHealthy = async (timeout = 15000) => {
15988
+ return Promise.race([
15989
+ (async () => {
15990
+ const interval = 50; // ms
15991
+ for (let i = 0; i <= timeout; i += interval) {
15992
+ try {
15993
+ return await this.connectionOpen;
15994
+ }
15995
+ catch (caught) {
15996
+ const error = caught;
15997
+ if (i === timeout) {
15998
+ throw new Error(JSON.stringify({
15999
+ code: error.code,
16000
+ StatusCode: error.StatusCode,
16001
+ message: error.message,
16002
+ isWSFailure: error.isWSFailure,
16003
+ }));
16004
+ }
16005
+ await sleep(interval);
16006
+ }
16007
+ }
16008
+ })(),
16009
+ (async () => {
16010
+ await sleep(timeout);
16011
+ this.isConnecting = false;
16012
+ throw new Error(JSON.stringify({
16013
+ code: '',
16014
+ StatusCode: '',
16015
+ message: 'initial WS connection could not be established',
16016
+ isWSFailure: true,
16017
+ }));
16018
+ })(),
16019
+ ]);
16020
+ };
15254
16021
  /**
15255
16022
  * Builds and returns the url for websocket.
15256
16023
  * @private
@@ -15263,11 +16030,166 @@ class StableWSConnection {
15263
16030
  params.set('X-Stream-Client', this.client.getUserAgent());
15264
16031
  return `${this.client.wsBaseURL}/connect?${params.toString()}`;
15265
16032
  };
16033
+ /**
16034
+ * disconnect - Disconnect the connection and doesn't recover...
16035
+ */
16036
+ this.disconnect = (timeout) => {
16037
+ this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
16038
+ this.wsID += 1;
16039
+ this.isConnecting = false;
16040
+ this.isDisconnected = true;
16041
+ // start by removing all the listeners
16042
+ if (this.healthCheckTimeoutRef) {
16043
+ getTimers().clearInterval(this.healthCheckTimeoutRef);
16044
+ }
16045
+ if (this.connectionCheckTimeoutRef) {
16046
+ clearInterval(this.connectionCheckTimeoutRef);
16047
+ }
16048
+ removeConnectionEventListeners(this.onlineStatusChanged);
16049
+ this.isHealthy = false;
16050
+ let isClosedPromise;
16051
+ // and finally close...
16052
+ // Assigning to local here because we will remove it from this before the
16053
+ // promise resolves.
16054
+ const { ws } = this;
16055
+ if (ws && ws.close && ws.readyState === ws.OPEN) {
16056
+ isClosedPromise = new Promise((resolve) => {
16057
+ const onclose = (event) => {
16058
+ this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
16059
+ resolve();
16060
+ };
16061
+ ws.onclose = onclose;
16062
+ // In case we don't receive close frame websocket server in time,
16063
+ // lets not wait for more than 1 second.
16064
+ setTimeout(onclose, timeout != null ? timeout : 1000);
16065
+ });
16066
+ this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
16067
+ ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
16068
+ }
16069
+ else {
16070
+ this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
16071
+ isClosedPromise = Promise.resolve();
16072
+ }
16073
+ delete this.ws;
16074
+ return isClosedPromise;
16075
+ };
16076
+ /**
16077
+ * _connect - Connect to the WS endpoint
16078
+ *
16079
+ * @param timeoutMs handshake watchdog deadline in ms. Defaults to
16080
+ * `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
16081
+ * passes its own timeout through so caller-supplied deadlines are honored.
16082
+ * @return Promise that completes once the first health check message is received
16083
+ */
16084
+ this._connect = async (timeoutMs) => {
16085
+ if (this.isConnecting)
16086
+ return; // ignore _connect if it's currently trying to connect
16087
+ this.isConnecting = true;
16088
+ // Snapshot of the connection-id reject closure owned by THIS attempt.
16089
+ // Captured at function entry so that even early failures (e.g.,
16090
+ // tokenManager.loadToken throwing before we reach the WS phase) can
16091
+ // settle the promise the caller is awaiting. Re-captured below if
16092
+ // _connect itself sets up a fresh promise. If a concurrent
16093
+ // openConnection() rotates `client.rejectConnectionId` later, our
16094
+ // captured closure still settles only the original promise (P1) and
16095
+ // never poisons the newer one (P2).
16096
+ let ownRejectConnectionId = this.client.rejectConnectionId;
16097
+ let isTokenReady = false;
16098
+ try {
16099
+ this._log(`_connect() - waiting for token`);
16100
+ await this.client.tokenManager.tokenReady();
16101
+ isTokenReady = true;
16102
+ }
16103
+ catch {
16104
+ // token provider has failed before, so try again
16105
+ }
16106
+ try {
16107
+ if (!isTokenReady) {
16108
+ this._log(`_connect() - tokenProvider failed before, so going to retry`);
16109
+ await this.client.tokenManager.loadToken();
16110
+ }
16111
+ if (!this.client.isConnectionIdPromisePending) {
16112
+ this.client._setupConnectionIdPromise();
16113
+ // recapture: we just rotated the resolver ourselves, the new
16114
+ // closure is the one bound to the promise this attempt owns.
16115
+ ownRejectConnectionId = this.client.rejectConnectionId;
16116
+ }
16117
+ this._setupConnectionPromise();
16118
+ const wsURL = this._buildUrl();
16119
+ this._log(`_connect() - Connecting to ${wsURL}`);
16120
+ const WS = this.client.options.WebSocketImpl ?? WebSocket;
16121
+ this.ws = new WS(wsURL);
16122
+ this.ws.onopen = this.onopen.bind(this, this.wsID);
16123
+ this.ws.onclose = this.onclose.bind(this, this.wsID);
16124
+ this.ws.onerror = this.onerror.bind(this, this.wsID);
16125
+ this.ws.onmessage = this.onmessage.bind(this, this.wsID);
16126
+ // race the WS handshake against an explicit deadline so a silent
16127
+ // network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
16128
+ const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
16129
+ const timers = getTimers();
16130
+ let handshakeTimeoutId;
16131
+ let response;
16132
+ try {
16133
+ response = await Promise.race([
16134
+ this.connectionOpen,
16135
+ new Promise((_, reject) => {
16136
+ handshakeTimeoutId = timers.setTimeout(() => {
16137
+ const err = new Error(`WS handshake timed out after ${handshakeTimeout}ms`);
16138
+ err.isWSFailure = true;
16139
+ reject(err);
16140
+ }, handshakeTimeout);
16141
+ }),
16142
+ ]);
16143
+ }
16144
+ finally {
16145
+ timers.clearTimeout(handshakeTimeoutId);
16146
+ }
16147
+ this.isConnecting = false;
16148
+ // If we were disconnected during the handshake (e.g. closeConnection()
16149
+ // ran while a background _reconnect's _connect was in flight), tear
16150
+ // down the new WS and throw so the caller of connect() does not get
16151
+ // a misleading "success" for a connection that has already been
16152
+ // aborted. We must NOT skip the throw and just return undefined: the
16153
+ // outer connect() would otherwise fall through to _waitForHealthy(),
16154
+ // which would observe the already-resolved connectionOpen promise
16155
+ // and resolve with a ConnectedEvent for a torn-down connection.
16156
+ if (this.isDisconnected) {
16157
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
16158
+ this._destroyCurrentWSConnection();
16159
+ }
16160
+ throw new Error('WS handshake aborted: disconnect() ran while connecting');
16161
+ }
16162
+ if (response) {
16163
+ this.connectionID = response.connection_id;
16164
+ this.client.resolveConnectionId?.(this.connectionID);
16165
+ return response;
16166
+ }
16167
+ }
16168
+ catch (caught) {
16169
+ const err = caught;
16170
+ this.isConnecting = false;
16171
+ this._log(`_connect() - Error - `, err);
16172
+ // Reject THIS attempt's connection-id promise (P1) directly via the
16173
+ // captured closure. Whether or not a concurrent openConnection() has
16174
+ // since rotated client.rejectConnectionId to a newer promise (P2),
16175
+ // calling ownRejectConnectionId only settles P1 - P2 is untouched.
16176
+ // P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
16177
+ // therefore fail fast instead of being orphaned.
16178
+ ownRejectConnectionId?.(err);
16179
+ // connectionOpen is per-instance and not subject to rotation, so
16180
+ // calling it unconditionally is safe (and a no-op if already settled).
16181
+ this.rejectConnectionOpen?.(err);
16182
+ // tear down a half-open WS so it does not linger and fire a stale wsID later
16183
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
16184
+ this._destroyCurrentWSConnection();
16185
+ }
16186
+ throw err;
16187
+ }
16188
+ };
15266
16189
  /**
15267
16190
  * onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
15268
16191
  *
15269
16192
  * @param {Event} event Event with type online or offline
15270
- *
15271
16193
  */
15272
16194
  this.onlineStatusChanged = (event) => {
15273
16195
  if (event.type === 'offline') {
@@ -15365,16 +16287,12 @@ class StableWSConnection {
15365
16287
  return;
15366
16288
  this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
15367
16289
  if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
15368
- // this is a permanent error raised by stream..
16290
+ // this is a permanent error raised by stream.
15369
16291
  // usually caused by invalid auth details
15370
16292
  const error = new Error(`WS connection reject with error ${event.reason}`);
15371
- // @ts-expect-error type issue
15372
16293
  error.reason = event.reason;
15373
- // @ts-expect-error type issue
15374
16294
  error.code = event.code;
15375
- // @ts-expect-error type issue
15376
16295
  error.wasClean = event.wasClean;
15377
- // @ts-expect-error type issue
15378
16296
  error.target = event.target;
15379
16297
  this.rejectConnectionOpen?.(error);
15380
16298
  this._log(`onclose() - WS connection reject with error ${event.reason}`, {
@@ -15512,205 +16430,8 @@ class StableWSConnection {
15512
16430
  }, this.connectionCheckTimeout);
15513
16431
  };
15514
16432
  this.client = client;
15515
- /** consecutive failures influence the duration of the timeout */
15516
- this.consecutiveFailures = 0;
15517
- /** keep track of the total number of failures */
15518
- this.totalFailures = 0;
15519
- /** We only make 1 attempt to reconnect at the same time.. */
15520
- this.isConnecting = false;
15521
- /** To avoid reconnect if client is disconnected */
15522
- this.isDisconnected = false;
15523
- /** Boolean that indicates if the connection promise is resolved */
15524
- this.isConnectionOpenResolved = false;
15525
- /** Boolean that indicates if we have a working connection to the server */
15526
- this.isHealthy = false;
15527
- /** Incremented when a new WS connection is made */
15528
- this.wsID = 1;
15529
- /** Store the last event time for health checks */
15530
- this.lastEvent = null;
15531
- /** Send a health check message every 25 seconds */
15532
- this.pingInterval = 25 * 1000;
15533
- this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
15534
16433
  addConnectionEventListeners(this.onlineStatusChanged);
15535
16434
  }
15536
- /**
15537
- * connect - Connect to the WS URL
15538
- * the default 15s timeout allows between 2~3 tries
15539
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
15540
- */
15541
- async connect(timeout = 15000) {
15542
- if (this.isConnecting) {
15543
- throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
15544
- }
15545
- this.isDisconnected = false;
15546
- try {
15547
- const healthCheck = await this._connect();
15548
- this.consecutiveFailures = 0;
15549
- this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
15550
- }
15551
- catch (error) {
15552
- this.isHealthy = false;
15553
- this.consecutiveFailures += 1;
15554
- if (
15555
- // @ts-expect-error type issue
15556
- error.code === KnownCodes.TOKEN_EXPIRED &&
15557
- !this.client.tokenManager.isStatic()) {
15558
- this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
15559
- this._reconnect({ refreshToken: true });
15560
- }
15561
- else {
15562
- // @ts-expect-error type issue
15563
- if (!error.isWSFailure) {
15564
- // API rejected the connection and we should not retry
15565
- throw new Error(JSON.stringify({
15566
- // @ts-expect-error type issue
15567
- code: error.code,
15568
- // @ts-expect-error type issue
15569
- StatusCode: error.StatusCode,
15570
- // @ts-expect-error type issue
15571
- message: error.message,
15572
- // @ts-expect-error type issue
15573
- isWSFailure: error.isWSFailure,
15574
- }));
15575
- }
15576
- }
15577
- }
15578
- return await this._waitForHealthy(timeout);
15579
- }
15580
- /**
15581
- * _waitForHealthy polls the promise connection to see if its resolved until it times out
15582
- * the default 15s timeout allows between 2~3 tries
15583
- * @param timeout duration(ms)
15584
- */
15585
- async _waitForHealthy(timeout = 15000) {
15586
- return Promise.race([
15587
- (async () => {
15588
- const interval = 50; // ms
15589
- for (let i = 0; i <= timeout; i += interval) {
15590
- try {
15591
- return await this.connectionOpen;
15592
- }
15593
- catch (error) {
15594
- if (i === timeout) {
15595
- throw new Error(JSON.stringify({
15596
- code: error.code,
15597
- StatusCode: error.StatusCode,
15598
- message: error.message,
15599
- isWSFailure: error.isWSFailure,
15600
- }));
15601
- }
15602
- await sleep(interval);
15603
- }
15604
- }
15605
- })(),
15606
- (async () => {
15607
- await sleep(timeout);
15608
- this.isConnecting = false;
15609
- throw new Error(JSON.stringify({
15610
- code: '',
15611
- StatusCode: '',
15612
- message: 'initial WS connection could not be established',
15613
- isWSFailure: true,
15614
- }));
15615
- })(),
15616
- ]);
15617
- }
15618
- /**
15619
- * disconnect - Disconnect the connection and doesn't recover...
15620
- *
15621
- */
15622
- disconnect(timeout) {
15623
- this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
15624
- this.wsID += 1;
15625
- this.isConnecting = false;
15626
- this.isDisconnected = true;
15627
- // start by removing all the listeners
15628
- if (this.healthCheckTimeoutRef) {
15629
- getTimers().clearInterval(this.healthCheckTimeoutRef);
15630
- }
15631
- if (this.connectionCheckTimeoutRef) {
15632
- clearInterval(this.connectionCheckTimeoutRef);
15633
- }
15634
- removeConnectionEventListeners(this.onlineStatusChanged);
15635
- this.isHealthy = false;
15636
- let isClosedPromise;
15637
- // and finally close...
15638
- // Assigning to local here because we will remove it from this before the
15639
- // promise resolves.
15640
- const { ws } = this;
15641
- if (ws && ws.close && ws.readyState === ws.OPEN) {
15642
- isClosedPromise = new Promise((resolve) => {
15643
- const onclose = (event) => {
15644
- this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
15645
- resolve();
15646
- };
15647
- ws.onclose = onclose;
15648
- // In case we don't receive close frame websocket server in time,
15649
- // lets not wait for more than 1 second.
15650
- setTimeout(onclose, timeout != null ? timeout : 1000);
15651
- });
15652
- this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
15653
- ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
15654
- }
15655
- else {
15656
- this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
15657
- isClosedPromise = Promise.resolve();
15658
- }
15659
- delete this.ws;
15660
- return isClosedPromise;
15661
- }
15662
- /**
15663
- * _connect - Connect to the WS endpoint
15664
- *
15665
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
15666
- */
15667
- async _connect() {
15668
- if (this.isConnecting)
15669
- return; // ignore _connect if it's currently trying to connect
15670
- this.isConnecting = true;
15671
- let isTokenReady = false;
15672
- try {
15673
- this._log(`_connect() - waiting for token`);
15674
- await this.client.tokenManager.tokenReady();
15675
- isTokenReady = true;
15676
- }
15677
- catch {
15678
- // token provider has failed before, so try again
15679
- }
15680
- try {
15681
- if (!isTokenReady) {
15682
- this._log(`_connect() - tokenProvider failed before, so going to retry`);
15683
- await this.client.tokenManager.loadToken();
15684
- }
15685
- if (!this.client.isConnectionIsPromisePending) {
15686
- this.client._setupConnectionIdPromise();
15687
- }
15688
- this._setupConnectionPromise();
15689
- const wsURL = this._buildUrl();
15690
- this._log(`_connect() - Connecting to ${wsURL}`);
15691
- const WS = this.client.options.WebSocketImpl ?? WebSocket;
15692
- this.ws = new WS(wsURL);
15693
- this.ws.onopen = this.onopen.bind(this, this.wsID);
15694
- this.ws.onclose = this.onclose.bind(this, this.wsID);
15695
- this.ws.onerror = this.onerror.bind(this, this.wsID);
15696
- this.ws.onmessage = this.onmessage.bind(this, this.wsID);
15697
- const response = await this.connectionOpen;
15698
- this.isConnecting = false;
15699
- if (response) {
15700
- this.connectionID = response.connection_id;
15701
- this.client.resolveConnectionId?.(this.connectionID);
15702
- return response;
15703
- }
15704
- }
15705
- catch (err) {
15706
- this.client._setupConnectionIdPromise();
15707
- this.isConnecting = false;
15708
- // @ts-expect-error type issue
15709
- this._log(`_connect() - Error - `, err);
15710
- this.client.rejectConnectionId?.(err);
15711
- throw err;
15712
- }
15713
- }
15714
16435
  /**
15715
16436
  * _reconnect - Retry the connection to WS endpoint
15716
16437
  *
@@ -15757,7 +16478,8 @@ class StableWSConnection {
15757
16478
  this._log('_reconnect() - Finished recoverCallBack');
15758
16479
  this.consecutiveFailures = 0;
15759
16480
  }
15760
- catch (error) {
16481
+ catch (caught) {
16482
+ const error = caught;
15761
16483
  this.isHealthy = false;
15762
16484
  this.consecutiveFailures += 1;
15763
16485
  if (error.code === KnownCodes.TOKEN_EXPIRED &&
@@ -16314,7 +17036,7 @@ class StreamClient {
16314
17036
  this.getUserAgent = () => {
16315
17037
  if (!this.cachedUserAgent) {
16316
17038
  const { clientAppIdentifier = {} } = this.options;
16317
- const { sdkName = 'js', sdkVersion = "1.49.0", ...extras } = clientAppIdentifier;
17039
+ const { sdkName = 'js', sdkVersion = "1.51.0", ...extras } = clientAppIdentifier;
16318
17040
  this.cachedUserAgent = [
16319
17041
  `stream-video-${sdkName}-v${sdkVersion}`,
16320
17042
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -16422,7 +17144,7 @@ class StreamClient {
16422
17144
  get connectionIdPromise() {
16423
17145
  return this.connectionIdPromiseSafe?.();
16424
17146
  }
16425
- get isConnectionIsPromisePending() {
17147
+ get isConnectionIdPromisePending() {
16426
17148
  return this.connectionIdPromiseSafe?.checkPending() ?? false;
16427
17149
  }
16428
17150
  get wsPromise() {