@stream-io/video-client 1.49.0 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.browser.es.js +1086 -594
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1086 -594
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1086 -594
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +42 -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/DeviceManager.d.ts +3 -0
  14. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  15. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  16. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  17. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  18. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  19. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  20. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  21. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  22. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  23. package/dist/src/helpers/browsers.d.ts +13 -0
  24. package/dist/src/helpers/concurrency.d.ts +6 -4
  25. package/dist/src/rtc/Publisher.d.ts +17 -0
  26. package/dist/src/rtc/Subscriber.d.ts +1 -0
  27. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  28. package/dist/src/stats/rtc/types.d.ts +1 -1
  29. package/dist/src/store/rxUtils.d.ts +9 -0
  30. package/dist/src/types.d.ts +18 -0
  31. package/package.json +2 -2
  32. package/src/Call.ts +89 -22
  33. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  34. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  35. package/src/coordinator/connection/client.ts +1 -1
  36. package/src/coordinator/connection/connection.ts +149 -96
  37. package/src/coordinator/connection/types.ts +15 -0
  38. package/src/coordinator/connection/utils.ts +15 -0
  39. package/src/devices/DeviceManager.ts +92 -32
  40. package/src/devices/DeviceManagerState.ts +0 -1
  41. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  42. package/src/devices/__tests__/mocks.ts +2 -0
  43. package/src/gen/video/sfu/event/events.ts +15 -0
  44. package/src/gen/video/sfu/models/models.ts +44 -0
  45. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  46. package/src/helpers/BlockedAudioTracker.ts +74 -0
  47. package/src/helpers/DynascaleManager.ts +46 -337
  48. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  49. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  50. package/src/helpers/ViewportTracker.ts +74 -19
  51. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  52. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  53. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  54. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  55. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  56. package/src/helpers/__tests__/browsers.test.ts +85 -1
  57. package/src/helpers/browsers.ts +24 -0
  58. package/src/helpers/concurrency.ts +9 -10
  59. package/src/rtc/Publisher.ts +47 -1
  60. package/src/rtc/Subscriber.ts +42 -14
  61. package/src/rtc/__tests__/Publisher.test.ts +122 -10
  62. package/src/rtc/__tests__/Subscriber.test.ts +146 -1
  63. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  64. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  65. package/src/rtc/helpers/degradationPreference.ts +22 -0
  66. package/src/stats/rtc/types.ts +1 -0
  67. package/src/store/__tests__/rxUtils.test.ts +276 -0
  68. package/src/store/rxUtils.ts +19 -0
  69. package/src/types.ts +19 -0
package/dist/index.cjs.js CHANGED
@@ -1418,6 +1418,35 @@ var ClientCapability;
1418
1418
  */
1419
1419
  ClientCapability[ClientCapability["SUBSCRIBER_VIDEO_PAUSE"] = 1] = "SUBSCRIBER_VIDEO_PAUSE";
1420
1420
  })(ClientCapability || (ClientCapability = {}));
1421
+ /**
1422
+ * DegradationPreference represents the RTCDegradationPreference from WebRTC.
1423
+ * See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
1424
+ *
1425
+ * @generated from protobuf enum stream.video.sfu.models.DegradationPreference
1426
+ */
1427
+ var DegradationPreference;
1428
+ (function (DegradationPreference) {
1429
+ /**
1430
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
1431
+ */
1432
+ DegradationPreference[DegradationPreference["UNSPECIFIED"] = 0] = "UNSPECIFIED";
1433
+ /**
1434
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
1435
+ */
1436
+ DegradationPreference[DegradationPreference["BALANCED"] = 1] = "BALANCED";
1437
+ /**
1438
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
1439
+ */
1440
+ DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE"] = 2] = "MAINTAIN_FRAMERATE";
1441
+ /**
1442
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
1443
+ */
1444
+ DegradationPreference[DegradationPreference["MAINTAIN_RESOLUTION"] = 3] = "MAINTAIN_RESOLUTION";
1445
+ /**
1446
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
1447
+ */
1448
+ DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE_AND_RESOLUTION"] = 4] = "MAINTAIN_FRAMERATE_AND_RESOLUTION";
1449
+ })(DegradationPreference || (DegradationPreference = {}));
1421
1450
  // @generated message type with reflection information, may provide speed optimized methods
1422
1451
  class CallState$Type extends runtime.MessageType {
1423
1452
  constructor() {
@@ -1687,6 +1716,16 @@ class PublishOption$Type extends runtime.MessageType {
1687
1716
  repeat: 2 /*RepeatType.UNPACKED*/,
1688
1717
  T: () => AudioBitrate,
1689
1718
  },
1719
+ {
1720
+ no: 11,
1721
+ name: 'degradation_preference',
1722
+ kind: 'enum',
1723
+ T: () => [
1724
+ 'stream.video.sfu.models.DegradationPreference',
1725
+ DegradationPreference,
1726
+ 'DEGRADATION_PREFERENCE_',
1727
+ ],
1728
+ },
1690
1729
  ]);
1691
1730
  }
1692
1731
  }
@@ -2133,6 +2172,7 @@ var models = /*#__PURE__*/Object.freeze({
2133
2172
  ClientDetails: ClientDetails,
2134
2173
  Codec: Codec,
2135
2174
  get ConnectionQuality () { return ConnectionQuality; },
2175
+ get DegradationPreference () { return DegradationPreference; },
2136
2176
  Device: Device,
2137
2177
  Error: Error$2,
2138
2178
  get ErrorCode () { return ErrorCode; },
@@ -3520,6 +3560,16 @@ class VideoSender$Type extends runtime.MessageType {
3520
3560
  kind: 'scalar',
3521
3561
  T: 5 /*ScalarType.INT32*/,
3522
3562
  },
3563
+ {
3564
+ no: 6,
3565
+ name: 'degradation_preference',
3566
+ kind: 'enum',
3567
+ T: () => [
3568
+ 'stream.video.sfu.models.DegradationPreference',
3569
+ DegradationPreference,
3570
+ 'DEGRADATION_PREFERENCE_',
3571
+ ],
3572
+ },
3523
3573
  ]);
3524
3574
  }
3525
3575
  }
@@ -3885,6 +3935,18 @@ const createSignalClient = (options) => {
3885
3935
  };
3886
3936
 
3887
3937
  const sleep = (m) => new Promise((r) => setTimeout(r, m));
