@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
@@ -1398,6 +1398,35 @@ var ClientCapability;
1398
1398
  */
1399
1399
  ClientCapability[ClientCapability["SUBSCRIBER_VIDEO_PAUSE"] = 1] = "SUBSCRIBER_VIDEO_PAUSE";
1400
1400
  })(ClientCapability || (ClientCapability = {}));
1401
+ /**
1402
+ * DegradationPreference represents the RTCDegradationPreference from WebRTC.
1403
+ * See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
1404
+ *
1405
+ * @generated from protobuf enum stream.video.sfu.models.DegradationPreference
1406
+ */
1407
+ var DegradationPreference;
1408
+ (function (DegradationPreference) {
1409
+ /**
1410
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
1411
+ */
1412
+ DegradationPreference[DegradationPreference["UNSPECIFIED"] = 0] = "UNSPECIFIED";
1413
+ /**
1414
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
1415
+ */
1416
+ DegradationPreference[DegradationPreference["BALANCED"] = 1] = "BALANCED";
1417
+ /**
1418
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
1419
+ */
1420
+ DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE"] = 2] = "MAINTAIN_FRAMERATE";
1421
+ /**
1422
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
1423
+ */
1424
+ DegradationPreference[DegradationPreference["MAINTAIN_RESOLUTION"] = 3] = "MAINTAIN_RESOLUTION";
1425
+ /**
1426
+ * @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
1427
+ */
1428
+ DegradationPreference[DegradationPreference["MAINTAIN_FRAMERATE_AND_RESOLUTION"] = 4] = "MAINTAIN_FRAMERATE_AND_RESOLUTION";
1429
+ })(DegradationPreference || (DegradationPreference = {}));
1401
1430
  // @generated message type with reflection information, may provide speed optimized methods
1402
1431
  class CallState$Type extends MessageType {
1403
1432
  constructor() {
@@ -1667,6 +1696,16 @@ class PublishOption$Type extends MessageType {
1667
1696
  repeat: 2 /*RepeatType.UNPACKED*/,
1668
1697
  T: () => AudioBitrate,
1669
1698
  },
1699
+ {
1700
+ no: 11,
1701
+ name: 'degradation_preference',
1702
+ kind: 'enum',
1703
+ T: () => [
1704
+ 'stream.video.sfu.models.DegradationPreference',
1705
+ DegradationPreference,
1706
+ 'DEGRADATION_PREFERENCE_',
1707
+ ],
1708
+ },
1670
1709
  ]);
1671
1710
  }
1672
1711
  }
@@ -2113,6 +2152,7 @@ var models = /*#__PURE__*/Object.freeze({
2113
2152
  ClientDetails: ClientDetails,
2114
2153
  Codec: Codec,
2115
2154
  get ConnectionQuality () { return ConnectionQuality; },
2155
+ get DegradationPreference () { return DegradationPreference; },
2116
2156
  Device: Device,
2117
2157
  Error: Error$2,
2118
2158
  get ErrorCode () { return ErrorCode; },
@@ -3500,6 +3540,16 @@ class VideoSender$Type extends MessageType {
3500
3540
  kind: 'scalar',
3501
3541
  T: 5 /*ScalarType.INT32*/,
3502
3542
  },
3543
+ {
3544
+ no: 6,
3545
+ name: 'degradation_preference',
3546
+ kind: 'enum',
3547
+ T: () => [
3548
+ 'stream.video.sfu.models.DegradationPreference',
3549
+ DegradationPreference,
3550
+ 'DEGRADATION_PREFERENCE_',
3551
+ ],
3552
+ },
3503
3553
  ]);
3504
3554
  }
3505
3555
  }
@@ -3865,6 +3915,18 @@ const createSignalClient = (options) => {
3865
3915
  };
3866
3916
 
3867
3917
  const sleep = (m) => new Promise((r) => setTimeout(r, m));
