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