3938
+ const timeboxed = async (promises, ms) => {
3939
+ let timerId;
3940
+ const timeout = new Promise((_, reject) => {
3941
+ timerId = setTimeout(() => reject(new Error('timebox error')), ms);
3942
+ });
3943
+ try {
3944
+ return await Promise.race([Promise.all(promises), timeout]);
3945
+ }
3946
+ finally {
3947
+ clearTimeout(timerId);
3948
+ }
3949
+ };
3888
3950
  function isFunction(value) {
3889
3951
  return (value &&
3890
3952
  (Object.prototype.toString.call(value) === '[object Function]' ||
@@ -4624,6 +4686,20 @@ const setCurrentValue = (subject, update) => {
4624
4686
  subject.next(next);
4625
4687
  return next;
4626
4688
  };
4689
+ /**
4690
+ * Updates the value of the provided Subject asynchronously.
4691
+ * Locks the subject to prevent concurrent updates.
4692
+ *
4693
+ * @param subject the subject to update.
4694
+ * @param update the update to apply to the subject.
4695
+ */
4696
+ const setCurrentValueAsync = async (subject, update) => {
4697
+ return withoutConcurrency(subject, async () => {
4698
+ const next = await update(getCurrentValue(subject));
4699
+ subject.next(next);
4700
+ return next;
4701
+ });
4702
+ };
4627
4703
  /**
4628
4704
  * Updates the value of the provided Subject and returns the previous value
4629
4705
  * and a function to roll back the update.
@@ -4678,6 +4754,7 @@ var rxUtils = /*#__PURE__*/Object.freeze({
4678
4754
  createSubscription: createSubscription,
4679
4755
  getCurrentValue: getCurrentValue,
4680
4756
  setCurrentValue: setCurrentValue,
4757
+ setCurrentValueAsync: setCurrentValueAsync,
4681
4758
  updateValue: updateValue
4682
4759
  });
4683
4760
 
@@ -6302,7 +6379,7 @@ const getSdkVersion = (sdk) => {
6302
6379
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6303
6380
  };
6304
6381
 
6305
- const version = "1.49.0";
6382
+ const version = "1.50.0";
6306
6383
  const [major, minor, patch] = version.split('.');
6307
6384
  let sdkInfo = {
6308
6385
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -6455,6 +6532,31 @@ const isSafari = () => {
6455
6532
  return false;
6456
6533
  return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
6457
6534
  };
6535
+ /**
6536
+ * Checks whether the current runtime is a WebKit-engine browser.
6537
+ *
6538
+ * Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
6539
+ * (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
6540
+ * Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
6541
+ * `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
6542
+ * share the underlying WebKit quirks.
6543
+ *
6544
+ * Returns false for desktop Chromium-based browsers (which reuse the
6545
+ * `AppleWebKit/` token in their UA) and Android.
6546
+ */
6547
+ const isWebKit = () => {
6548
+ if (typeof navigator === 'undefined')
6549
+ return false;
6550
+ const ua = navigator.userAgent || '';
6551
+ if (!/AppleWebKit\//.test(ua))
6552
+ return false;
6553
+ // Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
6554
+ // `Chromium/` markers are only present on desktop Chromium builds
6555
+ // (their iOS counterparts use `CriOS/` instead). `Android` rules out
6556
+ // the mobile Blink stack.
6557
+ const regExp = /Chrome\/|Chromium\/|Android/;
6558
+ return !regExp.test(ua);
6559
+ };
6458
6560
  /**
6459
6561
  * Checks whether the current browser is Firefox.
6460
6562
  */
@@ -6498,7 +6600,8 @@ var browsers = /*#__PURE__*/Object.freeze({
6498
6600
  isChrome: isChrome,
6499
6601
  isFirefox: isFirefox,
6500
6602
  isSafari: isSafari,
6501
- isSupportedBrowser: isSupportedBrowser
6603
+ isSupportedBrowser: isSupportedBrowser,
6604
+ isWebKit: isWebKit
6502
6605
  });
6503
6606
 
6504
6607
  /**
@@ -7974,6 +8077,24 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
7974
8077
  }));
7975
8078
  };
7976
8079
 
8080
+ const toRTCDegradationPreference = (preference) => {
8081
+ switch (preference) {
8082
+ case DegradationPreference.BALANCED:
8083
+ return 'balanced';
8084
+ case DegradationPreference.MAINTAIN_FRAMERATE:
8085
+ return 'maintain-framerate';
8086
+ case DegradationPreference.MAINTAIN_RESOLUTION:
8087
+ return 'maintain-resolution';
8088
+ case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
8089
+ // @ts-expect-error not in the typedefs yet
8090
+ return 'maintain-framerate-and-resolution';
8091
+ case DegradationPreference.UNSPECIFIED:
8092
+ return undefined;
8093
+ default:
8094
+ ensureExhausted(preference, 'Unknown degradation preference');
8095
+ }
8096
+ };
8097
+
7977
8098
  /**
7978
8099
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
7979
8100
  *
@@ -8035,7 +8156,9 @@ class Publisher extends BasePeerConnection {
8035
8156
  sendEncodings,
8036
8157
  });
8037
8158
  const params = transceiver.sender.getParameters();
8038
- params.degradationPreference = 'maintain-framerate';
8159
+ params.degradationPreference =
8160
+ toRTCDegradationPreference(publishOption.degradationPreference) ??
8161
+ 'maintain-framerate';
8039
8162
  await transceiver.sender.setParameters(params);
8040
8163
  const trackType = publishOption.trackType;
8041
8164
  this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
@@ -8132,6 +8255,40 @@ class Publisher extends BasePeerConnection {
8132
8255
  }
8133
8256
  return false;
8134
8257
  };
8258
+ /**
8259
+ * Re-arms the encoder for the given track type by detaching and
8260
+ * reattaching the currently published track on each matching sender.
8261
+ *
8262
+ * Workaround for a WebKit / iOS Safari quirk: after a system audio
8263
+ * session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
8264
+ * can stop producing RTP packets even though the underlying
8265
+ * `MediaStreamTrack` is `live` and `track.muted === false`.
8266
+ * `replaceTrack(null)` followed by `replaceTrack(track)` resets the
8267
+ * sender's encoder pipeline without renegotiation, restoring packet
8268
+ * flow with the same SSRC.
8269
+ *
8270
+ * No-op when nothing is published for the given track type.
8271
+ *
8272
+ * @param trackType the track type to refresh.
8273
+ */
8274
+ this.refreshTrack = async (trackType) => {
8275
+ for (const item of this.transceiverCache.items()) {
8276
+ if (item.publishOption.trackType !== trackType)
8277
+ continue;
8278
+ const { sender } = item.transceiver;
8279
+ const track = sender.track;
8280
+ if (!track || track.readyState !== 'live')
8281
+ continue;
8282
+ try {
8283
+ await sender.replaceTrack(null);
8284
+ await sender.replaceTrack(track);
8285
+ this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
8286
+ }
8287
+ catch (err) {
8288
+ this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
8289
+ }
8290
+ }
8291
+ };
8135
8292
  /**
8136
8293
  * Stops the cloned track that is being published to the SFU.
8137
8294
  */
@@ -8209,6 +8366,12 @@ class Publisher extends BasePeerConnection {
8209
8366
  changed = true;
8210
8367
  }
8211
8368
  }
8369
+ const degradationPreference = toRTCDegradationPreference(videoSender.degradationPreference);
8370
+ if (degradationPreference &&
8371
+ params.degradationPreference !== degradationPreference) {
8372
+ params.degradationPreference = degradationPreference;
8373
+ changed = true;
8374
+ }
8212
8375
  const activeEncoders = params.encodings.filter((e) => e.active);
8213
8376
  if (!changed) {
8214
8377
  return this.logger.info(`${tag} no change:`, activeEncoders);
@@ -8389,6 +8552,36 @@ class Publisher extends BasePeerConnection {
8389
8552
  }
8390
8553
  }
8391
8554
 
8555
+ /**
8556
+ * Adds unique values to an array.
8557
+ *
8558
+ * @param arr the array to add to.
8559
+ * @param values the values to add.
8560
+ */
8561
+ const pushToIfMissing = (arr, ...values) => {
8562
+ for (const v of values) {
8563
+ if (!arr.includes(v)) {
8564
+ arr.push(v);
8565
+ }
8566
+ }
8567
+ return arr;
8568
+ };
8569
+ /**
8570
+ * Removes values from an array if they are present.
8571
+ *
8572
+ * @param arr the array to remove from.
8573
+ * @param values the values to remove.
8574
+ */
8575
+ const removeFromIfPresent = (arr, ...values) => {
8576
+ for (const v of values) {
8577
+ const index = arr.indexOf(v);
8578
+ if (index !== -1) {
8579
+ arr.splice(index, 1);
8580
+ }
8581
+ }
8582
+ return arr;
8583
+ };
8584
+
8392
8585
  /**
8393
8586
  * A wrapper around the `RTCPeerConnection` that handles the incoming
8394
8587
  * media streams from the SFU.
@@ -8430,27 +8623,34 @@ class Subscriber extends BasePeerConnection {
8430
8623
  }
8431
8624
  };
8432
8625
  this.handleOnTrack = (e) => {
8433
- const [primaryStream] = e.streams;
8626
+ const { streams, track } = e;
8627
+ const [primaryStream] = streams;
8434
8628
  // example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
8435
8629
  const [trackId, rawTrackType] = primaryStream.id.split(':');
8436
8630
  const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
8437
- this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, e.track.id, e.track);
8631
+ this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
8632
+ const trackType = toTrackType(rawTrackType);
8633
+ if (!trackType) {
8634
+ return this.logger.error(`Unknown track type: ${rawTrackType}`);
8635
+ }
8438
8636
  const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
8439
- e.track.addEventListener('mute', () => {
8637
+ track.addEventListener('mute', () => {
8440
8638
  this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
8639
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
8441
8640
  });
8442
- e.track.addEventListener('unmute', () => {
8641
+ track.addEventListener('unmute', () => {
8443
8642
  this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
8643
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
8444
8644
  });
8445
- e.track.addEventListener('ended', () => {
8645
+ track.addEventListener('ended', () => {
8446
8646
  this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
8647
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
8447
8648
  this.state.removeOrphanedTrack(primaryStream.id);
8448
8649
  });
8449
- const trackType = toTrackType(rawTrackType);
8450
- if (!trackType) {
8451
- return this.logger.error(`Unknown track type: ${rawTrackType}`);
8650
+ if (track.muted) {
8651
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
8452
8652
  }
8453
- this.trackIdToTrackType.set(e.track.id, trackType);
8653
+ this.trackIdToTrackType.set(track.id, trackType);
8454
8654
  if (!participantToUpdate) {
8455
8655
  this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
8456
8656
  this.state.registerOrphanedTrack({
@@ -8476,13 +8676,30 @@ class Subscriber extends BasePeerConnection {
8476
8676
  });
8477
8677
  // now, dispose the previous stream if it exists
8478
8678
  if (previousStream) {
8479
- this.logger.info(`[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`);
8679
+ this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
8480
8680
  previousStream.getTracks().forEach((t) => {
8481
8681
  t.stop();
8482
8682
  previousStream.removeTrack(t);
8483
8683
  });
8484
8684
  }
8485
8685
  };
8686
+ this.setRemoteTrackInterrupted = (trackId, trackType, interrupted) => {
8687
+ if (trackType !== TrackType.AUDIO)
8688
+ return;
8689
+ const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
8690
+ if (!target)
8691
+ return;
8692
+ this.state.updateParticipant(target.sessionId, (p) => {
8693
+ const current = p.interruptedTracks ?? [];
8694
+ const has = current.includes(trackType);
8695
+ if (interrupted === has)
8696
+ return {};
8697
+ const next = interrupted
8698
+ ? pushToIfMissing([...current], trackType)
8699
+ : removeFromIfPresent([...current], trackType);
8700
+ return { interruptedTracks: next };
8701
+ });
8702
+ };
8486
8703
  this.negotiate = async (subscriberOffer) => {
8487
8704
  await this.pc.setRemoteDescription({
8488
8705
  type: 'offer',
@@ -9205,36 +9422,6 @@ const watchCallGrantsUpdated = (state) => {
9205
9422
  };
9206
9423
  };
9207
9424
 
9208
- /**
9209
- * Adds unique values to an array.
9210
- *
9211
- * @param arr the array to add to.
9212
- * @param values the values to add.
9213
- */
9214
- const pushToIfMissing = (arr, ...values) => {
9215
- for (const v of values) {
9216
- if (!arr.includes(v)) {
9217
- arr.push(v);
9218
- }
9219
- }
9220
- return arr;
9221
- };
9222
- /**
9223
- * Removes values from an array if they are present.
9224
- *
9225
- * @param arr the array to remove from.
9226
- * @param values the values to remove.
9227
- */
9228
- const removeFromIfPresent = (arr, ...values) => {
9229
- for (const v of values) {
9230
- const index = arr.indexOf(v);
9231
- if (index !== -1) {
9232
- arr.splice(index, 1);
9233
- }
9234
- }
9235
- return arr;
9236
- };
9237
-
9238
9425
  const watchConnectionQualityChanged = (dispatcher, state) => {
9239
9426
  return dispatcher.on('connectionQualityChanged', '*', (e) => {
9240
9427
  const { connectionQualityUpdates } = e;
@@ -9567,91 +9754,6 @@ const registerRingingCallEventHandlers = (call) => {
9567
9754
  };
9568
9755
  };
9569
9756
 
9570
- const DEFAULT_THRESHOLD = 0.35;
9571
- class ViewportTracker {
9572
- constructor() {
9573
- /**
9574
- * @private
9575
- */
9576
- this.elementHandlerMap = new Map();
9577
- /**
9578
- * @private
9579
- */
9580
- this.observer = null;
9581
- // in React children render before viewport is set, add
9582
- // them to the queue and observe them once the observer is ready
9583
- /**
9584
- * @private
9585
- */
9586
- this.queueSet = new Set();
9587
- /**
9588
- * Method to set scrollable viewport as root for the IntersectionObserver, returns
9589
- * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
9590
- *
9591
- * @param viewportElement
9592
- * @param options
9593
- * @returns Unobserve
9594
- */
9595
- this.setViewport = (viewportElement, options) => {
9596
- const cleanup = () => {
9597
- this.observer?.disconnect();
9598
- this.observer = null;
9599
- this.elementHandlerMap.clear();
9600
- };
9601
- this.observer = new IntersectionObserver((entries) => {
9602
- entries.forEach((entry) => {
9603
- const handler = this.elementHandlerMap.get(entry.target);
9604
- handler?.(entry);
9605
- });
9606
- }, {
9607
- root: viewportElement,
9608
- ...options,
9609
- threshold: options?.threshold ?? DEFAULT_THRESHOLD,
9610
- });
9611
- if (this.queueSet.size) {
9612
- this.queueSet.forEach(([queueElement, queueHandler]) => {
9613
- // check if element which requested observation is
9614
- // a child of a viewport element, skip if isn't
9615
- if (!viewportElement.contains(queueElement))
9616
- return;
9617
- this.observer.observe(queueElement);
9618
- this.elementHandlerMap.set(queueElement, queueHandler);
9619
- });
9620
- this.queueSet.clear();
9621
- }
9622
- return cleanup;
9623
- };
9624
- /**
9625
- * Method to set element to observe and handler to be triggered whenever IntersectionObserver
9626
- * detects a possible change in element's visibility within specified viewport, returns
9627
- * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
9628
- *
9629
- * @param element
9630
- * @param handler
9631
- * @returns Unobserve
9632
- */
9633
- this.observe = (element, handler) => {
9634
- const queueItem = [element, handler];
9635
- const cleanup = () => {
9636
- this.elementHandlerMap.delete(element);
9637
- this.observer?.unobserve(element);
9638
- this.queueSet.delete(queueItem);
9639
- };
9640
- if (this.elementHandlerMap.has(element))
9641
- return cleanup;
9642
- if (!this.observer) {
9643
- this.queueSet.add(queueItem);
9644
- return cleanup;
9645
- }
9646
- if (this.observer.root.contains(element)) {
9647
- this.elementHandlerMap.set(element, handler);
9648
- this.observer.observe(element);
9649
- }
9650
- return cleanup;
9651
- };
9652
- }
9653
- }
9654
-
9655
9757
  const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
9656
9758
  /**
9657
9759
  * Tracks audio element bindings and periodically warns about
@@ -9659,8 +9761,6 @@ const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${tr
9659
9761
  */
9660
9762
  class AudioBindingsWatchdog {
9661
9763
  constructor(state, tracer) {
9662
- this.state = state;
9663
- this.tracer = tracer;
9664
9764
  this.bindings = new Map();
9665
9765
  this.enabled = true;
9666
9766
  this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
@@ -9668,14 +9768,14 @@ class AudioBindingsWatchdog {
9668
9768
  * Registers an audio element binding for the given session and track type.
9669
9769
  * Warns if a different element is already bound to the same key.
9670
9770
  */
9671
- this.register = (audioElement, sessionId, trackType) => {
9771
+ this.register = (element, sessionId, trackType) => {
9672
9772
  const key = toBindingKey(sessionId, trackType);
9673
9773
  const existing = this.bindings.get(key);
9674
- if (existing && existing !== audioElement) {
9774
+ if (existing && existing !== element) {
9675
9775
  this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
9676
9776
  this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
9677
9777
  }
9678
- this.bindings.set(key, audioElement);
9778
+ this.bindings.set(key, element);
9679
9779
  };
9680
9780
  /**
9681
9781
  * Removes the audio element binding for the given session and track type.
@@ -9701,6 +9801,7 @@ class AudioBindingsWatchdog {
9701
9801
  */
9702
9802
  this.dispose = () => {
9703
9803
  this.stop();
9804
+ this.bindings.clear();
9704
9805
  this.unsubscribeCallingState();
9705
9806
  };
9706
9807
  this.start = () => {
@@ -9732,6 +9833,8 @@ class AudioBindingsWatchdog {
9732
9833
  this.stop = () => {
9733
9834
  clearInterval(this.watchdogInterval);
9734
9835
  };
9836
+ this.tracer = tracer;
9837
+ this.state = state;
9735
9838
  this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
9736
9839
  if (!this.enabled)
9737
9840
  return;
@@ -9745,64 +9848,100 @@ class AudioBindingsWatchdog {
9745
9848
  }
9746
9849
  }
9747
9850
 
9748
- const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
9749
- videoTrack: exports.VisibilityState.UNKNOWN,
9750
- screenShareTrack: exports.VisibilityState.UNKNOWN,
9751
- };
9752
- const globalOverrideKey = Symbol('globalOverrideKey');
9753
9851
  /**
9754
- * A manager class that handles dynascale related tasks like:
9755
- *
9756
- * - binding video elements to session ids
9757
- * - binding audio elements to session ids
9758
- * - tracking element visibility
9759
- * - updating subscriptions based on viewport visibility
9760
- * - updating subscriptions based on video element dimensions
9761
- * - updating subscriptions based on published tracks
9852
+ * Tracks audio elements that the browser's autoplay policy has blocked.
9762
9853
  */
9763
- class DynascaleManager {
9764
- /**
9765
- * Creates a new DynascaleManager instance.
9766
- */
9767
- constructor(callState, speaker, tracer) {
9854
+ class BlockedAudioTracker {
9855
+ constructor(tracer) {
9856
+ this.logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
9857
+ this.blockedElementsSubject = new rxjs.BehaviorSubject(new Set());
9768
9858
  /**
9769
- * The viewport tracker instance.
9859
+ * Whether the browser's autoplay policy is blocking audio playback.
9860
+ * Will be `true` when at least one audio element is currently blocked.
9861
+ * Use {@link resumeAudio} within a user gesture to unblock.
9770
9862
  */
9771
- this.viewportTracker = new ViewportTracker();
9772
- this.logger = videoLoggerSystem.getLogger('DynascaleManager');
9773
- this.useWebAudio = false;
9774
- this.pendingSubscriptionsUpdate = null;
9863
+ this.autoplayBlocked$ = this.blockedElementsSubject.pipe(rxjs.map((elements) => elements.size > 0), rxjs.distinctUntilChanged());
9775
9864
  /**
9776
- * Audio elements that were blocked by the browser's autoplay policy.
9777
- * These can be retried by calling `resumeAudio()` from a user gesture.
9865
+ * Registers an audio element as blocked by the browser's autoplay policy.
9778
9866
  */
9779
- this.blockedAudioElementsSubject = new rxjs.BehaviorSubject(new Set());
9780
- /**
9781
- * Whether the browser's autoplay policy is blocking audio playback.
9782
- * Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
9783
- * Use `resumeAudio()` within a user gesture to unblock.
9784
- */
9785
- this.autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(rxjs.map((elements) => elements.size > 0), rxjs.distinctUntilChanged());
9786
- this.addBlockedAudioElement = (audioElement) => {
9787
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9788
- const next = new Set(elements);
9789
- next.add(audioElement);
9790
- return next;
9867
+ this.markBlocked = (audioElement, blocked) => {
9868
+ setCurrentValue(this.blockedElementsSubject, (elements) => {
9869
+ if (blocked)
9870
+ elements.add(audioElement);
9871
+ else
9872
+ elements.delete(audioElement);
9873
+ return elements;
9791
9874
  });
9792
9875
  };
9793
- this.removeBlockedAudioElement = (audioElement) => {
9794
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9795
- const nextElements = new Set(elements);
9796
- nextElements.delete(audioElement);
9797
- return nextElements;
9876
+ /**
9877
+ * Returns whether the given audio element is currently flagged as blocked
9878
+ * by the browser's autoplay policy.
9879
+ */
9880
+ this.isBlocked = (audioElement) => {
9881
+ return this.blockedElementsSubject.getValue().has(audioElement);
9882
+ };
9883
+ /**
9884
+ * Plays all audio elements blocked by the browser's autoplay policy.
9885
+ * Must be called from within a user gesture (e.g., click handler).
9886
+ */
9887
+ this.resumeAudio = async () => {
9888
+ this.tracer.trace('resumeAudio', null);
9889
+ await setCurrentValueAsync(this.blockedElementsSubject, async (elements) => {
9890
+ await Promise.all(Array.from(elements, async (element) => {
9891
+ try {
9892
+ if (element.srcObject)
9893
+ await timeboxed([element.play()], 2000);
9894
+ elements.delete(element);
9895
+ }
9896
+ catch (err) {
9897
+ this.logger.warn(`Can't resume audio for element`, element, err);
9898
+ }
9899
+ }));
9900
+ return elements;
9798
9901
  });
9799
9902
  };
9800
- this.videoTrackSubscriptionOverridesSubject = new rxjs.BehaviorSubject({});
9801
- this.videoTrackSubscriptionOverrides$ = this.videoTrackSubscriptionOverridesSubject.asObservable();
9802
- this.incomingVideoSettings$ = this.videoTrackSubscriptionOverrides$.pipe(rxjs.map((overrides) => {
9803
- const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
9804
- return {
9805
- enabled: globalSettings?.enabled !== false,
9903
+ this.tracer = tracer;
9904
+ }
9905
+ }
9906
+
9907
+ /** Symbol key for the "applies to all participants" override slot. */
9908
+ const globalOverrideKey = Symbol('globalOverrideKey');
9909
+ /**
9910
+ * Owns the SFU-side video-subscription machinery for a `Call`:
9911
+ *
9912
+ * - Holds the per-session / global override state in a
9913
+ * `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
9914
+ * - Derives the SFU subscription list from `CallState` participants +
9915
+ * current overrides via the `subscriptions` getter.
9916
+ * - Debounces and pushes the list to the SFU through
9917
+ * `sfuClient.updateSubscriptions`.
9918
+ * - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
9919
+ * the override state for React hooks.
9920
+ *
9921
+ * Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
9922
+ * `DynascaleManager.bindVideoElement` triggers `apply()` on every
9923
+ * dimension / visibility change.
9924
+ */
9925
+ class TrackSubscriptionManager {
9926
+ /**
9927
+ * Constructs new TrackSubscriptionManager instance.
9928
+ *
9929
+ * @param callState the call state.
9930
+ * @param tracer the tracer to use.
9931
+ */
9932
+ constructor(callState, tracer) {
9933
+ this.logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
9934
+ this.pendingUpdate = null;
9935
+ this.overridesSubject = new rxjs.BehaviorSubject({});
9936
+ this.overrides$ = this.overridesSubject.asObservable();
9937
+ /**
9938
+ * Consumer-friendly projection of the override state. Used by the
9939
+ * `useIncomingVideoSettings()` React hook.
9940
+ */
9941
+ this.incomingVideoSettings$ = this.overrides$.pipe(rxjs.map((overrides) => {
9942
+ const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
9943
+ return {
9944
+ enabled: globalSettings?.enabled !== false,
9806
9945
  preferredResolution: globalSettings?.enabled
9807
9946
  ? globalSettings.dimension
9808
9947
  : undefined,
@@ -9821,106 +9960,255 @@ class DynascaleManager {
9821
9960
  };
9822
9961
  }), rxjs.shareReplay(1));
9823
9962
  /**
9824
- * Disposes the allocated resources and closes the audio context if it was created.
9963
+ * Sets the SFU client used by `apply()` to push subscription updates.
9964
+ * Called by the owner on call join; cleared on leave.
9825
9965
  */
9826
- this.dispose = async () => {
9827
- if (this.pendingSubscriptionsUpdate) {
9828
- clearTimeout(this.pendingSubscriptionsUpdate);
9829
- }
9830
- this.audioBindingsWatchdog?.dispose();
9831
- setCurrentValue(this.blockedAudioElementsSubject, new Set());
9832
- const context = this.audioContext;
9833
- if (context && context.state !== 'closed') {
9834
- document.removeEventListener('click', this.resumeAudioContext);
9835
- await context.close();
9836
- this.audioContext = undefined;
9966
+ this.setSfuClient = (sfuClient) => {
9967
+ this.sfuClient = sfuClient;
9968
+ };
9969
+ /**
9970
+ * Cancels any pending debounced subscription push. Idempotent.
9971
+ */
9972
+ this.dispose = () => {
9973
+ if (this.pendingUpdate) {
9974
+ clearTimeout(this.pendingUpdate);
9975
+ this.pendingUpdate = null;
9837
9976
  }
9838
9977
  };
9839
- this.setVideoTrackSubscriptionOverrides = (override, sessionIds) => {
9840
- this.tracer.trace('setVideoTrackSubscriptionOverrides', [
9841
- override,
9842
- sessionIds,
9843
- ]);
9978
+ /**
9979
+ * Sets video-subscription overrides. Called by
9980
+ * `Call.setIncomingVideoEnabled` and
9981
+ * `Call.setPreferredIncomingVideoResolution`.
9982
+ *
9983
+ * - `sessionIds` omitted → applies `override` globally (or clears the
9984
+ * global override if `override` is `undefined`).
9985
+ * - `sessionIds` provided → applies `override` to each listed session.
9986
+ */
9987
+ this.setOverrides = (override, sessionIds) => {
9988
+ this.tracer.trace('setOverrides', [override, sessionIds]);
9844
9989
  if (!sessionIds) {
9845
- return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, override ? { [globalOverrideKey]: override } : {});
9990
+ return setCurrentValue(this.overridesSubject, override ? { [globalOverrideKey]: override } : {});
9846
9991
  }
9847
- return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, (overrides) => ({
9992
+ return setCurrentValue(this.overridesSubject, (overrides) => ({
9848
9993
  ...overrides,
9849
9994
  ...Object.fromEntries(sessionIds.map((id) => [id, override])),
9850
9995
  }));
9851
9996
  };
9852
- this.applyTrackSubscriptions = (debounceType = exports.DebounceType.SLOW) => {
9853
- if (this.pendingSubscriptionsUpdate) {
9854
- clearTimeout(this.pendingSubscriptionsUpdate);
9997
+ /**
9998
+ * Pushes `subscriptions` to the SFU. Debounced by `debounceType`
9999
+ * (SLOW by default). Multiple rapid calls coalesce into one RPC.
10000
+ * Passing `0` fires synchronously.
10001
+ */
10002
+ this.apply = (debounceType = exports.DebounceType.SLOW) => {
10003
+ if (this.pendingUpdate) {
10004
+ clearTimeout(this.pendingUpdate);
9855
10005
  }
9856
10006
  const updateSubscriptions = () => {
9857
- this.pendingSubscriptionsUpdate = null;
10007
+ this.pendingUpdate = null;
9858
10008
  this.sfuClient
9859
- ?.updateSubscriptions(this.trackSubscriptions)
10009
+ ?.updateSubscriptions(this.subscriptions)
9860
10010
  .catch((err) => {
9861
10011
  this.logger.debug(`Failed to update track subscriptions`, err);
9862
10012
  });
9863
10013
  };
9864
10014
  if (debounceType) {
9865
- this.pendingSubscriptionsUpdate = setTimeout(updateSubscriptions, debounceType);
10015
+ this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
9866
10016
  }
9867
10017
  else {
9868
10018
  updateSubscriptions();
9869
10019
  }
9870
10020
  };
9871
- /**
9872
- * Will begin tracking the given element for visibility changes within the
9873
- * configured viewport element (`call.setViewport`).
9874
- *
9875
- * @param element the element to track.
9876
- * @param sessionId the session id.
9877
- * @param trackType the kind of video.
9878
- * @returns Untrack.
9879
- */
9880
- this.trackElementVisibility = (element, sessionId, trackType) => {
9881
- const cleanup = this.viewportTracker.observe(element, (entry) => {
9882
- this.callState.updateParticipant(sessionId, (participant) => {
9883
- const previousVisibilityState = participant.viewportVisibilityState ??
9884
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
9885
- // observer triggers when the element is "moved" to be a fullscreen element
9886
- // keep it VISIBLE if that happens to prevent fullscreen with placeholder
9887
- const isVisible = entry.isIntersecting || document.fullscreenElement === element
9888
- ? exports.VisibilityState.VISIBLE
9889
- : exports.VisibilityState.INVISIBLE;
9890
- return {
9891
- ...participant,
9892
- viewportVisibilityState: {
9893
- ...previousVisibilityState,
9894
- [trackType]: isVisible,
9895
- },
9896
- };
10021
+ this.tracer = tracer;
10022
+ this.callState = callState;
10023
+ }
10024
+ /**
10025
+ * The current SFU subscription list, computed from `CallState`
10026
+ * participants and the override state. Used by:
10027
+ *
10028
+ * - `apply()` to push to the SFU each time the set changes.
10029
+ * - `Call.getReconnectDetails` to include the subscription list in
10030
+ * the reconnect payload.
10031
+ */
10032
+ get subscriptions() {
10033
+ const subscriptions = [];
10034
+ // Use getParticipantsSnapshot() to bypass the observable pipeline
10035
+ // and avoid stale data caused by shareReplay with no active subscribers
10036
+ const participants = this.callState.getParticipantsSnapshot();
10037
+ const overrides = this.overridesSubject.getValue();
10038
+ for (const p of participants) {
10039
+ if (p.isLocalParticipant)
10040
+ continue;
10041
+ // NOTE: audio tracks don't have to be requested explicitly
10042
+ // as the SFU will implicitly subscribe us to all of them,
10043
+ // once they become available.
10044
+ if (p.videoDimension && hasVideo(p)) {
10045
+ const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
10046
+ if (override?.enabled !== false) {
10047
+ subscriptions.push({
10048
+ userId: p.userId,
10049
+ sessionId: p.sessionId,
10050
+ trackType: TrackType.VIDEO,
10051
+ dimension: override?.dimension ?? p.videoDimension,
10052
+ });
10053
+ }
10054
+ }
10055
+ if (p.screenShareDimension && hasScreenShare(p)) {
10056
+ subscriptions.push({
10057
+ userId: p.userId,
10058
+ sessionId: p.sessionId,
10059
+ trackType: TrackType.SCREEN_SHARE,
10060
+ dimension: p.screenShareDimension,
9897
10061
  });
10062
+ }
10063
+ if (hasScreenShareAudio(p)) {
10064
+ subscriptions.push({
10065
+ userId: p.userId,
10066
+ sessionId: p.sessionId,
10067
+ trackType: TrackType.SCREEN_SHARE_AUDIO,
10068
+ });
10069
+ }
10070
+ }
10071
+ return subscriptions;
10072
+ }
10073
+ get overrides() {
10074
+ return getCurrentValue(this.overrides$);
10075
+ }
10076
+ }
10077
+
10078
+ /**
10079
+ * Watches a single audio or video element and attempts to recover playback
10080
+ * after the element transitions to a paused or suspended state unexpectedly.
10081
+ */
10082
+ class MediaPlaybackWatchdog {
10083
+ constructor(opts) {
10084
+ this.logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
10085
+ this.controller = new AbortController();
10086
+ this.attempt = 0;
10087
+ this.disposed = false;
10088
+ this.attach = () => {
10089
+ if (this.disposed)
10090
+ return;
10091
+ const { signal } = this.controller;
10092
+ this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
10093
+ this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
10094
+ this.element.addEventListener('playing', this.onPlaying, { signal });
10095
+ };
10096
+ this.dispose = () => {
10097
+ if (this.disposed)
10098
+ return;
10099
+ this.disposed = true;
10100
+ this.controller.abort();
10101
+ if (this.pendingTimer)
10102
+ clearTimeout(this.pendingTimer);
10103
+ this.pendingTimer = undefined;
10104
+ };
10105
+ this.onPlaying = () => {
10106
+ if (this.attempt > 0) {
10107
+ this.tracer.trace('mediaPlayback.recover.success', {
10108
+ kind: this.kind,
10109
+ attempts: this.attempt,
10110
+ });
10111
+ }
10112
+ this.attempt = 0;
10113
+ if (this.pendingTimer)
10114
+ clearTimeout(this.pendingTimer);
10115
+ this.pendingTimer = undefined;
10116
+ };
10117
+ this.onPauseOrSuspend = (event) => {
10118
+ if (this.disposed)
10119
+ return;
10120
+ this.tracer.trace('mediaPlayback.paused', {
10121
+ kind: this.kind,
10122
+ reason: event.type,
9898
10123
  });
9899
- return () => {
9900
- cleanup();
9901
- // reset visibility state to UNKNOWN upon cleanup
9902
- // so that the layouts that are not actively observed
9903
- // can still function normally (runtime layout switching)
9904
- this.callState.updateParticipant(sessionId, (participant) => {
9905
- const previousVisibilityState = participant.viewportVisibilityState ??
9906
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
9907
- return {
9908
- ...participant,
9909
- viewportVisibilityState: {
9910
- ...previousVisibilityState,
9911
- [trackType]: exports.VisibilityState.UNKNOWN,
9912
- },
9913
- };
10124
+ this.scheduleRecovery();
10125
+ };
10126
+ this.scheduleRecovery = () => {
10127
+ if (this.disposed || this.pendingTimer)
10128
+ return;
10129
+ const skipReason = this.computeSkipReason();
10130
+ if (skipReason) {
10131
+ this.tracer.trace('mediaPlayback.recover.skipped', {
10132
+ kind: this.kind,
10133
+ reason: skipReason,
9914
10134
  });
9915
- };
10135
+ return;
10136
+ }
10137
+ const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
10138
+ this.pendingTimer = setTimeout(this.attemptPlay, delay);
10139
+ };
10140
+ this.computeSkipReason = () => {
10141
+ if (this.disposed)
10142
+ return 'disposed';
10143
+ if (!this.element.srcObject)
10144
+ return 'noSrc';
10145
+ if (this.element.ended)
10146
+ return 'ended';
10147
+ if (this.isBlocked())
10148
+ return 'blocked';
10149
+ const HAVE_CURRENT_DATA = 2;
10150
+ if (this.element.readyState < HAVE_CURRENT_DATA)
10151
+ return 'notReady';
10152
+ if (!this.element.paused)
10153
+ return 'notPaused';
10154
+ };
10155
+ this.attemptPlay = async () => {
10156
+ this.pendingTimer = undefined;
10157
+ if (this.disposed)
10158
+ return;
10159
+ this.attempt += 1;
10160
+ this.tracer.trace('mediaPlayback.recover.attempt', {
10161
+ kind: this.kind,
10162
+ attempt: this.attempt,
10163
+ });
10164
+ try {
10165
+ await timeboxed([this.element.play()], 2000);
10166
+ }
10167
+ catch (err) {
10168
+ if (this.disposed)
10169
+ return;
10170
+ this.logger.warn(`Failed to recover ${this.kind} playback`, err);
10171
+ if (this.attempt >= 10) {
10172
+ this.tracer.trace('mediaPlayback.recover.giveUp', {
10173
+ kind: this.kind,
10174
+ attempts: this.attempt,
10175
+ });
10176
+ return;
10177
+ }
10178
+ this.scheduleRecovery();
10179
+ }
9916
10180
  };
10181
+ this.element = opts.element;
10182
+ this.kind = opts.kind;
10183
+ this.tracer = opts.tracer;
10184
+ this.isBlocked = opts.isBlocked ?? (() => false);
10185
+ this.attach();
10186
+ }
10187
+ }
10188
+
10189
+ /**
10190
+ * A manager class that handles dynascale related tasks like:
10191
+ *
10192
+ * - binding video elements to session ids
10193
+ * - binding audio elements to session ids
10194
+ */
10195
+ class DynascaleManager {
10196
+ /**
10197
+ * Creates a new DynascaleManager instance.
10198
+ */
10199
+ constructor(callState, speaker, tracer, trackSubscriptionManager, blockedAudioTracker) {
10200
+ this.logger = videoLoggerSystem.getLogger('DynascaleManager');
10201
+ this.useWebAudio = false;
9917
10202
  /**
9918
- * Sets the viewport element to track bound video elements for visibility.
9919
- *
9920
- * @param element the viewport element.
10203
+ * Closes the audio context if it was created.
9921
10204
  */
9922
- this.setViewport = (element) => {
9923
- return this.viewportTracker.setViewport(element);
10205
+ this.dispose = async () => {
10206
+ const context = this.audioContext;
10207
+ if (context && context.state !== 'closed') {
10208
+ document.removeEventListener('click', this.resumeAudioContext);
10209
+ await context.close();
10210
+ this.audioContext = undefined;
10211
+ }
9924
10212
  };
9925
10213
  /**
9926
10214
  * Sets whether to use WebAudio API for audio playback.
@@ -9965,7 +10253,7 @@ class DynascaleManager {
9965
10253
  this.callState.updateParticipantTracks(trackType, {
9966
10254
  [sessionId]: { dimension },
9967
10255
  });
9968
- this.applyTrackSubscriptions(debounceType);
10256
+ this.trackSubscriptionManager.apply(debounceType);
9969
10257
  };
9970
10258
  const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((participant) => !!participant), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
9971
10259
  /**
@@ -10054,6 +10342,11 @@ class DynascaleManager {
10054
10342
  // without prior user interaction:
10055
10343
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
10056
10344
  videoElement.muted = true;
10345
+ const playbackWatchdog = new MediaPlaybackWatchdog({
10346
+ element: videoElement,
10347
+ kind: 'video',
10348
+ tracer: this.tracer,
10349
+ });
10057
10350
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
10058
10351
  const streamSubscription = participant$
10059
10352
  .pipe(rxjs.distinctUntilKeyChanged(trackKey))
@@ -10063,14 +10356,14 @@ class DynascaleManager {
10063
10356
  return;
10064
10357
  videoElement.srcObject = source ?? null;
10065
10358
  if (isSafari() || isFirefox()) {
10066
- setTimeout(() => {
10359
+ setTimeout(async () => {
10067
10360
  videoElement.srcObject = source ?? null;
10068
- videoElement.play().catch((e) => {
10361
+ try {
10362
+ await timeboxed([videoElement.play()], 2000);
10363
+ }
10364
+ catch (e) {
10069
10365
  this.logger.warn(`Failed to play stream`, e);
10070
- });
10071
- // we add extra delay until we attempt to force-play
10072
- // the participant's media stream in Firefox and Safari,
10073
- // as they seem to have some timing issues
10366
+ }
10074
10367
  }, 25);
10075
10368
  }
10076
10369
  });
@@ -10080,6 +10373,7 @@ class DynascaleManager {
10080
10373
  publishedTracksSubscription?.unsubscribe();
10081
10374
  streamSubscription.unsubscribe();
10082
10375
  resizeObserver?.disconnect();
10376
+ playbackWatchdog.dispose();
10083
10377
  };
10084
10378
  };
10085
10379
  /**
@@ -10097,7 +10391,6 @@ class DynascaleManager {
10097
10391
  const participant = this.callState.findParticipantBySessionId(sessionId);
10098
10392
  if (!participant || participant.isLocalParticipant)
10099
10393
  return;
10100
- this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
10101
10394
  const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((p) => !!p), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
10102
10395
  const updateSinkId = (deviceId, audioContext) => {
10103
10396
  if (!deviceId)
@@ -10116,6 +10409,7 @@ class DynascaleManager {
10116
10409
  };
10117
10410
  let sourceNode = undefined;
10118
10411
  let gainNode = undefined;
10412
+ let audioWatchdog = undefined;
10119
10413
  const isAudioTrack = trackType === 'audioTrack';
10120
10414
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
10121
10415
  const updateMediaStreamSubscription = participant$
@@ -10126,8 +10420,10 @@ class DynascaleManager {
10126
10420
  return;
10127
10421
  setTimeout(() => {
10128
10422
  audioElement.srcObject = source ?? null;
10423
+ audioWatchdog?.dispose();
10424
+ audioWatchdog = undefined;
10129
10425
  if (!source) {
10130
- this.removeBlockedAudioElement(audioElement);
10426
+ this.blockedAudioTracker.markBlocked(audioElement, false);
10131
10427
  return;
10132
10428
  }
10133
10429
  // Safari has a special quirk that prevents playing audio until the user
@@ -10155,10 +10451,16 @@ class DynascaleManager {
10155
10451
  this.tracer.trace('audioPlaybackError', e.message);
10156
10452
  if (e.name === 'NotAllowedError') {
10157
10453
  this.tracer.trace('audioPlaybackBlocked', null);
10158
- this.addBlockedAudioElement(audioElement);
10454
+ this.blockedAudioTracker.markBlocked(audioElement, true);
10159
10455
  }
10160
10456
  this.logger.warn(`Failed to play audio stream`, e);
10161
10457
  });
10458
+ audioWatchdog = new MediaPlaybackWatchdog({
10459
+ element: audioElement,
10460
+ kind: 'audio',
10461
+ tracer: this.tracer,
10462
+ isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
10463
+ });
10162
10464
  }
10163
10465
  const { selectedDevice } = this.speaker.state;
10164
10466
  if (selectedDevice)
@@ -10182,38 +10484,17 @@ class DynascaleManager {
10182
10484
  });
10183
10485
  audioElement.autoplay = true;
10184
10486
  return () => {
10185
- this.audioBindingsWatchdog?.unregister(sessionId, trackType);
10186
- this.removeBlockedAudioElement(audioElement);
10487
+ this.blockedAudioTracker.markBlocked(audioElement, false);
10187
10488
  sinkIdSubscription?.unsubscribe();
10188
10489
  volumeSubscription.unsubscribe();
10189
10490
  updateMediaStreamSubscription.unsubscribe();
10190
10491
  audioElement.srcObject = null;
10191
10492
  sourceNode?.disconnect();
10192
10493
  gainNode?.disconnect();
10494
+ audioWatchdog?.dispose();
10495
+ audioWatchdog = undefined;
10193
10496
  };
10194
10497
  };
10195
- /**
10196
- * Plays all audio elements blocked by the browser's autoplay policy.
10197
- * Must be called from within a user gesture (e.g., click handler).
10198
- *
10199
- * @returns a promise that resolves when all blocked elements have been retried.
10200
- */
10201
- this.resumeAudio = async () => {
10202
- this.tracer.trace('resumeAudio', null);
10203
- const blocked = new Set();
10204
- await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
10205
- try {
10206
- if (el.srcObject) {
10207
- await el.play();
10208
- }
10209
- }
10210
- catch {
10211
- this.logger.warn(`Can't resume audio for element: `, el);
10212
- blocked.add(el);
10213
- }
10214
- }));
10215
- setCurrentValue(this.blockedAudioElementsSubject, blocked);
10216
- };
10217
10498
  this.getOrCreateAudioContext = () => {
10218
10499
  if (!this.useWebAudio)
10219
10500
  return;
@@ -10266,57 +10547,124 @@ class DynascaleManager {
10266
10547
  this.callState = callState;
10267
10548
  this.speaker = speaker;
10268
10549
  this.tracer = tracer;
10269
- if (!isReactNative()) {
10270
- this.audioBindingsWatchdog = new AudioBindingsWatchdog(callState, tracer);
10271
- }
10272
- }
10273
- setSfuClient(sfuClient) {
10274
- this.sfuClient = sfuClient;
10550
+ this.trackSubscriptionManager = trackSubscriptionManager;
10551
+ this.blockedAudioTracker = blockedAudioTracker;
10275
10552
  }
10276
- get trackSubscriptions() {
10277
- const subscriptions = [];
10278
- // Use getParticipantsSnapshot() to bypass the observable pipeline
10279
- // and avoid stale data caused by shareReplay with no active subscribers
10280
- const participants = this.callState.getParticipantsSnapshot();
10281
- const videoTrackSubscriptionOverrides = this.videoTrackSubscriptionOverridesSubject.getValue();
10282
- for (const p of participants) {
10283
- if (p.isLocalParticipant)
10284
- continue;
10285
- // NOTE: audio tracks don't have to be requested explicitly
10286
- // as the SFU will implicitly subscribe us to all of them,
10287
- // once they become available.
10288
- if (p.videoDimension && hasVideo(p)) {
10289
- const override = videoTrackSubscriptionOverrides[p.sessionId] ??
10290
- videoTrackSubscriptionOverrides[globalOverrideKey];
10291
- if (override?.enabled !== false) {
10292
- subscriptions.push({
10293
- userId: p.userId,
10294
- sessionId: p.sessionId,
10295
- trackType: TrackType.VIDEO,
10296
- dimension: override?.dimension ?? p.videoDimension,
10297
- });
10298
- }
10553
+ }
10554
+
10555
+ const DEFAULT_THRESHOLD = 0.35;
10556
+ const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
10557
+ videoTrack: exports.VisibilityState.UNKNOWN,
10558
+ screenShareTrack: exports.VisibilityState.UNKNOWN,
10559
+ };
10560
+ class ViewportTracker {
10561
+ constructor(callState) {
10562
+ this.elementHandlerMap = new Map();
10563
+ this.observer = null;
10564
+ // in React children render before viewport is set, add
10565
+ // them to the queue and observe them once the observer is ready
10566
+ this.queueSet = new Set();
10567
+ /**
10568
+ * Method to set scrollable viewport as root for the IntersectionObserver, returns
10569
+ * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
10570
+ */
10571
+ this.setViewport = (viewportElement, options) => {
10572
+ const cleanup = () => {
10573
+ this.observer?.disconnect();
10574
+ this.observer = null;
10575
+ this.elementHandlerMap.clear();
10576
+ };
10577
+ this.observer = new IntersectionObserver((entries) => {
10578
+ entries.forEach((entry) => {
10579
+ const handler = this.elementHandlerMap.get(entry.target);
10580
+ handler?.(entry);
10581
+ });
10582
+ }, {
10583
+ root: viewportElement,
10584
+ ...options,
10585
+ threshold: options?.threshold ?? DEFAULT_THRESHOLD,
10586
+ });
10587
+ if (this.queueSet.size) {
10588
+ this.queueSet.forEach(([queueElement, queueHandler]) => {
10589
+ // check if element which requested observation is
10590
+ // a child of a viewport element, skip if isn't
10591
+ if (!viewportElement.contains(queueElement))
10592
+ return;
10593
+ this.observer.observe(queueElement);
10594
+ this.elementHandlerMap.set(queueElement, queueHandler);
10595
+ });
10596
+ this.queueSet.clear();
10299
10597
  }
10300
- if (p.screenShareDimension && hasScreenShare(p)) {
10301
- subscriptions.push({
10302
- userId: p.userId,
10303
- sessionId: p.sessionId,
10304
- trackType: TrackType.SCREEN_SHARE,
10305
- dimension: p.screenShareDimension,
10598
+ return cleanup;
10599
+ };
10600
+ /**
10601
+ * Method to set element to observe and handler to be triggered whenever IntersectionObserver
10602
+ * detects a possible change in element's visibility within specified viewport, returns
10603
+ * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
10604
+ */
10605
+ this.observe = (element, handler) => {
10606
+ const queueItem = [element, handler];
10607
+ const cleanup = () => {
10608
+ this.elementHandlerMap.delete(element);
10609
+ this.observer?.unobserve(element);
10610
+ this.queueSet.delete(queueItem);
10611
+ };
10612
+ if (this.elementHandlerMap.has(element))
10613
+ return cleanup;
10614
+ if (!this.observer) {
10615
+ this.queueSet.add(queueItem);
10616
+ return cleanup;
10617
+ }
10618
+ if (this.observer.root.contains(element)) {
10619
+ this.elementHandlerMap.set(element, handler);
10620
+ this.observer.observe(element);
10621
+ }
10622
+ return cleanup;
10623
+ };
10624
+ /**
10625
+ * Tracks the given element for visibility changes and mirrors the result
10626
+ * into `participant.viewportVisibilityState[trackType]` in `CallState`.
10627
+ * Returns a function that unobserves the element and resets the visibility
10628
+ * state back to `UNKNOWN`.
10629
+ */
10630
+ this.trackElementVisibility = (element, sessionId, trackType) => {
10631
+ const cleanup = this.observe(element, (entry) => {
10632
+ this.callState.updateParticipant(sessionId, (participant) => {
10633
+ const previousVisibilityState = participant.viewportVisibilityState ??
10634
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
10635
+ // observer triggers when the element is "moved" to be a fullscreen element
10636
+ // keep it VISIBLE if that happens to prevent fullscreen with placeholder
10637
+ const isVisible = entry.isIntersecting || document.fullscreenElement === element
10638
+ ? exports.VisibilityState.VISIBLE
10639
+ : exports.VisibilityState.INVISIBLE;
10640
+ return {
10641
+ ...participant,
10642
+ viewportVisibilityState: {
10643
+ ...previousVisibilityState,
10644
+ [trackType]: isVisible,
10645
+ },
10646
+ };
10306
10647
  });
10307
- }
10308
- if (hasScreenShareAudio(p)) {
10309
- subscriptions.push({
10310
- userId: p.userId,
10311
- sessionId: p.sessionId,
10312
- trackType: TrackType.SCREEN_SHARE_AUDIO,
10648
+ });
10649
+ return () => {
10650
+ cleanup();
10651
+ // reset visibility state to UNKNOWN upon cleanup
10652
+ // so that the layouts that are not actively observed
10653
+ // can still function normally (runtime layout switching)
10654
+ this.callState.updateParticipant(sessionId, (participant) => {
10655
+ const previousVisibilityState = participant.viewportVisibilityState ??
10656
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
10657
+ return {
10658
+ ...participant,
10659
+ viewportVisibilityState: {
10660
+ ...previousVisibilityState,
10661
+ [trackType]: exports.VisibilityState.UNKNOWN,
10662
+ },
10663
+ };
10313
10664
  });
10314
- }
10315
- }
10316
- return subscriptions;
10317
- }
10318
- get videoTrackSubscriptionOverrides() {
10319
- return getCurrentValue(this.videoTrackSubscriptionOverrides$);
10665
+ };
10666
+ };
10667
+ this.callState = callState;
10320
10668
  }
10321
10669
  }
10322
10670
 
@@ -11034,6 +11382,7 @@ class DeviceManager {
11034
11382
  */
11035
11383
  this.stopOnLeave = true;
11036
11384
  this.subscriptions = [];
11385
+ this.currentStreamCleanups = [];
11037
11386
  this.areSubscriptionsSetUp = false;
11038
11387
  this.isTrackStoppedDueToTrackEnd = false;
11039
11388
  this.filters = [];
@@ -11045,10 +11394,30 @@ class DeviceManager {
11045
11394
  * @internal
11046
11395
  */
11047
11396
  this.dispose = () => {
11397
+ this.runCurrentStreamCleanups();
11048
11398
  this.subscriptions.forEach((s) => s());
11049
11399
  this.subscriptions = [];
11050
11400
  this.areSubscriptionsSetUp = false;
11051
11401
  };
11402
+ this.runCurrentStreamCleanups = () => {
11403
+ this.currentStreamCleanups.forEach((c) => c());
11404
+ this.currentStreamCleanups = [];
11405
+ };
11406
+ this.setLocalInterrupted = (interrupted) => {
11407
+ const localParticipant = this.call.state.localParticipant;
11408
+ if (!localParticipant)
11409
+ return;
11410
+ this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
11411
+ const current = p.interruptedTracks ?? [];
11412
+ const has = current.includes(this.trackType);
11413
+ if (interrupted === has)
11414
+ return {};
11415
+ const next = interrupted
11416
+ ? pushToIfMissing([...current], this.trackType)
11417
+ : removeFromIfPresent([...current], this.trackType);
11418
+ return { interruptedTracks: next };
11419
+ });
11420
+ };
11052
11421
  this.call = call;
11053
11422
  this.state = state;
11054
11423
  this.trackType = trackType;
@@ -11272,7 +11641,9 @@ class DeviceManager {
11272
11641
  // @ts-expect-error called to dispose the stream in RN
11273
11642
  mediaStream.release();
11274
11643
  }
11644
+ this.runCurrentStreamCleanups();
11275
11645
  this.state.setMediaStream(undefined, undefined);
11646
+ this.setLocalInterrupted(false);
11276
11647
  this.filters.forEach((entry) => entry.stop?.());
11277
11648
  }
11278
11649
  }
@@ -11308,13 +11679,17 @@ class DeviceManager {
11308
11679
  async unmuteStream() {
11309
11680
  this.logger.debug('Starting stream');
11310
11681
  let stream;
11311
- let rootStream;
11682
+ let rootStreamPromise;
11312
11683
  if (this.state.mediaStream &&
11313
11684
  this.getTracks().every((t) => t.readyState === 'live')) {
11314
11685
  stream = this.state.mediaStream;
11315
11686
  this.enableTracks();
11316
11687
  }
11317
11688
  else {
11689
+ // We are about to compose a fresh filter chain and acquire a new
11690
+ // root stream. Drop any listeners bound to the previous root stream
11691
+ // before chainWith below registers new ones for the new chain.
11692
+ this.runCurrentStreamCleanups();
11318
11693
  const defaultConstraints = this.state.defaultConstraints;
11319
11694
  const constraints = {
11320
11695
  ...defaultConstraints,
@@ -11370,7 +11745,7 @@ class DeviceManager {
11370
11745
  });
11371
11746
  };
11372
11747
  parentTrack.addEventListener('ended', handleParentTrackEnded);
11373
- this.subscriptions.push(() => {
11748
+ this.currentStreamCleanups.push(() => {
11374
11749
  parentTrack.removeEventListener('ended', handleParentTrackEnded);
11375
11750
  });
11376
11751
  });
@@ -11378,7 +11753,7 @@ class DeviceManager {
11378
11753
  };
11379
11754
  // the rootStream represents the stream coming from the actual device
11380
11755
  // e.g. camera or microphone stream
11381
- rootStream = this.getStream(constraints);
11756
+ rootStreamPromise = this.getStream(constraints);
11382
11757
  // we publish the last MediaStream of the chain
11383
11758
  stream = await this.filters.reduce((parent, entry) => parent
11384
11759
  .then((inputStream) => {
@@ -11389,42 +11764,70 @@ class DeviceManager {
11389
11764
  .then(chainWith(parent), (error) => {
11390
11765
  this.logger.warn('Filter failed to start and will be ignored', error);
11391
11766
  return parent;
11392
- }), rootStream);
11767
+ }), rootStreamPromise);
11393
11768
  }
11394
11769
  if (this.call.state.callingState === exports.CallingState.JOINED) {
11395
11770
  await this.publishStream(stream);
11396
11771
  }
11397
11772
  if (this.state.mediaStream !== stream) {
11398
- this.state.setMediaStream(stream, await rootStream);
11399
- const handleTrackEnded = async () => {
11400
- await this.statusChangeSettled();
11401
- if (this.enabled) {
11402
- this.isTrackStoppedDueToTrackEnd = true;
11403
- setTimeout(() => {
11404
- this.isTrackStoppedDueToTrackEnd = false;
11405
- }, 2000);
11406
- await this.disable();
11407
- }
11408
- };
11409
- const createTrackMuteHandler = (muted) => () => {
11410
- if (!isMobile() || this.trackType !== TrackType.VIDEO)
11411
- return;
11412
- this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
11413
- this.logger.warn('Error while notifying track mute state', err);
11414
- });
11415
- };
11416
- stream.getTracks().forEach((track) => {
11417
- const muteHandler = createTrackMuteHandler(true);
11418
- const unmuteHandler = createTrackMuteHandler(false);
11419
- track.addEventListener('mute', muteHandler);
11420
- track.addEventListener('unmute', unmuteHandler);
11421
- track.addEventListener('ended', handleTrackEnded);
11422
- this.subscriptions.push(() => {
11423
- track.removeEventListener('mute', muteHandler);
11424
- track.removeEventListener('unmute', unmuteHandler);
11425
- track.removeEventListener('ended', handleTrackEnded);
11773
+ const rootStream = await rootStreamPromise;
11774
+ this.state.setMediaStream(stream, rootStream);
11775
+ if (rootStream) {
11776
+ const handleTrackEnded = async () => {
11777
+ this.setLocalInterrupted(false);
11778
+ await this.statusChangeSettled();
11779
+ if (this.enabled) {
11780
+ this.isTrackStoppedDueToTrackEnd = true;
11781
+ setTimeout(() => {
11782
+ this.isTrackStoppedDueToTrackEnd = false;
11783
+ }, 2000);
11784
+ await this.disable();
11785
+ }
11786
+ };
11787
+ const createTrackMuteHandler = (muted) => () => {
11788
+ this.setLocalInterrupted(muted);
11789
+ // WebKit's RTCRtpSender encoder can stay stalled after an iOS /
11790
+ // macOS audio session interruption even though the track is
11791
+ // unmuted. Re-arm the sender on every unmute for any WebKit
11792
+ // runtime (Safari + plain iOS WKWebViews). Skipped when the
11793
+ // page is hidden because the encoder won't resume until
11794
+ // foreground anyway.
11795
+ if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
11796
+ this.call.refreshPublishedTrack(this.trackType).catch((err) => {
11797
+ this.logger.warn('Failed to refresh track on system unmute', err);
11798
+ });
11799
+ }
11800
+ // report all tracks on mobile, and only Video on desktop browsers
11801
+ if (isMobile() || this.trackType == TrackType.VIDEO) {
11802
+ this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
11803
+ trackType: TrackType[this.trackType],
11804
+ muted,
11805
+ });
11806
+ this.call
11807
+ .notifyTrackMuteState(muted, this.trackType)
11808
+ .catch((err) => {
11809
+ this.logger.warn('Error while notifying track mute state', err);
11810
+ });
11811
+ }
11812
+ };
11813
+ rootStream.getTracks().forEach((track) => {
11814
+ const muteHandler = createTrackMuteHandler(true);
11815
+ const unmuteHandler = createTrackMuteHandler(false);
11816
+ track.addEventListener('mute', muteHandler);
11817
+ track.addEventListener('unmute', unmuteHandler);
11818
+ track.addEventListener('ended', handleTrackEnded);
11819
+ this.currentStreamCleanups.push(() => {
11820
+ track.removeEventListener('mute', muteHandler);
11821
+ track.removeEventListener('unmute', unmuteHandler);
11822
+ track.removeEventListener('ended', handleTrackEnded);
11823
+ });
11426
11824
  });
11427
- });
11825
+ const initialMuted = rootStream.getTracks().some((t) => t.muted);
11826
+ this.setLocalInterrupted(initialMuted);
11827
+ }
11828
+ else {
11829
+ this.setLocalInterrupted(false);
11830
+ }
11428
11831
  }
11429
11832
  }
11430
11833
  get mediaDeviceKind() {
@@ -11570,7 +11973,6 @@ class DeviceManagerState {
11570
11973
  this.defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
11571
11974
  /**
11572
11975
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
11573
- *
11574
11976
  */
11575
11977
  this.mediaStream$ = this.mediaStreamSubject.asObservable();
11576
11978
  /**
@@ -13225,8 +13627,10 @@ class Call {
13225
13627
  this.publisher = undefined;
13226
13628
  await this.sfuClient?.leaveAndClose(leaveReason);
13227
13629
  this.sfuClient = undefined;
13228
- this.dynascaleManager.setSfuClient(undefined);
13229
- await this.dynascaleManager.dispose();
13630
+ this.trackSubscriptionManager.setSfuClient(undefined);
13631
+ this.trackSubscriptionManager.dispose();
13632
+ this.audioBindingsWatchdog?.dispose();
13633
+ await this.dynascaleManager?.dispose();
13230
13634
  this.state.setCallingState(exports.CallingState.LEFT);
13231
13635
  this.state.setParticipants([]);
13232
13636
  this.state.dispose();
@@ -13536,7 +13940,7 @@ class Call {
13536
13940
  : previousSfuClient;
13537
13941
  this.sfuClient = sfuClient;
13538
13942
  this.unifiedSessionId ?? (this.unifiedSessionId = sfuClient.sessionId);
13539
- this.dynascaleManager.setSfuClient(sfuClient);
13943
+ this.trackSubscriptionManager.setSfuClient(sfuClient);
13540
13944
  const clientDetails = await getClientDetails();
13541
13945
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
13542
13946
  if (previousSfuClient !== sfuClient) {
@@ -13671,7 +14075,7 @@ class Call {
13671
14075
  return {
13672
14076
  strategy,
13673
14077
  announcedTracks,
13674
- subscriptions: this.dynascaleManager.trackSubscriptions,
14078
+ subscriptions: this.trackSubscriptionManager.subscriptions,
13675
14079
  reconnectAttempt: this.reconnectAttempts,
13676
14080
  fromSfuId: migratingFromSfuId || '',
13677
14081
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
@@ -14218,7 +14622,7 @@ class Call {
14218
14622
  const { remoteParticipants } = this.state;
14219
14623
  if (remoteParticipants.length <= 0)
14220
14624
  return;
14221
- this.dynascaleManager.applyTrackSubscriptions(undefined);
14625
+ this.trackSubscriptionManager.apply(undefined);
14222
14626
  };
14223
14627
  /**
14224
14628
  * Starts publishing the given video stream to the call.
@@ -14318,6 +14722,20 @@ class Call {
14318
14722
  }));
14319
14723
  }
14320
14724
  };
14725
+ /**
14726
+ * Re-arms the encoder for a currently published track type. Useful for
14727
+ * working around WebKit's stalled sender bug after an iOS audio session
14728
+ * interruption (Siri, PSTN call).
14729
+ *
14730
+ * @internal
14731
+ *
14732
+ * @param trackType the track type to refresh.
14733
+ */
14734
+ this.refreshPublishedTrack = async (trackType) => {
14735
+ if (!this.publisher)
14736
+ return;
14737
+ await this.publisher.refreshTrack(trackType);
14738
+ };
14321
14739
  /**
14322
14740
  * Updates the preferred publishing options
14323
14741
  *
@@ -14979,7 +15397,7 @@ class Call {
14979
15397
  * @param trackType the video mode.
14980
15398
  */
14981
15399
  this.trackElementVisibility = (element, sessionId, trackType) => {
14982
- return this.dynascaleManager.trackElementVisibility(element, sessionId, trackType);
15400
+ return this.viewportTracker?.trackElementVisibility(element, sessionId, trackType);
14983
15401
  };
14984
15402
  /**
14985
15403
  * Sets the viewport element to track bound video elements for visibility.
@@ -14987,7 +15405,7 @@ class Call {
14987
15405
  * @param element the viewport element.
14988
15406
  */
14989
15407
  this.setViewport = (element) => {
14990
- return this.dynascaleManager.setViewport(element);
15408
+ return this.viewportTracker?.setViewport(element);
14991
15409
  };
14992
15410
  /**
14993
15411
  * Binds a DOM <video> element to the given session id.
@@ -15005,7 +15423,7 @@ class Call {
15005
15423
  * @param trackType the kind of video.
15006
15424
  */
15007
15425
  this.bindVideoElement = (videoElement, sessionId, trackType) => {
15008
- const unbind = this.dynascaleManager.bindVideoElement(videoElement, sessionId, trackType);
15426
+ const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
15009
15427
  if (!unbind)
15010
15428
  return;
15011
15429
  this.leaveCallHooks.add(unbind);
@@ -15025,21 +15443,28 @@ class Call {
15025
15443
  * @param trackType the kind of audio.
15026
15444
  */
15027
15445
  this.bindAudioElement = (audioElement, sessionId, trackType = 'audioTrack') => {
15028
- const unbind = this.dynascaleManager.bindAudioElement(audioElement, sessionId, trackType);
15446
+ const unbind = this.dynascaleManager?.bindAudioElement(audioElement, sessionId, trackType);
15029
15447
  if (!unbind)
15030
15448
  return;
15031
- this.leaveCallHooks.add(unbind);
15032
- return () => {
15033
- this.leaveCallHooks.delete(unbind);
15449
+ this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
15450
+ const cleanup = () => {
15034
15451
  unbind();
15452
+ this.audioBindingsWatchdog?.unregister(sessionId, trackType);
15453
+ };
15454
+ this.leaveCallHooks.add(cleanup);
15455
+ return () => {
15456
+ this.leaveCallHooks.delete(cleanup);
15457
+ cleanup();
15035
15458
  };
15036
15459
  };
15037
15460
  /**
15038
15461
  * Plays all audio elements blocked by the browser's autoplay policy.
15462
+ * Must be called from within a user gesture (e.g., click handler).
15463
+ *
15464
+ * Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
15465
+ * gesture is required.
15039
15466
  */
15040
- this.resumeAudio = () => {
15041
- return this.dynascaleManager.resumeAudio();
15042
- };
15467
+ this.resumeAudio = () => this.blockedAudioTracker.resumeAudio();
15043
15468
  /**
15044
15469
  * Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
15045
15470
  *
@@ -15077,21 +15502,21 @@ class Call {
15077
15502
  * preference has effect on. Affects all participants by default.
15078
15503
  */
15079
15504
  this.setPreferredIncomingVideoResolution = (resolution, sessionIds) => {
15080
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(resolution
15505
+ this.trackSubscriptionManager.setOverrides(resolution
15081
15506
  ? {
15082
15507
  enabled: true,
15083
15508
  dimension: resolution,
15084
15509
  }
15085
15510
  : undefined, sessionIds);
15086
- this.dynascaleManager.applyTrackSubscriptions();
15511
+ this.trackSubscriptionManager.apply();
15087
15512
  };
15088
15513
  /**
15089
15514
  * Enables or disables incoming video from all remote call participants,
15090
15515
  * and removes any preference for preferred resolution.
15091
15516
  */
15092
15517
  this.setIncomingVideoEnabled = (enabled) => {
15093
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(enabled ? undefined : { enabled: false });
15094
- this.dynascaleManager.applyTrackSubscriptions();
15518
+ this.trackSubscriptionManager.setOverrides(enabled ? undefined : { enabled: false });
15519
+ this.trackSubscriptionManager.apply();
15095
15520
  };
15096
15521
  /**
15097
15522
  * Sets the maximum amount of time a user can remain waiting for a reconnect
@@ -15172,7 +15597,13 @@ class Call {
15172
15597
  this.microphone = new MicrophoneManager(this, preferences);
15173
15598
  this.speaker = new SpeakerManager(this, preferences);
15174
15599
  this.screenShare = new ScreenShareManager(this);
15175
- this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer);
15600
+ this.trackSubscriptionManager = new TrackSubscriptionManager(this.state, this.tracer);
15601
+ this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
15602
+ if (typeof document !== 'undefined') {
15603
+ this.audioBindingsWatchdog = new AudioBindingsWatchdog(this.state, this.tracer);
15604
+ this.viewportTracker = new ViewportTracker(this.state);
15605
+ this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer, this.trackSubscriptionManager, this.blockedAudioTracker);
15606
+ }
15176
15607
  }
15177
15608
  /**
15178
15609
  * A flag indicating whether the call is "ringing" type of call.
@@ -15245,12 +15676,118 @@ const APIErrorCodes = {
15245
15676
  */
15246
15677
  class StableWSConnection {
15247
15678
  constructor(client) {
15679
+ /** Incremented when a new WS connection is made */
15680
+ this.wsID = 1;
15681
+ // Connection lifecycle flags.
15682
+ /** We only make 1 attempt to reconnect at the same time.. */
15683
+ this.isConnecting = false;
15684
+ /** To avoid reconnect if client is disconnected */
15685
+ this.isDisconnected = false;
15686
+ /** Boolean that indicates if we have a working connection to the server */
15687
+ this.isHealthy = false;
15688
+ /** Boolean that indicates if the connection promise is resolved */
15689
+ this.isConnectionOpenResolved = false;
15690
+ // Failure counters (drive retry/backoff scheduling).
15691
+ /** consecutive failures influence the duration of the timeout */
15692
+ this.consecutiveFailures = 0;
15693
+ /** keep track of the total number of failures */
15694
+ this.totalFailures = 0;
15695
+ // Health-check pings + connection-staleness check.
15696
+ /** Send a health check message every 25 seconds */
15697
+ this.pingInterval = 25 * 1000;
15698
+ this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
15699
+ /** Store the last event time for health checks */
15700
+ this.lastEvent = null;
15248
15701
  this._log = (msg, extra = {}, level = 'info') => {
15249
15702
  this.client.logger[level](`connection:${msg}`, extra);
15250
15703
  };
15251
15704
  this.setClient = (client) => {
15252
15705
  this.client = client;
15253
15706
  };
15707
+ /**
15708
+ * connect - Connect to the WS URL
15709
+ * the default 15s timeout allows between 2~3 tries
15710
+ * @return Promise that completes once the first health check message is received
15711
+ */
15712
+ this.connect = async (timeout = 15000) => {
15713
+ if (this.isConnecting) {
15714
+ throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
15715
+ }
15716
+ this.isDisconnected = false;
15717
+ try {
15718
+ const healthCheck = await this._connect(timeout);
15719
+ this.consecutiveFailures = 0;
15720
+ this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
15721
+ }
15722
+ catch (caught) {
15723
+ const error = caught;
15724
+ this.isHealthy = false;
15725
+ this.consecutiveFailures += 1;
15726
+ if (error.code === KnownCodes.TOKEN_EXPIRED &&
15727
+ !this.client.tokenManager.isStatic()) {
15728
+ this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
15729
+ this._reconnect({ refreshToken: true });
15730
+ }
15731
+ else if (!error.isWSFailure) {
15732
+ // API rejected the connection and we should not retry
15733
+ throw new Error(JSON.stringify({
15734
+ code: error.code,
15735
+ StatusCode: error.StatusCode,
15736
+ message: error.message,
15737
+ isWSFailure: error.isWSFailure,
15738
+ }));
15739
+ }
15740
+ else {
15741
+ // Transient WS failure (e.g., handshake watchdog). Kick off a
15742
+ // reconnect chain so _waitForHealthy(timeout) below has something
15743
+ // to poll for. Owning the trigger here (rather than inside
15744
+ // _connect()'s catch) keeps a single failure from spawning two
15745
+ // parallel chains - one from this catch and one from _reconnect's
15746
+ // own catch when _connect was called from there.
15747
+ this._reconnect();
15748
+ }
15749
+ }
15750
+ return await this._waitForHealthy(timeout);
15751
+ };
15752
+ /**
15753
+ * _waitForHealthy polls the promise connection to see if its resolved until it times out
15754
+ * the default 15s timeout allows between 2~3 tries
15755
+ * @param timeout duration(ms)
15756
+ */
15757
+ this._waitForHealthy = async (timeout = 15000) => {
15758
+ return Promise.race([
15759
+ (async () => {
15760
+ const interval = 50; // ms
15761
+ for (let i = 0; i <= timeout; i += interval) {
15762
+ try {
15763
+ return await this.connectionOpen;
15764
+ }
15765
+ catch (caught) {
15766
+ const error = caught;
15767
+ if (i === timeout) {
15768
+ throw new Error(JSON.stringify({
15769
+ code: error.code,
15770
+ StatusCode: error.StatusCode,
15771
+ message: error.message,
15772
+ isWSFailure: error.isWSFailure,
15773
+ }));
15774
+ }
15775
+ await sleep(interval);
15776
+ }
15777
+ }
15778
+ })(),
15779
+ (async () => {
15780
+ await sleep(timeout);
15781
+ this.isConnecting = false;
15782
+ throw new Error(JSON.stringify({
15783
+ code: '',
15784
+ StatusCode: '',
15785
+ message: 'initial WS connection could not be established',
15786
+ isWSFailure: true,
15787
+ }));
15788
+ })(),
15789
+ ]);
15790
+ };
15254
15791
  /**
15255
15792
  * Builds and returns the url for websocket.
15256
15793
  * @private
@@ -15263,11 +15800,166 @@ class StableWSConnection {
15263
15800
  params.set('X-Stream-Client', this.client.getUserAgent());
15264
15801
  return `${this.client.wsBaseURL}/connect?${params.toString()}`;
15265
15802
  };
15803
+ /**
15804
+ * disconnect - Disconnect the connection and doesn't recover...
15805
+ */
15806
+ this.disconnect = (timeout) => {
15807
+ this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
15808
+ this.wsID += 1;
15809
+ this.isConnecting = false;
15810
+ this.isDisconnected = true;
15811
+ // start by removing all the listeners
15812
+ if (this.healthCheckTimeoutRef) {
15813
+ getTimers().clearInterval(this.healthCheckTimeoutRef);
15814
+ }
15815
+ if (this.connectionCheckTimeoutRef) {
15816
+ clearInterval(this.connectionCheckTimeoutRef);
15817
+ }
15818
+ removeConnectionEventListeners(this.onlineStatusChanged);
15819
+ this.isHealthy = false;
15820
+ let isClosedPromise;
15821
+ // and finally close...
15822
+ // Assigning to local here because we will remove it from this before the
15823
+ // promise resolves.
15824
+ const { ws } = this;
15825
+ if (ws && ws.close && ws.readyState === ws.OPEN) {
15826
+ isClosedPromise = new Promise((resolve) => {
15827
+ const onclose = (event) => {
15828
+ this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
15829
+ resolve();
15830
+ };
15831
+ ws.onclose = onclose;
15832
+ // In case we don't receive close frame websocket server in time,
15833
+ // lets not wait for more than 1 second.
15834
+ setTimeout(onclose, timeout != null ? timeout : 1000);
15835
+ });
15836
+ this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
15837
+ ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
15838
+ }
15839
+ else {
15840
+ this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
15841
+ isClosedPromise = Promise.resolve();
15842
+ }
15843
+ delete this.ws;
15844
+ return isClosedPromise;
15845
+ };
15846
+ /**
15847
+ * _connect - Connect to the WS endpoint
15848
+ *
15849
+ * @param timeoutMs handshake watchdog deadline in ms. Defaults to
15850
+ * `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
15851
+ * passes its own timeout through so caller-supplied deadlines are honored.
15852
+ * @return Promise that completes once the first health check message is received
15853
+ */
15854
+ this._connect = async (timeoutMs) => {
15855
+ if (this.isConnecting)
15856
+ return; // ignore _connect if it's currently trying to connect
15857
+ this.isConnecting = true;
15858
+ // Snapshot of the connection-id reject closure owned by THIS attempt.
15859
+ // Captured at function entry so that even early failures (e.g.,
15860
+ // tokenManager.loadToken throwing before we reach the WS phase) can
15861
+ // settle the promise the caller is awaiting. Re-captured below if
15862
+ // _connect itself sets up a fresh promise. If a concurrent
15863
+ // openConnection() rotates `client.rejectConnectionId` later, our
15864
+ // captured closure still settles only the original promise (P1) and
15865
+ // never poisons the newer one (P2).
15866
+ let ownRejectConnectionId = this.client.rejectConnectionId;
15867
+ let isTokenReady = false;
15868
+ try {
15869
+ this._log(`_connect() - waiting for token`);
15870
+ await this.client.tokenManager.tokenReady();
15871
+ isTokenReady = true;
15872
+ }
15873
+ catch {
15874
+ // token provider has failed before, so try again
15875
+ }
15876
+ try {
15877
+ if (!isTokenReady) {
15878
+ this._log(`_connect() - tokenProvider failed before, so going to retry`);
15879
+ await this.client.tokenManager.loadToken();
15880
+ }
15881
+ if (!this.client.isConnectionIdPromisePending) {
15882
+ this.client._setupConnectionIdPromise();
15883
+ // recapture: we just rotated the resolver ourselves, the new
15884
+ // closure is the one bound to the promise this attempt owns.
15885
+ ownRejectConnectionId = this.client.rejectConnectionId;
15886
+ }
15887
+ this._setupConnectionPromise();
15888
+ const wsURL = this._buildUrl();
15889
+ this._log(`_connect() - Connecting to ${wsURL}`);
15890
+ const WS = this.client.options.WebSocketImpl ?? WebSocket;
15891
+ this.ws = new WS(wsURL);
15892
+ this.ws.onopen = this.onopen.bind(this, this.wsID);
15893
+ this.ws.onclose = this.onclose.bind(this, this.wsID);
15894
+ this.ws.onerror = this.onerror.bind(this, this.wsID);
15895
+ this.ws.onmessage = this.onmessage.bind(this, this.wsID);
15896
+ // race the WS handshake against an explicit deadline so a silent
15897
+ // network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
15898
+ const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
15899
+ const timers = getTimers();
15900
+ let handshakeTimeoutId;
15901
+ let response;
15902
+ try {
15903
+ response = await Promise.race([
15904
+ this.connectionOpen,
15905
+ new Promise((_, reject) => {
15906
+ handshakeTimeoutId = timers.setTimeout(() => {
15907
+ const err = new Error(`WS handshake timed out after ${handshakeTimeout}ms`);
15908
+ err.isWSFailure = true;
15909
+ reject(err);
15910
+ }, handshakeTimeout);
15911
+ }),
15912
+ ]);
15913
+ }
15914
+ finally {
15915
+ timers.clearTimeout(handshakeTimeoutId);
15916
+ }
15917
+ this.isConnecting = false;
15918
+ // If we were disconnected during the handshake (e.g. closeConnection()
15919
+ // ran while a background _reconnect's _connect was in flight), tear
15920
+ // down the new WS and throw so the caller of connect() does not get
15921
+ // a misleading "success" for a connection that has already been
15922
+ // aborted. We must NOT skip the throw and just return undefined: the
15923
+ // outer connect() would otherwise fall through to _waitForHealthy(),
15924
+ // which would observe the already-resolved connectionOpen promise
15925
+ // and resolve with a ConnectedEvent for a torn-down connection.
15926
+ if (this.isDisconnected) {
15927
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
15928
+ this._destroyCurrentWSConnection();
15929
+ }
15930
+ throw new Error('WS handshake aborted: disconnect() ran while connecting');
15931
+ }
15932
+ if (response) {
15933
+ this.connectionID = response.connection_id;
15934
+ this.client.resolveConnectionId?.(this.connectionID);
15935
+ return response;
15936
+ }
15937
+ }
15938
+ catch (caught) {
15939
+ const err = caught;
15940
+ this.isConnecting = false;
15941
+ this._log(`_connect() - Error - `, err);
15942
+ // Reject THIS attempt's connection-id promise (P1) directly via the
15943
+ // captured closure. Whether or not a concurrent openConnection() has
15944
+ // since rotated client.rejectConnectionId to a newer promise (P2),
15945
+ // calling ownRejectConnectionId only settles P1 - P2 is untouched.
15946
+ // P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
15947
+ // therefore fail fast instead of being orphaned.
15948
+ ownRejectConnectionId?.(err);
15949
+ // connectionOpen is per-instance and not subject to rotation, so
15950
+ // calling it unconditionally is safe (and a no-op if already settled).
15951
+ this.rejectConnectionOpen?.(err);
15952
+ // tear down a half-open WS so it does not linger and fire a stale wsID later
15953
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
15954
+ this._destroyCurrentWSConnection();
15955
+ }
15956
+ throw err;
15957
+ }
15958
+ };
15266
15959
  /**
15267
15960
  * onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
15268
15961
  *
15269
15962
  * @param {Event} event Event with type online or offline
15270
- *
15271
15963
  */
15272
15964
  this.onlineStatusChanged = (event) => {
15273
15965
  if (event.type === 'offline') {
@@ -15365,16 +16057,12 @@ class StableWSConnection {
15365
16057
  return;
15366
16058
  this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
15367
16059
  if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
15368
- // this is a permanent error raised by stream..
16060
+ // this is a permanent error raised by stream.
15369
16061
  // usually caused by invalid auth details
15370
16062
  const error = new Error(`WS connection reject with error ${event.reason}`);
15371
- // @ts-expect-error type issue
15372
16063
  error.reason = event.reason;
15373
- // @ts-expect-error type issue
15374
16064
  error.code = event.code;
15375
- // @ts-expect-error type issue
15376
16065
  error.wasClean = event.wasClean;
15377
- // @ts-expect-error type issue
15378
16066
  error.target = event.target;
15379
16067
  this.rejectConnectionOpen?.(error);
15380
16068
  this._log(`onclose() - WS connection reject with error ${event.reason}`, {
@@ -15512,205 +16200,8 @@ class StableWSConnection {
15512
16200
  }, this.connectionCheckTimeout);
15513
16201
  };
15514
16202
  this.client = client;
15515
- /** consecutive failures influence the duration of the timeout */
15516
- this.consecutiveFailures = 0;
15517
- /** keep track of the total number of failures */
15518
- this.totalFailures = 0;
15519
- /** We only make 1 attempt to reconnect at the same time.. */
15520
- this.isConnecting = false;
15521
- /** To avoid reconnect if client is disconnected */
15522
- this.isDisconnected = false;
15523
- /** Boolean that indicates if the connection promise is resolved */
15524
- this.isConnectionOpenResolved = false;
15525
- /** Boolean that indicates if we have a working connection to the server */
15526
- this.isHealthy = false;
15527
- /** Incremented when a new WS connection is made */
15528
- this.wsID = 1;
15529
- /** Store the last event time for health checks */
15530
- this.lastEvent = null;
15531
- /** Send a health check message every 25 seconds */
15532
- this.pingInterval = 25 * 1000;
15533
- this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
15534
16203
  addConnectionEventListeners(this.onlineStatusChanged);
15535
16204
  }
15536
- /**
15537
- * connect - Connect to the WS URL
15538
- * the default 15s timeout allows between 2~3 tries
15539
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
15540
- */
15541
- async connect(timeout = 15000) {
15542
- if (this.isConnecting) {
15543
- throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
15544
- }
15545
- this.isDisconnected = false;
15546
- try {
15547
- const healthCheck = await this._connect();
15548
- this.consecutiveFailures = 0;
15549
- this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
15550
- }
15551
- catch (error) {
15552
- this.isHealthy = false;
15553
- this.consecutiveFailures += 1;
15554
- if (
15555
- // @ts-expect-error type issue
15556
- error.code === KnownCodes.TOKEN_EXPIRED &&
15557
- !this.client.tokenManager.isStatic()) {
15558
- this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
15559
- this._reconnect({ refreshToken: true });
15560
- }
15561
- else {
15562
- // @ts-expect-error type issue
15563
- if (!error.isWSFailure) {
15564
- // API rejected the connection and we should not retry
15565
- throw new Error(JSON.stringify({
15566
- // @ts-expect-error type issue
15567
- code: error.code,
15568
- // @ts-expect-error type issue
15569
- StatusCode: error.StatusCode,
15570
- // @ts-expect-error type issue
15571
- message: error.message,
15572
- // @ts-expect-error type issue
15573
- isWSFailure: error.isWSFailure,
15574
- }));
15575
- }
15576
- }
15577
- }
15578
- return await this._waitForHealthy(timeout);
15579
- }
15580
- /**
15581
- * _waitForHealthy polls the promise connection to see if its resolved until it times out
15582
- * the default 15s timeout allows between 2~3 tries
15583
- * @param timeout duration(ms)
15584
- */
15585
- async _waitForHealthy(timeout = 15000) {
15586
- return Promise.race([
15587
- (async () => {
15588
- const interval = 50; // ms
15589
- for (let i = 0; i <= timeout; i += interval) {
15590
- try {
15591
- return await this.connectionOpen;
15592
- }
15593
- catch (error) {
15594
- if (i === timeout) {
15595
- throw new Error(JSON.stringify({
15596
- code: error.code,
15597
- StatusCode: error.StatusCode,
15598
- message: error.message,
15599
- isWSFailure: error.isWSFailure,
15600
- }));
15601
- }
15602
- await sleep(interval);
15603
- }
15604
- }
15605
- })(),
15606
- (async () => {
15607
- await sleep(timeout);
15608
- this.isConnecting = false;
15609
- throw new Error(JSON.stringify({
15610
- code: '',
15611
- StatusCode: '',
15612
- message: 'initial WS connection could not be established',
15613
- isWSFailure: true,
15614
- }));
15615
- })(),
15616
- ]);
15617
- }
15618
- /**
15619
- * disconnect - Disconnect the connection and doesn't recover...
15620
- *
15621
- */
15622
- disconnect(timeout) {
15623
- this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
15624
- this.wsID += 1;
15625
- this.isConnecting = false;
15626
- this.isDisconnected = true;
15627
- // start by removing all the listeners
15628
- if (this.healthCheckTimeoutRef) {
15629
- getTimers().clearInterval(this.healthCheckTimeoutRef);
15630
- }
15631
- if (this.connectionCheckTimeoutRef) {
15632
- clearInterval(this.connectionCheckTimeoutRef);
15633
- }
15634
- removeConnectionEventListeners(this.onlineStatusChanged);
15635
- this.isHealthy = false;
15636
- let isClosedPromise;
15637
- // and finally close...
15638
- // Assigning to local here because we will remove it from this before the
15639
- // promise resolves.
15640
- const { ws } = this;
15641
- if (ws && ws.close && ws.readyState === ws.OPEN) {
15642
- isClosedPromise = new Promise((resolve) => {
15643
- const onclose = (event) => {
15644
- this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
15645
- resolve();
15646
- };
15647
- ws.onclose = onclose;
15648
- // In case we don't receive close frame websocket server in time,
15649
- // lets not wait for more than 1 second.
15650
- setTimeout(onclose, timeout != null ? timeout : 1000);
15651
- });
15652
- this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
15653
- ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
15654
- }
15655
- else {
15656
- this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
15657
- isClosedPromise = Promise.resolve();
15658
- }
15659
- delete this.ws;
15660
- return isClosedPromise;
15661
- }
15662
- /**
15663
- * _connect - Connect to the WS endpoint
15664
- *
15665
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
15666
- */
15667
- async _connect() {
15668
- if (this.isConnecting)
15669
- return; // ignore _connect if it's currently trying to connect
15670
- this.isConnecting = true;
15671
- let isTokenReady = false;
15672
- try {
15673
- this._log(`_connect() - waiting for token`);
15674
- await this.client.tokenManager.tokenReady();
15675
- isTokenReady = true;
15676
- }
15677
- catch {
15678
- // token provider has failed before, so try again
15679
- }
15680
- try {
15681
- if (!isTokenReady) {
15682
- this._log(`_connect() - tokenProvider failed before, so going to retry`);
15683
- await this.client.tokenManager.loadToken();
15684
- }
15685
- if (!this.client.isConnectionIsPromisePending) {
15686
- this.client._setupConnectionIdPromise();
15687
- }
15688
- this._setupConnectionPromise();
15689
- const wsURL = this._buildUrl();
15690
- this._log(`_connect() - Connecting to ${wsURL}`);
15691
- const WS = this.client.options.WebSocketImpl ?? WebSocket;
15692
- this.ws = new WS(wsURL);
15693
- this.ws.onopen = this.onopen.bind(this, this.wsID);
15694
- this.ws.onclose = this.onclose.bind(this, this.wsID);
15695
- this.ws.onerror = this.onerror.bind(this, this.wsID);
15696
- this.ws.onmessage = this.onmessage.bind(this, this.wsID);
15697
- const response = await this.connectionOpen;
15698
- this.isConnecting = false;
15699
- if (response) {
15700
- this.connectionID = response.connection_id;
15701
- this.client.resolveConnectionId?.(this.connectionID);
15702
- return response;
15703
- }
15704
- }
15705
- catch (err) {
15706
- this.client._setupConnectionIdPromise();
15707
- this.isConnecting = false;
15708
- // @ts-expect-error type issue
15709
- this._log(`_connect() - Error - `, err);
15710
- this.client.rejectConnectionId?.(err);
15711
- throw err;
15712
- }
15713
- }
15714
16205
  /**
15715
16206
  * _reconnect - Retry the connection to WS endpoint
15716
16207
  *
@@ -15757,7 +16248,8 @@ class StableWSConnection {
15757
16248
  this._log('_reconnect() - Finished recoverCallBack');
15758
16249
  this.consecutiveFailures = 0;
15759
16250
  }
15760
- catch (error) {
16251
+ catch (caught) {
16252
+ const error = caught;
15761
16253
  this.isHealthy = false;
15762
16254
  this.consecutiveFailures += 1;
15763
16255
  if (error.code === KnownCodes.TOKEN_EXPIRED &&
@@ -16314,7 +16806,7 @@ class StreamClient {
16314
16806
  this.getUserAgent = () => {
16315
16807
  if (!this.cachedUserAgent) {
16316
16808
  const { clientAppIdentifier = {} } = this.options;
16317
- const { sdkName = 'js', sdkVersion = "1.49.0", ...extras } = clientAppIdentifier;
16809
+ const { sdkName = 'js', sdkVersion = "1.50.0", ...extras } = clientAppIdentifier;
16318
16810
  this.cachedUserAgent = [
16319
16811
  `stream-video-${sdkName}-v${sdkVersion}`,
16320
16812
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -16422,7 +16914,7 @@ class StreamClient {
16422
16914
  get connectionIdPromise() {
16423
16915
  return this.connectionIdPromiseSafe?.();
16424
16916
  }
16425
- get isConnectionIsPromisePending() {
16917
+ get isConnectionIdPromisePending() {
16426
16918
  return this.connectionIdPromiseSafe?.checkPending() ?? false;
16427
16919
  }
16428
16920
  get wsPromise() {