@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.es.js CHANGED
@@ -1399,6 +1399,35 @@ var ClientCapability;
1399
1399
  */
1400
1400
  ClientCapability[ClientCapability["SUBSCRIBER_VIDEO_PAUSE"] = 1] = "SUBSCRIBER_VIDEO_PAUSE";
1401
1401
  })(ClientCapability || (ClientCapability = {}));
1402
+ /**
1403
+ * DegradationPreference represents the RTCDegradationPreference from WebRTC.
1404
+ * See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
1405
+ *
1406
+ * @generated from protobuf enum stream.video.sfu.models.DegradationPreference
1407
+ */
1408
+ var DegradationPreference;
1409
+ (function (DegradationPreference) {
1410
+ /**
1411
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
1412
+ */
1413
+ DegradationPreference[DegradationPreference["UNSPECIFIED"] = 0] = "UNSPECIFIED";
1414
+ /**
1415
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
1416
+ */
1417
+ DegradationPreference[DegradationPreference["BALANCED"] = 1] = "BALANCED";
1418
+ /**
1419
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
1420
+ */
1421
+ DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE"] = 2] = "MAINTAIN_FRAMERATE";
1422
+ /**
1423
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
1424
+ */
1425
+ DegradationPreference[DegradationPreference["MAINTAIN_RESOLUTION"] = 3] = "MAINTAIN_RESOLUTION";
1426
+ /**
1427
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
1428
+ */
1429
+ DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE_AND_RESOLUTION"] = 4] = "MAINTAIN_FRAMERATE_AND_RESOLUTION";
1430
+ })(DegradationPreference || (DegradationPreference = {}));
1402
1431
  // @generated message type with reflection information, may provide speed optimized methods
1403
1432
  class CallState$Type extends MessageType {
1404
1433
  constructor() {
@@ -1668,6 +1697,16 @@ class PublishOption$Type extends MessageType {
1668
1697
  repeat: 2 /*RepeatType.UNPACKED*/,
1669
1698
  T: () => AudioBitrate,
1670
1699
  },
1700
+ {
1701
+ no: 11,
1702
+ name: 'degradation_preference',
1703
+ kind: 'enum',
1704
+ T: () => [
1705
+ 'stream.video.sfu.models.DegradationPreference',
1706
+ DegradationPreference,
1707
+ 'DEGRADATION_PREFERENCE_',
1708
+ ],
1709
+ },
1671
1710
  ]);
1672
1711
  }
1673
1712
  }
@@ -2114,6 +2153,7 @@ var models = /*#__PURE__*/Object.freeze({
2114
2153
  ClientDetails: ClientDetails,
2115
2154
  Codec: Codec,
2116
2155
  get ConnectionQuality () { return ConnectionQuality; },
2156
+ get DegradationPreference () { return DegradationPreference; },
2117
2157
  Device: Device,
2118
2158
  Error: Error$2,
2119
2159
  get ErrorCode () { return ErrorCode; },
@@ -3501,6 +3541,16 @@ class VideoSender$Type extends MessageType {
3501
3541
  kind: 'scalar',
3502
3542
  T: 5 /*ScalarType.INT32*/,
3503
3543
  },
3544
+ {
3545
+ no: 6,
3546
+ name: 'degradation_preference',
3547
+ kind: 'enum',
3548
+ T: () => [
3549
+ 'stream.video.sfu.models.DegradationPreference',
3550
+ DegradationPreference,
3551
+ 'DEGRADATION_PREFERENCE_',
3552
+ ],
3553
+ },
3504
3554
  ]);
3505
3555
  }
3506
3556
  }
@@ -3866,6 +3916,18 @@ const createSignalClient = (options) => {
3866
3916
  };
3867
3917
 
3868
3918
  const sleep = (m) => new Promise((r) => setTimeout(r, m));
