@stream-io/video-client 1.49.0 → 1.50.0

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