3918
+ const timeboxed = async (promises, ms) => {
3919
+ let timerId;
3920
+ const timeout = new Promise((_, reject) => {
3921
+ timerId = setTimeout(() => reject(new Error('timebox error')), ms);
3922
+ });
3923
+ try {
3924
+ return await Promise.race([Promise.all(promises), timeout]);
3925
+ }
3926
+ finally {
3927
+ clearTimeout(timerId);
3928
+ }
3929
+ };
3868
3930
  function isFunction(value) {
3869
3931
  return (value &&
3870
3932
  (Object.prototype.toString.call(value) === '[object Function]' ||
@@ -4604,6 +4666,20 @@ const setCurrentValue = (subject, update) => {
4604
4666
  subject.next(next);
4605
4667
  return next;
4606
4668
  };
4669
+ /**
4670
+ * Updates the value of the provided Subject asynchronously.
4671
+ * Locks the subject to prevent concurrent updates.
4672
+ *
4673
+ * @param subject the subject to update.
4674
+ * @param update the update to apply to the subject.
4675
+ */
4676
+ const setCurrentValueAsync = async (subject, update) => {
4677
+ return withoutConcurrency(subject, async () => {
4678
+ const next = await update(getCurrentValue(subject));
4679
+ subject.next(next);
4680
+ return next;
4681
+ });
4682
+ };
4607
4683
  /**
4608
4684
  * Updates the value of the provided Subject and returns the previous value
4609
4685
  * and a function to roll back the update.
@@ -4658,6 +4734,7 @@ var rxUtils = /*#__PURE__*/Object.freeze({
4658
4734
  createSubscription: createSubscription,
4659
4735
  getCurrentValue: getCurrentValue,
4660
4736
  setCurrentValue: setCurrentValue,
4737
+ setCurrentValueAsync: setCurrentValueAsync,
4661
4738
  updateValue: updateValue
4662
4739
  });
4663
4740
 
@@ -6282,7 +6359,7 @@ const getSdkVersion = (sdk) => {
6282
6359
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
6283
6360
  };
6284
6361
 
6285
- const version = "1.49.0";
6362
+ const version = "1.50.0";
6286
6363
  const [major, minor, patch] = version.split('.');
6287
6364
  let sdkInfo = {
6288
6365
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -6435,6 +6512,31 @@ const isSafari = () => {
6435
6512
  return false;
6436
6513
  return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || '');
6437
6514
  };
6515
+ /**
6516
+ * Checks whether the current runtime is a WebKit-engine browser.
6517
+ *
6518
+ * Returns true for desktop Safari, iOS Safari, bare iOS WKWebView hosts
6519
+ * (in-app browsers, React Native WebView, Tauri-on-macOS, etc.), and for
6520
+ * Chromium / Gecko-branded iOS browsers (`CriOS`, `EdgiOS`, `OPiOS`,
6521
+ * `FxiOS`) since Apple forces every iOS browser onto WKWebView and they
6522
+ * share the underlying WebKit quirks.
6523
+ *
6524
+ * Returns false for desktop Chromium-based browsers (which reuse the
6525
+ * `AppleWebKit/` token in their UA) and Android.
6526
+ */
6527
+ const isWebKit = () => {
6528
+ if (typeof navigator === 'undefined')
6529
+ return false;
6530
+ const ua = navigator.userAgent || '';
6531
+ if (!/AppleWebKit\//.test(ua))
6532
+ return false;
6533
+ // Desktop Chromium reuses the AppleWebKit/ token. The `Chrome/` and
6534
+ // `Chromium/` markers are only present on desktop Chromium builds
6535
+ // (their iOS counterparts use `CriOS/` instead). `Android` rules out
6536
+ // the mobile Blink stack.
6537
+ const regExp = /Chrome\/|Chromium\/|Android/;
6538
+ return !regExp.test(ua);
6539
+ };
6438
6540
  /**
6439
6541
  * Checks whether the current browser is Firefox.
6440
6542
  */
@@ -6478,7 +6580,8 @@ var browsers = /*#__PURE__*/Object.freeze({
6478
6580
  isChrome: isChrome,
6479
6581
  isFirefox: isFirefox,
6480
6582
  isSafari: isSafari,
6481
- isSupportedBrowser: isSupportedBrowser
6583
+ isSupportedBrowser: isSupportedBrowser,
6584
+ isWebKit: isWebKit
6482
6585
  });
6483
6586
 
6484
6587
  /**
@@ -7954,6 +8057,24 @@ const withSimulcastConstraints = (width, height, optimalVideoLayers, useSingleLa
7954
8057
  }));
7955
8058
  };
7956
8059
 
8060
+ const toRTCDegradationPreference = (preference) => {
8061
+ switch (preference) {
8062
+ case DegradationPreference.BALANCED:
8063
+ return 'balanced';
8064
+ case DegradationPreference.MAINTAIN_FRAMERATE:
8065
+ return 'maintain-framerate';
8066
+ case DegradationPreference.MAINTAIN_RESOLUTION:
8067
+ return 'maintain-resolution';
8068
+ case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
8069
+ // @ts-expect-error not in the typedefs yet
8070
+ return 'maintain-framerate-and-resolution';
8071
+ case DegradationPreference.UNSPECIFIED:
8072
+ return undefined;
8073
+ default:
8074
+ ensureExhausted(preference, 'Unknown degradation preference');
8075
+ }
8076
+ };
8077
+
7957
8078
  /**
7958
8079
  * The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
7959
8080
  *
@@ -8015,7 +8136,9 @@ class Publisher extends BasePeerConnection {
8015
8136
  sendEncodings,
8016
8137
  });
8017
8138
  const params = transceiver.sender.getParameters();
8018
- params.degradationPreference = 'maintain-framerate';
8139
+ params.degradationPreference =
8140
+ toRTCDegradationPreference(publishOption.degradationPreference) ??
8141
+ 'maintain-framerate';
8019
8142
  await transceiver.sender.setParameters(params);
8020
8143
  const trackType = publishOption.trackType;
8021
8144
  this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
@@ -8112,6 +8235,40 @@ class Publisher extends BasePeerConnection {
8112
8235
  }
8113
8236
  return false;
8114
8237
  };
8238
+ /**
8239
+ * Re-arms the encoder for the given track type by detaching and
8240
+ * reattaching the currently published track on each matching sender.
8241
+ *
8242
+ * Workaround for a WebKit / iOS Safari quirk: after a system audio
8243
+ * session interruption (Siri, PSTN call), the `RTCRtpSender` encoder
8244
+ * can stop producing RTP packets even though the underlying
8245
+ * `MediaStreamTrack` is `live` and `track.muted === false`.
8246
+ * `replaceTrack(null)` followed by `replaceTrack(track)` resets the
8247
+ * sender's encoder pipeline without renegotiation, restoring packet
8248
+ * flow with the same SSRC.
8249
+ *
8250
+ * No-op when nothing is published for the given track type.
8251
+ *
8252
+ * @param trackType the track type to refresh.
8253
+ */
8254
+ this.refreshTrack = async (trackType) => {
8255
+ for (const item of this.transceiverCache.items()) {
8256
+ if (item.publishOption.trackType !== trackType)
8257
+ continue;
8258
+ const { sender } = item.transceiver;
8259
+ const track = sender.track;
8260
+ if (!track || track.readyState !== 'live')
8261
+ continue;
8262
+ try {
8263
+ await sender.replaceTrack(null);
8264
+ await sender.replaceTrack(track);
8265
+ this.logger.debug(`Refreshed ${TrackType[trackType]} sender`);
8266
+ }
8267
+ catch (err) {
8268
+ this.logger.warn(`Failed to refresh ${TrackType[trackType]}`, err);
8269
+ }
8270
+ }
8271
+ };
8115
8272
  /**
8116
8273
  * Stops the cloned track that is being published to the SFU.
8117
8274
  */
@@ -8189,6 +8346,12 @@ class Publisher extends BasePeerConnection {
8189
8346
  changed = true;
8190
8347
  }
8191
8348
  }
8349
+ const degradationPreference = toRTCDegradationPreference(videoSender.degradationPreference);
8350
+ if (degradationPreference &&
8351
+ params.degradationPreference !== degradationPreference) {
8352
+ params.degradationPreference = degradationPreference;
8353
+ changed = true;
8354
+ }
8192
8355
  const activeEncoders = params.encodings.filter((e) => e.active);
8193
8356
  if (!changed) {
8194
8357
  return this.logger.info(`${tag} no change:`, activeEncoders);
@@ -8369,6 +8532,36 @@ class Publisher extends BasePeerConnection {
8369
8532
  }
8370
8533
  }
8371
8534
 
8535
+ /**
8536
+ * Adds unique values to an array.
8537
+ *
8538
+ * @param arr the array to add to.
8539
+ * @param values the values to add.
8540
+ */
8541
+ const pushToIfMissing = (arr, ...values) => {
8542
+ for (const v of values) {
8543
+ if (!arr.includes(v)) {
8544
+ arr.push(v);
8545
+ }
8546
+ }
8547
+ return arr;
8548
+ };
8549
+ /**
8550
+ * Removes values from an array if they are present.
8551
+ *
8552
+ * @param arr the array to remove from.
8553
+ * @param values the values to remove.
8554
+ */
8555
+ const removeFromIfPresent = (arr, ...values) => {
8556
+ for (const v of values) {
8557
+ const index = arr.indexOf(v);
8558
+ if (index !== -1) {
8559
+ arr.splice(index, 1);
8560
+ }
8561
+ }
8562
+ return arr;
8563
+ };
8564
+
8372
8565
  /**
8373
8566
  * A wrapper around the `RTCPeerConnection` that handles the incoming
8374
8567
  * media streams from the SFU.
@@ -8410,27 +8603,34 @@ class Subscriber extends BasePeerConnection {
8410
8603
  }
8411
8604
  };
8412
8605
  this.handleOnTrack = (e) => {
8413
- const [primaryStream] = e.streams;
8606
+ const { streams, track } = e;
8607
+ const [primaryStream] = streams;
8414
8608
  // example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
8415
8609
  const [trackId, rawTrackType] = primaryStream.id.split(':');
8416
8610
  const participantToUpdate = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
8417
- this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, e.track.id, e.track);
8611
+ this.logger.debug(`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`, track.id, track);
8612
+ const trackType = toTrackType(rawTrackType);
8613
+ if (!trackType) {
8614
+ return this.logger.error(`Unknown track type: ${rawTrackType}`);
8615
+ }
8418
8616
  const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
8419
- e.track.addEventListener('mute', () => {
8617
+ track.addEventListener('mute', () => {
8420
8618
  this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
8619
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
8421
8620
  });
8422
- e.track.addEventListener('unmute', () => {
8621
+ track.addEventListener('unmute', () => {
8423
8622
  this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
8623
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
8424
8624
  });
8425
- e.track.addEventListener('ended', () => {
8625
+ track.addEventListener('ended', () => {
8426
8626
  this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
8627
+ this.setRemoteTrackInterrupted(trackId, trackType, false);
8427
8628
  this.state.removeOrphanedTrack(primaryStream.id);
8428
8629
  });
8429
- const trackType = toTrackType(rawTrackType);
8430
- if (!trackType) {
8431
- return this.logger.error(`Unknown track type: ${rawTrackType}`);
8630
+ if (track.muted) {
8631
+ this.setRemoteTrackInterrupted(trackId, trackType, true);
8432
8632
  }
8433
- this.trackIdToTrackType.set(e.track.id, trackType);
8633
+ this.trackIdToTrackType.set(track.id, trackType);
8434
8634
  if (!participantToUpdate) {
8435
8635
  this.logger.warn(`[onTrack]: Received track for unknown participant: ${trackId}`, e);
8436
8636
  this.state.registerOrphanedTrack({
@@ -8456,13 +8656,30 @@ class Subscriber extends BasePeerConnection {
8456
8656
  });
8457
8657
  // now, dispose the previous stream if it exists
8458
8658
  if (previousStream) {
8459
- this.logger.info(`[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`);
8659
+ this.logger.info(`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`);
8460
8660
  previousStream.getTracks().forEach((t) => {
8461
8661
  t.stop();
8462
8662
  previousStream.removeTrack(t);
8463
8663
  });
8464
8664
  }
8465
8665
  };
8666
+ this.setRemoteTrackInterrupted = (trackId, trackType, interrupted) => {
8667
+ if (trackType !== TrackType.AUDIO)
8668
+ return;
8669
+ const target = this.state.participants.find((p) => p.trackLookupPrefix === trackId);
8670
+ if (!target)
8671
+ return;
8672
+ this.state.updateParticipant(target.sessionId, (p) => {
8673
+ const current = p.interruptedTracks ?? [];
8674
+ const has = current.includes(trackType);
8675
+ if (interrupted === has)
8676
+ return {};
8677
+ const next = interrupted
8678
+ ? pushToIfMissing([...current], trackType)
8679
+ : removeFromIfPresent([...current], trackType);
8680
+ return { interruptedTracks: next };
8681
+ });
8682
+ };
8466
8683
  this.negotiate = async (subscriberOffer) => {
8467
8684
  await this.pc.setRemoteDescription({
8468
8685
  type: 'offer',
@@ -9185,36 +9402,6 @@ const watchCallGrantsUpdated = (state) => {
9185
9402
  };
9186
9403
  };
9187
9404
 
9188
- /**
9189
- * Adds unique values to an array.
9190
- *
9191
- * @param arr the array to add to.
9192
- * @param values the values to add.
9193
- */
9194
- const pushToIfMissing = (arr, ...values) => {
9195
- for (const v of values) {
9196
- if (!arr.includes(v)) {
9197
- arr.push(v);
9198
- }
9199
- }
9200
- return arr;
9201
- };
9202
- /**
9203
- * Removes values from an array if they are present.
9204
- *
9205
- * @param arr the array to remove from.
9206
- * @param values the values to remove.
9207
- */
9208
- const removeFromIfPresent = (arr, ...values) => {
9209
- for (const v of values) {
9210
- const index = arr.indexOf(v);
9211
- if (index !== -1) {
9212
- arr.splice(index, 1);
9213
- }
9214
- }
9215
- return arr;
9216
- };
9217
-
9218
9405
  const watchConnectionQualityChanged = (dispatcher, state) => {
9219
9406
  return dispatcher.on('connectionQualityChanged', '*', (e) => {
9220
9407
  const { connectionQualityUpdates } = e;
@@ -9547,91 +9734,6 @@ const registerRingingCallEventHandlers = (call) => {
9547
9734
  };
9548
9735
  };
9549
9736
 
9550
- const DEFAULT_THRESHOLD = 0.35;
9551
- class ViewportTracker {
9552
- constructor() {
9553
- /**
9554
- * @private
9555
- */
9556
- this.elementHandlerMap = new Map();
9557
- /**
9558
- * @private
9559
- */
9560
- this.observer = null;
9561
- // in React children render before viewport is set, add
9562
- // them to the queue and observe them once the observer is ready
9563
- /**
9564
- * @private
9565
- */
9566
- this.queueSet = new Set();
9567
- /**
9568
- * Method to set scrollable viewport as root for the IntersectionObserver, returns
9569
- * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
9570
- *
9571
- * @param viewportElement
9572
- * @param options
9573
- * @returns Unobserve
9574
- */
9575
- this.setViewport = (viewportElement, options) => {
9576
- const cleanup = () => {
9577
- this.observer?.disconnect();
9578
- this.observer = null;
9579
- this.elementHandlerMap.clear();
9580
- };
9581
- this.observer = new IntersectionObserver((entries) => {
9582
- entries.forEach((entry) => {
9583
- const handler = this.elementHandlerMap.get(entry.target);
9584
- handler?.(entry);
9585
- });
9586
- }, {
9587
- root: viewportElement,
9588
- ...options,
9589
- threshold: options?.threshold ?? DEFAULT_THRESHOLD,
9590
- });
9591
- if (this.queueSet.size) {
9592
- this.queueSet.forEach(([queueElement, queueHandler]) => {
9593
- // check if element which requested observation is
9594
- // a child of a viewport element, skip if isn't
9595
- if (!viewportElement.contains(queueElement))
9596
- return;
9597
- this.observer.observe(queueElement);
9598
- this.elementHandlerMap.set(queueElement, queueHandler);
9599
- });
9600
- this.queueSet.clear();
9601
- }
9602
- return cleanup;
9603
- };
9604
- /**
9605
- * Method to set element to observe and handler to be triggered whenever IntersectionObserver
9606
- * detects a possible change in element's visibility within specified viewport, returns
9607
- * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
9608
- *
9609
- * @param element
9610
- * @param handler
9611
- * @returns Unobserve
9612
- */
9613
- this.observe = (element, handler) => {
9614
- const queueItem = [element, handler];
9615
- const cleanup = () => {
9616
- this.elementHandlerMap.delete(element);
9617
- this.observer?.unobserve(element);
9618
- this.queueSet.delete(queueItem);
9619
- };
9620
- if (this.elementHandlerMap.has(element))
9621
- return cleanup;
9622
- if (!this.observer) {
9623
- this.queueSet.add(queueItem);
9624
- return cleanup;
9625
- }
9626
- if (this.observer.root.contains(element)) {
9627
- this.elementHandlerMap.set(element, handler);
9628
- this.observer.observe(element);
9629
- }
9630
- return cleanup;
9631
- };
9632
- }
9633
- }
9634
-
9635
9737
  const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${trackType}`;
9636
9738
  /**
9637
9739
  * Tracks audio element bindings and periodically warns about
@@ -9639,8 +9741,6 @@ const toBindingKey = (sessionId, trackType = 'audioTrack') => `${sessionId}/${tr
9639
9741
  */
9640
9742
  class AudioBindingsWatchdog {
9641
9743
  constructor(state, tracer) {
9642
- this.state = state;
9643
- this.tracer = tracer;
9644
9744
  this.bindings = new Map();
9645
9745
  this.enabled = true;
9646
9746
  this.logger = videoLoggerSystem.getLogger('AudioBindingsWatchdog');
@@ -9648,14 +9748,14 @@ class AudioBindingsWatchdog {
9648
9748
  * Registers an audio element binding for the given session and track type.
9649
9749
  * Warns if a different element is already bound to the same key.
9650
9750
  */
9651
- this.register = (audioElement, sessionId, trackType) => {
9751
+ this.register = (element, sessionId, trackType) => {
9652
9752
  const key = toBindingKey(sessionId, trackType);
9653
9753
  const existing = this.bindings.get(key);
9654
- if (existing && existing !== audioElement) {
9754
+ if (existing && existing !== element) {
9655
9755
  this.logger.warn(`Audio element already bound to ${sessionId} and ${trackType}`);
9656
9756
  this.tracer.trace('audioBinding.alreadyBoundWarning', trackType);
9657
9757
  }
9658
- this.bindings.set(key, audioElement);
9758
+ this.bindings.set(key, element);
9659
9759
  };
9660
9760
  /**
9661
9761
  * Removes the audio element binding for the given session and track type.
@@ -9681,6 +9781,7 @@ class AudioBindingsWatchdog {
9681
9781
  */
9682
9782
  this.dispose = () => {
9683
9783
  this.stop();
9784
+ this.bindings.clear();
9684
9785
  this.unsubscribeCallingState();
9685
9786
  };
9686
9787
  this.start = () => {
@@ -9712,6 +9813,8 @@ class AudioBindingsWatchdog {
9712
9813
  this.stop = () => {
9713
9814
  clearInterval(this.watchdogInterval);
9714
9815
  };
9816
+ this.tracer = tracer;
9817
+ this.state = state;
9715
9818
  this.unsubscribeCallingState = createSubscription(state.callingState$, (callingState) => {
9716
9819
  if (!this.enabled)
9717
9820
  return;
@@ -9725,64 +9828,100 @@ class AudioBindingsWatchdog {
9725
9828
  }
9726
9829
  }
9727
9830
 
9728
- const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
9729
- videoTrack: VisibilityState.UNKNOWN,
9730
- screenShareTrack: VisibilityState.UNKNOWN,
9731
- };
9732
- const globalOverrideKey = Symbol('globalOverrideKey');
9733
9831
  /**
9734
- * A manager class that handles dynascale related tasks like:
9735
- *
9736
- * - binding video elements to session ids
9737
- * - binding audio elements to session ids
9738
- * - tracking element visibility
9739
- * - updating subscriptions based on viewport visibility
9740
- * - updating subscriptions based on video element dimensions
9741
- * - updating subscriptions based on published tracks
9832
+ * Tracks audio elements that the browser's autoplay policy has blocked.
9742
9833
  */
9743
- class DynascaleManager {
9744
- /**
9745
- * Creates a new DynascaleManager instance.
9746
- */
9747
- constructor(callState, speaker, tracer) {
9834
+ class BlockedAudioTracker {
9835
+ constructor(tracer) {
9836
+ this.logger = videoLoggerSystem.getLogger('BlockedAudioTracker');
9837
+ this.blockedElementsSubject = new BehaviorSubject(new Set());
9748
9838
  /**
9749
- * The viewport tracker instance.
9839
+ * Whether the browser's autoplay policy is blocking audio playback.
9840
+ * Will be `true` when at least one audio element is currently blocked.
9841
+ * Use {@link resumeAudio} within a user gesture to unblock.
9750
9842
  */
9751
- this.viewportTracker = new ViewportTracker();
9752
- this.logger = videoLoggerSystem.getLogger('DynascaleManager');
9753
- this.useWebAudio = false;
9754
- this.pendingSubscriptionsUpdate = null;
9843
+ this.autoplayBlocked$ = this.blockedElementsSubject.pipe(map((elements) => elements.size > 0), distinctUntilChanged());
9755
9844
  /**
9756
- * Audio elements that were blocked by the browser's autoplay policy.
9757
- * These can be retried by calling `resumeAudio()` from a user gesture.
9845
+ * Registers an audio element as blocked by the browser's autoplay policy.
9758
9846
  */
9759
- this.blockedAudioElementsSubject = new BehaviorSubject(new Set());
9760
- /**
9761
- * Whether the browser's autoplay policy is blocking audio playback.
9762
- * Will be `true` when the browser blocks autoplay (e.g., no prior user interaction).
9763
- * Use `resumeAudio()` within a user gesture to unblock.
9764
- */
9765
- this.autoplayBlocked$ = this.blockedAudioElementsSubject.pipe(map((elements) => elements.size > 0), distinctUntilChanged());
9766
- this.addBlockedAudioElement = (audioElement) => {
9767
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9768
- const next = new Set(elements);
9769
- next.add(audioElement);
9770
- return next;
9847
+ this.markBlocked = (audioElement, blocked) => {
9848
+ setCurrentValue(this.blockedElementsSubject, (elements) => {
9849
+ if (blocked)
9850
+ elements.add(audioElement);
9851
+ else
9852
+ elements.delete(audioElement);
9853
+ return elements;
9771
9854
  });
9772
9855
  };
9773
- this.removeBlockedAudioElement = (audioElement) => {
9774
- setCurrentValue(this.blockedAudioElementsSubject, (elements) => {
9775
- const nextElements = new Set(elements);
9776
- nextElements.delete(audioElement);
9777
- return nextElements;
9856
+ /**
9857
+ * Returns whether the given audio element is currently flagged as blocked
9858
+ * by the browser's autoplay policy.
9859
+ */
9860
+ this.isBlocked = (audioElement) => {
9861
+ return this.blockedElementsSubject.getValue().has(audioElement);
9862
+ };
9863
+ /**
9864
+ * Plays all audio elements blocked by the browser's autoplay policy.
9865
+ * Must be called from within a user gesture (e.g., click handler).
9866
+ */
9867
+ this.resumeAudio = async () => {
9868
+ this.tracer.trace('resumeAudio', null);
9869
+ await setCurrentValueAsync(this.blockedElementsSubject, async (elements) => {
9870
+ await Promise.all(Array.from(elements, async (element) => {
9871
+ try {
9872
+ if (element.srcObject)
9873
+ await timeboxed([element.play()], 2000);
9874
+ elements.delete(element);
9875
+ }
9876
+ catch (err) {
9877
+ this.logger.warn(`Can't resume audio for element`, element, err);
9878
+ }
9879
+ }));
9880
+ return elements;
9778
9881
  });
9779
9882
  };
9780
- this.videoTrackSubscriptionOverridesSubject = new BehaviorSubject({});
9781
- this.videoTrackSubscriptionOverrides$ = this.videoTrackSubscriptionOverridesSubject.asObservable();
9782
- this.incomingVideoSettings$ = this.videoTrackSubscriptionOverrides$.pipe(map((overrides) => {
9783
- const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
9784
- return {
9785
- enabled: globalSettings?.enabled !== false,
9883
+ this.tracer = tracer;
9884
+ }
9885
+ }
9886
+
9887
+ /** Symbol key for the "applies to all participants" override slot. */
9888
+ const globalOverrideKey = Symbol('globalOverrideKey');
9889
+ /**
9890
+ * Owns the SFU-side video-subscription machinery for a `Call`:
9891
+ *
9892
+ * - Holds the per-session / global override state in a
9893
+ * `BehaviorSubject<VideoTrackSubscriptionOverrides>`.
9894
+ * - Derives the SFU subscription list from `CallState` participants +
9895
+ * current overrides via the `subscriptions` getter.
9896
+ * - Debounces and pushes the list to the SFU through
9897
+ * `sfuClient.updateSubscriptions`.
9898
+ * - Exposes `incomingVideoSettings$`: a consumer-friendly projection of
9899
+ * the override state for React hooks.
9900
+ *
9901
+ * Owned by `DynascaleManager` as `readonly trackSubscriptionManager`.
9902
+ * `DynascaleManager.bindVideoElement` triggers `apply()` on every
9903
+ * dimension / visibility change.
9904
+ */
9905
+ class TrackSubscriptionManager {
9906
+ /**
9907
+ * Constructs new TrackSubscriptionManager instance.
9908
+ *
9909
+ * @param callState the call state.
9910
+ * @param tracer the tracer to use.
9911
+ */
9912
+ constructor(callState, tracer) {
9913
+ this.logger = videoLoggerSystem.getLogger('TrackSubscriptionManager');
9914
+ this.pendingUpdate = null;
9915
+ this.overridesSubject = new BehaviorSubject({});
9916
+ this.overrides$ = this.overridesSubject.asObservable();
9917
+ /**
9918
+ * Consumer-friendly projection of the override state. Used by the
9919
+ * `useIncomingVideoSettings()` React hook.
9920
+ */
9921
+ this.incomingVideoSettings$ = this.overrides$.pipe(map((overrides) => {
9922
+ const { [globalOverrideKey]: globalSettings, ...participants } = overrides;
9923
+ return {
9924
+ enabled: globalSettings?.enabled !== false,
9786
9925
  preferredResolution: globalSettings?.enabled
9787
9926
  ? globalSettings.dimension
9788
9927
  : undefined,
@@ -9801,106 +9940,255 @@ class DynascaleManager {
9801
9940
  };
9802
9941
  }), shareReplay(1));
9803
9942
  /**
9804
- * Disposes the allocated resources and closes the audio context if it was created.
9943
+ * Sets the SFU client used by `apply()` to push subscription updates.
9944
+ * Called by the owner on call join; cleared on leave.
9805
9945
  */
9806
- this.dispose = async () => {
9807
- if (this.pendingSubscriptionsUpdate) {
9808
- clearTimeout(this.pendingSubscriptionsUpdate);
9809
- }
9810
- this.audioBindingsWatchdog?.dispose();
9811
- setCurrentValue(this.blockedAudioElementsSubject, new Set());
9812
- const context = this.audioContext;
9813
- if (context && context.state !== 'closed') {
9814
- document.removeEventListener('click', this.resumeAudioContext);
9815
- await context.close();
9816
- this.audioContext = undefined;
9946
+ this.setSfuClient = (sfuClient) => {
9947
+ this.sfuClient = sfuClient;
9948
+ };
9949
+ /**
9950
+ * Cancels any pending debounced subscription push. Idempotent.
9951
+ */
9952
+ this.dispose = () => {
9953
+ if (this.pendingUpdate) {
9954
+ clearTimeout(this.pendingUpdate);
9955
+ this.pendingUpdate = null;
9817
9956
  }
9818
9957
  };
9819
- this.setVideoTrackSubscriptionOverrides = (override, sessionIds) => {
9820
- this.tracer.trace('setVideoTrackSubscriptionOverrides', [
9821
- override,
9822
- sessionIds,
9823
- ]);
9958
+ /**
9959
+ * Sets video-subscription overrides. Called by
9960
+ * `Call.setIncomingVideoEnabled` and
9961
+ * `Call.setPreferredIncomingVideoResolution`.
9962
+ *
9963
+ * - `sessionIds` omitted → applies `override` globally (or clears the
9964
+ * global override if `override` is `undefined`).
9965
+ * - `sessionIds` provided → applies `override` to each listed session.
9966
+ */
9967
+ this.setOverrides = (override, sessionIds) => {
9968
+ this.tracer.trace('setOverrides', [override, sessionIds]);
9824
9969
  if (!sessionIds) {
9825
- return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, override ? { [globalOverrideKey]: override } : {});
9970
+ return setCurrentValue(this.overridesSubject, override ? { [globalOverrideKey]: override } : {});
9826
9971
  }
9827
- return setCurrentValue(this.videoTrackSubscriptionOverridesSubject, (overrides) => ({
9972
+ return setCurrentValue(this.overridesSubject, (overrides) => ({
9828
9973
  ...overrides,
9829
9974
  ...Object.fromEntries(sessionIds.map((id) => [id, override])),
9830
9975
  }));
9831
9976
  };
9832
- this.applyTrackSubscriptions = (debounceType = DebounceType.SLOW) => {
9833
- if (this.pendingSubscriptionsUpdate) {
9834
- clearTimeout(this.pendingSubscriptionsUpdate);
9977
+ /**
9978
+ * Pushes `subscriptions` to the SFU. Debounced by `debounceType`
9979
+ * (SLOW by default). Multiple rapid calls coalesce into one RPC.
9980
+ * Passing `0` fires synchronously.
9981
+ */
9982
+ this.apply = (debounceType = DebounceType.SLOW) => {
9983
+ if (this.pendingUpdate) {
9984
+ clearTimeout(this.pendingUpdate);
9835
9985
  }
9836
9986
  const updateSubscriptions = () => {
9837
- this.pendingSubscriptionsUpdate = null;
9987
+ this.pendingUpdate = null;
9838
9988
  this.sfuClient
9839
- ?.updateSubscriptions(this.trackSubscriptions)
9989
+ ?.updateSubscriptions(this.subscriptions)
9840
9990
  .catch((err) => {
9841
9991
  this.logger.debug(`Failed to update track subscriptions`, err);
9842
9992
  });
9843
9993
  };
9844
9994
  if (debounceType) {
9845
- this.pendingSubscriptionsUpdate = setTimeout(updateSubscriptions, debounceType);
9995
+ this.pendingUpdate = setTimeout(updateSubscriptions, debounceType);
9846
9996
  }
9847
9997
  else {
9848
9998
  updateSubscriptions();
9849
9999
  }
9850
10000
  };
9851
- /**
9852
- * Will begin tracking the given element for visibility changes within the
9853
- * configured viewport element (`call.setViewport`).
9854
- *
9855
- * @param element the element to track.
9856
- * @param sessionId the session id.
9857
- * @param trackType the kind of video.
9858
- * @returns Untrack.
9859
- */
9860
- this.trackElementVisibility = (element, sessionId, trackType) => {
9861
- const cleanup = this.viewportTracker.observe(element, (entry) => {
9862
- this.callState.updateParticipant(sessionId, (participant) => {
9863
- const previousVisibilityState = participant.viewportVisibilityState ??
9864
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
9865
- // observer triggers when the element is "moved" to be a fullscreen element
9866
- // keep it VISIBLE if that happens to prevent fullscreen with placeholder
9867
- const isVisible = entry.isIntersecting || document.fullscreenElement === element
9868
- ? VisibilityState.VISIBLE
9869
- : VisibilityState.INVISIBLE;
9870
- return {
9871
- ...participant,
9872
- viewportVisibilityState: {
9873
- ...previousVisibilityState,
9874
- [trackType]: isVisible,
9875
- },
9876
- };
10001
+ this.tracer = tracer;
10002
+ this.callState = callState;
10003
+ }
10004
+ /**
10005
+ * The current SFU subscription list, computed from `CallState`
10006
+ * participants and the override state. Used by:
10007
+ *
10008
+ * - `apply()` to push to the SFU each time the set changes.
10009
+ * - `Call.getReconnectDetails` to include the subscription list in
10010
+ * the reconnect payload.
10011
+ */
10012
+ get subscriptions() {
10013
+ const subscriptions = [];
10014
+ // Use getParticipantsSnapshot() to bypass the observable pipeline
10015
+ // and avoid stale data caused by shareReplay with no active subscribers
10016
+ const participants = this.callState.getParticipantsSnapshot();
10017
+ const overrides = this.overridesSubject.getValue();
10018
+ for (const p of participants) {
10019
+ if (p.isLocalParticipant)
10020
+ continue;
10021
+ // NOTE: audio tracks don't have to be requested explicitly
10022
+ // as the SFU will implicitly subscribe us to all of them,
10023
+ // once they become available.
10024
+ if (p.videoDimension && hasVideo(p)) {
10025
+ const override = overrides[p.sessionId] ?? overrides[globalOverrideKey];
10026
+ if (override?.enabled !== false) {
10027
+ subscriptions.push({
10028
+ userId: p.userId,
10029
+ sessionId: p.sessionId,
10030
+ trackType: TrackType.VIDEO,
10031
+ dimension: override?.dimension ?? p.videoDimension,
10032
+ });
10033
+ }
10034
+ }
10035
+ if (p.screenShareDimension && hasScreenShare(p)) {
10036
+ subscriptions.push({
10037
+ userId: p.userId,
10038
+ sessionId: p.sessionId,
10039
+ trackType: TrackType.SCREEN_SHARE,
10040
+ dimension: p.screenShareDimension,
9877
10041
  });
10042
+ }
10043
+ if (hasScreenShareAudio(p)) {
10044
+ subscriptions.push({
10045
+ userId: p.userId,
10046
+ sessionId: p.sessionId,
10047
+ trackType: TrackType.SCREEN_SHARE_AUDIO,
10048
+ });
10049
+ }
10050
+ }
10051
+ return subscriptions;
10052
+ }
10053
+ get overrides() {
10054
+ return getCurrentValue(this.overrides$);
10055
+ }
10056
+ }
10057
+
10058
+ /**
10059
+ * Watches a single audio or video element and attempts to recover playback
10060
+ * after the element transitions to a paused or suspended state unexpectedly.
10061
+ */
10062
+ class MediaPlaybackWatchdog {
10063
+ constructor(opts) {
10064
+ this.logger = videoLoggerSystem.getLogger('MediaPlaybackWatchdog');
10065
+ this.controller = new AbortController();
10066
+ this.attempt = 0;
10067
+ this.disposed = false;
10068
+ this.attach = () => {
10069
+ if (this.disposed)
10070
+ return;
10071
+ const { signal } = this.controller;
10072
+ this.element.addEventListener('pause', this.onPauseOrSuspend, { signal });
10073
+ this.element.addEventListener('suspend', this.onPauseOrSuspend, { signal });
10074
+ this.element.addEventListener('playing', this.onPlaying, { signal });
10075
+ };
10076
+ this.dispose = () => {
10077
+ if (this.disposed)
10078
+ return;
10079
+ this.disposed = true;
10080
+ this.controller.abort();
10081
+ if (this.pendingTimer)
10082
+ clearTimeout(this.pendingTimer);
10083
+ this.pendingTimer = undefined;
10084
+ };
10085
+ this.onPlaying = () => {
10086
+ if (this.attempt > 0) {
10087
+ this.tracer.trace('mediaPlayback.recover.success', {
10088
+ kind: this.kind,
10089
+ attempts: this.attempt,
10090
+ });
10091
+ }
10092
+ this.attempt = 0;
10093
+ if (this.pendingTimer)
10094
+ clearTimeout(this.pendingTimer);
10095
+ this.pendingTimer = undefined;
10096
+ };
10097
+ this.onPauseOrSuspend = (event) => {
10098
+ if (this.disposed)
10099
+ return;
10100
+ this.tracer.trace('mediaPlayback.paused', {
10101
+ kind: this.kind,
10102
+ reason: event.type,
9878
10103
  });
9879
- return () => {
9880
- cleanup();
9881
- // reset visibility state to UNKNOWN upon cleanup
9882
- // so that the layouts that are not actively observed
9883
- // can still function normally (runtime layout switching)
9884
- this.callState.updateParticipant(sessionId, (participant) => {
9885
- const previousVisibilityState = participant.viewportVisibilityState ??
9886
- DEFAULT_VIEWPORT_VISIBILITY_STATE;
9887
- return {
9888
- ...participant,
9889
- viewportVisibilityState: {
9890
- ...previousVisibilityState,
9891
- [trackType]: VisibilityState.UNKNOWN,
9892
- },
9893
- };
10104
+ this.scheduleRecovery();
10105
+ };
10106
+ this.scheduleRecovery = () => {
10107
+ if (this.disposed || this.pendingTimer)
10108
+ return;
10109
+ const skipReason = this.computeSkipReason();
10110
+ if (skipReason) {
10111
+ this.tracer.trace('mediaPlayback.recover.skipped', {
10112
+ kind: this.kind,
10113
+ reason: skipReason,
9894
10114
  });
9895
- };
10115
+ return;
10116
+ }
10117
+ const delay = this.attempt === 0 ? 0 : retryInterval(this.attempt);
10118
+ this.pendingTimer = setTimeout(this.attemptPlay, delay);
10119
+ };
10120
+ this.computeSkipReason = () => {
10121
+ if (this.disposed)
10122
+ return 'disposed';
10123
+ if (!this.element.srcObject)
10124
+ return 'noSrc';
10125
+ if (this.element.ended)
10126
+ return 'ended';
10127
+ if (this.isBlocked())
10128
+ return 'blocked';
10129
+ const HAVE_CURRENT_DATA = 2;
10130
+ if (this.element.readyState < HAVE_CURRENT_DATA)
10131
+ return 'notReady';
10132
+ if (!this.element.paused)
10133
+ return 'notPaused';
10134
+ };
10135
+ this.attemptPlay = async () => {
10136
+ this.pendingTimer = undefined;
10137
+ if (this.disposed)
10138
+ return;
10139
+ this.attempt += 1;
10140
+ this.tracer.trace('mediaPlayback.recover.attempt', {
10141
+ kind: this.kind,
10142
+ attempt: this.attempt,
10143
+ });
10144
+ try {
10145
+ await timeboxed([this.element.play()], 2000);
10146
+ }
10147
+ catch (err) {
10148
+ if (this.disposed)
10149
+ return;
10150
+ this.logger.warn(`Failed to recover ${this.kind} playback`, err);
10151
+ if (this.attempt >= 10) {
10152
+ this.tracer.trace('mediaPlayback.recover.giveUp', {
10153
+ kind: this.kind,
10154
+ attempts: this.attempt,
10155
+ });
10156
+ return;
10157
+ }
10158
+ this.scheduleRecovery();
10159
+ }
9896
10160
  };
10161
+ this.element = opts.element;
10162
+ this.kind = opts.kind;
10163
+ this.tracer = opts.tracer;
10164
+ this.isBlocked = opts.isBlocked ?? (() => false);
10165
+ this.attach();
10166
+ }
10167
+ }
10168
+
10169
+ /**
10170
+ * A manager class that handles dynascale related tasks like:
10171
+ *
10172
+ * - binding video elements to session ids
10173
+ * - binding audio elements to session ids
10174
+ */
10175
+ class DynascaleManager {
10176
+ /**
10177
+ * Creates a new DynascaleManager instance.
10178
+ */
10179
+ constructor(callState, speaker, tracer, trackSubscriptionManager, blockedAudioTracker) {
10180
+ this.logger = videoLoggerSystem.getLogger('DynascaleManager');
10181
+ this.useWebAudio = false;
9897
10182
  /**
9898
- * Sets the viewport element to track bound video elements for visibility.
9899
- *
9900
- * @param element the viewport element.
10183
+ * Closes the audio context if it was created.
9901
10184
  */
9902
- this.setViewport = (element) => {
9903
- return this.viewportTracker.setViewport(element);
10185
+ this.dispose = async () => {
10186
+ const context = this.audioContext;
10187
+ if (context && context.state !== 'closed') {
10188
+ document.removeEventListener('click', this.resumeAudioContext);
10189
+ await context.close();
10190
+ this.audioContext = undefined;
10191
+ }
9904
10192
  };
9905
10193
  /**
9906
10194
  * Sets whether to use WebAudio API for audio playback.
@@ -9945,7 +10233,7 @@ class DynascaleManager {
9945
10233
  this.callState.updateParticipantTracks(trackType, {
9946
10234
  [sessionId]: { dimension },
9947
10235
  });
9948
- this.applyTrackSubscriptions(debounceType);
10236
+ this.trackSubscriptionManager.apply(debounceType);
9949
10237
  };
9950
10238
  const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((participant) => !!participant), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
9951
10239
  /**
@@ -10034,6 +10322,11 @@ class DynascaleManager {
10034
10322
  // without prior user interaction:
10035
10323
  // https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide
10036
10324
  videoElement.muted = true;
10325
+ const playbackWatchdog = new MediaPlaybackWatchdog({
10326
+ element: videoElement,
10327
+ kind: 'video',
10328
+ tracer: this.tracer,
10329
+ });
10037
10330
  const trackKey = isVideoTrack ? 'videoStream' : 'screenShareStream';
10038
10331
  const streamSubscription = participant$
10039
10332
  .pipe(distinctUntilKeyChanged(trackKey))
@@ -10043,14 +10336,14 @@ class DynascaleManager {
10043
10336
  return;
10044
10337
  videoElement.srcObject = source ?? null;
10045
10338
  if (isSafari() || isFirefox()) {
10046
- setTimeout(() => {
10339
+ setTimeout(async () => {
10047
10340
  videoElement.srcObject = source ?? null;
10048
- videoElement.play().catch((e) => {
10341
+ try {
10342
+ await timeboxed([videoElement.play()], 2000);
10343
+ }
10344
+ catch (e) {
10049
10345
  this.logger.warn(`Failed to play stream`, e);
10050
- });
10051
- // we add extra delay until we attempt to force-play
10052
- // the participant's media stream in Firefox and Safari,
10053
- // as they seem to have some timing issues
10346
+ }
10054
10347
  }, 25);
10055
10348
  }
10056
10349
  });
@@ -10060,6 +10353,7 @@ class DynascaleManager {
10060
10353
  publishedTracksSubscription?.unsubscribe();
10061
10354
  streamSubscription.unsubscribe();
10062
10355
  resizeObserver?.disconnect();
10356
+ playbackWatchdog.dispose();
10063
10357
  };
10064
10358
  };
10065
10359
  /**
@@ -10077,7 +10371,6 @@ class DynascaleManager {
10077
10371
  const participant = this.callState.findParticipantBySessionId(sessionId);
10078
10372
  if (!participant || participant.isLocalParticipant)
10079
10373
  return;
10080
- this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
10081
10374
  const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
10082
10375
  const updateSinkId = (deviceId, audioContext) => {
10083
10376
  if (!deviceId)
@@ -10096,6 +10389,7 @@ class DynascaleManager {
10096
10389
  };
10097
10390
  let sourceNode = undefined;
10098
10391
  let gainNode = undefined;
10392
+ let audioWatchdog = undefined;
10099
10393
  const isAudioTrack = trackType === 'audioTrack';
10100
10394
  const trackKey = isAudioTrack ? 'audioStream' : 'screenShareAudioStream';
10101
10395
  const updateMediaStreamSubscription = participant$
@@ -10106,8 +10400,10 @@ class DynascaleManager {
10106
10400
  return;
10107
10401
  setTimeout(() => {
10108
10402
  audioElement.srcObject = source ?? null;
10403
+ audioWatchdog?.dispose();
10404
+ audioWatchdog = undefined;
10109
10405
  if (!source) {
10110
- this.removeBlockedAudioElement(audioElement);
10406
+ this.blockedAudioTracker.markBlocked(audioElement, false);
10111
10407
  return;
10112
10408
  }
10113
10409
  // Safari has a special quirk that prevents playing audio until the user
@@ -10135,10 +10431,16 @@ class DynascaleManager {
10135
10431
  this.tracer.trace('audioPlaybackError', e.message);
10136
10432
  if (e.name === 'NotAllowedError') {
10137
10433
  this.tracer.trace('audioPlaybackBlocked', null);
10138
- this.addBlockedAudioElement(audioElement);
10434
+ this.blockedAudioTracker.markBlocked(audioElement, true);
10139
10435
  }
10140
10436
  this.logger.warn(`Failed to play audio stream`, e);
10141
10437
  });
10438
+ audioWatchdog = new MediaPlaybackWatchdog({
10439
+ element: audioElement,
10440
+ kind: 'audio',
10441
+ tracer: this.tracer,
10442
+ isBlocked: () => this.blockedAudioTracker.isBlocked(audioElement),
10443
+ });
10142
10444
  }
10143
10445
  const { selectedDevice } = this.speaker.state;
10144
10446
  if (selectedDevice)
@@ -10162,38 +10464,17 @@ class DynascaleManager {
10162
10464
  });
10163
10465
  audioElement.autoplay = true;
10164
10466
  return () => {
10165
- this.audioBindingsWatchdog?.unregister(sessionId, trackType);
10166
- this.removeBlockedAudioElement(audioElement);
10467
+ this.blockedAudioTracker.markBlocked(audioElement, false);
10167
10468
  sinkIdSubscription?.unsubscribe();
10168
10469
  volumeSubscription.unsubscribe();
10169
10470
  updateMediaStreamSubscription.unsubscribe();
10170
10471
  audioElement.srcObject = null;
10171
10472
  sourceNode?.disconnect();
10172
10473
  gainNode?.disconnect();
10474
+ audioWatchdog?.dispose();
10475
+ audioWatchdog = undefined;
10173
10476
  };
10174
10477
  };
10175
- /**
10176
- * Plays all audio elements blocked by the browser's autoplay policy.
10177
- * Must be called from within a user gesture (e.g., click handler).
10178
- *
10179
- * @returns a promise that resolves when all blocked elements have been retried.
10180
- */
10181
- this.resumeAudio = async () => {
10182
- this.tracer.trace('resumeAudio', null);
10183
- const blocked = new Set();
10184
- await Promise.all(Array.from(getCurrentValue(this.blockedAudioElementsSubject), async (el) => {
10185
- try {
10186
- if (el.srcObject) {
10187
- await el.play();
10188
- }
10189
- }
10190
- catch {
10191
- this.logger.warn(`Can't resume audio for element: `, el);
10192
- blocked.add(el);
10193
- }
10194
- }));
10195
- setCurrentValue(this.blockedAudioElementsSubject, blocked);
10196
- };
10197
10478
  this.getOrCreateAudioContext = () => {
10198
10479
  if (!this.useWebAudio)
10199
10480
  return;
@@ -10246,57 +10527,124 @@ class DynascaleManager {
10246
10527
  this.callState = callState;
10247
10528
  this.speaker = speaker;
10248
10529
  this.tracer = tracer;
10249
- if (!isReactNative()) {
10250
- this.audioBindingsWatchdog = new AudioBindingsWatchdog(callState, tracer);
10251
- }
10252
- }
10253
- setSfuClient(sfuClient) {
10254
- this.sfuClient = sfuClient;
10530
+ this.trackSubscriptionManager = trackSubscriptionManager;
10531
+ this.blockedAudioTracker = blockedAudioTracker;
10255
10532
  }
10256
- get trackSubscriptions() {
10257
- const subscriptions = [];
10258
- // Use getParticipantsSnapshot() to bypass the observable pipeline
10259
- // and avoid stale data caused by shareReplay with no active subscribers
10260
- const participants = this.callState.getParticipantsSnapshot();
10261
- const videoTrackSubscriptionOverrides = this.videoTrackSubscriptionOverridesSubject.getValue();
10262
- for (const p of participants) {
10263
- if (p.isLocalParticipant)
10264
- continue;
10265
- // NOTE: audio tracks don't have to be requested explicitly
10266
- // as the SFU will implicitly subscribe us to all of them,
10267
- // once they become available.
10268
- if (p.videoDimension && hasVideo(p)) {
10269
- const override = videoTrackSubscriptionOverrides[p.sessionId] ??
10270
- videoTrackSubscriptionOverrides[globalOverrideKey];
10271
- if (override?.enabled !== false) {
10272
- subscriptions.push({
10273
- userId: p.userId,
10274
- sessionId: p.sessionId,
10275
- trackType: TrackType.VIDEO,
10276
- dimension: override?.dimension ?? p.videoDimension,
10277
- });
10278
- }
10533
+ }
10534
+
10535
+ const DEFAULT_THRESHOLD = 0.35;
10536
+ const DEFAULT_VIEWPORT_VISIBILITY_STATE = {
10537
+ videoTrack: VisibilityState.UNKNOWN,
10538
+ screenShareTrack: VisibilityState.UNKNOWN,
10539
+ };
10540
+ class ViewportTracker {
10541
+ constructor(callState) {
10542
+ this.elementHandlerMap = new Map();
10543
+ this.observer = null;
10544
+ // in React children render before viewport is set, add
10545
+ // them to the queue and observe them once the observer is ready
10546
+ this.queueSet = new Set();
10547
+ /**
10548
+ * Method to set scrollable viewport as root for the IntersectionObserver, returns
10549
+ * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
10550
+ */
10551
+ this.setViewport = (viewportElement, options) => {
10552
+ const cleanup = () => {
10553
+ this.observer?.disconnect();
10554
+ this.observer = null;
10555
+ this.elementHandlerMap.clear();
10556
+ };
10557
+ this.observer = new IntersectionObserver((entries) => {
10558
+ entries.forEach((entry) => {
10559
+ const handler = this.elementHandlerMap.get(entry.target);
10560
+ handler?.(entry);
10561
+ });
10562
+ }, {
10563
+ root: viewportElement,
10564
+ ...options,
10565
+ threshold: options?.threshold ?? DEFAULT_THRESHOLD,
10566
+ });
10567
+ if (this.queueSet.size) {
10568
+ this.queueSet.forEach(([queueElement, queueHandler]) => {
10569
+ // check if element which requested observation is
10570
+ // a child of a viewport element, skip if isn't
10571
+ if (!viewportElement.contains(queueElement))
10572
+ return;
10573
+ this.observer.observe(queueElement);
10574
+ this.elementHandlerMap.set(queueElement, queueHandler);
10575
+ });
10576
+ this.queueSet.clear();
10279
10577
  }
10280
- if (p.screenShareDimension && hasScreenShare(p)) {
10281
- subscriptions.push({
10282
- userId: p.userId,
10283
- sessionId: p.sessionId,
10284
- trackType: TrackType.SCREEN_SHARE,
10285
- dimension: p.screenShareDimension,
10578
+ return cleanup;
10579
+ };
10580
+ /**
10581
+ * Method to set element to observe and handler to be triggered whenever IntersectionObserver
10582
+ * detects a possible change in element's visibility within specified viewport, returns
10583
+ * cleanup function to be invoked upon disposing of the DOM element to prevent memory leaks
10584
+ */
10585
+ this.observe = (element, handler) => {
10586
+ const queueItem = [element, handler];
10587
+ const cleanup = () => {
10588
+ this.elementHandlerMap.delete(element);
10589
+ this.observer?.unobserve(element);
10590
+ this.queueSet.delete(queueItem);
10591
+ };
10592
+ if (this.elementHandlerMap.has(element))
10593
+ return cleanup;
10594
+ if (!this.observer) {
10595
+ this.queueSet.add(queueItem);
10596
+ return cleanup;
10597
+ }
10598
+ if (this.observer.root.contains(element)) {
10599
+ this.elementHandlerMap.set(element, handler);
10600
+ this.observer.observe(element);
10601
+ }
10602
+ return cleanup;
10603
+ };
10604
+ /**
10605
+ * Tracks the given element for visibility changes and mirrors the result
10606
+ * into `participant.viewportVisibilityState[trackType]` in `CallState`.
10607
+ * Returns a function that unobserves the element and resets the visibility
10608
+ * state back to `UNKNOWN`.
10609
+ */
10610
+ this.trackElementVisibility = (element, sessionId, trackType) => {
10611
+ const cleanup = this.observe(element, (entry) => {
10612
+ this.callState.updateParticipant(sessionId, (participant) => {
10613
+ const previousVisibilityState = participant.viewportVisibilityState ??
10614
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
10615
+ // observer triggers when the element is "moved" to be a fullscreen element
10616
+ // keep it VISIBLE if that happens to prevent fullscreen with placeholder
10617
+ const isVisible = entry.isIntersecting || document.fullscreenElement === element
10618
+ ? VisibilityState.VISIBLE
10619
+ : VisibilityState.INVISIBLE;
10620
+ return {
10621
+ ...participant,
10622
+ viewportVisibilityState: {
10623
+ ...previousVisibilityState,
10624
+ [trackType]: isVisible,
10625
+ },
10626
+ };
10286
10627
  });
10287
- }
10288
- if (hasScreenShareAudio(p)) {
10289
- subscriptions.push({
10290
- userId: p.userId,
10291
- sessionId: p.sessionId,
10292
- trackType: TrackType.SCREEN_SHARE_AUDIO,
10628
+ });
10629
+ return () => {
10630
+ cleanup();
10631
+ // reset visibility state to UNKNOWN upon cleanup
10632
+ // so that the layouts that are not actively observed
10633
+ // can still function normally (runtime layout switching)
10634
+ this.callState.updateParticipant(sessionId, (participant) => {
10635
+ const previousVisibilityState = participant.viewportVisibilityState ??
10636
+ DEFAULT_VIEWPORT_VISIBILITY_STATE;
10637
+ return {
10638
+ ...participant,
10639
+ viewportVisibilityState: {
10640
+ ...previousVisibilityState,
10641
+ [trackType]: VisibilityState.UNKNOWN,
10642
+ },
10643
+ };
10293
10644
  });
10294
- }
10295
- }
10296
- return subscriptions;
10297
- }
10298
- get videoTrackSubscriptionOverrides() {
10299
- return getCurrentValue(this.videoTrackSubscriptionOverrides$);
10645
+ };
10646
+ };
10647
+ this.callState = callState;
10300
10648
  }
10301
10649
  }
10302
10650
 
@@ -11014,6 +11362,7 @@ class DeviceManager {
11014
11362
  */
11015
11363
  this.stopOnLeave = true;
11016
11364
  this.subscriptions = [];
11365
+ this.currentStreamCleanups = [];
11017
11366
  this.areSubscriptionsSetUp = false;
11018
11367
  this.isTrackStoppedDueToTrackEnd = false;
11019
11368
  this.filters = [];
@@ -11025,10 +11374,30 @@ class DeviceManager {
11025
11374
  * @internal
11026
11375
  */
11027
11376
  this.dispose = () => {
11377
+ this.runCurrentStreamCleanups();
11028
11378
  this.subscriptions.forEach((s) => s());
11029
11379
  this.subscriptions = [];
11030
11380
  this.areSubscriptionsSetUp = false;
11031
11381
  };
11382
+ this.runCurrentStreamCleanups = () => {
11383
+ this.currentStreamCleanups.forEach((c) => c());
11384
+ this.currentStreamCleanups = [];
11385
+ };
11386
+ this.setLocalInterrupted = (interrupted) => {
11387
+ const localParticipant = this.call.state.localParticipant;
11388
+ if (!localParticipant)
11389
+ return;
11390
+ this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
11391
+ const current = p.interruptedTracks ?? [];
11392
+ const has = current.includes(this.trackType);
11393
+ if (interrupted === has)
11394
+ return {};
11395
+ const next = interrupted
11396
+ ? pushToIfMissing([...current], this.trackType)
11397
+ : removeFromIfPresent([...current], this.trackType);
11398
+ return { interruptedTracks: next };
11399
+ });
11400
+ };
11032
11401
  this.call = call;
11033
11402
  this.state = state;
11034
11403
  this.trackType = trackType;
@@ -11252,7 +11621,9 @@ class DeviceManager {
11252
11621
  // @ts-expect-error called to dispose the stream in RN
11253
11622
  mediaStream.release();
11254
11623
  }
11624
+ this.runCurrentStreamCleanups();
11255
11625
  this.state.setMediaStream(undefined, undefined);
11626
+ this.setLocalInterrupted(false);
11256
11627
  this.filters.forEach((entry) => entry.stop?.());
11257
11628
  }
11258
11629
  }
@@ -11288,13 +11659,17 @@ class DeviceManager {
11288
11659
  async unmuteStream() {
11289
11660
  this.logger.debug('Starting stream');
11290
11661
  let stream;
11291
- let rootStream;
11662
+ let rootStreamPromise;
11292
11663
  if (this.state.mediaStream &&
11293
11664
  this.getTracks().every((t) => t.readyState === 'live')) {
11294
11665
  stream = this.state.mediaStream;
11295
11666
  this.enableTracks();
11296
11667
  }
11297
11668
  else {
11669
+ // We are about to compose a fresh filter chain and acquire a new
11670
+ // root stream. Drop any listeners bound to the previous root stream
11671
+ // before chainWith below registers new ones for the new chain.
11672
+ this.runCurrentStreamCleanups();
11298
11673
  const defaultConstraints = this.state.defaultConstraints;
11299
11674
  const constraints = {
11300
11675
  ...defaultConstraints,
@@ -11350,7 +11725,7 @@ class DeviceManager {
11350
11725
  });
11351
11726
  };
11352
11727
  parentTrack.addEventListener('ended', handleParentTrackEnded);
11353
- this.subscriptions.push(() => {
11728
+ this.currentStreamCleanups.push(() => {
11354
11729
  parentTrack.removeEventListener('ended', handleParentTrackEnded);
11355
11730
  });
11356
11731
  });
@@ -11358,7 +11733,7 @@ class DeviceManager {
11358
11733
  };
11359
11734
  // the rootStream represents the stream coming from the actual device
11360
11735
  // e.g. camera or microphone stream
11361
- rootStream = this.getStream(constraints);
11736
+ rootStreamPromise = this.getStream(constraints);
11362
11737
  // we publish the last MediaStream of the chain
11363
11738
  stream = await this.filters.reduce((parent, entry) => parent
11364
11739
  .then((inputStream) => {
@@ -11369,42 +11744,70 @@ class DeviceManager {
11369
11744
  .then(chainWith(parent), (error) => {
11370
11745
  this.logger.warn('Filter failed to start and will be ignored', error);
11371
11746
  return parent;
11372
- }), rootStream);
11747
+ }), rootStreamPromise);
11373
11748
  }
11374
11749
  if (this.call.state.callingState === CallingState.JOINED) {
11375
11750
  await this.publishStream(stream);
11376
11751
  }
11377
11752
  if (this.state.mediaStream !== stream) {
11378
- this.state.setMediaStream(stream, await rootStream);
11379
- const handleTrackEnded = async () => {
11380
- await this.statusChangeSettled();
11381
- if (this.enabled) {
11382
- this.isTrackStoppedDueToTrackEnd = true;
11383
- setTimeout(() => {
11384
- this.isTrackStoppedDueToTrackEnd = false;
11385
- }, 2000);
11386
- await this.disable();
11387
- }
11388
- };
11389
- const createTrackMuteHandler = (muted) => () => {
11390
- if (!isMobile() || this.trackType !== TrackType.VIDEO)
11391
- return;
11392
- this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
11393
- this.logger.warn('Error while notifying track mute state', err);
11394
- });
11395
- };
11396
- stream.getTracks().forEach((track) => {
11397
- const muteHandler = createTrackMuteHandler(true);
11398
- const unmuteHandler = createTrackMuteHandler(false);
11399
- track.addEventListener('mute', muteHandler);
11400
- track.addEventListener('unmute', unmuteHandler);
11401
- track.addEventListener('ended', handleTrackEnded);
11402
- this.subscriptions.push(() => {
11403
- track.removeEventListener('mute', muteHandler);
11404
- track.removeEventListener('unmute', unmuteHandler);
11405
- track.removeEventListener('ended', handleTrackEnded);
11753
+ const rootStream = await rootStreamPromise;
11754
+ this.state.setMediaStream(stream, rootStream);
11755
+ if (rootStream) {
11756
+ const handleTrackEnded = async () => {
11757
+ this.setLocalInterrupted(false);
11758
+ await this.statusChangeSettled();
11759
+ if (this.enabled) {
11760
+ this.isTrackStoppedDueToTrackEnd = true;
11761
+ setTimeout(() => {
11762
+ this.isTrackStoppedDueToTrackEnd = false;
11763
+ }, 2000);
11764
+ await this.disable();
11765
+ }
11766
+ };
11767
+ const createTrackMuteHandler = (muted) => () => {
11768
+ this.setLocalInterrupted(muted);
11769
+ // WebKit's RTCRtpSender encoder can stay stalled after an iOS /
11770
+ // macOS audio session interruption even though the track is
11771
+ // unmuted. Re-arm the sender on every unmute for any WebKit
11772
+ // runtime (Safari + plain iOS WKWebViews). Skipped when the
11773
+ // page is hidden because the encoder won't resume until
11774
+ // foreground anyway.
11775
+ if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
11776
+ this.call.refreshPublishedTrack(this.trackType).catch((err) => {
11777
+ this.logger.warn('Failed to refresh track on system unmute', err);
11778
+ });
11779
+ }
11780
+ // report all tracks on mobile, and only Video on desktop browsers
11781
+ if (isMobile() || this.trackType == TrackType.VIDEO) {
11782
+ this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
11783
+ trackType: TrackType[this.trackType],
11784
+ muted,
11785
+ });
11786
+ this.call
11787
+ .notifyTrackMuteState(muted, this.trackType)
11788
+ .catch((err) => {
11789
+ this.logger.warn('Error while notifying track mute state', err);
11790
+ });
11791
+ }
11792
+ };
11793
+ rootStream.getTracks().forEach((track) => {
11794
+ const muteHandler = createTrackMuteHandler(true);
11795
+ const unmuteHandler = createTrackMuteHandler(false);
11796
+ track.addEventListener('mute', muteHandler);
11797
+ track.addEventListener('unmute', unmuteHandler);
11798
+ track.addEventListener('ended', handleTrackEnded);
11799
+ this.currentStreamCleanups.push(() => {
11800
+ track.removeEventListener('mute', muteHandler);
11801
+ track.removeEventListener('unmute', unmuteHandler);
11802
+ track.removeEventListener('ended', handleTrackEnded);
11803
+ });
11406
11804
  });
11407
- });
11805
+ const initialMuted = rootStream.getTracks().some((t) => t.muted);
11806
+ this.setLocalInterrupted(initialMuted);
11807
+ }
11808
+ else {
11809
+ this.setLocalInterrupted(false);
11810
+ }
11408
11811
  }
11409
11812
  }
11410
11813
  get mediaDeviceKind() {
@@ -11550,7 +11953,6 @@ class DeviceManagerState {
11550
11953
  this.defaultConstraintsSubject = new BehaviorSubject(undefined);
11551
11954
  /**
11552
11955
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
11553
- *
11554
11956
  */
11555
11957
  this.mediaStream$ = this.mediaStreamSubject.asObservable();
11556
11958
  /**
@@ -13205,8 +13607,10 @@ class Call {
13205
13607
  this.publisher = undefined;
13206
13608
  await this.sfuClient?.leaveAndClose(leaveReason);
13207
13609
  this.sfuClient = undefined;
13208
- this.dynascaleManager.setSfuClient(undefined);
13209
- await this.dynascaleManager.dispose();
13610
+ this.trackSubscriptionManager.setSfuClient(undefined);
13611
+ this.trackSubscriptionManager.dispose();
13612
+ this.audioBindingsWatchdog?.dispose();
13613
+ await this.dynascaleManager?.dispose();
13210
13614
  this.state.setCallingState(CallingState.LEFT);
13211
13615
  this.state.setParticipants([]);
13212
13616
  this.state.dispose();
@@ -13516,7 +13920,7 @@ class Call {
13516
13920
  : previousSfuClient;
13517
13921
  this.sfuClient = sfuClient;
13518
13922
  this.unifiedSessionId ?? (this.unifiedSessionId = sfuClient.sessionId);
13519
- this.dynascaleManager.setSfuClient(sfuClient);
13923
+ this.trackSubscriptionManager.setSfuClient(sfuClient);
13520
13924
  const clientDetails = await getClientDetails();
13521
13925
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
13522
13926
  if (previousSfuClient !== sfuClient) {
@@ -13651,7 +14055,7 @@ class Call {
13651
14055
  return {
13652
14056
  strategy,
13653
14057
  announcedTracks,
13654
- subscriptions: this.dynascaleManager.trackSubscriptions,
14058
+ subscriptions: this.trackSubscriptionManager.subscriptions,
13655
14059
  reconnectAttempt: this.reconnectAttempts,
13656
14060
  fromSfuId: migratingFromSfuId || '',
13657
14061
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
@@ -14198,7 +14602,7 @@ class Call {
14198
14602
  const { remoteParticipants } = this.state;
14199
14603
  if (remoteParticipants.length <= 0)
14200
14604
  return;
14201
- this.dynascaleManager.applyTrackSubscriptions(undefined);
14605
+ this.trackSubscriptionManager.apply(undefined);
14202
14606
  };
14203
14607
  /**
14204
14608
  * Starts publishing the given video stream to the call.
@@ -14298,6 +14702,20 @@ class Call {
14298
14702
  }));
14299
14703
  }
14300
14704
  };
14705
+ /**
14706
+ * Re-arms the encoder for a currently published track type. Useful for
14707
+ * working around WebKit's stalled sender bug after an iOS audio session
14708
+ * interruption (Siri, PSTN call).
14709
+ *
14710
+ * @internal
14711
+ *
14712
+ * @param trackType the track type to refresh.
14713
+ */
14714
+ this.refreshPublishedTrack = async (trackType) => {
14715
+ if (!this.publisher)
14716
+ return;
14717
+ await this.publisher.refreshTrack(trackType);
14718
+ };
14301
14719
  /**
14302
14720
  * Updates the preferred publishing options
14303
14721
  *
@@ -14959,7 +15377,7 @@ class Call {
14959
15377
  * @param trackType the video mode.
14960
15378
  */
14961
15379
  this.trackElementVisibility = (element, sessionId, trackType) => {
14962
- return this.dynascaleManager.trackElementVisibility(element, sessionId, trackType);
15380
+ return this.viewportTracker?.trackElementVisibility(element, sessionId, trackType);
14963
15381
  };
14964
15382
  /**
14965
15383
  * Sets the viewport element to track bound video elements for visibility.
@@ -14967,7 +15385,7 @@ class Call {
14967
15385
  * @param element the viewport element.
14968
15386
  */
14969
15387
  this.setViewport = (element) => {
14970
- return this.dynascaleManager.setViewport(element);
15388
+ return this.viewportTracker?.setViewport(element);
14971
15389
  };
14972
15390
  /**
14973
15391
  * Binds a DOM <video> element to the given session id.
@@ -14985,7 +15403,7 @@ class Call {
14985
15403
  * @param trackType the kind of video.
14986
15404
  */
14987
15405
  this.bindVideoElement = (videoElement, sessionId, trackType) => {
14988
- const unbind = this.dynascaleManager.bindVideoElement(videoElement, sessionId, trackType);
15406
+ const unbind = this.dynascaleManager?.bindVideoElement(videoElement, sessionId, trackType);
14989
15407
  if (!unbind)
14990
15408
  return;
14991
15409
  this.leaveCallHooks.add(unbind);
@@ -15005,21 +15423,28 @@ class Call {
15005
15423
  * @param trackType the kind of audio.
15006
15424
  */
15007
15425
  this.bindAudioElement = (audioElement, sessionId, trackType = 'audioTrack') => {
15008
- const unbind = this.dynascaleManager.bindAudioElement(audioElement, sessionId, trackType);
15426
+ const unbind = this.dynascaleManager?.bindAudioElement(audioElement, sessionId, trackType);
15009
15427
  if (!unbind)
15010
15428
  return;
15011
- this.leaveCallHooks.add(unbind);
15012
- return () => {
15013
- this.leaveCallHooks.delete(unbind);
15429
+ this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
15430
+ const cleanup = () => {
15014
15431
  unbind();
15432
+ this.audioBindingsWatchdog?.unregister(sessionId, trackType);
15433
+ };
15434
+ this.leaveCallHooks.add(cleanup);
15435
+ return () => {
15436
+ this.leaveCallHooks.delete(cleanup);
15437
+ cleanup();
15015
15438
  };
15016
15439
  };
15017
15440
  /**
15018
15441
  * Plays all audio elements blocked by the browser's autoplay policy.
15442
+ * Must be called from within a user gesture (e.g., click handler).
15443
+ *
15444
+ * Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
15445
+ * gesture is required.
15019
15446
  */
15020
- this.resumeAudio = () => {
15021
- return this.dynascaleManager.resumeAudio();
15022
- };
15447
+ this.resumeAudio = () => this.blockedAudioTracker.resumeAudio();
15023
15448
  /**
15024
15449
  * Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
15025
15450
  *
@@ -15057,21 +15482,21 @@ class Call {
15057
15482
  * preference has effect on. Affects all participants by default.
15058
15483
  */
15059
15484
  this.setPreferredIncomingVideoResolution = (resolution, sessionIds) => {
15060
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(resolution
15485
+ this.trackSubscriptionManager.setOverrides(resolution
15061
15486
  ? {
15062
15487
  enabled: true,
15063
15488
  dimension: resolution,
15064
15489
  }
15065
15490
  : undefined, sessionIds);
15066
- this.dynascaleManager.applyTrackSubscriptions();
15491
+ this.trackSubscriptionManager.apply();
15067
15492
  };
15068
15493
  /**
15069
15494
  * Enables or disables incoming video from all remote call participants,
15070
15495
  * and removes any preference for preferred resolution.
15071
15496
  */
15072
15497
  this.setIncomingVideoEnabled = (enabled) => {
15073
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(enabled ? undefined : { enabled: false });
15074
- this.dynascaleManager.applyTrackSubscriptions();
15498
+ this.trackSubscriptionManager.setOverrides(enabled ? undefined : { enabled: false });
15499
+ this.trackSubscriptionManager.apply();
15075
15500
  };
15076
15501
  /**
15077
15502
  * Sets the maximum amount of time a user can remain waiting for a reconnect
@@ -15152,7 +15577,13 @@ class Call {
15152
15577
  this.microphone = new MicrophoneManager(this, preferences);
15153
15578
  this.speaker = new SpeakerManager(this, preferences);
15154
15579
  this.screenShare = new ScreenShareManager(this);
15155
- this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer);
15580
+ this.trackSubscriptionManager = new TrackSubscriptionManager(this.state, this.tracer);
15581
+ this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
15582
+ if (typeof document !== 'undefined') {
15583
+ this.audioBindingsWatchdog = new AudioBindingsWatchdog(this.state, this.tracer);
15584
+ this.viewportTracker = new ViewportTracker(this.state);
15585
+ this.dynascaleManager = new DynascaleManager(this.state, this.speaker, this.tracer, this.trackSubscriptionManager, this.blockedAudioTracker);
15586
+ }
15156
15587
  }
15157
15588
  /**
15158
15589
  * A flag indicating whether the call is "ringing" type of call.
@@ -15227,12 +15658,118 @@ const APIErrorCodes = {
15227
15658
  */
15228
15659
  class StableWSConnection {
15229
15660
  constructor(client) {
15661
+ /** Incremented when a new WS connection is made */
15662
+ this.wsID = 1;
15663
+ // Connection lifecycle flags.
15664
+ /** We only make 1 attempt to reconnect at the same time.. */
15665
+ this.isConnecting = false;
15666
+ /** To avoid reconnect if client is disconnected */
15667
+ this.isDisconnected = false;
15668
+ /** Boolean that indicates if we have a working connection to the server */
15669
+ this.isHealthy = false;
15670
+ /** Boolean that indicates if the connection promise is resolved */
15671
+ this.isConnectionOpenResolved = false;
15672
+ // Failure counters (drive retry/backoff scheduling).
15673
+ /** consecutive failures influence the duration of the timeout */
15674
+ this.consecutiveFailures = 0;
15675
+ /** keep track of the total number of failures */
15676
+ this.totalFailures = 0;
15677
+ // Health-check pings + connection-staleness check.
15678
+ /** Send a health check message every 25 seconds */
15679
+ this.pingInterval = 25 * 1000;
15680
+ this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
15681
+ /** Store the last event time for health checks */
15682
+ this.lastEvent = null;
15230
15683
  this._log = (msg, extra = {}, level = 'info') => {
15231
15684
  this.client.logger[level](`connection:${msg}`, extra);
15232
15685
  };
15233
15686
  this.setClient = (client) => {
15234
15687
  this.client = client;
15235
15688
  };
15689
+ /**
15690
+ * connect - Connect to the WS URL
15691
+ * the default 15s timeout allows between 2~3 tries
15692
+ * @return Promise that completes once the first health check message is received
15693
+ */
15694
+ this.connect = async (timeout = 15000) => {
15695
+ if (this.isConnecting) {
15696
+ throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
15697
+ }
15698
+ this.isDisconnected = false;
15699
+ try {
15700
+ const healthCheck = await this._connect(timeout);
15701
+ this.consecutiveFailures = 0;
15702
+ this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
15703
+ }
15704
+ catch (caught) {
15705
+ const error = caught;
15706
+ this.isHealthy = false;
15707
+ this.consecutiveFailures += 1;
15708
+ if (error.code === KnownCodes.TOKEN_EXPIRED &&
15709
+ !this.client.tokenManager.isStatic()) {
15710
+ this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
15711
+ this._reconnect({ refreshToken: true });
15712
+ }
15713
+ else if (!error.isWSFailure) {
15714
+ // API rejected the connection and we should not retry
15715
+ throw new Error(JSON.stringify({
15716
+ code: error.code,
15717
+ StatusCode: error.StatusCode,
15718
+ message: error.message,
15719
+ isWSFailure: error.isWSFailure,
15720
+ }));
15721
+ }
15722
+ else {
15723
+ // Transient WS failure (e.g., handshake watchdog). Kick off a
15724
+ // reconnect chain so _waitForHealthy(timeout) below has something
15725
+ // to poll for. Owning the trigger here (rather than inside
15726
+ // _connect()'s catch) keeps a single failure from spawning two
15727
+ // parallel chains - one from this catch and one from _reconnect's
15728
+ // own catch when _connect was called from there.
15729
+ this._reconnect();
15730
+ }
15731
+ }
15732
+ return await this._waitForHealthy(timeout);
15733
+ };
15734
+ /**
15735
+ * _waitForHealthy polls the promise connection to see if its resolved until it times out
15736
+ * the default 15s timeout allows between 2~3 tries
15737
+ * @param timeout duration(ms)
15738
+ */
15739
+ this._waitForHealthy = async (timeout = 15000) => {
15740
+ return Promise.race([
15741
+ (async () => {
15742
+ const interval = 50; // ms
15743
+ for (let i = 0; i <= timeout; i += interval) {
15744
+ try {
15745
+ return await this.connectionOpen;
15746
+ }
15747
+ catch (caught) {
15748
+ const error = caught;
15749
+ if (i === timeout) {
15750
+ throw new Error(JSON.stringify({
15751
+ code: error.code,
15752
+ StatusCode: error.StatusCode,
15753
+ message: error.message,
15754
+ isWSFailure: error.isWSFailure,
15755
+ }));
15756
+ }
15757
+ await sleep(interval);
15758
+ }
15759
+ }
15760
+ })(),
15761
+ (async () => {
15762
+ await sleep(timeout);
15763
+ this.isConnecting = false;
15764
+ throw new Error(JSON.stringify({
15765
+ code: '',
15766
+ StatusCode: '',
15767
+ message: 'initial WS connection could not be established',
15768
+ isWSFailure: true,
15769
+ }));
15770
+ })(),
15771
+ ]);
15772
+ };
15236
15773
  /**
15237
15774
  * Builds and returns the url for websocket.
15238
15775
  * @private
@@ -15245,11 +15782,166 @@ class StableWSConnection {
15245
15782
  params.set('X-Stream-Client', this.client.getUserAgent());
15246
15783
  return `${this.client.wsBaseURL}/connect?${params.toString()}`;
15247
15784
  };
15785
+ /**
15786
+ * disconnect - Disconnect the connection and doesn't recover...
15787
+ */
15788
+ this.disconnect = (timeout) => {
15789
+ this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
15790
+ this.wsID += 1;
15791
+ this.isConnecting = false;
15792
+ this.isDisconnected = true;
15793
+ // start by removing all the listeners
15794
+ if (this.healthCheckTimeoutRef) {
15795
+ getTimers().clearInterval(this.healthCheckTimeoutRef);
15796
+ }
15797
+ if (this.connectionCheckTimeoutRef) {
15798
+ clearInterval(this.connectionCheckTimeoutRef);
15799
+ }
15800
+ removeConnectionEventListeners(this.onlineStatusChanged);
15801
+ this.isHealthy = false;
15802
+ let isClosedPromise;
15803
+ // and finally close...
15804
+ // Assigning to local here because we will remove it from this before the
15805
+ // promise resolves.
15806
+ const { ws } = this;
15807
+ if (ws && ws.close && ws.readyState === ws.OPEN) {
15808
+ isClosedPromise = new Promise((resolve) => {
15809
+ const onclose = (event) => {
15810
+ this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
15811
+ resolve();
15812
+ };
15813
+ ws.onclose = onclose;
15814
+ // In case we don't receive close frame websocket server in time,
15815
+ // lets not wait for more than 1 second.
15816
+ setTimeout(onclose, timeout != null ? timeout : 1000);
15817
+ });
15818
+ this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
15819
+ ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
15820
+ }
15821
+ else {
15822
+ this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
15823
+ isClosedPromise = Promise.resolve();
15824
+ }
15825
+ delete this.ws;
15826
+ return isClosedPromise;
15827
+ };
15828
+ /**
15829
+ * _connect - Connect to the WS endpoint
15830
+ *
15831
+ * @param timeoutMs handshake watchdog deadline in ms. Defaults to
15832
+ * `client.defaultWSTimeout` when not provided. Top-level `connect(timeout)`
15833
+ * passes its own timeout through so caller-supplied deadlines are honored.
15834
+ * @return Promise that completes once the first health check message is received
15835
+ */
15836
+ this._connect = async (timeoutMs) => {
15837
+ if (this.isConnecting)
15838
+ return; // ignore _connect if it's currently trying to connect
15839
+ this.isConnecting = true;
15840
+ // Snapshot of the connection-id reject closure owned by THIS attempt.
15841
+ // Captured at function entry so that even early failures (e.g.,
15842
+ // tokenManager.loadToken throwing before we reach the WS phase) can
15843
+ // settle the promise the caller is awaiting. Re-captured below if
15844
+ // _connect itself sets up a fresh promise. If a concurrent
15845
+ // openConnection() rotates `client.rejectConnectionId` later, our
15846
+ // captured closure still settles only the original promise (P1) and
15847
+ // never poisons the newer one (P2).
15848
+ let ownRejectConnectionId = this.client.rejectConnectionId;
15849
+ let isTokenReady = false;
15850
+ try {
15851
+ this._log(`_connect() - waiting for token`);
15852
+ await this.client.tokenManager.tokenReady();
15853
+ isTokenReady = true;
15854
+ }
15855
+ catch {
15856
+ // token provider has failed before, so try again
15857
+ }
15858
+ try {
15859
+ if (!isTokenReady) {
15860
+ this._log(`_connect() - tokenProvider failed before, so going to retry`);
15861
+ await this.client.tokenManager.loadToken();
15862
+ }
15863
+ if (!this.client.isConnectionIdPromisePending) {
15864
+ this.client._setupConnectionIdPromise();
15865
+ // recapture: we just rotated the resolver ourselves, the new
15866
+ // closure is the one bound to the promise this attempt owns.
15867
+ ownRejectConnectionId = this.client.rejectConnectionId;
15868
+ }
15869
+ this._setupConnectionPromise();
15870
+ const wsURL = this._buildUrl();
15871
+ this._log(`_connect() - Connecting to ${wsURL}`);
15872
+ const WS = this.client.options.WebSocketImpl ?? WebSocket;
15873
+ this.ws = new WS(wsURL);
15874
+ this.ws.onopen = this.onopen.bind(this, this.wsID);
15875
+ this.ws.onclose = this.onclose.bind(this, this.wsID);
15876
+ this.ws.onerror = this.onerror.bind(this, this.wsID);
15877
+ this.ws.onmessage = this.onmessage.bind(this, this.wsID);
15878
+ // race the WS handshake against an explicit deadline so a silent
15879
+ // network drop (e.g., carrier NAT or firewall) cannot wedge _connect()
15880
+ const handshakeTimeout = timeoutMs ?? this.client.defaultWSTimeout;
15881
+ const timers = getTimers();
15882
+ let handshakeTimeoutId;
15883
+ let response;
15884
+ try {
15885
+ response = await Promise.race([
15886
+ this.connectionOpen,
15887
+ new Promise((_, reject) => {
15888
+ handshakeTimeoutId = timers.setTimeout(() => {
15889
+ const err = new Error(`WS handshake timed out after ${handshakeTimeout}ms`);
15890
+ err.isWSFailure = true;
15891
+ reject(err);
15892
+ }, handshakeTimeout);
15893
+ }),
15894
+ ]);
15895
+ }
15896
+ finally {
15897
+ timers.clearTimeout(handshakeTimeoutId);
15898
+ }
15899
+ this.isConnecting = false;
15900
+ // If we were disconnected during the handshake (e.g. closeConnection()
15901
+ // ran while a background _reconnect's _connect was in flight), tear
15902
+ // down the new WS and throw so the caller of connect() does not get
15903
+ // a misleading "success" for a connection that has already been
15904
+ // aborted. We must NOT skip the throw and just return undefined: the
15905
+ // outer connect() would otherwise fall through to _waitForHealthy(),
15906
+ // which would observe the already-resolved connectionOpen promise
15907
+ // and resolve with a ConnectedEvent for a torn-down connection.
15908
+ if (this.isDisconnected) {
15909
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
15910
+ this._destroyCurrentWSConnection();
15911
+ }
15912
+ throw new Error('WS handshake aborted: disconnect() ran while connecting');
15913
+ }
15914
+ if (response) {
15915
+ this.connectionID = response.connection_id;
15916
+ this.client.resolveConnectionId?.(this.connectionID);
15917
+ return response;
15918
+ }
15919
+ }
15920
+ catch (caught) {
15921
+ const err = caught;
15922
+ this.isConnecting = false;
15923
+ this._log(`_connect() - Error - `, err);
15924
+ // Reject THIS attempt's connection-id promise (P1) directly via the
15925
+ // captured closure. Whether or not a concurrent openConnection() has
15926
+ // since rotated client.rejectConnectionId to a newer promise (P2),
15927
+ // calling ownRejectConnectionId only settles P1 - P2 is untouched.
15928
+ // P1's awaiters (e.g., doAxiosRequest awaiting connectionIdPromise)
15929
+ // therefore fail fast instead of being orphaned.
15930
+ ownRejectConnectionId?.(err);
15931
+ // connectionOpen is per-instance and not subject to rotation, so
15932
+ // calling it unconditionally is safe (and a no-op if already settled).
15933
+ this.rejectConnectionOpen?.(err);
15934
+ // tear down a half-open WS so it does not linger and fire a stale wsID later
15935
+ if (this.ws && this.ws.readyState !== this.ws.CLOSED) {
15936
+ this._destroyCurrentWSConnection();
15937
+ }
15938
+ throw err;
15939
+ }
15940
+ };
15248
15941
  /**
15249
15942
  * onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
15250
15943
  *
15251
15944
  * @param {Event} event Event with type online or offline
15252
- *
15253
15945
  */
15254
15946
  this.onlineStatusChanged = (event) => {
15255
15947
  if (event.type === 'offline') {
@@ -15347,16 +16039,12 @@ class StableWSConnection {
15347
16039
  return;
15348
16040
  this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
15349
16041
  if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
15350
- // this is a permanent error raised by stream..
16042
+ // this is a permanent error raised by stream.
15351
16043
  // usually caused by invalid auth details
15352
16044
  const error = new Error(`WS connection reject with error ${event.reason}`);
15353
- // @ts-expect-error type issue
15354
16045
  error.reason = event.reason;
15355
- // @ts-expect-error type issue
15356
16046
  error.code = event.code;
15357
- // @ts-expect-error type issue
15358
16047
  error.wasClean = event.wasClean;
15359
- // @ts-expect-error type issue
15360
16048
  error.target = event.target;
15361
16049
  this.rejectConnectionOpen?.(error);
15362
16050
  this._log(`onclose() - WS connection reject with error ${event.reason}`, {
@@ -15494,205 +16182,8 @@ class StableWSConnection {
15494
16182
  }, this.connectionCheckTimeout);
15495
16183
  };
15496
16184
  this.client = client;
15497
- /** consecutive failures influence the duration of the timeout */
15498
- this.consecutiveFailures = 0;
15499
- /** keep track of the total number of failures */
15500
- this.totalFailures = 0;
15501
- /** We only make 1 attempt to reconnect at the same time.. */
15502
- this.isConnecting = false;
15503
- /** To avoid reconnect if client is disconnected */
15504
- this.isDisconnected = false;
15505
- /** Boolean that indicates if the connection promise is resolved */
15506
- this.isConnectionOpenResolved = false;
15507
- /** Boolean that indicates if we have a working connection to the server */
15508
- this.isHealthy = false;
15509
- /** Incremented when a new WS connection is made */
15510
- this.wsID = 1;
15511
- /** Store the last event time for health checks */
15512
- this.lastEvent = null;
15513
- /** Send a health check message every 25 seconds */
15514
- this.pingInterval = 25 * 1000;
15515
- this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
15516
16185
  addConnectionEventListeners(this.onlineStatusChanged);
15517
16186
  }
15518
- /**
15519
- * connect - Connect to the WS URL
15520
- * the default 15s timeout allows between 2~3 tries
15521
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
15522
- */
15523
- async connect(timeout = 15000) {
15524
- if (this.isConnecting) {
15525
- throw Error(`You've called connect twice, can only attempt 1 connection at the time`);
15526
- }
15527
- this.isDisconnected = false;
15528
- try {
15529
- const healthCheck = await this._connect();
15530
- this.consecutiveFailures = 0;
15531
- this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
15532
- }
15533
- catch (error) {
15534
- this.isHealthy = false;
15535
- this.consecutiveFailures += 1;
15536
- if (
15537
- // @ts-expect-error type issue
15538
- error.code === KnownCodes.TOKEN_EXPIRED &&
15539
- !this.client.tokenManager.isStatic()) {
15540
- this._log('connect() - WS failure due to expired token, so going to try to reload token and reconnect');
15541
- this._reconnect({ refreshToken: true });
15542
- }
15543
- else {
15544
- // @ts-expect-error type issue
15545
- if (!error.isWSFailure) {
15546
- // API rejected the connection and we should not retry
15547
- throw new Error(JSON.stringify({
15548
- // @ts-expect-error type issue
15549
- code: error.code,
15550
- // @ts-expect-error type issue
15551
- StatusCode: error.StatusCode,
15552
- // @ts-expect-error type issue
15553
- message: error.message,
15554
- // @ts-expect-error type issue
15555
- isWSFailure: error.isWSFailure,
15556
- }));
15557
- }
15558
- }
15559
- }
15560
- return await this._waitForHealthy(timeout);
15561
- }
15562
- /**
15563
- * _waitForHealthy polls the promise connection to see if its resolved until it times out
15564
- * the default 15s timeout allows between 2~3 tries
15565
- * @param timeout duration(ms)
15566
- */
15567
- async _waitForHealthy(timeout = 15000) {
15568
- return Promise.race([
15569
- (async () => {
15570
- const interval = 50; // ms
15571
- for (let i = 0; i <= timeout; i += interval) {
15572
- try {
15573
- return await this.connectionOpen;
15574
- }
15575
- catch (error) {
15576
- if (i === timeout) {
15577
- throw new Error(JSON.stringify({
15578
- code: error.code,
15579
- StatusCode: error.StatusCode,
15580
- message: error.message,
15581
- isWSFailure: error.isWSFailure,
15582
- }));
15583
- }
15584
- await sleep(interval);
15585
- }
15586
- }
15587
- })(),
15588
- (async () => {
15589
- await sleep(timeout);
15590
- this.isConnecting = false;
15591
- throw new Error(JSON.stringify({
15592
- code: '',
15593
- StatusCode: '',
15594
- message: 'initial WS connection could not be established',
15595
- isWSFailure: true,
15596
- }));
15597
- })(),
15598
- ]);
15599
- }
15600
- /**
15601
- * disconnect - Disconnect the connection and doesn't recover...
15602
- *
15603
- */
15604
- disconnect(timeout) {
15605
- this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`);
15606
- this.wsID += 1;
15607
- this.isConnecting = false;
15608
- this.isDisconnected = true;
15609
- // start by removing all the listeners
15610
- if (this.healthCheckTimeoutRef) {
15611
- getTimers().clearInterval(this.healthCheckTimeoutRef);
15612
- }
15613
- if (this.connectionCheckTimeoutRef) {
15614
- clearInterval(this.connectionCheckTimeoutRef);
15615
- }
15616
- removeConnectionEventListeners(this.onlineStatusChanged);
15617
- this.isHealthy = false;
15618
- let isClosedPromise;
15619
- // and finally close...
15620
- // Assigning to local here because we will remove it from this before the
15621
- // promise resolves.
15622
- const { ws } = this;
15623
- if (ws && ws.close && ws.readyState === ws.OPEN) {
15624
- isClosedPromise = new Promise((resolve) => {
15625
- const onclose = (event) => {
15626
- this._log(`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, { event });
15627
- resolve();
15628
- };
15629
- ws.onclose = onclose;
15630
- // In case we don't receive close frame websocket server in time,
15631
- // lets not wait for more than 1 second.
15632
- setTimeout(onclose, timeout != null ? timeout : 1000);
15633
- });
15634
- this._log(`disconnect() - Manually closed connection by calling client.disconnect()`);
15635
- ws.close(KnownCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()');
15636
- }
15637
- else {
15638
- this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
15639
- isClosedPromise = Promise.resolve();
15640
- }
15641
- delete this.ws;
15642
- return isClosedPromise;
15643
- }
15644
- /**
15645
- * _connect - Connect to the WS endpoint
15646
- *
15647
- * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
15648
- */
15649
- async _connect() {
15650
- if (this.isConnecting)
15651
- return; // ignore _connect if it's currently trying to connect
15652
- this.isConnecting = true;
15653
- let isTokenReady = false;
15654
- try {
15655
- this._log(`_connect() - waiting for token`);
15656
- await this.client.tokenManager.tokenReady();
15657
- isTokenReady = true;
15658
- }
15659
- catch {
15660
- // token provider has failed before, so try again
15661
- }
15662
- try {
15663
- if (!isTokenReady) {
15664
- this._log(`_connect() - tokenProvider failed before, so going to retry`);
15665
- await this.client.tokenManager.loadToken();
15666
- }
15667
- if (!this.client.isConnectionIsPromisePending) {
15668
- this.client._setupConnectionIdPromise();
15669
- }
15670
- this._setupConnectionPromise();
15671
- const wsURL = this._buildUrl();
15672
- this._log(`_connect() - Connecting to ${wsURL}`);
15673
- const WS = this.client.options.WebSocketImpl ?? WebSocket;
15674
- this.ws = new WS(wsURL);
15675
- this.ws.onopen = this.onopen.bind(this, this.wsID);
15676
- this.ws.onclose = this.onclose.bind(this, this.wsID);
15677
- this.ws.onerror = this.onerror.bind(this, this.wsID);
15678
- this.ws.onmessage = this.onmessage.bind(this, this.wsID);
15679
- const response = await this.connectionOpen;
15680
- this.isConnecting = false;
15681
- if (response) {
15682
- this.connectionID = response.connection_id;
15683
- this.client.resolveConnectionId?.(this.connectionID);
15684
- return response;
15685
- }
15686
- }
15687
- catch (err) {
15688
- this.client._setupConnectionIdPromise();
15689
- this.isConnecting = false;
15690
- // @ts-expect-error type issue
15691
- this._log(`_connect() - Error - `, err);
15692
- this.client.rejectConnectionId?.(err);
15693
- throw err;
15694
- }
15695
- }
15696
16187
  /**
15697
16188
  * _reconnect - Retry the connection to WS endpoint
15698
16189
  *
@@ -15739,7 +16230,8 @@ class StableWSConnection {
15739
16230
  this._log('_reconnect() - Finished recoverCallBack');
15740
16231
  this.consecutiveFailures = 0;
15741
16232
  }
15742
- catch (error) {
16233
+ catch (caught) {
16234
+ const error = caught;
15743
16235
  this.isHealthy = false;
15744
16236
  this.consecutiveFailures += 1;
15745
16237
  if (error.code === KnownCodes.TOKEN_EXPIRED &&
@@ -16296,7 +16788,7 @@ class StreamClient {
16296
16788
  this.getUserAgent = () => {
16297
16789
  if (!this.cachedUserAgent) {
16298
16790
  const { clientAppIdentifier = {} } = this.options;
16299
- const { sdkName = 'js', sdkVersion = "1.49.0", ...extras } = clientAppIdentifier;
16791
+ const { sdkName = 'js', sdkVersion = "1.50.0", ...extras } = clientAppIdentifier;
16300
16792
  this.cachedUserAgent = [
16301
16793
  `stream-video-${sdkName}-v${sdkVersion}`,
16302
16794
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -16404,7 +16896,7 @@ class StreamClient {
16404
16896
  get connectionIdPromise() {
16405
16897
  return this.connectionIdPromiseSafe?.();
16406
16898
  }
16407
- get isConnectionIsPromisePending() {
16899
+ get isConnectionIdPromisePending() {
16408
16900
  return this.connectionIdPromiseSafe?.checkPending() ?? false;
16409
16901
  }
16410
16902
  get wsPromise() {