3919
+ const timeboxed = async (promises, ms) => {
3920
+ let timerId;
3921
+ const timeout = new Promise((_, reject) => {
3922
+ timerId = setTimeout(() => reject(new Error('timebox error')), ms);
3923
+ });
3924
+ try {
3925
+ return await Promise.race([Promise.all(promises), timeout]);
3926
+ }
3927
+ finally {
3928
+ clearTimeout(timerId);
3929
+ }
3930
+ };
3869
3931
  function isFunction(value) {
3870
3932
  return (value &&
3871
3933
  (Object.prototype.toString.call(value) === '[object Function]' ||
@@ -4605,6 +4667,20 @@ const setCurrentValue = (subject, update) => {
4605
4667
  subject.next(next);
4606
4668
  return next;
4607
4669
  };
4670
+ /**
4671
+ * Updates the value of the provided Subject asynchronously.
4672
+ * Locks the subject to prevent concurrent updates.
4673
+ *
4674
+ * @param subject the subject to update.
4675
+ * @param update the update to apply to the subject.
4676
+ */
4677
+ const setCurrentValueAsync = async (subject, update) => {
4678
+ return withoutConcurrency(subject, async () => {
4679
+ const next = await update(getCurrentValue(subject));
4680
+ subject.next(next);
4681
+ return next;
4682
+ });
4683
+ };
4608
4684
  /**
4609
4685
  * Updates the value of the provided Subject and returns the previous value
4610
4686
  * and a function to roll back the update.
@@ -4659,6 +4735,7 @@ var rxUtils = /*#__PURE__*/Object.freeze({
4659
4735
  createSubscription: createSubscription,
4660
4736
  getCurrentValue: getCurrentValue,
4661
4737
  setCurrentValue: setCurrentValue,
4738
+ setCurrentValueAsync: setCurrentValueAsync,
4662
4739
  updateValue: updateValue
4663
4740
  });
4664
4741
 
@@ -6283,7 +6360,7 @@ const getSdkVersion = (sdk) => {
6283
6360
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6284
6361
  };
6285
6362
 
6286
- const version = "1.49.0";
6363
+ const version = "1.51.0";
6287
6364
  const [major, minor, patch] = version.split('.');
6288
6365
  let sdkInfo = {
6289
6366
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -6436,6 +6513,31 @@ const isSafari = () => {
6436
6513
  return false;
6437
6514
  return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
6438
6515
  };
6516
+ /**
6517
+ * Checks whether the current runtime is a WebKit-engine browser.
6518
+ *
6519
+ * Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
6520
+ * (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
6521
+ * Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
6522
+ * `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
6523
+ * share the underlying WebKit quirks.
6524
+ *
6525
+ * Returns false for desktop Chromium-based browsers (which reuse the
6526
+ * `AppleWebKit/` token in their UA) and Android.
6527
+ */
6528
+ const isWebKit = () => {
6529
+ if (typeof navigator === 'undefined')
6530
+ return false;
6531
+ const ua = navigator.userAgent || '';
6532
+ if (!/AppleWebKit\//.test(ua))
6533
+ return false;
6534
+ // Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
6535
+ // `Chromium/` markers are only present on desktop Chromium builds
6536
+ // (their iOS counterparts use `CriOS/` instead). `Android` rules out
6537
+ // the mobile Blink stack.
6538
+ const regExp = /Chrome\/|Chromium\/|Android/;
6539
+ return !regExp.test(ua);
6540
+ };
6439
6541
  /**
6440
6542
  * Checks whether the current browser is Firefox.
6441
6543
  */
@@ -6479,7 +6581,8 @@ var browsers = /*#__PURE__*/Object.freeze({
6479
6581
  isChrome: isChrome,
6480
6582
  isFirefox: isFirefox,
6481
6583
  isSafari: isSafari,
6482
- isSupportedBrowser: isSupportedBrowser
6584
+ isSupportedBrowser: isSupportedBrowser,
6585
+ isWebKit: isWebKit
6483
6586
  });
6484
6587
 
6485
6588
  /**
@@ -7376,7 +7479,7 @@ class BasePeerConnection {
7376
7479
  this.on = (event, fn) => {
7377
7480
  const getTag = () => this.tag;
7378
7481
  this.subscriptions.push(this.dispatcher.on(event, getTag, (e) => {
7379
- const lockKey = `pc.${this.lock}.${event}`;
7482
+ const lockKey = this.eventLockKey(event);
7380
7483
  withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
7381
7484
  if (this.isDisposed)
7382
7485
  return;
@@ -7384,6 +7487,13 @@ class BasePeerConnection {
7384
7487
  });
7385
7488
  }));
7386
7489
  };
7490
+ /**
7491
+ * Returns the per-event `withoutConcurrency` tag used to serialize the
7492
+ * dispatcher handler for `event` on this peer connection.
7493
+ */
7494
+ this.eventLockKey = (event) => {
7495
+ return `pc.${this.lock}.${event}`;
7496
+ };
7387
7497
  /**
7388
7498
  * Appends the trickled ICE candidates to the `RTCPeerConnection`.
7389
7499
  */
@@ -7637,7 +7747,7 @@ class BasePeerConnection {
7637
7747
  /**
7638
7748
  * Disposes the `RTCPeerConnection` instance.
7639
7749
  */
7640
- dispose() {
7750
+ async dispose() {
7641
7751
  clearTimeout(this.iceRestartTimeout);
7642
7752
  this.iceRestartTimeout = undefined;
7643
7753
  clearTimeout(this.preConnectStuckTimeout);
@@ -7659,6 +7769,7 @@ class BasePeerConnection {
7659
7769
  pc.removeEventListener('signalingstatechange', this.onSignalingChange);
7660
7770
  pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
7661
7771
  pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
7772
+ pc.removeEventListener('connectionstatechange', this.onConnectionStateChange);
7662
7773
  this.unsubscribeIceTrickle?.();
7663
7774
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
7664
7775
  this.subscriptions = [];
@@ -7686,8 +7797,14 @@ class TransceiverCache {
7686
7797
  * Gets the transceiver for the given publish option.
7687
7798
  */
7688
7799
  this.get = (publishOption) => {
7689
- return this.cache.find((bundle) => bundle.publishOption.id === publishOption.id &&
7690
- bundle.publishOption.trackType === publishOption.trackType);
7800
+ return this.getBy(publishOption.id, publishOption.trackType);
7801
+ };
7802
+ /**
7803
+ * Gets the transceiver for the given publish option id and track type.
7804
+ */
7805
+ this.getBy = (publishOptionId, trackType) => {
7806
+ return this.cache.find((bundle) => bundle.publishOption.id === publishOptionId &&
7807
+ bundle.publishOption.trackType === trackType);
7691
7808
  };
7692
7809
  /**
7693
7810
  * Updates the cached bundle with the given patch.
@@ -7955,6 +8072,39 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
7955
8072
  }));
7956
8073
  };
7957
8074
 
8075
+ const toRTCDegradationPreference = (preference) => {
8076
+ switch (preference) {
8077
+ case DegradationPreference.BALANCED:
8078
+ return 'balanced';
8079
+ case DegradationPreference.MAINTAIN_FRAMERATE:
8080
+ return 'maintain-framerate';
8081
+ case DegradationPreference.MAINTAIN_RESOLUTION:
8082
+ return 'maintain-resolution';
8083
+ case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
8084
+ // @ts-expect-error not in the typedefs yet
8085
+ return 'maintain-framerate-and-resolution';
8086
+ case DegradationPreference.UNSPECIFIED:
8087
+ return undefined;
8088
+ default:
8089
+ ensureExhausted(preference, 'Unknown degradation preference');
8090
+ }
8091
+ };
8092
+ const fromRTCDegradationPreference = (preference) => {
8093
+ switch (preference) {
8094
+ case 'balanced':
8095
+ return DegradationPreference.BALANCED;
8096
+ case 'maintain-framerate':
8097
+ return DegradationPreference.MAINTAIN_FRAMERATE;
8098
+ case 'maintain-resolution':
8099
+ return DegradationPreference.MAINTAIN_RESOLUTION;
8100
+ // @ts-expect-error not in the typedefs yet
8101
+ case 'maintain-framerate-and-resolution':
8102
+ return DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION;
8103
+ default:
8104
+ return DegradationPreference.UNSPECIFIED;
8105
+ }
8106
+ };
8107
+
7958
8108
  /**
7959
8109
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
7960
8110
  *
@@ -7988,13 +8138,13 @@ class Publisher extends BasePeerConnection {
7988
8138
  // create a clone of the track as otherwise the same trackId will
7989
8139
  // appear in the SDP in multiple transceivers
7990
8140
  const trackToPublish = this.cloneTrack(track);
7991
- const { transceiver } = this.transceiverCache.get(publishOption) || {};
7992
- if (!transceiver) {
8141
+ const bundle = this.transceiverCache.get(publishOption);
8142
+ if (!bundle) {
7993
8143
  await this.addTransceiver(trackToPublish, publishOption, options);
7994
8144
  }
7995
8145
  else {
7996
- const previousTrack = transceiver.sender.track;
7997
- await this.updateTransceiver(transceiver, trackToPublish, trackType, options);
8146
+ const previousTrack = bundle.transceiver.sender.track;
8147
+ await this.updateTransceiver(bundle, trackToPublish, options);
7998
8148
  if (!isReactNative()) {
7999
8149
  this.stopTrack(previousTrack);
8000
8150
  }
@@ -8016,7 +8166,9 @@ class Publisher extends BasePeerConnection {
8016
8166
  sendEncodings,
8017
8167
  });
8018
8168
  const params = transceiver.sender.getParameters();
8019
- params.degradationPreference = 'maintain-framerate';
8169
+ params.degradationPreference =
8170
+ toRTCDegradationPreference(publishOption.degradationPreference) ??
8171
+ 'maintain-framerate';
8020
8172
  await transceiver.sender.setParameters(params);
8021
8173
  const trackType = publishOption.trackType;
8022
8174
  this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
@@ -8027,13 +8179,20 @@ class Publisher extends BasePeerConnection {
8027
8179
  /**
8028
8180
  * Updates the transceiver with the given track and track type.
8029
8181
  */
8030
- this.updateTransceiver = async (transceiver, track, trackType, options = {}) => {
8182
+ this.updateTransceiver = async (bundle, track, options = {}) => {
8183
+ const { transceiver, publishOption } = bundle;
8184
+ const trackType = publishOption.trackType;
8031
8185
  const sender = transceiver.sender;
8032
8186
  if (sender.track)
8033
8187
  this.trackIdToTrackType.delete(sender.track.id);
8034
8188
  await sender.replaceTrack(track);
8035
- if (track)
8189
+ if (track) {
8036
8190
  this.trackIdToTrackType.set(track.id, trackType);
8191
+ if (isFirefox() && bundle.videoSender) {
8192
+ // restore the encoding config from the cache, if any
8193
+ await this.changePublishQuality(bundle.videoSender, bundle);
8194
+ }
8195
+ }
8037
8196
  if (isAudioTrackType(trackType)) {
8038
8197
  await this.updateAudioPublishOptions(trackType, options);
8039
8198
  }
@@ -8093,7 +8252,7 @@ class Publisher extends BasePeerConnection {
8093
8252
  continue;
8094
8253
  // it is safe to stop the track here, it is a clone
8095
8254
  this.stopTrack(transceiver.sender.track);
8096
- await this.updateTransceiver(transceiver, null, publishOption.trackType);
8255
+ await this.updateTransceiver(item, null);
8097
8256
  }
8098
8257
  };
8099
8258
  /**
@@ -8114,35 +8273,74 @@ class Publisher extends BasePeerConnection {
8114
8273
  return false;
8115
8274
  };
8116
8275
  /**
8117
- * Stops the cloned track that is being published to the SFU.
8276
+ * Re-arms the encoder for the given track type by detaching and
8277
+ * reattaching the currently published track on each matching sender.
8278
+ *
8279
+ * Workaround for a WebKit / iOS Safari quirk: after a system audio
8280
+ * session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
8281
+ * can stop producing RTP packets even though the underlying
8282
+ * `MediaStreamTrack` is `live` and `track.muted === false`.
8283
+ * `replaceTrack(null)` followed by `replaceTrack(track)` resets the
8284
+ * sender's encoder pipeline without renegotiation, restoring packet
8285
+ * flow with the same SSRC.
8286
+ *
8287
+ * No-op when nothing is published for the given track type.
8288
+ *
8289
+ * @param trackType the track type to refresh.
8118
8290
  */
8119
- this.stopTracks = (...trackTypes) => {
8291
+ this.refreshTrack = async (trackType) => {
8120
8292
  for (const item of this.transceiverCache.items()) {
8121
- const { publishOption, transceiver } = item;
8122
- if (!trackTypes.includes(publishOption.trackType))
8293
+ if (item.publishOption.trackType !== trackType)
8123
8294
  continue;
8124
- this.stopTrack(transceiver.sender.track);
8295
+ const { sender } = item.transceiver;
8296
+ const track = sender.track;
8297
+ if (!track || track.readyState !== 'live')
8298
+ continue;
8299
+ try {
8300
+ await sender.replaceTrack(null);
8301
+ await sender.replaceTrack(track);
8302
+ this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
8303
+ }
8304
+ catch (err) {
8305
+ this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
8306
+ }
8125
8307
  }
8126
8308
  };
8309
+ /**
8310
+ * Stops the cloned track that is being published to the SFU.
8311
+ */
8312
+ this.stopTracks = async (...trackTypes) => {
8313
+ return withoutConcurrency(this.eventLockKey('changePublishQuality'), async () => {
8314
+ for (const item of this.transceiverCache.items()) {
8315
+ const { publishOption, transceiver } = item;
8316
+ if (!trackTypes.includes(publishOption.trackType))
8317
+ continue;
8318
+ const track = transceiver.sender.track;
8319
+ await this.silenceSenderOnFirefox(item);
8320
+ this.stopTrack(track);
8321
+ }
8322
+ });
8323
+ };
8127
8324
  /**
8128
8325
  * Stops all the cloned tracks that are being published to the SFU.
8129
8326
  */
8130
- this.stopAllTracks = () => {
8131
- for (const { transceiver } of this.transceiverCache.items()) {
8132
- this.stopTrack(transceiver.sender.track);
8133
- }
8134
- for (const track of this.clonedTracks) {
8135
- this.stopTrack(track);
8136
- }
8327
+ this.stopAllTracks = async () => {
8328
+ return withoutConcurrency(this.eventLockKey('changePublishQuality'), async () => {
8329
+ for (const item of this.transceiverCache.items()) {
8330
+ const track = item.transceiver.sender.track;
8331
+ await this.silenceSenderOnFirefox(item);
8332
+ this.stopTrack(track);
8333
+ }
8334
+ for (const track of this.clonedTracks) {
8335
+ this.stopTrack(track);
8336
+ }
8337
+ });
8137
8338
  };
8138
- this.changePublishQuality = async (videoSender) => {
8139
- const { trackType, layers, publishOptionId } = videoSender;
8140
- const enabledLayers = layers.filter((l) => l.active);
8339
+ this.changePublishQuality = async (videoSender, bundle) => {
8340
+ const enabledLayers = videoSender.layers.filter((l) => l.active);
8141
8341
  const tag = 'Update publish quality:';
8142
8342
  this.logger.info(`${tag} requested layers by SFU:`, enabledLayers);
8143
- const transceiverId = this.transceiverCache.find((t) => t.publishOption.id === publishOptionId &&
8144
- t.publishOption.trackType === trackType);
8145
- const sender = transceiverId?.transceiver.sender;
8343
+ const sender = bundle?.transceiver.sender;
8146
8344
  if (!sender) {
8147
8345
  return this.logger.warn(`${tag} no video sender found.`);
8148
8346
  }
@@ -8150,7 +8348,7 @@ class Publisher extends BasePeerConnection {
8150
8348
  if (params.encodings.length === 0) {
8151
8349
  return this.logger.warn(`${tag} there are no encodings set.`);
8152
8350
  }
8153
- const codecInUse = transceiverId?.publishOption.codec?.name;
8351
+ const codecInUse = bundle?.publishOption.codec?.name;
8154
8352
  const usesSvcCodec = codecInUse && isSvcCodec(codecInUse);
8155
8353
  let changed = false;
8156
8354
  for (const encoder of params.encodings) {
@@ -8190,6 +8388,12 @@ class Publisher extends BasePeerConnection {
8190
8388
  changed = true;
8191
8389
  }
8192
8390
  }
8391
+ const degradationPreference = toRTCDegradationPreference(videoSender.degradationPreference);
8392
+ if (degradationPreference &&
8393
+ params.degradationPreference !== degradationPreference) {
8394
+ params.degradationPreference = degradationPreference;
8395
+ changed = true;
8396
+ }
8193
8397
  const activeEncoders = params.encodings.filter((e) => e.active);
8194
8398
  if (!changed) {
8195
8399
  return this.logger.info(`${tag} no change:`, activeEncoders);
@@ -8344,6 +8548,72 @@ class Publisher extends BasePeerConnection {
8344
8548
  track.stop();
8345
8549
  this.clonedTracks.delete(track);
8346
8550
  };
8551
+ /**
8552
+ * Silences a Firefox sender on the wire during unpublish.
8553
+ *
8554
+ * Firefox keeps emitting RTP after track.stop(), but the right lever
8555
+ * differs by track type:
8556
+ * - audio: `replaceTrack(null)` is the only reliable silencer;
8557
+ * `setParameters({encodings:[...active:false]})` does NOT stop
8558
+ * the Opus encoder.
8559
+ * - video: `setParameters({encodings:[...active:false]})` pauses
8560
+ * the encoder; `replaceTrack(null)` does NOT reliably stop the
8561
+ * video encoder. The prior active=true configuration is captured
8562
+ * onto `bundle.videoSender` so `updateTransceiver` can restore
8563
+ * it on the next publish.
8564
+ *
8565
+ * No-op on non-Firefox browsers and during teardown.
8566
+ */
8567
+ this.silenceSenderOnFirefox = async (bundle) => {
8568
+ if (this.isDisposed || !isFirefox())
8569
+ return;
8570
+ const { transceiver, publishOption } = bundle;
8571
+ if (isAudioTrackType(publishOption.trackType)) {
8572
+ await transceiver.sender.replaceTrack(null).catch((err) => {
8573
+ this.logger.warn('Failed to clear audio sender track', err);
8574
+ });
8575
+ return;
8576
+ }
8577
+ await this.disableAllEncodings(bundle);
8578
+ };
8579
+ this.disableAllEncodings = async (bundle) => {
8580
+ const { transceiver, publishOption } = bundle;
8581
+ const sender = transceiver.sender;
8582
+ const params = sender.getParameters();
8583
+ if (!params.encodings || params.encodings.length === 0)
8584
+ return;
8585
+ if (!bundle.videoSender) {
8586
+ this.transceiverCache.update(publishOption, {
8587
+ videoSender: {
8588
+ trackType: publishOption.trackType,
8589
+ publishOptionId: publishOption.id,
8590
+ codec: publishOption.codec,
8591
+ degradationPreference: fromRTCDegradationPreference(params.degradationPreference),
8592
+ layers: params.encodings.map((e) => ({
8593
+ name: e.rid ?? 'q',
8594
+ active: e.active ?? true,
8595
+ maxBitrate: e.maxBitrate ?? 0,
8596
+ scaleResolutionDownBy: e.scaleResolutionDownBy ?? 0,
8597
+ maxFramerate: e.maxFramerate ?? 0,
8598
+ // @ts-expect-error scalabilityMode is not in the typedefs yet
8599
+ scalabilityMode: e.scalabilityMode ?? '',
8600
+ })),
8601
+ },
8602
+ });
8603
+ }
8604
+ let changed = false;
8605
+ for (const encoding of params.encodings) {
8606
+ if (encoding.active !== false) {
8607
+ encoding.active = false;
8608
+ changed = true;
8609
+ }
8610
+ }
8611
+ if (!changed)
8612
+ return;
8613
+ await sender.setParameters(params).catch((err) => {
8614
+ this.logger.error('Failed to disable video sender encodings:', err);
8615
+ });
8616
+ };
8347
8617
  this.publishOptions = publishOptions;
8348
8618
  this.on('iceRestart', (iceRestart) => {
8349
8619
  if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
@@ -8352,7 +8622,16 @@ class Publisher extends BasePeerConnection {
8352
8622
  });
8353
8623
  this.on('changePublishQuality', async (event) => {
8354
8624
  for (const videoSender of event.videoSenders) {
8355
- await this.changePublishQuality(videoSender);
8625
+ // if not publishing, update the encodingConfigCache and don't modify the state.
8626
+ // we'll apply this config on the next publish/unmute.
8627
+ const { trackType, publishOptionId } = videoSender;
8628
+ const bundle = this.transceiverCache.getBy(publishOptionId, trackType);
8629
+ if (bundle) {
8630
+ this.transceiverCache.update(bundle.publishOption, { videoSender });
8631
+ }
8632
+ if (isFirefox() && !this.isPublishing(trackType))
8633
+ continue;
8634
+ await this.changePublishQuality(videoSender, bundle);
8356
8635
  }
8357
8636
  });
8358
8637
  this.on('changePublishOptions', (event) => {
@@ -8363,13 +8642,48 @@ class Publisher extends BasePeerConnection {
8363
8642
  /**
8364
8643
  * Disposes this Publisher instance.
8365
8644
  */
8366
- dispose() {
8367
- super.dispose();
8368
- this.stopAllTracks();
8645
+ async dispose() {
8646
+ await super.dispose();
8647
+ try {
8648
+ await this.stopAllTracks();
8649
+ }
8650
+ catch (err) {
8651
+ this.logger.warn('Failed to stop tracks during dispose', err);
8652
+ }
8369
8653
  this.clonedTracks.clear();
8370
8654
  }
8371
8655
  }
8372
8656
 
8657
+ /**
8658
+ * Adds unique values to an array.
8659
+ *
8660
+ * @param arr the array to add to.
8661
+ * @param values the values to add.
8662
+ */
8663
+ const pushToIfMissing = (arr, ...values) => {
8664
+ for (const v of values) {
8665
+ if (!arr.includes(v)) {
8666
+ arr.push(v);
8667
+ }
8668
+ }
8669
+ return arr;
8670
+ };
8671
+ /**
8672
+ * Removes values from an array if they are present.
8673
+ *
8674
+ * @param arr the array to remove from.
8675
+ * @param values the values to remove.
8676
+ */
8677
+ const removeFromIfPresent = (arr, ...values) => {
8678
+ for (const v of values) {
8679
+ const index = arr.indexOf(v);
8680
+ if (index !== -1) {
8681
+ arr.splice(index, 1);
8682
+ }
8683
+ }
8684
+ return arr;
8685
+ };
8686
+
8373
8687
  /**
8374
8688
  * A wrapper around the `RTCPeerConnection` that handles the incoming
8375
8689
  * media streams from the SFU.
@@ -8411,27 +8725,34 @@ class Subscriber extends BasePeerConnection {
8411
8725
  }
8412
8726
  };
8413
8727
  this.handleOnTrack = (e) => {
8414
- const [primaryStream] = e.streams;
8728
+ const { streams, track } = e;
8729
+ const [primaryStream] = streams;
8415
8730
  // example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
8416
8731
  const [trackId, rawTrackType] = primaryStream.id.split(':');
8417
8732
  const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
8418
- this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, e.track.id, e.track);
8733
+ this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
8734
+ const trackType = toTrackType(rawTrackType);
8735
+ if (!trackType) {
8736
+ return this.logger.error(`Unknown track type: ${rawTrackType}`);
8737
+ }
8419
8738
  const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
8420
- e.track.addEventListener('mute', () => {
8739
+ track.addEventListener('mute', () => {
8421
8740
  this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
8741
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
8422
8742
  });
8423
- e.track.addEventListener('unmute', () => {
8743
+ track.addEventListener('unmute', () => {
8424
8744
  this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
8745
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
8425
8746
  });
8426
- e.track.addEventListener('ended', () => {
8747
+ track.addEventListener('ended', () => {
8427
8748
  this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
8749
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
8428
8750
  this.state.removeOrphanedTrack(primaryStream.id);
8429
8751
  });
8430
- const trackType = toTrackType(rawTrackType);
8431
- if (!trackType) {
8432
- return this.logger.error(`Unknown track type: ${rawTrackType}`);
8752
+ if (track.muted) {
8753
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
8433
8754
  }
8434
- this.trackIdToTrackType.set(e.track.id, trackType);
8755
+ this.trackIdToTrackType.set(track.id, trackType);
8435
8756
  if (!participantToUpdate) {
8436
8757
  this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
8437
8758
  this.state.registerOrphanedTrack({
@@ -8457,13 +8778,30 @@ class Subscriber extends BasePeerConnection {
8457
8778
  });
8458
8779
  // now, dispose the previous stream if it exists
8459
8780
  if (previousStream) {
8460
- this.logger.info(`[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`);
8781
+ this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
8461
8782
  previousStream.getTracks().forEach((t) => {
8462
8783
  t.stop();
8463
8784
  previousStream.removeTrack(t);
8464
8785
  });
8465
8786
  }
8466
8787
  };
8788
+ this.setRemoteTrackInterrupted = (trackId, trackType, interrupted) => {
8789
+ if (trackType !== TrackType.AUDIO)
8790
+ return;
8791
+ const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
8792
+ if (!target)
8793
+ return;
8794
+ this.state.updateParticipant(target.sessionId, (p) => {
8795
+ const current = p.interruptedTracks ?? [];
8796
+ const has = current.includes(trackType);
8797
+ if (interrupted === has)
8798
+ return {};
8799
+ const next = interrupted
8800
+ ? pushToIfMissing([...current], trackType)
8801
+ : removeFromIfPresent([...current], trackType);
8802
+ return { interruptedTracks: next };
8803
+ });
8804
+ };
8467
8805
  this.negotiate = async (subscriberOffer) => {
8468
8806
  await this.pc.setRemoteDescription({
8469
8807
  type: 'offer',
@@ -9186,36 +9524,6 @@ const watchCallGrantsUpdated = (state) => {
9186
9524
  };
9187
9525
  };
9188
9526
 
9189
- /**
9190
- * Adds unique values to an array.
9191
- *
9192
- * @param arr the array to add to.
9193
- * @param values the values to add.
9194
- */
9195
- const pushToIfMissing = (arr, ...values) => {
9196
- for (const v of values) {
9197
- if (!arr.includes(v)) {
9198
- arr.push(v);
9199
- }
9200
- }
9201
- return arr;
9202
- };
9203
- /**
9204
- * Removes values from an array if they are present.
9205
- *
9206
- * @param arr the array to remove from.
9207
- * @param values the values to remove.
9208
- */
9209
- const removeFromIfPresent = (arr, ...values) => {
9210
- for (const v of values) {
9211
- const index = arr.indexOf(v);
9212
- if (index !== -1) {
9213
- arr.splice(index, 1);
9214
- }
9215
- }
9216
- return arr;
9217
- };
9218
-
9219
9527
  const watchConnectionQualityChanged = (dispatcher, state) => {
9220
9528
  return dispatcher.on('connectionQualityChanged', '*', (e) => {
9221
9529
  const { connectionQualityUpdates } = e;
@@ -9548,140 +9856,54 @@ const registerRingingCallEventHandlers = (call) => {
9548
9856
  };
9549
9857
  };
9550
9858
 
9551
- const DEFAULT_THRESHOLD = 0.35;
9552
- class ViewportTracker {
9553
- constructor() {
9859
+ const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
9860
+ /**
9861
+ * Tracks audio element bindings and periodically warns about
9862
+ * remote participants whose audio streams have no bound element.
9863
+ */
9864
+ class AudioBindingsWatchdog {
9865
+ constructor(state, tracer) {
9866
+ this.bindings = new Map();
9867
+ this.enabled = true;
9868
+ this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
9554
9869
  /**
9555
- * @private
9870
+ * Registers an audio element binding for the given session and track type.
9871
+ * Warns if a different element is already bound to the same key.
9556
9872
  */
9557
- this.elementHandlerMap = new Map();
9873
+ this.register = (element, sessionId, trackType) => {
9874
+ const key = toBindingKey(sessionId, trackType);
9875
+ const existing = this.bindings.get(key);
9876
+ if (existing && existing !== element) {
9877
+ this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
9878
+ this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
9879
+ }
9880
+ this.bindings.set(key, element);
9881
+ };
9558
9882
  /**
9559
- * @private
9883
+ * Removes the audio element binding for the given session and track type.
9560
9884
  */
9561
- this.observer = null;
9562
- // in React children render before viewport is set, add
9563
- // them to the queue and observe them once the observer is ready
9885
+ this.unregister = (sessionId, trackType) => {
9886
+ this.bindings.delete(toBindingKey(sessionId, trackType));
9887
+ };
9564
9888
  /**
9565
- * @private
9889
+ * Enables or disables the watchdog.
9890
+ * When disabled, the periodic check stops but bindings are still tracked.
9566
9891
  */
9567
- this.queueSet = new Set();
9892
+ this.setEnabled = (enabled) => {
9893
+ this.enabled = enabled;
9894
+ if (enabled) {
9895
+ this.start();
9896
+ }
9897
+ else {
9898
+ this.stop();
9899
+ }
9900
+ };
9568
9901
  /**
9569
- * Method to set scrollable viewport as root for the IntersectionObserver, returns
9570
- * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
9571
- *
9572
- * @param viewportElement
9573
- * @param options
9574
- * @returns Unobserve
9575
- */
9576
- this.setViewport = (viewportElement, options) => {
9577
- const cleanup = () => {
9578
- this.observer?.disconnect();
9579
- this.observer = null;
9580
- this.elementHandlerMap.clear();
9581
- };
9582
- this.observer = new IntersectionObserver((entries) => {
9583
- entries.forEach((entry) => {
9584
- const handler = this.elementHandlerMap.get(entry.target);
9585
- handler?.(entry);
9586
- });
9587
- }, {
9588
- root: viewportElement,
9589
- ...options,
9590
- threshold: options?.threshold ?? DEFAULT_THRESHOLD,
9591
- });
9592
- if (this.queueSet.size) {
9593
- this.queueSet.forEach(([queueElement, queueHandler]) => {
9594
- // check if element which requested observation is
9595
- // a child of a viewport element, skip if isn't
9596
- if (!viewportElement.contains(queueElement))
9597
- return;
9598
- this.observer.observe(queueElement);
9599
- this.elementHandlerMap.set(queueElement, queueHandler);
9600
- });
9601
- this.queueSet.clear();
9602
- }
9603
- return cleanup;
9604
- };
9605
- /**
9606
- * Method to set element to observe and handler to be triggered whenever IntersectionObserver
9607
- * detects a possible change in element's visibility within specified viewport, returns
9608
- * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
9609
- *
9610
- * @param element
9611
- * @param handler
9612
- * @returns Unobserve
9613
- */
9614
- this.observe = (element, handler) => {
9615
- const queueItem = [element, handler];
9616
- const cleanup = () => {
9617
- this.elementHandlerMap.delete(element);
9618
- this.observer?.unobserve(element);
9619
- this.queueSet.delete(queueItem);
9620
- };
9621
- if (this.elementHandlerMap.has(element))
9622
- return cleanup;
9623
- if (!this.observer) {
9624
- this.queueSet.add(queueItem);
9625
- return cleanup;
9626
- }
9627
- if (this.observer.root.contains(element)) {
9628
- this.elementHandlerMap.set(element, handler);
9629
- this.observer.observe(element);
9630
- }
9631
- return cleanup;
9632
- };
9633
- }
9634
- }
9635
-
9636
- const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
9637
- /**
9638
- * Tracks audio element bindings and periodically warns about
9639
- * remote participants whose audio streams have no bound element.
9640
- */
9641
- class AudioBindingsWatchdog {
9642
- constructor(state, tracer) {
9643
- this.state = state;
9644
- this.tracer = tracer;
9645
- this.bindings = new Map();
9646
- this.enabled = true;
9647
- this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
9648
- /**
9649
- * Registers an audio element binding for the given session and track type.
9650
- * Warns if a different element is already bound to the same key.
9651
- */
9652
- this.register = (audioElement, sessionId, trackType) => {
9653
- const key = toBindingKey(sessionId, trackType);
9654
- const existing = this.bindings.get(key);
9655
- if (existing && existing !== audioElement) {
9656
- this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
9657
- this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
9658
- }
9659
- this.bindings.set(key, audioElement);
9660
- };
9661
- /**
9662
- * Removes the audio element binding for the given session and track type.
9663
- */
9664
- this.unregister = (sessionId, trackType) => {
9665
- this.bindings.delete(toBindingKey(sessionId, trackType));
9666
- };
9667
- /**
9668
- * Enables or disables the watchdog.
9669
- * When disabled, the periodic check stops but bindings are still tracked.
9670
- */
9671
- this.setEnabled = (enabled) => {
9672
- this.enabled = enabled;
9673
- if (enabled) {
9674
- this.start();
9675
- }
9676
- else {
9677
- this.stop();
9678
- }
9679
- };
9680
- /**
9681
- * Stops the watchdog and unsubscribes from callingState changes.
9902
+ * Stops the watchdog and unsubscribes from callingState changes.
9682
9903
  */
9683
9904
  this.dispose = () => {
9684
9905
  this.stop();
9906
+ this.bindings.clear();
9685
9907
  this.unsubscribeCallingState();
9686
9908
  };
9687
9909
  this.start = () => {
@@ -9713,6 +9935,8 @@ class AudioBindingsWatchdog {
9713
9935
  this.stop = () => {
9714
9936
  clearInterval(this.watchdogInterval);
9715
9937
  };
9938
+ this.tracer = tracer;
9939
+ this.state = state;
9716
9940
  this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
9717
9941
  if (!this.enabled)
9718
9942
  return;
@@ -9726,61 +9950,97 @@ class AudioBindingsWatchdog {
9726
9950
  }
9727
9951
  }
9728
9952
 
9729
- const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
9730
- videoTrack: VisibilityState.UNKNOWN,
9731
- screenShareTrack: VisibilityState.UNKNOWN,
9732
- };
9733
- const globalOverrideKey = Symbol('globalOverrideKey');
9734
9953
  /**
9735
- * A manager class that handles dynascale related tasks like:
9736
- *
9737
- * - binding video elements to session ids
9738
- * - binding audio elements to session ids
9739
- * - tracking element visibility
9740
- * - updating subscriptions based on viewport visibility
9741
- * - updating subscriptions based on video element dimensions
9742
- * - updating subscriptions based on published tracks
9954
+ * Tracks audio elements that the browser's autoplay policy has blocked.
9743
9955
  */
9744
- class DynascaleManager {
9745
- /**
9746
- * Creates a new DynascaleManager instance.
9747
- */
9748
- constructor(callState, speaker, tracer) {
9956
+ class BlockedAudioTracker {
9957
+ constructor(tracer) {
9958
+ this.logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
9959
+ this.blockedElementsSubject = new BehaviorSubject(new Set());
9749
9960
  /**
9750
- * The viewport tracker instance.
9961
+ * Whether the browser's autoplay policy is blocking audio playback.
9962
+ * Will be `true` when at least one audio element is currently blocked.
9963
+ * Use {@link resumeAudio} within a user gesture to unblock.
9751
9964
  */
9752
- this.viewportTracker = new ViewportTracker();
9753
- this.logger = videoLoggerSystem.getLogger('DynascaleManager');
9754
- this.useWebAudio = false;
9755
- this.pendingSubscriptionsUpdate = null;
9965
+ this.autoplayBlocked$ = this.blockedElementsSubject.pipe(map((elements) => elements.size > 0), distinctUntilChanged());
9756
9966
  /**
9757
- * Audio elements that were blocked by the browser's autoplay policy.
9758
- * These can be retried by calling `resumeAudio()` from a user gesture.
9967
+ * Registers an audio element as blocked by the browser's autoplay policy.
9759
9968
  */
9760
- this.blockedAudioElementsSubject = new BehaviorSubject(new Set());
9761
- /**
9762
- * Whether the browser's autoplay policy is blocking audio playback.
9763
- * Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
9764
- * Use `resumeAudio()` within a user gesture to unblock.
9765
- */
9766
- this.autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(map((elements) => elements.size > 0), distinctUntilChanged());
9767
- this.addBlockedAudioElement = (audioElement) => {
9768
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9769
- const next = new Set(elements);
9770
- next.add(audioElement);
9771
- return next;
9969
+ this.markBlocked = (audioElement, blocked) => {
9970
+ setCurrentValue(this.blockedElementsSubject, (elements) => {
9971
+ if (blocked)
9972
+ elements.add(audioElement);
9973
+ else
9974
+ elements.delete(audioElement);
9975
+ return elements;
9772
9976
  });
9773
9977
  };
9774
- this.removeBlockedAudioElement = (audioElement) => {
9775
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9776
- const nextElements = new Set(elements);
9777
- nextElements.delete(audioElement);
9778
- return nextElements;
9978
+ /**
9979
+ * Returns whether the given audio element is currently flagged as blocked
9980
+ * by the browser's autoplay policy.
9981
+ */
9982
+ this.isBlocked = (audioElement) => {
9983
+ return this.blockedElementsSubject.getValue().has(audioElement);
9984
+ };
9985
+ /**
9986
+ * Plays all audio elements blocked by the browser's autoplay policy.
9987
+ * Must be called from within a user gesture (e.g., click handler).
9988
+ */
9989
+ this.resumeAudio = async () => {
9990
+ this.tracer.trace('resumeAudio', null);
9991
+ await setCurrentValueAsync(this.blockedElementsSubject, async (elements) => {
9992
+ await Promise.all(Array.from(elements, async (element) => {
9993
+ try {
9994
+ if (element.srcObject)
9995
+ await timeboxed([element.play()], 2000);
9996
+ elements.delete(element);
9997
+ }
9998
+ catch (err) {
9999
+ this.logger.warn(`Can't resume audio for element`, element, err);
10000
+ }
10001
+ }));
10002
+ return elements;
9779
10003
  });
9780
10004
  };
9781
- this.videoTrackSubscriptionOverridesSubject = new BehaviorSubject({});
9782
- this.videoTrackSubscriptionOverrides$ = this.videoTrackSubscriptionOverridesSubject.asObservable();
9783
- this.incomingVideoSettings$ = this.videoTrackSubscriptionOverrides$.pipe(map((overrides) => {
10005
+ this.tracer = tracer;
10006
+ }
10007
+ }
10008
+
10009
+ /** Symbol key for the "applies to all participants" override slot. */
10010
+ const globalOverrideKey = Symbol('globalOverrideKey');
10011
+ /**
10012
+ * Owns the SFU-side video-subscription machinery for a `Call`:
10013
+ *
10014
+ * - Holds the per-session / global override state in a
10015
+ * `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
10016
+ * - Derives the SFU subscription list from `CallState` participants +
10017
+ * current overrides via the `subscriptions` getter.
10018
+ * - Debounces and pushes the list to the SFU through
10019
+ * `sfuClient.updateSubscriptions`.
10020
+ * - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
10021
+ * the override state for React hooks.
10022
+ *
10023
+ * Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
10024
+ * `DynascaleManager.bindVideoElement` triggers `apply()` on every
10025
+ * dimension / visibility change.
10026
+ */
10027
+ class TrackSubscriptionManager {
10028
+ /**
10029
+ * Constructs new TrackSubscriptionManager instance.
10030
+ *
10031
+ * @param callState the call state.
10032
+ * @param tracer the tracer to use.
10033
+ */
10034
+ constructor(callState, tracer) {
10035
+ this.logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
10036
+ this.pendingUpdate = null;
10037
+ this.overridesSubject = new BehaviorSubject({});
10038
+ this.overrides$ = this.overridesSubject.asObservable();
10039
+ /**
10040
+ * Consumer-friendly projection of the override state. Used by the
10041
+ * `useIncomingVideoSettings()` React hook.
10042
+ */
10043
+ this.incomingVideoSettings$ = this.overrides$.pipe(map((overrides) => {
9784
10044
  const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
9785
10045
  return {
9786
10046
  enabled: globalSettings?.enabled !== false,
@@ -9802,106 +10062,255 @@ class DynascaleManager {
9802
10062
  };
9803
10063
  }), shareReplay(1));
9804
10064
  /**
9805
- * Disposes the allocated resources and closes the audio context if it was created.
10065
+ * Sets the SFU client used by `apply()` to push subscription updates.
10066
+ * Called by the owner on call join; cleared on leave.
9806
10067
  */
9807
- this.dispose = async () => {
9808
- if (this.pendingSubscriptionsUpdate) {
9809
- clearTimeout(this.pendingSubscriptionsUpdate);
9810
- }
9811
- this.audioBindingsWatchdog?.dispose();
9812
- setCurrentValue(this.blockedAudioElementsSubject, new Set());
9813
- const context = this.audioContext;
9814
- if (context && context.state !== 'closed') {
9815
- document.removeEventListener('click', this.resumeAudioContext);
9816
- await context.close();
9817
- this.audioContext = undefined;
10068
+ this.setSfuClient = (sfuClient) => {
10069
+ this.sfuClient = sfuClient;
10070
+ };
10071
+ /**
10072
+ * Cancels any pending debounced subscription push. Idempotent.
10073
+ */
10074
+ this.dispose = () => {
10075
+ if (this.pendingUpdate) {
10076
+ clearTimeout(this.pendingUpdate);
10077
+ this.pendingUpdate = null;
9818
10078
  }
9819
10079
  };
9820
- this.setVideoTrackSubscriptionOverrides = (override, sessionIds) => {
9821
- this.tracer.trace('setVideoTrackSubscriptionOverrides', [
9822
- override,
9823
- sessionIds,
9824
- ]);
10080
+ /**
10081
+ * Sets video-subscription overrides. Called by
10082
+ * `Call.setIncomingVideoEnabled` and
10083
+ * `Call.setPreferredIncomingVideoResolution`.
10084
+ *
10085
+ * - `sessionIds` omitted → applies `override` globally (or clears the
10086
+ * global override if `override` is `undefined`).
10087
+ * - `sessionIds` provided → applies `override` to each listed session.
10088
+ */
10089
+ this.setOverrides = (override, sessionIds) => {
10090
+ this.tracer.trace('setOverrides', [override, sessionIds]);
9825
10091
  if (!sessionIds) {
9826
- return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, override ? { [globalOverrideKey]: override } : {});
10092
+ return setCurrentValue(this.overridesSubject, override ? { [globalOverrideKey]: override } : {});
9827
10093
  }
9828
- return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, (overrides) => ({
10094
+ return setCurrentValue(this.overridesSubject, (overrides) => ({
9829
10095
  ...overrides,
9830
10096
  ...Object.fromEntries(sessionIds.map((id) => [id, override])),
9831
10097
  }));
9832
10098
  };
9833
- this.applyTrackSubscriptions = (debounceType = DebounceType.SLOW) => {
9834
- if (this.pendingSubscriptionsUpdate) {
9835
- clearTimeout(this.pendingSubscriptionsUpdate);
10099
+ /**
10100
+ * Pushes `subscriptions` to the SFU. Debounced by `debounceType`
10101
+ * (SLOW by default). Multiple rapid calls coalesce into one RPC.
10102
+ * Passing `0` fires synchronously.
10103
+ */
10104
+ this.apply = (debounceType = DebounceType.SLOW) => {
10105
+ if (this.pendingUpdate) {
10106
+ clearTimeout(this.pendingUpdate);
9836
10107
  }
9837
10108
  const updateSubscriptions = () => {
9838
- this.pendingSubscriptionsUpdate = null;
10109
+ this.pendingUpdate = null;
9839
10110
  this.sfuClient
9840
- ?.updateSubscriptions(this.trackSubscriptions)
10111
+ ?.updateSubscriptions(this.subscriptions)
9841
10112
  .catch((err) => {
9842
10113
  this.logger.debug(`Failed to update track subscriptions`, err);
9843
10114
  });
9844
10115
  };
9845
10116
  if (debounceType) {
9846
- this.pendingSubscriptionsUpdate = setTimeout(updateSubscriptions, debounceType);
10117
+ this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
9847
10118
  }
9848
10119
  else {
9849
10120
  updateSubscriptions();
9850
10121
  }
9851
10122
  };
9852
- /**
9853
- * Will begin tracking the given element for visibility changes within the
9854
- * configured viewport element (`call.setViewport`).
9855
- *
9856
- * @param element the element to track.
9857
- * @param sessionId the session id.
9858
- * @param trackType the kind of video.
9859
- * @returns Untrack.
9860
- */
9861
- this.trackElementVisibility = (element, sessionId, trackType) => {
9862
- const cleanup = this.viewportTracker.observe(element, (entry) => {
9863
- this.callState.updateParticipant(sessionId, (participant) => {
9864
- const previousVisibilityState = participant.viewportVisibilityState ??
9865
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
9866
- // observer triggers when the element is "moved" to be a fullscreen element
9867
- // keep it VISIBLE if that happens to prevent fullscreen with placeholder
9868
- const isVisible = entry.isIntersecting || document.fullscreenElement === element
9869
- ? VisibilityState.VISIBLE
9870
- : VisibilityState.INVISIBLE;
9871
- return {
9872
- ...participant,
9873
- viewportVisibilityState: {
9874
- ...previousVisibilityState,
9875
- [trackType]: isVisible,
9876
- },
9877
- };
10123
+ this.tracer = tracer;
10124
+ this.callState = callState;
10125
+ }
10126
+ /**
10127
+ * The current SFU subscription list, computed from `CallState`
10128
+ * participants and the override state. Used by:
10129
+ *
10130
+ * - `apply()` to push to the SFU each time the set changes.
10131
+ * - `Call.getReconnectDetails` to include the subscription list in
10132
+ * the reconnect payload.
10133
+ */
10134
+ get subscriptions() {
10135
+ const subscriptions = [];
10136
+ // Use getParticipantsSnapshot() to bypass the observable pipeline
10137
+ // and avoid stale data caused by shareReplay with no active subscribers
10138
+ const participants = this.callState.getParticipantsSnapshot();
10139
+ const overrides = this.overridesSubject.getValue();
10140
+ for (const p of participants) {
10141
+ if (p.isLocalParticipant)
10142
+ continue;
10143
+ // NOTE: audio tracks don't have to be requested explicitly
10144
+ // as the SFU will implicitly subscribe us to all of them,
10145
+ // once they become available.
10146
+ if (p.videoDimension && hasVideo(p)) {
10147
+ const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
10148
+ if (override?.enabled !== false) {
10149
+ subscriptions.push({
10150
+ userId: p.userId,
10151
+ sessionId: p.sessionId,
10152
+ trackType: TrackType.VIDEO,
10153
+ dimension: override?.dimension ?? p.videoDimension,
10154
+ });
10155
+ }
10156
+ }
10157
+ if (p.screenShareDimension && hasScreenShare(p)) {
10158
+ subscriptions.push({
10159
+ userId: p.userId,
10160
+ sessionId: p.sessionId,
10161
+ trackType: TrackType.SCREEN_SHARE,
10162
+ dimension: p.screenShareDimension,
9878
10163
  });
10164
+ }
10165
+ if (hasScreenShareAudio(p)) {
10166
+ subscriptions.push({
10167
+ userId: p.userId,
10168
+ sessionId: p.sessionId,
10169
+ trackType: TrackType.SCREEN_SHARE_AUDIO,
10170
+ });
10171
+ }
10172
+ }
10173
+ return subscriptions;
10174
+ }
10175
+ get overrides() {
10176
+ return getCurrentValue(this.overrides$);
10177
+ }
10178
+ }
10179
+
10180
+ /**
10181
+ * Watches a single audio or video element and attempts to recover playback
10182
+ * after the element transitions to a paused or suspended state unexpectedly.
10183
+ */
10184
+ class MediaPlaybackWatchdog {
10185
+ constructor(opts) {
10186
+ this.logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
10187
+ this.controller = new AbortController();
10188
+ this.attempt = 0;
10189
+ this.disposed = false;
10190
+ this.attach = () => {
10191
+ if (this.disposed)
10192
+ return;
10193
+ const { signal } = this.controller;
10194
+ this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
10195
+ this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
10196
+ this.element.addEventListener('playing', this.onPlaying, { signal });
10197
+ };
10198
+ this.dispose = () => {
10199
+ if (this.disposed)
10200
+ return;
10201
+ this.disposed = true;
10202
+ this.controller.abort();
10203
+ if (this.pendingTimer)
10204
+ clearTimeout(this.pendingTimer);
10205
+ this.pendingTimer = undefined;
10206
+ };
10207
+ this.onPlaying = () => {
10208
+ if (this.attempt > 0) {
10209
+ this.tracer.trace('mediaPlayback.recover.success', {
10210
+ kind: this.kind,
10211
+ attempts: this.attempt,
10212
+ });
10213
+ }
10214
+ this.attempt = 0;
10215
+ if (this.pendingTimer)
10216
+ clearTimeout(this.pendingTimer);
10217
+ this.pendingTimer = undefined;
10218
+ };
10219
+ this.onPauseOrSuspend = (event) => {
10220
+ if (this.disposed)
10221
+ return;
10222
+ this.tracer.trace('mediaPlayback.paused', {
10223
+ kind: this.kind,
10224
+ reason: event.type,
9879
10225
  });
9880
- return () => {
9881
- cleanup();
9882
- // reset visibility state to UNKNOWN upon cleanup
9883
- // so that the layouts that are not actively observed
9884
- // can still function normally (runtime layout switching)
9885
- this.callState.updateParticipant(sessionId, (participant) => {
9886
- const previousVisibilityState = participant.viewportVisibilityState ??
9887
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
9888
- return {
9889
- ...participant,
9890
- viewportVisibilityState: {
9891
- ...previousVisibilityState,
9892
- [trackType]: VisibilityState.UNKNOWN,
9893
- },
9894
- };
10226
+ this.scheduleRecovery();
10227
+ };
10228
+ this.scheduleRecovery = () => {
10229
+ if (this.disposed || this.pendingTimer)
10230
+ return;
10231
+ const skipReason = this.computeSkipReason();
10232
+ if (skipReason) {
10233
+ this.tracer.trace('mediaPlayback.recover.skipped', {
10234
+ kind: this.kind,
10235
+ reason: skipReason,
9895
10236
  });
9896
- };
10237
+ return;
10238
+ }
10239
+ const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
10240
+ this.pendingTimer = setTimeout(this.attemptPlay, delay);
10241
+ };
10242
+ this.computeSkipReason = () => {
10243
+ if (this.disposed)
10244
+ return 'disposed';
10245
+ if (!this.element.srcObject)
10246
+ return 'noSrc';
10247
+ if (this.element.ended)
10248
+ return 'ended';
10249
+ if (this.isBlocked())
10250
+ return 'blocked';
10251
+ const HAVE_CURRENT_DATA = 2;
10252
+ if (this.element.readyState < HAVE_CURRENT_DATA)
10253
+ return 'notReady';
10254
+ if (!this.element.paused)
10255
+ return 'notPaused';
10256
+ };
10257
+ this.attemptPlay = async () => {
10258
+ this.pendingTimer = undefined;
10259
+ if (this.disposed)
10260
+ return;
10261
+ this.attempt += 1;
10262
+ this.tracer.trace('mediaPlayback.recover.attempt', {
10263
+ kind: this.kind,
10264
+ attempt: this.attempt,
10265
+ });
10266
+ try {
10267
+ await timeboxed([this.element.play()], 2000);
10268
+ }
10269
+ catch (err) {
10270
+ if (this.disposed)
10271
+ return;
10272
+ this.logger.warn(`Failed to recover ${this.kind} playback`, err);
10273
+ if (this.attempt >= 10) {
10274
+ this.tracer.trace('mediaPlayback.recover.giveUp', {
10275
+ kind: this.kind,
10276
+ attempts: this.attempt,
10277
+ });
10278
+ return;
10279
+ }
10280
+ this.scheduleRecovery();
10281
+ }
9897
10282
  };
10283
+ this.element = opts.element;
10284
+ this.kind = opts.kind;
10285
+ this.tracer = opts.tracer;
10286
+ this.isBlocked = opts.isBlocked ?? (() => false);
10287
+ this.attach();
10288
+ }
10289
+ }
10290
+
10291
+ /**
10292
+ * A manager class that handles dynascale related tasks like:
10293
+ *
10294
+ * - binding video elements to session ids
10295
+ * - binding audio elements to session ids
10296
+ */
10297
+ class DynascaleManager {
10298
+ /**
10299
+ * Creates a new DynascaleManager instance.
10300
+ */
10301
+ constructor(callState, speaker, tracer, trackSubscriptionManager, blockedAudioTracker) {
10302
+ this.logger = videoLoggerSystem.getLogger('DynascaleManager');
10303
+ this.useWebAudio = false;
9898
10304
  /**
9899
- * Sets the viewport element to track bound video elements for visibility.
9900
- *
9901
- * @param element the viewport element.
10305
+ * Closes the audio context if it was created.
9902
10306
  */
9903
- this.setViewport = (element) => {
9904
- return this.viewportTracker.setViewport(element);
10307
+ this.dispose = async () => {
10308
+ const context = this.audioContext;
10309
+ if (context && context.state !== 'closed') {
10310
+ document.removeEventListener('click', this.resumeAudioContext);
10311
+ await context.close();
10312
+ this.audioContext = undefined;
10313
+ }
9905
10314
  };
9906
10315
  /**
9907
10316
  * Sets whether to use WebAudio API for audio playback.
@@ -9946,7 +10355,7 @@ class DynascaleManager {
9946
10355
  this.callState.updateParticipantTracks(trackType, {
9947
10356
  [sessionId]: { dimension },
9948
10357
  });
9949
- this.applyTrackSubscriptions(debounceType);
10358
+ this.trackSubscriptionManager.apply(debounceType);
9950
10359
  };
9951
10360
  const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((participant) => !!participant), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
9952
10361
  /**
@@ -10035,6 +10444,11 @@ class DynascaleManager {
10035
10444
  // without prior user interaction:
10036
10445
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
10037
10446
  videoElement.muted = true;
10447
+ const playbackWatchdog = new MediaPlaybackWatchdog({
10448
+ element: videoElement,
10449
+ kind: 'video',
10450
+ tracer: this.tracer,
10451
+ });
10038
10452
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
10039
10453
  const streamSubscription = participant$
10040
10454
  .pipe(distinctUntilKeyChanged(trackKey))
@@ -10044,14 +10458,14 @@ class DynascaleManager {
10044
10458
  return;
10045
10459
  videoElement.srcObject = source ?? null;
10046
10460
  if (isSafari() || isFirefox()) {
10047
- setTimeout(() => {
10461
+ setTimeout(async () => {
10048
10462
  videoElement.srcObject = source ?? null;
10049
- videoElement.play().catch((e) => {
10463
+ try {
10464
+ await timeboxed([videoElement.play()], 2000);
10465
+ }
10466
+ catch (e) {
10050
10467
  this.logger.warn(`Failed to play stream`, e);
10051
- });
10052
- // we add extra delay until we attempt to force-play
10053
- // the participant's media stream in Firefox and Safari,
10054
- // as they seem to have some timing issues
10468
+ }
10055
10469
  }, 25);
10056
10470
  }
10057
10471
  });
@@ -10061,6 +10475,7 @@ class DynascaleManager {
10061
10475
  publishedTracksSubscription?.unsubscribe();
10062
10476
  streamSubscription.unsubscribe();
10063
10477
  resizeObserver?.disconnect();
10478
+ playbackWatchdog.dispose();
10064
10479
  };
10065
10480
  };
10066
10481
  /**
@@ -10078,7 +10493,6 @@ class DynascaleManager {
10078
10493
  const participant = this.callState.findParticipantBySessionId(sessionId);
10079
10494
  if (!participant || participant.isLocalParticipant)
10080
10495
  return;
10081
- this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
10082
10496
  const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
10083
10497
  const updateSinkId = (deviceId, audioContext) => {
10084
10498
  if (!deviceId)
@@ -10097,6 +10511,7 @@ class DynascaleManager {
10097
10511
  };
10098
10512
  let sourceNode = undefined;
10099
10513
  let gainNode = undefined;
10514
+ let audioWatchdog = undefined;
10100
10515
  const isAudioTrack = trackType === 'audioTrack';
10101
10516
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
10102
10517
  const updateMediaStreamSubscription = participant$
@@ -10107,8 +10522,10 @@ class DynascaleManager {
10107
10522
  return;
10108
10523
  setTimeout(() => {
10109
10524
  audioElement.srcObject = source ?? null;
10525
+ audioWatchdog?.dispose();
10526
+ audioWatchdog = undefined;
10110
10527
  if (!source) {
10111
- this.removeBlockedAudioElement(audioElement);
10528
+ this.blockedAudioTracker.markBlocked(audioElement, false);
10112
10529
  return;
10113
10530
  }
10114
10531
  // Safari has a special quirk that prevents playing audio until the user
@@ -10136,10 +10553,16 @@ class DynascaleManager {
10136
10553
  this.tracer.trace('audioPlaybackError', e.message);
10137
10554
  if (e.name === 'NotAllowedError') {
10138
10555
  this.tracer.trace('audioPlaybackBlocked', null);
10139
- this.addBlockedAudioElement(audioElement);
10556
+ this.blockedAudioTracker.markBlocked(audioElement, true);
10140
10557
  }
10141
10558
  this.logger.warn(`Failed to play audio stream`, e);
10142
10559
  });
10560
+ audioWatchdog = new MediaPlaybackWatchdog({
10561
+ element: audioElement,
10562
+ kind: 'audio',
10563
+ tracer: this.tracer,
10564
+ isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
10565
+ });
10143
10566
  }
10144
10567
  const { selectedDevice } = this.speaker.state;
10145
10568
  if (selectedDevice)
@@ -10163,38 +10586,17 @@ class DynascaleManager {
10163
10586
  });
10164
10587
  audioElement.autoplay = true;
10165
10588
  return () => {
10166
- this.audioBindingsWatchdog?.unregister(sessionId, trackType);
10167
- this.removeBlockedAudioElement(audioElement);
10589
+ this.blockedAudioTracker.markBlocked(audioElement, false);
10168
10590
  sinkIdSubscription?.unsubscribe();
10169
10591
  volumeSubscription.unsubscribe();
10170
10592
  updateMediaStreamSubscription.unsubscribe();
10171
10593
  audioElement.srcObject = null;
10172
10594
  sourceNode?.disconnect();
10173
10595
  gainNode?.disconnect();
10596
+ audioWatchdog?.dispose();
10597
+ audioWatchdog = undefined;
10174
10598
  };
10175
10599
  };
10176
- /**
10177
- * Plays all audio elements blocked by the browser's autoplay policy.
10178
- * Must be called from within a user gesture (e.g., click handler).
10179
- *
10180
- * @returns a promise that resolves when all blocked elements have been retried.
10181
- */
10182
- this.resumeAudio = async () => {
10183
- this.tracer.trace('resumeAudio', null);
10184
- const blocked = new Set();
10185
- await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
10186
- try {
10187
- if (el.srcObject) {
10188
- await el.play();
10189
- }
10190
- }
10191
- catch {
10192
- this.logger.warn(`Can't resume audio for element: `, el);
10193
- blocked.add(el);
10194
- }
10195
- }));
10196
- setCurrentValue(this.blockedAudioElementsSubject, blocked);
10197
- };
10198
10600
  this.getOrCreateAudioContext = () => {
10199
10601
  if (!this.useWebAudio)
10200
10602
  return;
@@ -10247,57 +10649,124 @@ class DynascaleManager {
10247
10649
  this.callState = callState;
10248
10650
  this.speaker = speaker;
10249
10651
  this.tracer = tracer;
10250
- if (!isReactNative()) {
10251
- this.audioBindingsWatchdog = new AudioBindingsWatchdog(callState, tracer);
10252
- }
10253
- }
10254
- setSfuClient(sfuClient) {
10255
- this.sfuClient = sfuClient;
10652
+ this.trackSubscriptionManager = trackSubscriptionManager;
10653
+ this.blockedAudioTracker = blockedAudioTracker;
10256
10654
  }
10257
- get trackSubscriptions() {
10258
- const subscriptions = [];
10259
- // Use getParticipantsSnapshot() to bypass the observable pipeline
10260
- // and avoid stale data caused by shareReplay with no active subscribers
10261
- const participants = this.callState.getParticipantsSnapshot();
10262
- const videoTrackSubscriptionOverrides = this.videoTrackSubscriptionOverridesSubject.getValue();
10263
- for (const p of participants) {
10264
- if (p.isLocalParticipant)
10265
- continue;
10266
- // NOTE: audio tracks don't have to be requested explicitly
10267
- // as the SFU will implicitly subscribe us to all of them,
10268
- // once they become available.
10269
- if (p.videoDimension && hasVideo(p)) {
10270
- const override = videoTrackSubscriptionOverrides[p.sessionId] ??
10271
- videoTrackSubscriptionOverrides[globalOverrideKey];
10272
- if (override?.enabled !== false) {
10273
- subscriptions.push({
10274
- userId: p.userId,
10275
- sessionId: p.sessionId,
10276
- trackType: TrackType.VIDEO,
10277
- dimension: override?.dimension ?? p.videoDimension,
10278
- });
10279
- }
10280
- }
10281
- if (p.screenShareDimension && hasScreenShare(p)) {
10282
- subscriptions.push({
10283
- userId: p.userId,
10284
- sessionId: p.sessionId,
10285
- trackType: TrackType.SCREEN_SHARE,
10286
- dimension: p.screenShareDimension,
10655
+ }
10656
+
10657
+ const DEFAULT_THRESHOLD = 0.35;
10658
+ const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
10659
+ videoTrack: VisibilityState.UNKNOWN,
10660
+ screenShareTrack: VisibilityState.UNKNOWN,
10661
+ };
10662
+ class ViewportTracker {
10663
+ constructor(callState) {
10664
+ this.elementHandlerMap = new Map();
10665
+ this.observer = null;
10666
+ // in React children render before viewport is set, add
10667
+ // them to the queue and observe them once the observer is ready
10668
+ this.queueSet = new Set();
10669
+ /**
10670
+ * Method to set scrollable viewport as root for the IntersectionObserver, returns
10671
+ * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
10672
+ */
10673
+ this.setViewport = (viewportElement, options) => {
10674
+ const cleanup = () => {
10675
+ this.observer?.disconnect();
10676
+ this.observer = null;
10677
+ this.elementHandlerMap.clear();
10678
+ };
10679
+ this.observer = new IntersectionObserver((entries) => {
10680
+ entries.forEach((entry) => {
10681
+ const handler = this.elementHandlerMap.get(entry.target);
10682
+ handler?.(entry);
10287
10683
  });
10288
- }
10289
- if (hasScreenShareAudio(p)) {
10290
- subscriptions.push({
10291
- userId: p.userId,
10292
- sessionId: p.sessionId,
10293
- trackType: TrackType.SCREEN_SHARE_AUDIO,
10684
+ }, {
10685
+ root: viewportElement,
10686
+ ...options,
10687
+ threshold: options?.threshold ?? DEFAULT_THRESHOLD,
10688
+ });
10689
+ if (this.queueSet.size) {
10690
+ this.queueSet.forEach(([queueElement, queueHandler]) => {
10691
+ // check if element which requested observation is
10692
+ // a child of a viewport element, skip if isn't
10693
+ if (!viewportElement.contains(queueElement))
10694
+ return;
10695
+ this.observer.observe(queueElement);
10696
+ this.elementHandlerMap.set(queueElement, queueHandler);
10294
10697
  });
10698
+ this.queueSet.clear();
10295
10699
  }
10296
- }
10297
- return subscriptions;
10298
- }
10299
- get videoTrackSubscriptionOverrides() {
10300
- return getCurrentValue(this.videoTrackSubscriptionOverrides$);
10700
+ return cleanup;
10701
+ };
10702
+ /**
10703
+ * Method to set element to observe and handler to be triggered whenever IntersectionObserver
10704
+ * detects a possible change in element's visibility within specified viewport, returns
10705
+ * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
10706
+ */
10707
+ this.observe = (element, handler) => {
10708
+ const queueItem = [element, handler];
10709
+ const cleanup = () => {
10710
+ this.elementHandlerMap.delete(element);
10711
+ this.observer?.unobserve(element);
10712
+ this.queueSet.delete(queueItem);
10713
+ };
10714
+ if (this.elementHandlerMap.has(element))
10715
+ return cleanup;
10716
+ if (!this.observer) {
10717
+ this.queueSet.add(queueItem);
10718
+ return cleanup;
10719
+ }
10720
+ if (this.observer.root.contains(element)) {
10721
+ this.elementHandlerMap.set(element, handler);
10722
+ this.observer.observe(element);
10723
+ }
10724
+ return cleanup;
10725
+ };
10726
+ /**
10727
+ * Tracks the given element for visibility changes and mirrors the result
10728
+ * into `participant.viewportVisibilityState[trackType]` in `CallState`.
10729
+ * Returns a function that unobserves the element and resets the visibility
10730
+ * state back to `UNKNOWN`.
10731
+ */
10732
+ this.trackElementVisibility = (element, sessionId, trackType) => {
10733
+ const cleanup = this.observe(element, (entry) => {
10734
+ this.callState.updateParticipant(sessionId, (participant) => {
10735
+ const previousVisibilityState = participant.viewportVisibilityState ??
10736
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
10737
+ // observer triggers when the element is "moved" to be a fullscreen element
10738
+ // keep it VISIBLE if that happens to prevent fullscreen with placeholder
10739
+ const isVisible = entry.isIntersecting || document.fullscreenElement === element
10740
+ ? VisibilityState.VISIBLE
10741
+ : VisibilityState.INVISIBLE;
10742
+ return {
10743
+ ...participant,
10744
+ viewportVisibilityState: {
10745
+ ...previousVisibilityState,
10746
+ [trackType]: isVisible,
10747
+ },
10748
+ };
10749
+ });
10750
+ });
10751
+ return () => {
10752
+ cleanup();
10753
+ // reset visibility state to UNKNOWN upon cleanup
10754
+ // so that the layouts that are not actively observed
10755
+ // can still function normally (runtime layout switching)
10756
+ this.callState.updateParticipant(sessionId, (participant) => {
10757
+ const previousVisibilityState = participant.viewportVisibilityState ??
10758
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
10759
+ return {
10760
+ ...participant,
10761
+ viewportVisibilityState: {
10762
+ ...previousVisibilityState,
10763
+ [trackType]: VisibilityState.UNKNOWN,
10764
+ },
10765
+ };
10766
+ });
10767
+ };
10768
+ };
10769
+ this.callState = callState;
10301
10770
  }
10302
10771
  }
10303
10772
 
@@ -10964,8 +11433,8 @@ const normalize = (options) => {
10964
11433
  : false,
10965
11434
  };
10966
11435
  };
10967
- const createSyntheticDevice = (deviceId, kind) => {
10968
- return { deviceId, kind, label: '', groupId: '' };
11436
+ const createSyntheticDevice = (deviceId, kind, label = '') => {
11437
+ return { deviceId, kind, label, groupId: '' };
10969
11438
  };
10970
11439
  const readPreferences = (storageKey) => {
10971
11440
  try {
@@ -11015,9 +11484,12 @@ class DeviceManager {
11015
11484
  */
11016
11485
  this.stopOnLeave = true;
11017
11486
  this.subscriptions = [];
11487
+ this.currentStreamCleanups = [];
11018
11488
  this.areSubscriptionsSetUp = false;
11019
11489
  this.isTrackStoppedDueToTrackEnd = false;
11020
11490
  this.filters = [];
11491
+ this.virtualDevicesSubject = new BehaviorSubject([]);
11492
+ this.virtualDeviceConcurrencyTag = Symbol('virtualDeviceConcurrencyTag');
11021
11493
  this.statusChangeConcurrencyTag = Symbol('statusChangeConcurrencyTag');
11022
11494
  this.filterRegistrationConcurrencyTag = Symbol('filterRegistrationConcurrencyTag');
11023
11495
  /**
@@ -11026,9 +11498,30 @@ class DeviceManager {
11026
11498
  * @internal
11027
11499
  */
11028
11500
  this.dispose = () => {
11501
+ this.runCurrentStreamCleanups();
11029
11502
  this.subscriptions.forEach((s) => s());
11030
11503
  this.subscriptions = [];
11031
11504
  this.areSubscriptionsSetUp = false;
11505
+ this.virtualDevicesSubject.next([]);
11506
+ };
11507
+ this.runCurrentStreamCleanups = () => {
11508
+ this.currentStreamCleanups.forEach((c) => c());
11509
+ this.currentStreamCleanups = [];
11510
+ };
11511
+ this.setLocalInterrupted = (interrupted) => {
11512
+ const localParticipant = this.call.state.localParticipant;
11513
+ if (!localParticipant)
11514
+ return;
11515
+ this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
11516
+ const current = p.interruptedTracks ?? [];
11517
+ const has = current.includes(this.trackType);
11518
+ if (interrupted === has)
11519
+ return {};
11520
+ const next = interrupted
11521
+ ? pushToIfMissing([...current], this.trackType)
11522
+ : removeFromIfPresent([...current], this.trackType);
11523
+ return { interruptedTracks: next };
11524
+ });
11032
11525
  };
11033
11526
  this.call = call;
11034
11527
  this.state = state;
@@ -11061,14 +11554,100 @@ class DeviceManager {
11061
11554
  }
11062
11555
  }
11063
11556
  /**
11064
- * Lists the available audio/video devices
11557
+ * Lists the available audio/video devices
11558
+ *
11559
+ * Note: It prompts the user for a permission to use devices (if not already granted)
11560
+ *
11561
+ * @returns an Observable that will be updated if a device is connected or disconnected
11562
+ */
11563
+ listDevices() {
11564
+ return combineLatest([this.getDevices(), this.virtualDevicesSubject]).pipe(map(([real, virtual]) => [
11565
+ ...real,
11566
+ ...virtual.map((d) => createSyntheticDevice(d.deviceId, d.kind, d.label)),
11567
+ ]));
11568
+ }
11569
+ /**
11570
+ * Registers a virtual camera or microphone backed by a caller-supplied
11571
+ * stream factory. The device appears in `listDevices()` and can be selected
11572
+ * via `select()` like any real device.
11065
11573
  *
11066
- * Note: It prompts the user for a permission to use devices (if not already granted)
11574
+ * Web only. React Native is not supported.
11067
11575
  *
11068
- * @returns an Observable that will be updated if a device is connected or disconnected
11576
+ * Only supported for camera and microphone managers; calling on any other
11577
+ * manager throws.
11069
11578
  */
11070
- listDevices() {
11071
- return this.getDevices();
11579
+ registerVirtualDevice(virtualDevice) {
11580
+ if (isReactNative()) {
11581
+ throw new Error('Virtual devices are not supported on React Native.');
11582
+ }
11583
+ if (this.trackType !== TrackType.AUDIO &&
11584
+ this.trackType !== TrackType.VIDEO) {
11585
+ throw new Error('Virtual devices are only supported for camera and microphone.');
11586
+ }
11587
+ const deviceId = `stream-virtual:${generateUUIDv4()}`;
11588
+ const entry = {
11589
+ deviceId,
11590
+ kind: this.mediaDeviceKind,
11591
+ ...virtualDevice,
11592
+ };
11593
+ setCurrentValue(this.virtualDevicesSubject, (current) => [
11594
+ ...current,
11595
+ entry,
11596
+ ]);
11597
+ return {
11598
+ deviceId: entry.deviceId,
11599
+ unregister: async () => {
11600
+ await withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
11601
+ setCurrentValue(this.virtualDevicesSubject, (current) => current.filter((d) => d !== entry));
11602
+ if (this.activeVirtualSession?.deviceId === deviceId) {
11603
+ await this.stopActiveVirtualSession();
11604
+ }
11605
+ });
11606
+ if (this.state.selectedDevice === deviceId) {
11607
+ await this.statusChangeSettled();
11608
+ await this.disable({ forceStop: true });
11609
+ await this.select(undefined);
11610
+ }
11611
+ },
11612
+ };
11613
+ }
11614
+ sanitizeVirtualStream(stream) {
11615
+ stream.getTracks().forEach((track) => {
11616
+ const originalGetSettings = track.getSettings.bind(track);
11617
+ track.getSettings = () => {
11618
+ const settings = originalGetSettings();
11619
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
11620
+ const { deviceId, ...rest } = settings;
11621
+ return rest;
11622
+ };
11623
+ });
11624
+ return stream;
11625
+ }
11626
+ findVirtualDevice(deviceId) {
11627
+ if (!deviceId)
11628
+ return undefined;
11629
+ return getCurrentValue(this.virtualDevicesSubject).find((d) => d.deviceId === deviceId);
11630
+ }
11631
+ async stopActiveVirtualSession() {
11632
+ const session = this.activeVirtualSession;
11633
+ this.activeVirtualSession = undefined;
11634
+ await session?.stop?.();
11635
+ }
11636
+ async getSelectedStream(constraints) {
11637
+ const deviceId = this.state.selectedDevice;
11638
+ if (!deviceId?.startsWith('stream-virtual')) {
11639
+ return this.getStream(constraints);
11640
+ }
11641
+ return withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
11642
+ const virtualDevice = this.findVirtualDevice(deviceId);
11643
+ if (!virtualDevice) {
11644
+ throw new Error(`Virtual device is not registered: ${deviceId}`);
11645
+ }
11646
+ await this.stopActiveVirtualSession();
11647
+ const { stream, stop } = await virtualDevice.getUserMedia(constraints);
11648
+ this.activeVirtualSession = { deviceId, stop };
11649
+ return this.sanitizeVirtualStream(stream);
11650
+ });
11072
11651
  }
11073
11652
  /**
11074
11653
  * Returns `true` when this device is in enabled state.
@@ -11228,6 +11807,9 @@ class DeviceManager {
11228
11807
  }
11229
11808
  });
11230
11809
  }
11810
+ getResolvedConstraints(constraints) {
11811
+ return constraints;
11812
+ }
11231
11813
  publishStream(stream, options) {
11232
11814
  return this.call.publish(stream, this.trackType, options);
11233
11815
  }
@@ -11248,12 +11830,15 @@ class DeviceManager {
11248
11830
  this.muteLocalStream(stopTracks);
11249
11831
  const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
11250
11832
  if (allEnded) {
11833
+ await this.stopActiveVirtualSession();
11251
11834
  // @ts-expect-error release() is present in react-native-webrtc
11252
11835
  if (typeof mediaStream.release === 'function') {
11253
11836
  // @ts-expect-error called to dispose the stream in RN
11254
11837
  mediaStream.release();
11255
11838
  }
11839
+ this.runCurrentStreamCleanups();
11256
11840
  this.state.setMediaStream(undefined, undefined);
11841
+ this.setLocalInterrupted(false);
11257
11842
  this.filters.forEach((entry) => entry.stop?.());
11258
11843
  }
11259
11844
  }
@@ -11289,20 +11874,24 @@ class DeviceManager {
11289
11874
  async unmuteStream() {
11290
11875
  this.logger.debug('Starting stream');
11291
11876
  let stream;
11292
- let rootStream;
11877
+ let rootStreamPromise;
11293
11878
  if (this.state.mediaStream &&
11294
11879
  this.getTracks().every((t) => t.readyState === 'live')) {
11295
11880
  stream = this.state.mediaStream;
11296
11881
  this.enableTracks();
11297
11882
  }
11298
11883
  else {
11884
+ // We are about to compose a fresh filter chain and acquire a new
11885
+ // root stream. Drop any listeners bound to the previous root stream
11886
+ // before chainWith below registers new ones for the new chain.
11887
+ this.runCurrentStreamCleanups();
11299
11888
  const defaultConstraints = this.state.defaultConstraints;
11300
- const constraints = {
11889
+ const constraints = this.getResolvedConstraints({
11301
11890
  ...defaultConstraints,
11302
11891
  deviceId: this.state.selectedDevice
11303
11892
  ? { exact: this.state.selectedDevice }
11304
11893
  : undefined,
11305
- };
11894
+ });
11306
11895
  /**
11307
11896
  * Chains two media streams together.
11308
11897
  *
@@ -11351,7 +11940,7 @@ class DeviceManager {
11351
11940
  });
11352
11941
  };
11353
11942
  parentTrack.addEventListener('ended', handleParentTrackEnded);
11354
- this.subscriptions.push(() => {
11943
+ this.currentStreamCleanups.push(() => {
11355
11944
  parentTrack.removeEventListener('ended', handleParentTrackEnded);
11356
11945
  });
11357
11946
  });
@@ -11359,7 +11948,7 @@ class DeviceManager {
11359
11948
  };
11360
11949
  // the rootStream represents the stream coming from the actual device
11361
11950
  // e.g. camera or microphone stream
11362
- rootStream = this.getStream(constraints);
11951
+ rootStreamPromise = this.getSelectedStream(constraints);
11363
11952
  // we publish the last MediaStream of the chain
11364
11953
  stream = await this.filters.reduce((parent, entry) => parent
11365
11954
  .then((inputStream) => {
@@ -11370,42 +11959,70 @@ class DeviceManager {
11370
11959
  .then(chainWith(parent), (error) => {
11371
11960
  this.logger.warn('Filter failed to start and will be ignored', error);
11372
11961
  return parent;
11373
- }), rootStream);
11962
+ }), rootStreamPromise);
11374
11963
  }
11375
11964
  if (this.call.state.callingState === CallingState.JOINED) {
11376
11965
  await this.publishStream(stream);
11377
11966
  }
11378
11967
  if (this.state.mediaStream !== stream) {
11379
- this.state.setMediaStream(stream, await rootStream);
11380
- const handleTrackEnded = async () => {
11381
- await this.statusChangeSettled();
11382
- if (this.enabled) {
11383
- this.isTrackStoppedDueToTrackEnd = true;
11384
- setTimeout(() => {
11385
- this.isTrackStoppedDueToTrackEnd = false;
11386
- }, 2000);
11387
- await this.disable();
11388
- }
11389
- };
11390
- const createTrackMuteHandler = (muted) => () => {
11391
- if (!isMobile() || this.trackType !== TrackType.VIDEO)
11392
- return;
11393
- this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
11394
- this.logger.warn('Error while notifying track mute state', err);
11395
- });
11396
- };
11397
- stream.getTracks().forEach((track) => {
11398
- const muteHandler = createTrackMuteHandler(true);
11399
- const unmuteHandler = createTrackMuteHandler(false);
11400
- track.addEventListener('mute', muteHandler);
11401
- track.addEventListener('unmute', unmuteHandler);
11402
- track.addEventListener('ended', handleTrackEnded);
11403
- this.subscriptions.push(() => {
11404
- track.removeEventListener('mute', muteHandler);
11405
- track.removeEventListener('unmute', unmuteHandler);
11406
- track.removeEventListener('ended', handleTrackEnded);
11968
+ const rootStream = await rootStreamPromise;
11969
+ this.state.setMediaStream(stream, rootStream);
11970
+ if (rootStream) {
11971
+ const handleTrackEnded = async () => {
11972
+ this.setLocalInterrupted(false);
11973
+ await this.statusChangeSettled();
11974
+ if (this.enabled) {
11975
+ this.isTrackStoppedDueToTrackEnd = true;
11976
+ setTimeout(() => {
11977
+ this.isTrackStoppedDueToTrackEnd = false;
11978
+ }, 2000);
11979
+ await this.disable();
11980
+ }
11981
+ };
11982
+ const createTrackMuteHandler = (muted) => () => {
11983
+ this.setLocalInterrupted(muted);
11984
+ // WebKit's RTCRtpSender encoder can stay stalled after an iOS /
11985
+ // macOS audio session interruption even though the track is
11986
+ // unmuted. Re-arm the sender on every unmute for any WebKit
11987
+ // runtime (Safari + plain iOS WKWebViews). Skipped when the
11988
+ // page is hidden because the encoder won't resume until
11989
+ // foreground anyway.
11990
+ if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
11991
+ this.call.refreshPublishedTrack(this.trackType).catch((err) => {
11992
+ this.logger.warn('Failed to refresh track on system unmute', err);
11993
+ });
11994
+ }
11995
+ // report all tracks on mobile, and only Video on desktop browsers
11996
+ if (isMobile() || this.trackType == TrackType.VIDEO) {
11997
+ this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
11998
+ trackType: TrackType[this.trackType],
11999
+ muted,
12000
+ });
12001
+ this.call
12002
+ .notifyTrackMuteState(muted, this.trackType)
12003
+ .catch((err) => {
12004
+ this.logger.warn('Error while notifying track mute state', err);
12005
+ });
12006
+ }
12007
+ };
12008
+ rootStream.getTracks().forEach((track) => {
12009
+ const muteHandler = createTrackMuteHandler(true);
12010
+ const unmuteHandler = createTrackMuteHandler(false);
12011
+ track.addEventListener('mute', muteHandler);
12012
+ track.addEventListener('unmute', unmuteHandler);
12013
+ track.addEventListener('ended', handleTrackEnded);
12014
+ this.currentStreamCleanups.push(() => {
12015
+ track.removeEventListener('mute', muteHandler);
12016
+ track.removeEventListener('unmute', unmuteHandler);
12017
+ track.removeEventListener('ended', handleTrackEnded);
12018
+ });
11407
12019
  });
11408
- });
12020
+ const initialMuted = rootStream.getTracks().some((t) => t.muted);
12021
+ this.setLocalInterrupted(initialMuted);
12022
+ }
12023
+ else {
12024
+ this.setLocalInterrupted(false);
12025
+ }
11409
12026
  }
11410
12027
  }
11411
12028
  get mediaDeviceKind() {
@@ -11551,7 +12168,6 @@ class DeviceManagerState {
11551
12168
  this.defaultConstraintsSubject = new BehaviorSubject(undefined);
11552
12169
  /**
11553
12170
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
11554
- *
11555
12171
  */
11556
12172
  this.mediaStream$ = this.mediaStreamSubject.asObservable();
11557
12173
  /**
@@ -11649,7 +12265,10 @@ class DeviceManagerState {
11649
12265
  setCurrentValue(this.mediaStreamSubject, stream);
11650
12266
  setCurrentValue(this.rootMediaStreamSubject, rootStream);
11651
12267
  if (rootStream) {
11652
- this.setDevice(this.getDeviceIdFromStream(rootStream));
12268
+ const derived = this.getDeviceIdFromStream(rootStream);
12269
+ if (derived) {
12270
+ this.setDevice(derived);
12271
+ }
11653
12272
  }
11654
12273
  }
11655
12274
  /**
@@ -11862,7 +12481,7 @@ class CameraManager extends DeviceManager {
11862
12481
  getDevices() {
11863
12482
  return getVideoDevices(this.call.tracer);
11864
12483
  }
11865
- getStream(constraints) {
12484
+ getResolvedConstraints(constraints) {
11866
12485
  constraints.width = this.targetResolution.width;
11867
12486
  constraints.height = this.targetResolution.height;
11868
12487
  // We can't set both device id and facing mode
@@ -11873,6 +12492,9 @@ class CameraManager extends DeviceManager {
11873
12492
  constraints.facingMode =
11874
12493
  this.state.direction === 'front' ? 'user' : 'environment';
11875
12494
  }
12495
+ return constraints;
12496
+ }
12497
+ getStream(constraints) {
11876
12498
  return getVideoStream(constraints, this.call.tracer);
11877
12499
  }
11878
12500
  }
@@ -13200,14 +13822,17 @@ class Call {
13200
13822
  this.sfuStatsReporter?.flush();
13201
13823
  this.sfuStatsReporter?.stop();
13202
13824
  this.sfuStatsReporter = undefined;
13203
- this.subscriber?.dispose();
13825
+ this.lastStatsOptions = undefined;
13826
+ await this.subscriber?.dispose();
13204
13827
  this.subscriber = undefined;
13205
- this.publisher?.dispose();
13828
+ await this.publisher?.dispose();
13206
13829
  this.publisher = undefined;
13207
13830
  await this.sfuClient?.leaveAndClose(leaveReason);
13208
13831
  this.sfuClient = undefined;
13209
- this.dynascaleManager.setSfuClient(undefined);
13210
- await this.dynascaleManager.dispose();
13832
+ this.trackSubscriptionManager.setSfuClient(undefined);
13833
+ this.trackSubscriptionManager.dispose();
13834
+ this.audioBindingsWatchdog?.dispose();
13835
+ await this.dynascaleManager?.dispose();
13211
13836
  this.state.setCallingState(CallingState.LEFT);
13212
13837
  this.state.setParticipants([]);
13213
13838
  this.state.dispose();
@@ -13476,15 +14101,17 @@ class Call {
13476
14101
  const performingMigration = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
13477
14102
  const performingRejoin = this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN;
13478
14103
  const performingFastReconnect = this.reconnectStrategy === WebsocketReconnectStrategy.FAST;
13479
- let statsOptions = this.sfuStatsReporter?.options;
14104
+ let statsOptions = this.lastStatsOptions;
13480
14105
  if (!this.credentials ||
13481
14106
  !statsOptions ||
13482
14107
  performingRejoin ||
13483
- performingMigration) {
14108
+ performingMigration ||
14109
+ data?.migrating_from) {
13484
14110
  try {
13485
14111
  const joinResponse = await this.doJoinRequest(data);
13486
14112
  this.credentials = joinResponse.credentials;
13487
14113
  statsOptions = joinResponse.stats_options;
14114
+ this.lastStatsOptions = statsOptions;
13488
14115
  }
13489
14116
  catch (error) {
13490
14117
  // prevent triggering reconnect flow if the state is OFFLINE
@@ -13517,7 +14144,7 @@ class Call {
13517
14144
  : previousSfuClient;
13518
14145
  this.sfuClient = sfuClient;
13519
14146
  this.unifiedSessionId ?? (this.unifiedSessionId = sfuClient.sessionId);
13520
- this.dynascaleManager.setSfuClient(sfuClient);
14147
+ this.trackSubscriptionManager.setSfuClient(sfuClient);
13521
14148
  const clientDetails = await getClientDetails();
13522
14149
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
13523
14150
  if (previousSfuClient !== sfuClient) {
@@ -13591,7 +14218,7 @@ class Call {
13591
14218
  }
13592
14219
  else {
13593
14220
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
13594
- this.initPublisherAndSubscriber({
14221
+ await this.initPublisherAndSubscriber({
13595
14222
  sfuClient,
13596
14223
  connectionConfig,
13597
14224
  clientDetails,
@@ -13652,7 +14279,7 @@ class Call {
13652
14279
  return {
13653
14280
  strategy,
13654
14281
  announcedTracks,
13655
- subscriptions: this.dynascaleManager.trackSubscriptions,
14282
+ subscriptions: this.trackSubscriptionManager.subscriptions,
13656
14283
  reconnectAttempt: this.reconnectAttempts,
13657
14284
  fromSfuId: migratingFromSfuId || '',
13658
14285
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
@@ -13736,11 +14363,11 @@ class Call {
13736
14363
  * Initializes the Publisher and Subscriber Peer Connections.
13737
14364
  * @internal
13738
14365
  */
13739
- this.initPublisherAndSubscriber = (opts) => {
14366
+ this.initPublisherAndSubscriber = async (opts) => {
13740
14367
  const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
13741
14368
  const { enable_rtc_stats: enableTracing } = statsOptions;
13742
14369
  if (closePreviousInstances && this.subscriber) {
13743
- this.subscriber.dispose();
14370
+ await this.subscriber.dispose();
13744
14371
  }
13745
14372
  const basePeerConnectionOptions = {
13746
14373
  sfuClient,
@@ -13769,7 +14396,7 @@ class Call {
13769
14396
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
13770
14397
  if (!isAnonymous) {
13771
14398
  if (closePreviousInstances && this.publisher) {
13772
- this.publisher.dispose();
14399
+ await this.publisher.dispose();
13773
14400
  }
13774
14401
  this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
13775
14402
  }
@@ -13872,10 +14499,17 @@ class Call {
13872
14499
  * `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
13873
14500
  */
13874
14501
  this.reconnect = async (strategy, reason) => {
13875
- if (this.state.callingState === CallingState.RECONNECTING ||
14502
+ if (this.state.callingState === CallingState.JOINING ||
14503
+ this.state.callingState === CallingState.RECONNECTING ||
13876
14504
  this.state.callingState === CallingState.MIGRATING ||
13877
14505
  this.state.callingState === CallingState.RECONNECTING_FAILED)
13878
14506
  return;
14507
+ // Drop redundant reconnect calls. If a reconnect is already queued or
14508
+ // running for this Call, that entry will resolve whatever broke;
14509
+ // queueing more entries just replays the full REJOIN cycle (one extra
14510
+ // `POST /join` per entry) once the call is already healthy again.
14511
+ if (hasPending(this.reconnectConcurrencyTag))
14512
+ return;
13879
14513
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
13880
14514
  const reconnectStartTime = Date.now();
13881
14515
  this.reconnectStrategy = strategy;
@@ -14080,8 +14714,8 @@ class Call {
14080
14714
  this.state.setCallingState(CallingState.JOINED);
14081
14715
  }
14082
14716
  finally {
14083
- currentSubscriber?.dispose();
14084
- currentPublisher?.dispose();
14717
+ await currentSubscriber?.dispose();
14718
+ await currentPublisher?.dispose();
14085
14719
  // and close the previous SFU client, without specifying close code
14086
14720
  currentSfuClient.close(StreamSfuClient.NORMAL_CLOSURE, 'Migrating away');
14087
14721
  }
@@ -14199,7 +14833,7 @@ class Call {
14199
14833
  const { remoteParticipants } = this.state;
14200
14834
  if (remoteParticipants.length <= 0)
14201
14835
  return;
14202
- this.dynascaleManager.applyTrackSubscriptions(undefined);
14836
+ this.trackSubscriptionManager.apply(undefined);
14203
14837
  };
14204
14838
  /**
14205
14839
  * Starts publishing the given video stream to the call.
@@ -14270,7 +14904,7 @@ class Call {
14270
14904
  this.stopPublish = async (...trackTypes) => {
14271
14905
  if (!this.sfuClient || !this.publisher)
14272
14906
  return;
14273
- this.publisher.stopTracks(...trackTypes);
14907
+ await this.publisher.stopTracks(...trackTypes);
14274
14908
  await this.updateLocalStreamState(undefined, ...trackTypes);
14275
14909
  };
14276
14910
  /**
@@ -14299,6 +14933,20 @@ class Call {
14299
14933
  }));
14300
14934
  }
14301
14935
  };
14936
+ /**
14937
+ * Re-arms the encoder for a currently published track type. Useful for
14938
+ * working around WebKit's stalled sender bug after an iOS audio session
14939
+ * interruption (Siri, PSTN call).
14940
+ *
14941
+ * @internal
14942
+ *
14943
+ * @param trackType the track type to refresh.
14944
+ */
14945
+ this.refreshPublishedTrack = async (trackType) => {
14946
+ if (!this.publisher)
14947
+ return;
14948
+ await this.publisher.refreshTrack(trackType);
14949
+ };
14302
14950
  /**
14303
14951
  * Updates the preferred publishing options
14304
14952
  *
@@ -14960,7 +15608,7 @@ class Call {
14960
15608
  * @param trackType the video mode.
14961
15609
  */
14962
15610
  this.trackElementVisibility = (element, sessionId, trackType) => {
14963
- return this.dynascaleManager.trackElementVisibility(element, sessionId, trackType);
15611
+ return this.viewportTracker?.trackElementVisibility(element, sessionId, trackType);
14964
15612
  };
14965
15613
  /**
14966
15614
  * Sets the viewport element to track bound video elements for visibility.
@@ -14968,7 +15616,7 @@ class Call {
14968
15616
  * @param element the viewport element.
14969
15617
  */
14970
15618
  this.setViewport = (element) => {
14971
- return this.dynascaleManager.setViewport(element);
15619
+ return this.viewportTracker?.setViewport(element);
14972
15620
  };
14973
15621
  /**
14974
15622
  * Binds a DOM <video> element to the given session id.
@@ -14986,7 +15634,7 @@ class Call {
14986
15634
  * @param trackType the kind of video.
14987
15635
  */
14988
15636
  this.bindVideoElement = (videoElement, sessionId, trackType) => {
14989
- const unbind = this.dynascaleManager.bindVideoElement(videoElement, sessionId, trackType);
15637
+ const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
14990
15638
  if (!unbind)
14991
15639
  return;
14992
15640
  this.leaveCallHooks.add(unbind);
@@ -15006,21 +15654,28 @@ class Call {
15006
15654
  * @param trackType the kind of audio.
15007
15655
  */
15008
15656
  this.bindAudioElement = (audioElement, sessionId, trackType = 'audioTrack') => {
15009
- const unbind = this.dynascaleManager.bindAudioElement(audioElement, sessionId, trackType);
15657
+ const unbind = this.dynascaleManager?.bindAudioElement(audioElement, sessionId, trackType);
15010
15658
  if (!unbind)
15011
15659
  return;
15012
- this.leaveCallHooks.add(unbind);
15013
- return () => {
15014
- this.leaveCallHooks.delete(unbind);
15660
+ this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
15661
+ const cleanup = () => {
15015
15662
  unbind();
15663
+ this.audioBindingsWatchdog?.unregister(sessionId, trackType);
15664
+ };
15665
+ this.leaveCallHooks.add(cleanup);
15666
+ return () => {
15667
+ this.leaveCallHooks.delete(cleanup);
15668
+ cleanup();
15016
15669
  };
15017
15670
  };
15018
15671
  /**
15019
15672
  * Plays all audio elements blocked by the browser's autoplay policy.
15673
+ * Must be called from within a user gesture (e.g., click handler).
15674
+ *
15675
+ * Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
15676
+ * gesture is required.
15020
15677
  */
15021
- this.resumeAudio = () => {
15022
- return this.dynascaleManager.resumeAudio();
15023
- };
15678
+ this.resumeAudio = () => this.blockedAudioTracker.resumeAudio();
15024
15679
  /**
15025
15680
  * Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
15026
15681
  *
@@ -15058,21 +15713,21 @@ class Call {
15058
15713
  * preference has effect on. Affects all participants by default.
15059
15714
  */
15060
15715
  this.setPreferredIncomingVideoResolution = (resolution, sessionIds) => {
15061
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(resolution
15716
+ this.trackSubscriptionManager.setOverrides(resolution
15062
15717
  ? {
15063
15718
  enabled: true,
15064
15719
  dimension: resolution,
15065
15720
  }
15066
15721
  : undefined, sessionIds);
15067
- this.dynascaleManager.applyTrackSubscriptions();
15722
+ this.trackSubscriptionManager.apply();
15068
15723
  };
15069
15724
  /**
15070
15725
  * Enables or disables incoming video from all remote call participants,
15071
15726
  * and removes any preference for preferred resolution.
15072
15727
  */
15073
15728
  this.setIncomingVideoEnabled = (enabled) => {
15074
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(enabled ? undefined : { enabled: false });
15075
- this.dynascaleManager.applyTrackSubscriptions();
15729
+ this.trackSubscriptionManager.setOverrides(enabled ? undefined : { enabled: false });
15730
+ this.trackSubscriptionManager.apply();
15076
15731
  };
15077
15732
  /**
15078
15733
  * Sets the maximum amount of time a user can remain waiting for a reconnect
@@ -15153,7 +15808,13 @@ class Call {
15153
15808
  this.microphone = new MicrophoneManager(this, preferences);
15154
15809
  this.speaker = new SpeakerManager(this, preferences);
15155
15810
  this.screenShare = new ScreenShareManager(this);
15156
- this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer);
15811
+ this.trackSubscriptionManager = new TrackSubscriptionManager(this.state, this.tracer);
15812
+ this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
15813
+ if (typeof document !== 'undefined') {
15814
+ this.audioBindingsWatchdog = new AudioBindingsWatchdog(this.state, this.tracer);
15815
+ this.viewportTracker = new ViewportTracker(this.state);
15816
+ this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer, this.trackSubscriptionManager, this.blockedAudioTracker);
15817
+ }
15157
15818
  }
15158
15819
  /**
15159
15820
  * A flag indicating whether the call is "ringing" type of call.
@@ -15226,12 +15887,118 @@ const APIErrorCodes = {
15226
15887
  */
15227
15888
  class StableWSConnection {
15228
15889
  constructor(client) {
15890
+ /** Incremented when a new WS connection is made */
15891
+ this.wsID = 1;
15892
+ // Connection lifecycle flags.
15893
+ /** We only make 1 attempt to reconnect at the same time.. */
15894
+ this.isConnecting = false;
15895
+ /** To avoid reconnect if client is disconnected */
15896
+ this.isDisconnected = false;
15897
+ /** Boolean that indicates if we have a working connection to the server */
15898
+ this.isHealthy = false;
15899
+ /** Boolean that indicates if the connection promise is resolved */
15900
+ this.isConnectionOpenResolved = false;
15901
+ // Failure counters (drive retry/backoff scheduling).
15902
+ /** consecutive failures influence the duration of the timeout */
15903
+ this.consecutiveFailures = 0;
15904
+ /** keep track of the total number of failures */
15905
+ this.totalFailures = 0;
15906
+ // Health-check pings + connection-staleness check.
15907
+ /** Send a health check message every 25 seconds */
15908
+ this.pingInterval = 25 * 1000;
15909
+ this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
15910
+ /** Store the last event time for health checks */
15911
+ this.lastEvent = null;
15229
15912
  this._log = (msg, extra = {}, level = 'info') => {
15230
15913
  this.client.logger[level](`connection:${msg}`, extra);
15231
15914
  };
15232
15915
  this.setClient = (client) => {
15233
15916
  this.client = client;
15234
15917
  };
15918
+ /**
15919
+ * connect - Connect to the WS URL
15920
+ * the default 15s timeout allows between 2~3 tries
15921
+ * @return Promise that completes once the first health check message is received
15922
+ */
15923
+ this.connect = async (timeout = 15000) => {
15924
+ if (this.isConnecting) {
15925
+ throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
15926
+ }
15927
+ this.isDisconnected = false;
15928
+ try {
15929
+ const healthCheck = await this._connect(timeout);
15930
+ this.consecutiveFailures = 0;
15931
+ this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
15932
+ }
15933
+ catch (caught) {
15934
+ const error = caught;
15935
+ this.isHealthy = false;
15936
+ this.consecutiveFailures += 1;
15937
+ if (error.code === KnownCodes.TOKEN_EXPIRED &&
15938
+ !this.client.tokenManager.isStatic()) {
15939
+ this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
15940
+ this._reconnect({ refreshToken: true });
15941
+ }
15942
+ else if (!error.isWSFailure) {
15943
+ // API rejected the connection and we should not retry
15944
+ throw new Error(JSON.stringify({
15945
+ code: error.code,
15946
+ StatusCode: error.StatusCode,
15947
+ message: error.message,
15948
+ isWSFailure: error.isWSFailure,
15949
+ }));
15950
+ }
15951
+ else {
15952
+ // Transient WS failure (e.g., handshake watchdog). Kick off a
15953
+ // reconnect chain so _waitForHealthy(timeout) below has something
15954
+ // to poll for. Owning the trigger here (rather than inside
15955
+ // _connect()'s catch) keeps a single failure from spawning two
15956
+ // parallel chains - one from this catch and one from _reconnect's
15957
+ // own catch when _connect was called from there.
15958
+ this._reconnect();
15959
+ }
15960
+ }
15961
+ return await this._waitForHealthy(timeout);
15962
+ };
15963
+ /**
15964
+ * _waitForHealthy polls the promise connection to see if its resolved until it times out
15965
+ * the default 15s timeout allows between 2~3 tries
15966
+ * @param timeout duration(ms)
15967
+ */
15968
+ this._waitForHealthy = async (timeout = 15000) => {
15969
+ return Promise.race([
15970
+ (async () => {
15971
+ const interval = 50; // ms
15972
+ for (let i = 0; i <= timeout; i += interval) {
15973
+ try {
15974
+ return await this.connectionOpen;
15975
+ }
15976
+ catch (caught) {
15977
+ const error = caught;
15978
+ if (i === timeout) {
15979
+ throw new Error(JSON.stringify({
15980
+ code: error.code,
15981
+ StatusCode: error.StatusCode,
15982
+ message: error.message,
15983
+ isWSFailure: error.isWSFailure,
15984
+ }));
15985
+ }
15986
+ await sleep(interval);
15987
+ }
15988
+ }
15989
+ })(),
15990
+ (async () => {
15991
+ await sleep(timeout);
15992
+ this.isConnecting = false;
15993
+ throw new Error(JSON.stringify({
15994
+ code: '',
15995
+ StatusCode: '',
15996
+ message: 'initial WS connection could not be established',
15997
+ isWSFailure: true,
15998
+ }));
15999
+ })(),
16000
+ ]);
16001
+ };
15235
16002
  /**
15236
16003
  * Builds and returns the url for websocket.
15237
16004
  * @private
@@ -15244,11 +16011,166 @@ class StableWSConnection {
15244
16011
  params.set('X-Stream-Client', this.client.getUserAgent());
15245
16012
  return `${this.client.wsBaseURL}/connect?${params.toString()}`;
15246
16013
  };
16014
+ /**
16015
+ * disconnect - Disconnect the connection and doesn't recover...
16016
+ */
16017
+ this.disconnect = (timeout) => {
16018
+ this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
16019
+ this.wsID += 1;
16020
+ this.isConnecting = false;
16021
+ this.isDisconnected = true;
16022
+ // start by removing all the listeners
16023
+ if (this.healthCheckTimeoutRef) {
16024
+ getTimers().clearInterval(this.healthCheckTimeoutRef);
16025
+ }
16026
+ if (this.connectionCheckTimeoutRef) {
16027
+ clearInterval(this.connectionCheckTimeoutRef);
16028
+ }
16029
+ removeConnectionEventListeners(this.onlineStatusChanged);
16030
+ this.isHealthy = false;
16031
+ let isClosedPromise;
16032
+ // and finally close...
16033
+ // Assigning to local here because we will remove it from this before the
16034
+ // promise resolves.
16035
+ const { ws } = this;
16036
+ if (ws && ws.close && ws.readyState === ws.OPEN) {
16037
+ isClosedPromise = new Promise((resolve) => {
16038
+ const onclose = (event) => {
16039
+ this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
16040
+ resolve();
16041
+ };
16042
+ ws.onclose = onclose;
16043
+ // In case we don't receive close frame websocket server in time,
16044
+ // lets not wait for more than 1 second.
16045
+ setTimeout(onclose, timeout != null ? timeout : 1000);
16046
+ });
16047
+ this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
16048
+ ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
16049
+ }
16050
+ else {
16051
+ this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
16052
+ isClosedPromise = Promise.resolve();
16053
+ }
16054
+ delete this.ws;
16055
+ return isClosedPromise;
16056
+ };
16057
+ /**
16058
+ * _connect - Connect to the WS endpoint
16059
+ *
16060
+ * @param timeoutMs handshake watchdog deadline in ms. Defaults to
16061
+ * `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
16062
+ * passes its own timeout through so caller-supplied deadlines are honored.
16063
+ * @return Promise that completes once the first health check message is received
16064
+ */
16065
+ this._connect = async (timeoutMs) => {
16066
+ if (this.isConnecting)
16067
+ return; // ignore _connect if it's currently trying to connect
16068
+ this.isConnecting = true;
16069
+ // Snapshot of the connection-id reject closure owned by THIS attempt.
16070
+ // Captured at function entry so that even early failures (e.g.,
16071
+ // tokenManager.loadToken throwing before we reach the WS phase) can
16072
+ // settle the promise the caller is awaiting. Re-captured below if
16073
+ // _connect itself sets up a fresh promise. If a concurrent
16074
+ // openConnection() rotates `client.rejectConnectionId` later, our
16075
+ // captured closure still settles only the original promise (P1) and
16076
+ // never poisons the newer one (P2).
16077
+ let ownRejectConnectionId = this.client.rejectConnectionId;
16078
+ let isTokenReady = false;
16079
+ try {
16080
+ this._log(`_connect() - waiting for token`);
16081
+ await this.client.tokenManager.tokenReady();
16082
+ isTokenReady = true;
16083
+ }
16084
+ catch {
16085
+ // token provider has failed before, so try again
16086
+ }
16087
+ try {
16088
+ if (!isTokenReady) {
16089
+ this._log(`_connect() - tokenProvider failed before, so going to retry`);
16090
+ await this.client.tokenManager.loadToken();
16091
+ }
16092
+ if (!this.client.isConnectionIdPromisePending) {
16093
+ this.client._setupConnectionIdPromise();
16094
+ // recapture: we just rotated the resolver ourselves, the new
16095
+ // closure is the one bound to the promise this attempt owns.
16096
+ ownRejectConnectionId = this.client.rejectConnectionId;
16097
+ }
16098
+ this._setupConnectionPromise();
16099
+ const wsURL = this._buildUrl();
16100
+ this._log(`_connect() - Connecting to ${wsURL}`);
16101
+ const WS = this.client.options.WebSocketImpl ?? WebSocket;
16102
+ this.ws = new WS(wsURL);
16103
+ this.ws.onopen = this.onopen.bind(this, this.wsID);
16104
+ this.ws.onclose = this.onclose.bind(this, this.wsID);
16105
+ this.ws.onerror = this.onerror.bind(this, this.wsID);
16106
+ this.ws.onmessage = this.onmessage.bind(this, this.wsID);
16107
+ // race the WS handshake against an explicit deadline so a silent
16108
+ // network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
16109
+ const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
16110
+ const timers = getTimers();
16111
+ let handshakeTimeoutId;
16112
+ let response;
16113
+ try {
16114
+ response = await Promise.race([
16115
+ this.connectionOpen,
16116
+ new Promise((_, reject) => {
16117
+ handshakeTimeoutId = timers.setTimeout(() => {
16118
+ const err = new Error(`WS handshake timed out after ${handshakeTimeout}ms`);
16119
+ err.isWSFailure = true;
16120
+ reject(err);
16121
+ }, handshakeTimeout);
16122
+ }),
16123
+ ]);
16124
+ }
16125
+ finally {
16126
+ timers.clearTimeout(handshakeTimeoutId);
16127
+ }
16128
+ this.isConnecting = false;
16129
+ // If we were disconnected during the handshake (e.g. closeConnection()
16130
+ // ran while a background _reconnect's _connect was in flight), tear
16131
+ // down the new WS and throw so the caller of connect() does not get
16132
+ // a misleading "success" for a connection that has already been
16133
+ // aborted. We must NOT skip the throw and just return undefined: the
16134
+ // outer connect() would otherwise fall through to _waitForHealthy(),
16135
+ // which would observe the already-resolved connectionOpen promise
16136
+ // and resolve with a ConnectedEvent for a torn-down connection.
16137
+ if (this.isDisconnected) {
16138
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
16139
+ this._destroyCurrentWSConnection();
16140
+ }
16141
+ throw new Error('WS handshake aborted: disconnect() ran while connecting');
16142
+ }
16143
+ if (response) {
16144
+ this.connectionID = response.connection_id;
16145
+ this.client.resolveConnectionId?.(this.connectionID);
16146
+ return response;
16147
+ }
16148
+ }
16149
+ catch (caught) {
16150
+ const err = caught;
16151
+ this.isConnecting = false;
16152
+ this._log(`_connect() - Error - `, err);
16153
+ // Reject THIS attempt's connection-id promise (P1) directly via the
16154
+ // captured closure. Whether or not a concurrent openConnection() has
16155
+ // since rotated client.rejectConnectionId to a newer promise (P2),
16156
+ // calling ownRejectConnectionId only settles P1 - P2 is untouched.
16157
+ // P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
16158
+ // therefore fail fast instead of being orphaned.
16159
+ ownRejectConnectionId?.(err);
16160
+ // connectionOpen is per-instance and not subject to rotation, so
16161
+ // calling it unconditionally is safe (and a no-op if already settled).
16162
+ this.rejectConnectionOpen?.(err);
16163
+ // tear down a half-open WS so it does not linger and fire a stale wsID later
16164
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
16165
+ this._destroyCurrentWSConnection();
16166
+ }
16167
+ throw err;
16168
+ }
16169
+ };
15247
16170
  /**
15248
16171
  * onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
15249
16172
  *
15250
16173
  * @param {Event} event Event with type online or offline
15251
- *
15252
16174
  */
15253
16175
  this.onlineStatusChanged = (event) => {
15254
16176
  if (event.type === 'offline') {
@@ -15346,16 +16268,12 @@ class StableWSConnection {
15346
16268
  return;
15347
16269
  this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
15348
16270
  if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
15349
- // this is a permanent error raised by stream..
16271
+ // this is a permanent error raised by stream.
15350
16272
  // usually caused by invalid auth details
15351
16273
  const error = new Error(`WS connection reject with error ${event.reason}`);
15352
- // @ts-expect-error type issue
15353
16274
  error.reason = event.reason;
15354
- // @ts-expect-error type issue
15355
16275
  error.code = event.code;
15356
- // @ts-expect-error type issue
15357
16276
  error.wasClean = event.wasClean;
15358
- // @ts-expect-error type issue
15359
16277
  error.target = event.target;
15360
16278
  this.rejectConnectionOpen?.(error);
15361
16279
  this._log(`onclose() - WS connection reject with error ${event.reason}`, {
@@ -15493,205 +16411,8 @@ class StableWSConnection {
15493
16411
  }, this.connectionCheckTimeout);
15494
16412
  };
15495
16413
  this.client = client;
15496
- /** consecutive failures influence the duration of the timeout */
15497
- this.consecutiveFailures = 0;
15498
- /** keep track of the total number of failures */
15499
- this.totalFailures = 0;
15500
- /** We only make 1 attempt to reconnect at the same time.. */
15501
- this.isConnecting = false;
15502
- /** To avoid reconnect if client is disconnected */
15503
- this.isDisconnected = false;
15504
- /** Boolean that indicates if the connection promise is resolved */
15505
- this.isConnectionOpenResolved = false;
15506
- /** Boolean that indicates if we have a working connection to the server */
15507
- this.isHealthy = false;
15508
- /** Incremented when a new WS connection is made */
15509
- this.wsID = 1;
15510
- /** Store the last event time for health checks */
15511
- this.lastEvent = null;
15512
- /** Send a health check message every 25 seconds */
15513
- this.pingInterval = 25 * 1000;
15514
- this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
15515
16414
  addConnectionEventListeners(this.onlineStatusChanged);
15516
16415
  }
15517
- /**
15518
- * connect - Connect to the WS URL
15519
- * the default 15s timeout allows between 2~3 tries
15520
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
15521
- */
15522
- async connect(timeout = 15000) {
15523
- if (this.isConnecting) {
15524
- throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
15525
- }
15526
- this.isDisconnected = false;
15527
- try {
15528
- const healthCheck = await this._connect();
15529
- this.consecutiveFailures = 0;
15530
- this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
15531
- }
15532
- catch (error) {
15533
- this.isHealthy = false;
15534
- this.consecutiveFailures += 1;
15535
- if (
15536
- // @ts-expect-error type issue
15537
- error.code === KnownCodes.TOKEN_EXPIRED &&
15538
- !this.client.tokenManager.isStatic()) {
15539
- this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
15540
- this._reconnect({ refreshToken: true });
15541
- }
15542
- else {
15543
- // @ts-expect-error type issue
15544
- if (!error.isWSFailure) {
15545
- // API rejected the connection and we should not retry
15546
- throw new Error(JSON.stringify({
15547
- // @ts-expect-error type issue
15548
- code: error.code,
15549
- // @ts-expect-error type issue
15550
- StatusCode: error.StatusCode,
15551
- // @ts-expect-error type issue
15552
- message: error.message,
15553
- // @ts-expect-error type issue
15554
- isWSFailure: error.isWSFailure,
15555
- }));
15556
- }
15557
- }
15558
- }
15559
- return await this._waitForHealthy(timeout);
15560
- }
15561
- /**
15562
- * _waitForHealthy polls the promise connection to see if its resolved until it times out
15563
- * the default 15s timeout allows between 2~3 tries
15564
- * @param timeout duration(ms)
15565
- */
15566
- async _waitForHealthy(timeout = 15000) {
15567
- return Promise.race([
15568
- (async () => {
15569
- const interval = 50; // ms
15570
- for (let i = 0; i <= timeout; i += interval) {
15571
- try {
15572
- return await this.connectionOpen;
15573
- }
15574
- catch (error) {
15575
- if (i === timeout) {
15576
- throw new Error(JSON.stringify({
15577
- code: error.code,
15578
- StatusCode: error.StatusCode,
15579
- message: error.message,
15580
- isWSFailure: error.isWSFailure,
15581
- }));
15582
- }
15583
- await sleep(interval);
15584
- }
15585
- }
15586
- })(),
15587
- (async () => {
15588
- await sleep(timeout);
15589
- this.isConnecting = false;
15590
- throw new Error(JSON.stringify({
15591
- code: '',
15592
- StatusCode: '',
15593
- message: 'initial WS connection could not be established',
15594
- isWSFailure: true,
15595
- }));
15596
- })(),
15597
- ]);
15598
- }
15599
- /**
15600
- * disconnect - Disconnect the connection and doesn't recover...
15601
- *
15602
- */
15603
- disconnect(timeout) {
15604
- this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
15605
- this.wsID += 1;
15606
- this.isConnecting = false;
15607
- this.isDisconnected = true;
15608
- // start by removing all the listeners
15609
- if (this.healthCheckTimeoutRef) {
15610
- getTimers().clearInterval(this.healthCheckTimeoutRef);
15611
- }
15612
- if (this.connectionCheckTimeoutRef) {
15613
- clearInterval(this.connectionCheckTimeoutRef);
15614
- }
15615
- removeConnectionEventListeners(this.onlineStatusChanged);
15616
- this.isHealthy = false;
15617
- let isClosedPromise;
15618
- // and finally close...
15619
- // Assigning to local here because we will remove it from this before the
15620
- // promise resolves.
15621
- const { ws } = this;
15622
- if (ws && ws.close && ws.readyState === ws.OPEN) {
15623
- isClosedPromise = new Promise((resolve) => {
15624
- const onclose = (event) => {
15625
- this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
15626
- resolve();
15627
- };
15628
- ws.onclose = onclose;
15629
- // In case we don't receive close frame websocket server in time,
15630
- // lets not wait for more than 1 second.
15631
- setTimeout(onclose, timeout != null ? timeout : 1000);
15632
- });
15633
- this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
15634
- ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
15635
- }
15636
- else {
15637
- this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
15638
- isClosedPromise = Promise.resolve();
15639
- }
15640
- delete this.ws;
15641
- return isClosedPromise;
15642
- }
15643
- /**
15644
- * _connect - Connect to the WS endpoint
15645
- *
15646
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
15647
- */
15648
- async _connect() {
15649
- if (this.isConnecting)
15650
- return; // ignore _connect if it's currently trying to connect
15651
- this.isConnecting = true;
15652
- let isTokenReady = false;
15653
- try {
15654
- this._log(`_connect() - waiting for token`);
15655
- await this.client.tokenManager.tokenReady();
15656
- isTokenReady = true;
15657
- }
15658
- catch {
15659
- // token provider has failed before, so try again
15660
- }
15661
- try {
15662
- if (!isTokenReady) {
15663
- this._log(`_connect() - tokenProvider failed before, so going to retry`);
15664
- await this.client.tokenManager.loadToken();
15665
- }
15666
- if (!this.client.isConnectionIsPromisePending) {
15667
- this.client._setupConnectionIdPromise();
15668
- }
15669
- this._setupConnectionPromise();
15670
- const wsURL = this._buildUrl();
15671
- this._log(`_connect() - Connecting to ${wsURL}`);
15672
- const WS = this.client.options.WebSocketImpl ?? WebSocket;
15673
- this.ws = new WS(wsURL);
15674
- this.ws.onopen = this.onopen.bind(this, this.wsID);
15675
- this.ws.onclose = this.onclose.bind(this, this.wsID);
15676
- this.ws.onerror = this.onerror.bind(this, this.wsID);
15677
- this.ws.onmessage = this.onmessage.bind(this, this.wsID);
15678
- const response = await this.connectionOpen;
15679
- this.isConnecting = false;
15680
- if (response) {
15681
- this.connectionID = response.connection_id;
15682
- this.client.resolveConnectionId?.(this.connectionID);
15683
- return response;
15684
- }
15685
- }
15686
- catch (err) {
15687
- this.client._setupConnectionIdPromise();
15688
- this.isConnecting = false;
15689
- // @ts-expect-error type issue
15690
- this._log(`_connect() - Error - `, err);
15691
- this.client.rejectConnectionId?.(err);
15692
- throw err;
15693
- }
15694
- }
15695
16416
  /**
15696
16417
  * _reconnect - Retry the connection to WS endpoint
15697
16418
  *
@@ -15738,7 +16459,8 @@ class StableWSConnection {
15738
16459
  this._log('_reconnect() - Finished recoverCallBack');
15739
16460
  this.consecutiveFailures = 0;
15740
16461
  }
15741
- catch (error) {
16462
+ catch (caught) {
16463
+ const error = caught;
15742
16464
  this.isHealthy = false;
15743
16465
  this.consecutiveFailures += 1;
15744
16466
  if (error.code === KnownCodes.TOKEN_EXPIRED &&
@@ -16295,7 +17017,7 @@ class StreamClient {
16295
17017
  this.getUserAgent = () => {
16296
17018
  if (!this.cachedUserAgent) {
16297
17019
  const { clientAppIdentifier = {} } = this.options;
16298
- const { sdkName = 'js', sdkVersion = "1.49.0", ...extras } = clientAppIdentifier;
17020
+ const { sdkName = 'js', sdkVersion = "1.51.0", ...extras } = clientAppIdentifier;
16299
17021
  this.cachedUserAgent = [
16300
17022
  `stream-video-${sdkName}-v${sdkVersion}`,
16301
17023
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -16403,7 +17125,7 @@ class StreamClient {
16403
17125
  get connectionIdPromise() {
16404
17126
  return this.connectionIdPromiseSafe?.();
16405
17127
  }
16406
- get isConnectionIsPromisePending() {
17128
+ get isConnectionIdPromisePending() {
16407
17129
  return this.connectionIdPromiseSafe?.checkPending() ?? false;
16408
17130
  }
16409
17131
  get wsPromise() {