@stream-io/video-client 1.48.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 (86) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/index.browser.es.js +1497 -677
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1497 -677
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1497 -677
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +77 -4
  9. package/dist/src/StreamSfuClient.d.ts +8 -1
  10. package/dist/src/coordinator/connection/client.d.ts +1 -1
  11. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  12. package/dist/src/coordinator/connection/types.d.ts +14 -0
  13. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +3 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +13 -1
  16. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  17. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  18. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  19. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  20. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  21. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  22. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  23. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  24. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  25. package/dist/src/helpers/browsers.d.ts +13 -0
  26. package/dist/src/helpers/concurrency.d.ts +6 -4
  27. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  28. package/dist/src/rtc/Publisher.d.ts +17 -0
  29. package/dist/src/rtc/Subscriber.d.ts +1 -0
  30. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/types.d.ts +33 -1
  33. package/dist/src/stats/rtc/types.d.ts +1 -1
  34. package/dist/src/store/rxUtils.d.ts +9 -0
  35. package/dist/src/types.d.ts +18 -0
  36. package/package.json +2 -2
  37. package/src/Call.ts +268 -40
  38. package/src/StreamSfuClient.ts +75 -12
  39. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  40. package/src/__tests__/Call.publishing.test.ts +103 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  42. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  43. package/src/coordinator/connection/client.ts +1 -1
  44. package/src/coordinator/connection/connection.ts +149 -96
  45. package/src/coordinator/connection/types.ts +15 -0
  46. package/src/coordinator/connection/utils.ts +15 -0
  47. package/src/devices/DeviceManager.ts +92 -32
  48. package/src/devices/DeviceManagerState.ts +20 -1
  49. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  50. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  51. package/src/devices/__tests__/mocks.ts +2 -0
  52. package/src/devices/devices.ts +2 -1
  53. package/src/gen/video/sfu/event/events.ts +15 -0
  54. package/src/gen/video/sfu/models/models.ts +44 -0
  55. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  56. package/src/helpers/BlockedAudioTracker.ts +74 -0
  57. package/src/helpers/DynascaleManager.ts +46 -337
  58. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  59. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  60. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  61. package/src/helpers/ViewportTracker.ts +74 -19
  62. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  63. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  64. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  65. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rpc/retryable.ts +0 -1
  72. package/src/rtc/BasePeerConnection.ts +96 -6
  73. package/src/rtc/Publisher.ts +49 -2
  74. package/src/rtc/Subscriber.ts +42 -14
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  76. package/src/rtc/__tests__/Publisher.test.ts +332 -10
  77. package/src/rtc/__tests__/Subscriber.test.ts +202 -1
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  80. package/src/rtc/helpers/degradationPreference.ts +22 -0
  81. package/src/rtc/index.ts +1 -0
  82. package/src/rtc/types.ts +38 -1
  83. package/src/stats/rtc/types.ts +1 -0
  84. package/src/store/__tests__/rxUtils.test.ts +276 -0
  85. package/src/store/rxUtils.ts +19 -0
  86. package/src/types.ts +19 -0
package/src/Call.ts CHANGED
@@ -7,7 +7,9 @@ import {
7
7
  isAudioTrackType,
8
8
  isSfuEvent,
9
9
  muteTypeToTrackType,
10
+ NegotiationError,
10
11
  Publisher,
12
+ ReconnectReason,
11
13
  Subscriber,
12
14
  toRtcConfiguration,
13
15
  TrackPublishOptions,
@@ -143,11 +145,16 @@ import {
143
145
  StatsReporter,
144
146
  Tracer,
145
147
  } from './stats';
148
+ import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
149
+ import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
150
+ import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
146
151
  import { DynascaleManager } from './helpers/DynascaleManager';
152
+ import { ViewportTracker } from './helpers/ViewportTracker';
147
153
  import { PermissionsContext } from './permissions';
148
154
  import { CallTypes } from './CallType';
149
155
  import { StreamClient } from './coordinator/connection/client';
150
156
  import { retryInterval, sleep } from './coordinator/connection/utils';
157
+ import { SlidingWindowRateLimiter } from './helpers/SlidingWindowRateLimiter';
151
158
  import {
152
159
  AllCallEvents,
153
160
  CallEventListener,
@@ -227,7 +234,32 @@ export class Call {
227
234
  /**
228
235
  * The DynascaleManager instance.
229
236
  */
230
- readonly dynascaleManager: DynascaleManager;
237
+ readonly dynascaleManager: DynascaleManager | undefined;
238
+
239
+ /**
240
+ * Tracks viewport visibility for participant video elements.
241
+ * Available only in DOM environments.
242
+ */
243
+ readonly viewportTracker: ViewportTracker | undefined;
244
+
245
+ /**
246
+ * Owns the SFU-side video-subscription state (per-session and global overrides).
247
+ */
248
+ readonly trackSubscriptionManager: TrackSubscriptionManager;
249
+
250
+ /**
251
+ * Warns periodically when a remote participant is publishing audio, but no
252
+ * `<audio>` element has been bound for them.
253
+ */
254
+ readonly audioBindingsWatchdog: AudioBindingsWatchdog | undefined;
255
+
256
+ /**
257
+ * Tracks audio elements blocked by the browser's autoplay policy.
258
+ * Subscribe to `blockedAudioTracker.autoplayBlocked$` to react to the
259
+ * blocked state, and call {@link Call.resumeAudio} inside a user gesture
260
+ * to retry playback.
261
+ */
262
+ readonly blockedAudioTracker: BlockedAudioTracker;
231
263
 
232
264
  subscriber?: Subscriber;
233
265
  publisher?: Publisher;
@@ -271,6 +303,17 @@ export class Call {
271
303
  private disconnectionTimeoutSeconds: number = 0;
272
304
  private lastOfflineTimestamp: number = 0;
273
305
  private networkAvailableTask: PromiseWithResolvers<void> | undefined;
306
+
307
+ // (10 attempts per rolling 120 s window).
308
+ private readonly rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
309
+ // "Network doesn't support WebRTC" detector: counts peer-connection
310
+ // failures where ICE never reached `connected`/`completed`.
311
+ private maxIceFailuresWithoutConnect = 2;
312
+ private iceFailuresWithoutConnect = 0;
313
+ // Consecutive-negotiation-failure detector: stops the reconnect loop when
314
+ // the SFU keeps failing to negotiate SDP for us.
315
+ private maxConsecutiveNegotiationFailures = 3;
316
+ private consecutiveNegotiationFailures = 0;
274
317
  // maintain the order of publishing tracks to restore them after a reconnection
275
318
  // it shouldn't contain duplicates
276
319
  private trackPublishOrder: TrackType[] = [];
@@ -348,11 +391,26 @@ export class Call {
348
391
  this.microphone = new MicrophoneManager(this, preferences);
349
392
  this.speaker = new SpeakerManager(this, preferences);
350
393
  this.screenShare = new ScreenShareManager(this);
351
- this.dynascaleManager = new DynascaleManager(
394
+ this.trackSubscriptionManager = new TrackSubscriptionManager(
352
395
  this.state,
353
- this.speaker,
354
396
  this.tracer,
355
397
  );
398
+ this.blockedAudioTracker = new BlockedAudioTracker(this.tracer);
399
+
400
+ if (typeof document !== 'undefined') {
401
+ this.audioBindingsWatchdog = new AudioBindingsWatchdog(
402
+ this.state,
403
+ this.tracer,
404
+ );
405
+ this.viewportTracker = new ViewportTracker(this.state);
406
+ this.dynascaleManager = new DynascaleManager(
407
+ this.state,
408
+ this.speaker,
409
+ this.tracer,
410
+ this.trackSubscriptionManager,
411
+ this.blockedAudioTracker,
412
+ );
413
+ }
356
414
  }
357
415
 
358
416
  /**
@@ -687,13 +745,29 @@ export class Call {
687
745
 
688
746
  await this.sfuClient?.leaveAndClose(leaveReason);
689
747
  this.sfuClient = undefined;
690
- this.dynascaleManager.setSfuClient(undefined);
691
- await this.dynascaleManager.dispose();
748
+ this.trackSubscriptionManager.setSfuClient(undefined);
749
+ this.trackSubscriptionManager.dispose();
750
+ this.audioBindingsWatchdog?.dispose();
751
+ await this.dynascaleManager?.dispose();
692
752
 
693
753
  this.state.setCallingState(CallingState.LEFT);
694
754
  this.state.setParticipants([]);
695
755
  this.state.dispose();
696
756
 
757
+ // Reset reconnect-related accumulators so a future `call.join()` on the
758
+ // same instance starts with a fresh budget. The `Call` may be reused
759
+ // (see `Call.test.ts` "can reuse call instance") so this is required.
760
+ // Strategy/reason/attempts must also be cleared: when `leave()` is
761
+ // reached via `giveUpAndLeave()` the success-path reset at the end of
762
+ // `joinFlow` never runs, leaving stale values that would make the next
763
+ // fresh `join()` send a stale `ReconnectDetails` to the SFU.
764
+ this.rejoinRateLimiter.reset();
765
+ this.iceFailuresWithoutConnect = 0;
766
+ this.consecutiveNegotiationFailures = 0;
767
+ this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
768
+ this.reconnectReason = '';
769
+ this.reconnectAttempts = 0;
770
+
697
771
  // Call all leave call hooks, e.g. to clean up global event handlers
698
772
  this.leaveCallHooks.forEach((hook) => hook());
699
773
  this.initialized = false;
@@ -1098,7 +1172,7 @@ export class Call {
1098
1172
  : previousSfuClient;
1099
1173
  this.sfuClient = sfuClient;
1100
1174
  this.unifiedSessionId ??= sfuClient.sessionId;
1101
- this.dynascaleManager.setSfuClient(sfuClient);
1175
+ this.trackSubscriptionManager.setSfuClient(sfuClient);
1102
1176
 
1103
1177
  const clientDetails = await getClientDetails();
1104
1178
  // we don't need to send JoinRequest if we are re-using an existing healthy SFU client
@@ -1169,9 +1243,23 @@ export class Call {
1169
1243
  // when performing fast reconnect, or when we reuse the same SFU client,
1170
1244
  // (ws remained healthy), we just need to restore the ICE connection
1171
1245
  if (performingFastReconnect) {
1172
- // the SFU automatically issues an ICE restart on the subscriber
1173
- // we don't have to do it ourselves
1174
- await this.restoreICE(sfuClient, { includeSubscriber: false });
1246
+ // The SFU automatically issues an ICE restart on the subscriber,
1247
+ // so we only need to decide about the publisher. If the publisher's
1248
+ // peer connection is still stable (ICE still connected end-to-end),
1249
+ // the signal WebSocket drop was the only problem — the new WS alone
1250
+ // is enough, and restarting ICE would add unnecessary SDP/ICE churn.
1251
+ const publisherIsStable = this.publisher?.isStable() ?? true;
1252
+ const includePublisher =
1253
+ !!this.publisher?.isPublishing() && !publisherIsStable;
1254
+ if (!includePublisher && this.publisher?.isPublishing()) {
1255
+ this.logger.info(
1256
+ '[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable',
1257
+ );
1258
+ }
1259
+ await this.restoreICE(sfuClient, {
1260
+ includeSubscriber: false,
1261
+ includePublisher,
1262
+ });
1175
1263
  } else {
1176
1264
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
1177
1265
  this.initPublisherAndSubscriber({
@@ -1223,6 +1311,15 @@ export class Call {
1223
1311
  // reset the reconnect strategy to unspecified after a successful reconnection
1224
1312
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
1225
1313
  this.reconnectReason = '';
1314
+ // A successful SFU join handshake resets the consecutive-negotiation
1315
+ // counter (negotiation just succeeded). It does NOT reset
1316
+ // `iceFailuresWithoutConnect` or the rolling `rejoinRateLimiter`;
1317
+ // those track WebRTC-level health and rejoin frequency, which are not
1318
+ // proven by the SFU handshake alone. ICE-failures-without-connect is
1319
+ // cleared via the `onIceConnected` callback when the peer connection
1320
+ // actually reaches `connected`/`completed` end-to-end. The rejoin
1321
+ // rolling window decays naturally as old timestamps age out.
1322
+ this.consecutiveNegotiationFailures = 0;
1226
1323
 
1227
1324
  this.logger.info(`Joined call ${this.cid}`);
1228
1325
  };
@@ -1242,7 +1339,7 @@ export class Call {
1242
1339
  return {
1243
1340
  strategy,
1244
1341
  announcedTracks,
1245
- subscriptions: this.dynascaleManager.trackSubscriptions,
1342
+ subscriptions: this.trackSubscriptionManager.subscriptions,
1246
1343
  reconnectAttempt: this.reconnectAttempts,
1247
1344
  fromSfuId: migratingFromSfuId || '',
1248
1345
  previousSessionId: performingRejoin ? previousSessionId || '' : '',
@@ -1375,6 +1472,12 @@ export class Call {
1375
1472
  this.logger.warn(message, err);
1376
1473
  });
1377
1474
  },
1475
+ onIceConnected: () => {
1476
+ // ICE has reached `connected`/`completed` end-to-end on at least
1477
+ // one peer connection, WebRTC is actually working, so the
1478
+ // "ICE never connected" failure budget can be cleared.
1479
+ this.iceFailuresWithoutConnect = 0;
1480
+ },
1378
1481
  };
1379
1482
 
1380
1483
  this.subscriber = new Subscriber(basePeerConnectionOptions);
@@ -1501,11 +1604,13 @@ export class Call {
1501
1604
  * @internal
1502
1605
  *
1503
1606
  * @param strategy the reconnection strategy to use.
1504
- * @param reason the reason for the reconnection.
1607
+ * @param reason the reason for the reconnection. Pass a `ReconnectReason.*`
1608
+ * constant when the SDK should react to it (e.g.
1609
+ * `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
1505
1610
  */
1506
1611
  private reconnect = async (
1507
1612
  strategy: WebsocketReconnectStrategy,
1508
- reason: string,
1613
+ reason: ReconnectReason,
1509
1614
  ): Promise<void> => {
1510
1615
  if (
1511
1616
  this.state.callingState === CallingState.RECONNECTING ||
@@ -1529,6 +1634,35 @@ export class Call {
1529
1634
  }
1530
1635
  };
1531
1636
 
1637
+ const giveUpAndLeave = async (message: string) => {
1638
+ this.logger.warn(
1639
+ `[Reconnect] Giving up: ${message}. Leaving the call.`,
1640
+ );
1641
+ // If we're mid-iteration, the state can be JOINING; `Call.leave` would
1642
+ // then wait for JOINED before proceeding, but no more attempts will run
1643
+ // so JOINED never comes. Transition to RECONNECTING so `leave()` proceeds.
1644
+ if (this.state.callingState === CallingState.JOINING) {
1645
+ this.state.setCallingState(CallingState.RECONNECTING);
1646
+ }
1647
+ try {
1648
+ await this.leave({ message });
1649
+ } catch (err) {
1650
+ this.logger.warn(`[Reconnect] leave() failed after ${message}`, err);
1651
+ }
1652
+ };
1653
+
1654
+ // Count this entry into reconnect if it was triggered by a peer
1655
+ // connection that never reached `connected`/`completed`.
1656
+ if (reason === ReconnectReason.ICE_NEVER_CONNECTED) {
1657
+ this.iceFailuresWithoutConnect++;
1658
+ if (
1659
+ this.iceFailuresWithoutConnect >= this.maxIceFailuresWithoutConnect
1660
+ ) {
1661
+ await giveUpAndLeave('webrtc_unsupported_network');
1662
+ return;
1663
+ }
1664
+ }
1665
+
1532
1666
  let attempt = 0;
1533
1667
  do {
1534
1668
  const reconnectingTime = Date.now() - reconnectStartTime;
@@ -1544,6 +1678,19 @@ export class Call {
1544
1678
  return;
1545
1679
  }
1546
1680
 
1681
+ // Rejoin rate limit: bound the number of REJOIN (and MIGRATE)
1682
+ // transitions inside a rolling window. FAST is not counted because
1683
+ // it does not issue a new backend `joinCall`.
1684
+ if (
1685
+ this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN ||
1686
+ this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE
1687
+ ) {
1688
+ if (!this.rejoinRateLimiter.tryRegister()) {
1689
+ await giveUpAndLeave('rejoin_attempt_limit_exceeded');
1690
+ return;
1691
+ }
1692
+ }
1693
+
1547
1694
  // we don't increment reconnect attempts for the FAST strategy.
1548
1695
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
1549
1696
  this.reconnectAttempts++;
@@ -1581,6 +1728,8 @@ export class Call {
1581
1728
  );
1582
1729
  break;
1583
1730
  }
1731
+ // reconnection worked — reset the negotiation-failure streak.
1732
+ this.consecutiveNegotiationFailures = 0;
1584
1733
  break; // do-while loop, reconnection worked, exit the loop
1585
1734
  } catch (error) {
1586
1735
  if (this.state.callingState === CallingState.OFFLINE) {
@@ -1599,8 +1748,19 @@ export class Call {
1599
1748
  await markAsReconnectingFailed();
1600
1749
  return;
1601
1750
  }
1751
+ if (error instanceof NegotiationError) {
1752
+ this.consecutiveNegotiationFailures++;
1753
+ if (
1754
+ this.consecutiveNegotiationFailures >=
1755
+ this.maxConsecutiveNegotiationFailures
1756
+ ) {
1757
+ await giveUpAndLeave('repeated_negotiation_failures');
1758
+ return;
1759
+ }
1760
+ }
1602
1761
 
1603
- await sleep(500);
1762
+ // exponential backoff with jitter, capped at 5 s
1763
+ await sleep(retryInterval(attempt));
1604
1764
 
1605
1765
  const wasMigrating =
1606
1766
  this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
@@ -1741,9 +1901,10 @@ export class Call {
1741
1901
  private registerReconnectHandlers = () => {
1742
1902
  // handles the legacy "goAway" event
1743
1903
  const unregisterGoAway = this.on('goAway', () => {
1744
- this.reconnect(WebsocketReconnectStrategy.MIGRATE, 'goAway').catch(
1745
- (err) => this.logger.warn('[Reconnect] Error reconnecting', err),
1746
- );
1904
+ this.reconnect(
1905
+ WebsocketReconnectStrategy.MIGRATE,
1906
+ ReconnectReason.GO_AWAY,
1907
+ ).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
1747
1908
  });
1748
1909
 
1749
1910
  // handles the "error" event, through which the SFU can request a reconnect
@@ -1760,7 +1921,10 @@ export class Call {
1760
1921
  this.logger.warn(`Can't leave call after disconnect request`, err);
1761
1922
  });
1762
1923
  } else {
1763
- this.reconnect(strategy, error?.message || 'SFU Error').catch((err) => {
1924
+ this.reconnect(
1925
+ strategy,
1926
+ error?.message || ReconnectReason.SFU_ERROR,
1927
+ ).catch((err) => {
1764
1928
  this.logger.warn('[Reconnect] Error reconnecting', err);
1765
1929
  });
1766
1930
  }
@@ -1787,12 +1951,14 @@ export class Call {
1787
1951
  }
1788
1952
  }
1789
1953
 
1790
- this.reconnect(strategy, 'Going online').catch((err) => {
1791
- this.logger.warn(
1792
- '[Reconnect] Error reconnecting after going online',
1793
- err,
1794
- );
1795
- });
1954
+ this.reconnect(strategy, ReconnectReason.NETWORK_BACK_ONLINE).catch(
1955
+ (err) => {
1956
+ this.logger.warn(
1957
+ '[Reconnect] Error reconnecting after going online',
1958
+ err,
1959
+ );
1960
+ },
1961
+ );
1796
1962
  });
1797
1963
  this.networkAvailableTask = networkAvailableTask;
1798
1964
  this.sfuStatsReporter?.stop();
@@ -1856,7 +2022,7 @@ export class Call {
1856
2022
  private restoreSubscribedTracks = () => {
1857
2023
  const { remoteParticipants } = this.state;
1858
2024
  if (remoteParticipants.length <= 0) return;
1859
- this.dynascaleManager.applyTrackSubscriptions(undefined);
2025
+ this.trackSubscriptionManager.apply(undefined);
1860
2026
  };
1861
2027
 
1862
2028
  /**
@@ -1958,10 +2124,12 @@ export class Call {
1958
2124
  mediaStream: MediaStream | undefined,
1959
2125
  ...trackTypes: TrackType[]
1960
2126
  ) => {
1961
- if (!this.sfuClient || !this.sfuClient.sessionId) return;
2127
+ const sessionId = this.sfuClient?.sessionId;
2128
+ if (!sessionId) return;
2129
+
1962
2130
  await this.notifyTrackMuteState(!mediaStream, ...trackTypes);
2131
+ if (this.sfuClient?.sessionId !== sessionId) return;
1963
2132
 
1964
- const { sessionId } = this.sfuClient;
1965
2133
  for (const trackType of trackTypes) {
1966
2134
  const streamStateProp = trackTypeToParticipantStreamKey(trackType);
1967
2135
  if (!streamStateProp) continue;
@@ -1975,6 +2143,20 @@ export class Call {
1975
2143
  }
1976
2144
  };
1977
2145
 
2146
+ /**
2147
+ * Re-arms the encoder for a currently published track type. Useful for
2148
+ * working around WebKit's stalled sender bug after an iOS audio session
2149
+ * interruption (Siri, PSTN call).
2150
+ *
2151
+ * @internal
2152
+ *
2153
+ * @param trackType the track type to refresh.
2154
+ */
2155
+ refreshPublishedTrack = async (trackType: TrackType) => {
2156
+ if (!this.publisher) return;
2157
+ await this.publisher.refreshTrack(trackType);
2158
+ };
2159
+
1978
2160
  /**
1979
2161
  * Updates the preferred publishing options
1980
2162
  *
@@ -2888,7 +3070,7 @@ export class Call {
2888
3070
  sessionId: string,
2889
3071
  trackType: VideoTrackType,
2890
3072
  ) => {
2891
- return this.dynascaleManager.trackElementVisibility(
3073
+ return this.viewportTracker?.trackElementVisibility(
2892
3074
  element,
2893
3075
  sessionId,
2894
3076
  trackType,
@@ -2901,7 +3083,7 @@ export class Call {
2901
3083
  * @param element the viewport element.
2902
3084
  */
2903
3085
  setViewport = <T extends HTMLElement>(element: T) => {
2904
- return this.dynascaleManager.setViewport(element);
3086
+ return this.viewportTracker?.setViewport(element);
2905
3087
  };
2906
3088
 
2907
3089
  /**
@@ -2924,7 +3106,7 @@ export class Call {
2924
3106
  sessionId: string,
2925
3107
  trackType: VideoTrackType,
2926
3108
  ) => {
2927
- const unbind = this.dynascaleManager.bindVideoElement(
3109
+ const unbind = this.dynascaleManager?.bindVideoElement(
2928
3110
  videoElement,
2929
3111
  sessionId,
2930
3112
  trackType,
@@ -2953,26 +3135,33 @@ export class Call {
2953
3135
  sessionId: string,
2954
3136
  trackType: AudioTrackType = 'audioTrack',
2955
3137
  ) => {
2956
- const unbind = this.dynascaleManager.bindAudioElement(
3138
+ const unbind = this.dynascaleManager?.bindAudioElement(
2957
3139
  audioElement,
2958
3140
  sessionId,
2959
3141
  trackType,
2960
3142
  );
2961
3143
 
2962
3144
  if (!unbind) return;
2963
- this.leaveCallHooks.add(unbind);
2964
- return () => {
2965
- this.leaveCallHooks.delete(unbind);
3145
+ this.audioBindingsWatchdog?.register(audioElement, sessionId, trackType);
3146
+ const cleanup = () => {
2966
3147
  unbind();
3148
+ this.audioBindingsWatchdog?.unregister(sessionId, trackType);
3149
+ };
3150
+ this.leaveCallHooks.add(cleanup);
3151
+ return () => {
3152
+ this.leaveCallHooks.delete(cleanup);
3153
+ cleanup();
2967
3154
  };
2968
3155
  };
2969
3156
 
2970
3157
  /**
2971
3158
  * Plays all audio elements blocked by the browser's autoplay policy.
3159
+ * Must be called from within a user gesture (e.g., click handler).
3160
+ *
3161
+ * Subscribe to `call.blockedAudioTracker.autoplayBlocked$` to know when a
3162
+ * gesture is required.
2972
3163
  */
2973
- resumeAudio = () => {
2974
- return this.dynascaleManager.resumeAudio();
2975
- };
3164
+ resumeAudio = () => this.blockedAudioTracker.resumeAudio();
2976
3165
 
2977
3166
  /**
2978
3167
  * Binds a DOM <img> element to this call's thumbnail (if enabled in settings).
@@ -3026,7 +3215,7 @@ export class Call {
3026
3215
  resolution: VideoDimension | undefined,
3027
3216
  sessionIds?: string[],
3028
3217
  ) => {
3029
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(
3218
+ this.trackSubscriptionManager.setOverrides(
3030
3219
  resolution
3031
3220
  ? {
3032
3221
  enabled: true,
@@ -3035,7 +3224,7 @@ export class Call {
3035
3224
  : undefined,
3036
3225
  sessionIds,
3037
3226
  );
3038
- this.dynascaleManager.applyTrackSubscriptions();
3227
+ this.trackSubscriptionManager.apply();
3039
3228
  };
3040
3229
 
3041
3230
  /**
@@ -3043,10 +3232,10 @@ export class Call {
3043
3232
  * and removes any preference for preferred resolution.
3044
3233
  */
3045
3234
  setIncomingVideoEnabled = (enabled: boolean) => {
3046
- this.dynascaleManager.setVideoTrackSubscriptionOverrides(
3235
+ this.trackSubscriptionManager.setOverrides(
3047
3236
  enabled ? undefined : { enabled: false },
3048
3237
  );
3049
- this.dynascaleManager.applyTrackSubscriptions();
3238
+ this.trackSubscriptionManager.apply();
3050
3239
  };
3051
3240
 
3052
3241
  /**
@@ -3058,6 +3247,45 @@ export class Call {
3058
3247
  this.disconnectionTimeoutSeconds = timeoutSeconds;
3059
3248
  };
3060
3249
 
3250
+ /**
3251
+ * Configures the rolling-window limit for REJOIN/MIGRATE attempts. Once
3252
+ * `maxAttempts` rejoins have been registered inside `windowSeconds`, the
3253
+ * SDK stops retrying and transitions the call to `LEFT` with the
3254
+ * `rejoin_attempt_limit_exceeded` leave message.
3255
+ *
3256
+ * Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
3257
+ * Both arguments are clamped to a minimum of 1.
3258
+ */
3259
+ setRejoinAttemptLimit = (maxAttempts: number, windowSeconds: number) => {
3260
+ this.rejoinRateLimiter.setLimits(
3261
+ Math.max(1, maxAttempts),
3262
+ Math.max(1, windowSeconds) * 1000,
3263
+ );
3264
+ };
3265
+
3266
+ /**
3267
+ * Configures how many peer-connection failures where ICE never reached
3268
+ * `connected`/`completed` are tolerated before the SDK concludes that the
3269
+ * current network cannot support WebRTC and transitions the call to
3270
+ * `LEFT` with the `webrtc_unsupported_network` leave message.
3271
+ *
3272
+ * Default: 2. Clamped to a minimum of 1.
3273
+ */
3274
+ setMaxIceFailuresWithoutConnect = (n: number) => {
3275
+ this.maxIceFailuresWithoutConnect = Math.max(1, n);
3276
+ };
3277
+
3278
+ /**
3279
+ * Configures how many consecutive SDP `NegotiationError`s are tolerated
3280
+ * before the SDK stops retrying and transitions the call to `LEFT` with
3281
+ * the `repeated_negotiation_failures` leave message.
3282
+ *
3283
+ * Default: 3. Clamped to a minimum of 1.
3284
+ */
3285
+ setMaxConsecutiveNegotiationFailures = (n: number) => {
3286
+ this.maxConsecutiveNegotiationFailures = Math.max(1, n);
3287
+ };
3288
+
3061
3289
  /**
3062
3290
  * Enables the provided client capabilities.
3063
3291
  */
@@ -28,7 +28,7 @@ import {
28
28
  } from './gen/video/sfu/signal_rpc/signal';
29
29
  import { ICETrickle } from './gen/video/sfu/models/models';
30
30
  import { StreamClient } from './coordinator/connection/client';
31
- import { generateUUIDv4 } from './coordinator/connection/utils';
31
+ import { generateUUIDv4, sleep } from './coordinator/connection/utils';
32
32
  import { Credentials } from './gen/coordinator';
33
33
  import { ScopedLogger, videoLoggerSystem } from './logger';
34
34
  import {
@@ -104,6 +104,21 @@ type SfuWebSocketParams = {
104
104
  cid: string;
105
105
  };
106
106
 
107
+ /**
108
+ * Creates a fresh `joinResponseTask` with a no-op rejection handler attached
109
+ * to the underlying promise. The handler marks the rejection path as handled
110
+ * so a teardown-time reject (e.g., from `close()` during disposal) does not
111
+ * surface as an `UnhandledPromiseRejection`. Explicit awaiters of
112
+ * `StreamSfuClient.joinTask` still observe the rejection through their own
113
+ * `then`/`catch` chain. `.catch()` returns a new promise; the original is
114
+ * unchanged.
115
+ */
116
+ const makeJoinResponseTask = (): PromiseWithResolvers<JoinResponse> => {
117
+ const task = promiseWithResolvers<JoinResponse>();
118
+ task.promise.catch(() => {}); // see the comment above
119
+ return task;
120
+ };
121
+
107
122
  /**
108
123
  * The client used for exchanging information with the SFU.
109
124
  */
@@ -171,9 +186,10 @@ export class StreamSfuClient {
171
186
  private networkAvailableTask: PromiseWithResolvers<void> | undefined;
172
187
  /**
173
188
  * Promise that resolves when the JoinResponse is received.
174
- * Rejects after a certain threshold if the response is not received.
189
+ * Rejects after a certain threshold if the response is not received,
190
+ * or when the SFU client is disposed before a join completes.
175
191
  */
176
- private joinResponseTask = promiseWithResolvers<JoinResponse>();
192
+ private joinResponseTask = makeJoinResponseTask();
177
193
 
178
194
  /**
179
195
  * Promise that resolves when the migration is complete.
@@ -207,6 +223,12 @@ export class StreamSfuClient {
207
223
  * The close code used when the client fails to join the call (on the SFU).
208
224
  */
209
225
  static JOIN_FAILED = 4101;
226
+ /**
227
+ * Best-effort grace period in `leaveAndClose` for an in-flight join to
228
+ * complete before we give up and close without sending `leaveCallRequest`.
229
+ * Bounded so a stuck join can never hang the leave path.
230
+ */
231
+ static LEAVE_NOTIFY_GRACE_MS = 1000;
210
232
 
211
233
  /**
212
234
  * Constructs a new SFU client.
@@ -358,15 +380,24 @@ export class StreamSfuClient {
358
380
 
359
381
  close = (code: number = StreamSfuClient.NORMAL_CLOSURE, reason?: string) => {
360
382
  this.isClosingClean = code !== StreamSfuClient.ERROR_CONNECTION_UNHEALTHY;
361
- if (this.signalWs.readyState === WebSocket.OPEN) {
383
+ // Close the WebSocket whether it has fully opened (`OPEN`) or is still
384
+ // mid-handshake (`CONNECTING`). The WebSocket spec aborts the handshake
385
+ // when `close()` is called on a CONNECTING socket. Without this, an
386
+ // SFU socket that opens just after teardown would dispatch events into
387
+ // a Call instance that has already moved on.
388
+ const ws = this.signalWs;
389
+ if (
390
+ ws.readyState === WebSocket.OPEN ||
391
+ ws.readyState === WebSocket.CONNECTING
392
+ ) {
362
393
  this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
363
- this.signalWs.close(code, `js-client: ${reason}`);
364
- this.signalWs.removeEventListener('close', this.handleWebSocketClose);
394
+ ws.close(code, `js-client: ${reason}`);
395
+ ws.removeEventListener('close', this.handleWebSocketClose);
365
396
  }
366
- this.dispose();
397
+ this.dispose(reason);
367
398
  };
368
399
 
369
- private dispose = () => {
400
+ private dispose = (reason?: string) => {
370
401
  this.logger.debug('Disposing SFU client');
371
402
  this.unsubscribeIceTrickle();
372
403
  this.unsubscribeNetworkChanged();
@@ -375,6 +406,19 @@ export class StreamSfuClient {
375
406
  clearTimeout(this.migrateAwayTimeout);
376
407
  this.abortController.abort();
377
408
  this.migrationTask?.resolve();
409
+ // Settle a pending `joinResponseTask` so `leaveAndClose`, `join()`, and
410
+ // any other awaiters (`await this.joinTask`) don't hang indefinitely
411
+ // when the SFU client is torn down before the SFU sent a JoinResponse.
412
+ if (
413
+ !this.joinResponseTask.isResolved() &&
414
+ !this.joinResponseTask.isRejected()
415
+ ) {
416
+ this.joinResponseTask.reject(
417
+ new Error(
418
+ `SFU client disposed before join completed${reason ? `: ${reason}` : ''}`,
419
+ ),
420
+ );
421
+ }
378
422
  this.iceTrickleBuffer.dispose();
379
423
  };
380
424
 
@@ -385,8 +429,27 @@ export class StreamSfuClient {
385
429
  leaveAndClose = async (reason: string) => {
386
430
  try {
387
431
  this.isLeaving = true;
388
- await this.joinTask;
389
- await this.notifyLeave(reason);
432
+ // Best-effort: give an in-flight join a short grace period to complete
433
+ // so we can send a graceful `leaveCallRequest`. Bounded so we never hang
434
+ // here if the SFU is unresponsive. If the task settles either way during
435
+ // the wait, the re-check below decides whether to notify.
436
+ if (
437
+ !this.joinResponseTask.isResolved() &&
438
+ !this.joinResponseTask.isRejected()
439
+ ) {
440
+ await Promise.race([
441
+ // swallow rejection — we re-check `isResolved()` below to decide
442
+ this.joinResponseTask.promise.catch(() => {}),
443
+ sleep(StreamSfuClient.LEAVE_NOTIFY_GRACE_MS),
444
+ ]);
445
+ }
446
+ if (this.joinResponseTask.isResolved()) {
447
+ await this.notifyLeave(reason);
448
+ } else {
449
+ this.logger.debug(
450
+ '[leaveAndClose] join not completed within grace period, skipping notifyLeave',
451
+ );
452
+ }
390
453
  } catch (err) {
391
454
  this.logger.debug('Error notifying SFU about leaving call', err);
392
455
  }
@@ -535,9 +598,9 @@ export class StreamSfuClient {
535
598
  ) {
536
599
  // we need to lock the RPC requests until we receive a JoinResponse.
537
600
  // that's why we have this primitive lock mechanism.
538
- // the client starts with already initialized joinResponseTask,
601
+ // the client starts with an already initialized joinResponseTask,
539
602
  // and this code creates a new one for the next join request.
540
- this.joinResponseTask = promiseWithResolvers<JoinResponse>();
603
+ this.joinResponseTask = makeJoinResponseTask();
541
604
  }
542
605
 
543
606
  // capture a reference to the current joinResponseTask as it might