@stream-io/video-client 1.59.1 → 1.61.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 (62) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.browser.es.js +744 -156
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +744 -155
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +744 -156
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +34 -8
  10. package/dist/src/coordinator/connection/types.d.ts +16 -0
  11. package/dist/src/events/call.d.ts +0 -11
  12. package/dist/src/gen/coordinator/index.d.ts +73 -0
  13. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  14. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  15. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  16. package/dist/src/ringing/index.d.ts +4 -0
  17. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  18. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  19. package/dist/src/rtc/Publisher.d.ts +18 -0
  20. package/dist/src/rtc/TransceiverCache.d.ts +10 -0
  21. package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
  22. package/dist/src/stats/utils.d.ts +6 -0
  23. package/dist/src/store/CallState.d.ts +10 -1
  24. package/dist/src/types.d.ts +30 -1
  25. package/index.ts +1 -0
  26. package/package.json +1 -1
  27. package/src/Call.ts +272 -108
  28. package/src/StreamSfuClient.ts +6 -2
  29. package/src/__tests__/Call.autodrop.test.ts +116 -64
  30. package/src/__tests__/Call.getRingState.test.ts +87 -0
  31. package/src/__tests__/Call.lifecycle.test.ts +498 -5
  32. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  33. package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
  34. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  35. package/src/coordinator/connection/types.ts +19 -0
  36. package/src/events/__tests__/call.test.ts +2 -217
  37. package/src/events/call.ts +1 -73
  38. package/src/events/callEventHandlers.ts +12 -23
  39. package/src/gen/coordinator/index.ts +67 -0
  40. package/src/reporting/ClientEventReporter.ts +30 -4
  41. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  42. package/src/ringing/RingStatePoller.ts +177 -0
  43. package/src/ringing/RingTimeout.ts +90 -0
  44. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  45. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  46. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  47. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  48. package/src/ringing/index.ts +4 -0
  49. package/src/ringing/reconcileRingState.ts +108 -0
  50. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  51. package/src/rtc/Publisher.ts +66 -12
  52. package/src/rtc/TransceiverCache.ts +17 -0
  53. package/src/rtc/__tests__/Publisher.test.ts +209 -0
  54. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
  55. package/src/rtc/e2ee/E2EEManager.ts +10 -1
  56. package/src/rtc/e2ee/EncryptionManager.ts +1 -1
  57. package/src/rtc/e2ee/SPEC.md +1 -1
  58. package/src/stats/__tests__/utils.test.ts +33 -0
  59. package/src/stats/utils.ts +15 -0
  60. package/src/store/CallState.ts +25 -0
  61. package/src/store/__tests__/CallState.test.ts +59 -0
  62. package/src/types.ts +37 -1
package/dist/index.es.js CHANGED
@@ -5082,7 +5082,7 @@ const toIceCandidate = (iceTrickle) => {
5082
5082
  }
5083
5083
  };
5084
5084
 
5085
- const version = "1.59.1";
5085
+ const version = "1.61.0";
5086
5086
  const [major, minor, patch] = version.split('.');
5087
5087
  let sdkInfo = {
5088
5088
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -7115,6 +7115,30 @@ class CallState {
7115
7115
  this.setCurrentValue(this.captioningSubject, call.captioning);
7116
7116
  this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
7117
7117
  };
7118
+ /**
7119
+ * Merges a polled ring state into the current session. Only the ring fields
7120
+ * are touched: the endpoint does not return the session roster.
7121
+ *
7122
+ * @internal
7123
+ *
7124
+ * @param ringState the ring state as returned by the coordinator.
7125
+ */
7126
+ updateFromRingState = (ringState) => {
7127
+ this.setCurrentValue(this.sessionSubject, (session) => {
7128
+ if (!session || session.id !== ringState.session_id)
7129
+ return session;
7130
+ return {
7131
+ ...session,
7132
+ accepted_by: ringState.accepted_by,
7133
+ rejected_by: ringState.rejected_by,
7134
+ missed_by: ringState.missed_by,
7135
+ ended_at: ringState.session_ended_at ?? session.ended_at,
7136
+ };
7137
+ });
7138
+ if (ringState.call_ended_at) {
7139
+ this.setEndedAt(new Date(ringState.call_ended_at));
7140
+ }
7141
+ };
7118
7142
  /**
7119
7143
  * Updates the call state with the data received from the SFU server.
7120
7144
  *
@@ -7426,6 +7450,19 @@ const getSdkName = (sdk) => {
7426
7450
  const getSdkVersion = (sdk) => {
7427
7451
  return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
7428
7452
  };
7453
+ /**
7454
+ * Returns the identifier the SFU receives in the `X-Stream-Client` header,
7455
+ * e.g. `stream-video-react-v1.43.1`. Matches the format the coordinator
7456
+ * already gets through the client's user agent.
7457
+ */
7458
+ const getStreamClientId = (sdk) => {
7459
+ const sdkPlatforms = {
7460
+ [SdkType.REACT]: 'react',
7461
+ [SdkType.REACT_NATIVE]: 'react-native',
7462
+ };
7463
+ const platform = (sdk && sdkPlatforms[sdk.type]) || 'js';
7464
+ return `stream-video-${platform}-v${getSdkVersion(sdk)}`;
7465
+ };
7429
7466
 
7430
7467
  /**
7431
7468
  * Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
@@ -9110,6 +9147,25 @@ class TransceiverCache {
9110
9147
  this.cache.push(bundle);
9111
9148
  this.transceiverOrder.push(bundle.transceiver);
9112
9149
  };
9150
+ /**
9151
+ * Retires a failed sender from the reusable cache.
9152
+ *
9153
+ * A transceiver that never reached an m-section also leaves the creation
9154
+ * history: once stopped, it is left out of every later offer, so counting
9155
+ * it would shift the `mid` index of every transceiver added after it. One
9156
+ * that is already associated keeps its slot, since its m-section survives
9157
+ * as a rejected one.
9158
+ */
9159
+ remove = (transceiver) => {
9160
+ const index = this.cache.findIndex((b) => b.transceiver === transceiver);
9161
+ if (index !== -1)
9162
+ this.cache.splice(index, 1);
9163
+ if (transceiver.mid)
9164
+ return;
9165
+ const orderIndex = this.transceiverOrder.indexOf(transceiver);
9166
+ if (orderIndex !== -1)
9167
+ this.transceiverOrder.splice(orderIndex, 1);
9168
+ };
9113
9169
  /**
9114
9170
  * Gets the transceiver for the given publish option.
9115
9171
  */
@@ -9281,7 +9337,9 @@ class Publisher extends BasePeerConnection {
9281
9337
  const sendEncodings = isSvcCodec(publishOption.codec?.name)
9282
9338
  ? toSvcEncodings(encodings)
9283
9339
  : encodings;
9284
- const transceiver = this.pc.addTransceiver(track, {
9340
+ // An E2EE sender must never have media before its transform is attached,
9341
+ // including when another track or an ICE restart negotiates this PC.
9342
+ const transceiver = this.pc.addTransceiver(this.e2ee ? track.kind : track, {
9285
9343
  direction: 'sendonly',
9286
9344
  sendEncodings,
9287
9345
  });
@@ -9291,16 +9349,63 @@ class Publisher extends BasePeerConnection {
9291
9349
  this.transceiverCache.add({ publishOption, transceiver, options });
9292
9350
  this.trackIdToTrackType.set(track.id, trackType);
9293
9351
  this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
9294
- const params = transceiver.sender.getParameters();
9352
+ if (this.e2ee) {
9353
+ await this.initEncryptedSender(transceiver, track, publishOption);
9354
+ }
9355
+ else {
9356
+ await this.setDegradationPreference(transceiver.sender, publishOption);
9357
+ }
9358
+ await this.negotiate();
9359
+ };
9360
+ /**
9361
+ * Attaches the encryptor, applies sender parameters, then attaches the track.
9362
+ *
9363
+ * `encrypt` must run before the first await: Chrome short-circuits a sender
9364
+ * whose encoded streams aren't created in the task that created it, and the
9365
+ * track goes on last so the sender has no media until both steps succeed.
9366
+ */
9367
+ initEncryptedSender = async (transceiver, track, publishOption) => {
9368
+ try {
9369
+ const { sender } = transceiver;
9370
+ const codec = publishOption.codec?.name.toLowerCase();
9371
+ this.e2ee.encrypt(sender, codec, TrackType[publishOption.trackType]);
9372
+ this.logger.debug('E2EE encryptor attached to sender');
9373
+ await this.setDegradationPreference(sender, publishOption);
9374
+ await sender.replaceTrack(track);
9375
+ }
9376
+ catch (err) {
9377
+ this.retireTransceiver(transceiver, track);
9378
+ throw err;
9379
+ }
9380
+ };
9381
+ setDegradationPreference = async (sender, publishOption) => {
9382
+ const params = sender.getParameters();
9295
9383
  params.degradationPreference =
9296
9384
  toRTCDegradationPreference(publishOption.degradationPreference) ??
9297
9385
  'maintain-framerate';
9298
- await transceiver.sender.setParameters(params);
9299
- if (this.e2ee) {
9300
- this.e2ee.encrypt(transceiver.sender, publishOption.codec?.name.toLowerCase(), TrackType[publishOption.trackType]);
9301
- this.logger.debug('E2EE encryptor attached to sender');
9386
+ await sender.setParameters(params);
9387
+ };
9388
+ /**
9389
+ * Discards a sender whose E2EE initialization failed.
9390
+ *
9391
+ * A partially initialized sender may carry no transform, so publish() must
9392
+ * not reuse it: retiring it from the cache is what keeps a later publish or
9393
+ * ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
9394
+ * the initialization failure is what reaches the caller.
9395
+ */
9396
+ retireTransceiver = (transceiver, track) => {
9397
+ this.transceiverCache.remove(transceiver);
9398
+ this.trackIdToTrackType.delete(track.id);
9399
+ // on React Native a clone shares its native source, so stopping it here
9400
+ // would stop the track the caller passed in. dispose() releases it.
9401
+ if (!isReactNative())
9402
+ this.stopTrack(track);
9403
+ try {
9404
+ transceiver.stop();
9405
+ }
9406
+ catch (err) {
9407
+ this.logger.debug('Failed to stop a retired transceiver', err);
9302
9408
  }
9303
- await this.negotiate();
9304
9409
  };
9305
9410
  /**
9306
9411
  * Updates the transceiver with the given track and track type.
@@ -10307,7 +10412,10 @@ class StreamSfuClient {
10307
10412
  this.rpc = createSignalClient({
10308
10413
  baseUrl: server.url,
10309
10414
  interceptors: [
10310
- withHeaders({ Authorization: `Bearer ${token}` }),
10415
+ withHeaders({
10416
+ Authorization: `Bearer ${token}`,
10417
+ 'X-Stream-Client': getStreamClientId(getSdkInfo()),
10418
+ }),
10311
10419
  this.tracer && withRequestTracer(this.tracer.trace),
10312
10420
  this.logger.getLogLevel() === 'trace' && withRequestLogger(this.logger),
10313
10421
  withTimeout(rpcRequestTimeout, this.tracer?.trace),
@@ -10644,64 +10752,370 @@ class StreamSfuClient {
10644
10752
  }
10645
10753
 
10646
10754
  /**
10647
- * Event handler that watched the delivery of `call.accepted`.
10648
- * Once the event is received, the call is joined.
10649
- */
10650
- const watchCallAccepted = (call) => {
10651
- return async function onCallAccepted(event) {
10652
- // We want to discard the event if it's from the current user
10653
- if (event.user.id === call.currentUserId)
10654
- return;
10655
- const { state } = call;
10656
- if (event.call.created_by.id === call.currentUserId &&
10657
- state.callingState === CallingState.RINGING) {
10658
- await call.join();
10755
+ * Decides what a ringing call should do next, based on the current call state.
10756
+ *
10757
+ * The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
10758
+ * state poller both run this. They differ only in how the state got there: the
10759
+ * handlers rely on `CallState.updateFromEvent`, which runs before them, and the
10760
+ * poller applies the polled ring state itself.
10761
+ *
10762
+ * @param call the call to reconcile.
10763
+ * @param joinSource which of the two triggered this run, reported on the
10764
+ * caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
10765
+ * poller.
10766
+ * @returns whether the ring reached a terminal state. A failed join is not
10767
+ * terminal: the caller should keep trying while the ring is open.
10768
+ */
10769
+ const reconcileRingState = async (call, joinSource) => {
10770
+ if (call.state.callingState !== CallingState.RINGING)
10771
+ return true;
10772
+ return call.isCreatedByMe
10773
+ ? reconcileAsCaller(call, joinSource)
10774
+ : reconcileAsCallee(call);
10775
+ };
10776
+ const reconcileAsCaller = async (call, joinSource) => {
10777
+ const { session, members, endedAt } = call.state;
10778
+ const currentUserId = call.currentUserId;
10779
+ // checked before `accepted_by`: an ended session cannot be joined
10780
+ if (endedAt || session?.ended_at) {
10781
+ call.logger.info('ring: the call has ended, leaving');
10782
+ // `leave` reports the remote end to callingx off `reason: 'ended'`
10783
+ return leave(call, {
10784
+ reject: false,
10785
+ reason: 'ended',
10786
+ message: 'ring: call ended',
10787
+ });
10788
+ }
10789
+ const acceptedBy = session?.accepted_by ?? {};
10790
+ if (Object.keys(acceptedBy).some((userId) => userId !== currentUserId)) {
10791
+ call.logger.info('ring: the call was accepted, joining');
10792
+ try {
10793
+ await call.join({ joinSource });
10659
10794
  }
10660
- };
10795
+ catch (err) {
10796
+ // `doJoin` restores the ringing state when a join fails, so the ring is
10797
+ // still open. Report it unsettled and let the next poll retry.
10798
+ call.logger.error('Failed to join an accepted call', err);
10799
+ return false;
10800
+ }
10801
+ return true;
10802
+ }
10803
+ const otherMembers = members
10804
+ .filter((member) => member.user_id !== currentUserId)
10805
+ .map((member) => member.user_id);
10806
+ if (otherMembers.length === 0)
10807
+ return false;
10808
+ const rejectedBy = session?.rejected_by ?? {};
10809
+ if (otherMembers.every((userId) => rejectedBy[userId])) {
10810
+ call.logger.info('ring: everyone rejected, leaving');
10811
+ return leave(call, {
10812
+ reject: true,
10813
+ reason: 'cancel',
10814
+ message: 'ring: everyone rejected',
10815
+ });
10816
+ }
10817
+ return false;
10818
+ };
10819
+ // the current user's own accept or reject, on this or another device, is
10820
+ // handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
10821
+ const reconcileAsCallee = async (call) => {
10822
+ const createdById = call.state.createdBy?.id;
10823
+ const rejectedBy = call.state.session?.rejected_by ?? {};
10824
+ if (createdById && rejectedBy[createdById]) {
10825
+ call.logger.info('ring: the caller cancelled, leaving');
10826
+ return leave(call, {
10827
+ reason: 'ended',
10828
+ message: 'ring: creator rejected',
10829
+ });
10830
+ }
10831
+ return false;
10661
10832
  };
10833
+ // `false` when the call could not be left, so the ring stays open for a retry.
10834
+ const leave = async (call, options) => {
10835
+ try {
10836
+ await call.leave(options);
10837
+ return true;
10838
+ }
10839
+ catch (err) {
10840
+ call.logger.error('Failed to leave a ringing call', err);
10841
+ return false;
10842
+ }
10843
+ };
10844
+
10845
+ // `doJoin` restores the previous calling state when a join fails, so JOINING can
10846
+ // go back to RINGING. Only a state the ring cannot return from ends the poller.
10847
+ const ringIsOver = (callingState) => callingState !== CallingState.RINGING &&
10848
+ callingState !== CallingState.JOINING;
10662
10849
  /**
10663
- * Event handler that watches delivery of `call.rejected` Websocket event.
10664
- * Once the event is received, the call is left.
10850
+ * Polls the coordinator for the outcome of a ring the current user started.
10851
+ *
10852
+ * `call.accepted`, `call.rejected` and `call.missed` are delivered best-effort,
10853
+ * with no store-and-forward, so a caller that drops one is left on a ringing
10854
+ * screen while the callee is already in the call. After a quiet period this
10855
+ * reads the ring state until a terminal outcome or the end of the ring window.
10665
10856
  */
10666
- const watchCallRejected = (call) => {
10667
- return async function onCallRejected(event) {
10668
- // We want to discard the event if it's from the current user
10669
- if (event.user.id === call.currentUserId)
10857
+ class RingStatePoller {
10858
+ logger = videoLoggerSystem.getLogger('RingStatePoller');
10859
+ call;
10860
+ startAfterMs;
10861
+ intervalMs;
10862
+ sessionId;
10863
+ deadlineAt = 0;
10864
+ idleTimeoutId;
10865
+ intervalId;
10866
+ stopped = false;
10867
+ wasPolling = false;
10868
+ inFlight = false;
10869
+ unsubscribe = [];
10870
+ constructor(call, options = {}) {
10871
+ this.call = call;
10872
+ this.startAfterMs = options.startAfterMs ?? 15_000;
10873
+ this.intervalMs = options.intervalMs ?? 5_000;
10874
+ }
10875
+ /**
10876
+ * Starts polling. Does nothing when the call has no session yet.
10877
+ */
10878
+ start = () => {
10879
+ if (this.stopped || this.sessionId)
10880
+ return;
10881
+ if (this.call.state.callingState !== CallingState.RINGING)
10670
10882
  return;
10671
- const { call: eventCall } = event;
10672
- const { session: callSession } = eventCall;
10673
- if (!callSession) {
10674
- call.logger.warn('No call session provided. Ignoring call.rejected event.', event);
10883
+ // captured once: `call.ended` clears the call's current session
10884
+ const sessionId = this.call.state.session?.id;
10885
+ if (!sessionId) {
10886
+ this.logger.warn('the call has no session');
10675
10887
  return;
10676
10888
  }
10677
- const rejectedBy = callSession.rejected_by;
10678
- const { members, callingState } = call.state;
10679
- if (callingState !== CallingState.RINGING) {
10680
- call.logger.info('Call is not in ringing mode (it is either accepted or rejected already). Ignoring call.rejected event.', event);
10889
+ this.sessionId = sessionId;
10890
+ const ring = this.call.state.settings?.ring;
10891
+ const maxDurationMs = ring?.auto_cancel_timeout_ms || ring?.missed_call_timeout_ms || 30_000;
10892
+ this.deadlineAt = Date.now() + maxDurationMs;
10893
+ // an incoming event proves the socket is alive, so the quiet period starts
10894
+ // over: in a group ring a single rejection does not settle the ring.
10895
+ this.unsubscribe.push(this.call.on('call.accepted', () => this.armIdleWindow()), this.call.on('call.rejected', () => this.armIdleWindow()), this.call.on('call.missed', () => this.armIdleWindow()),
10896
+ // the ring is over, whichever way it went. Joining an accepted call
10897
+ // never goes through `leave`, so this is the only signal for it.
10898
+ createSubscription(this.call.state.callingState$, (callingState) => {
10899
+ if (ringIsOver(callingState))
10900
+ this.stop();
10901
+ }));
10902
+ this.armIdleWindow();
10903
+ };
10904
+ /**
10905
+ * Pauses polling, keeping the captured session, the deadline and the event
10906
+ * subscriptions. Resuming does not extend the ring window.
10907
+ */
10908
+ pause = () => {
10909
+ if (this.stopped)
10910
+ return;
10911
+ this.wasPolling = this.intervalId !== undefined;
10912
+ const timers = getTimers();
10913
+ timers.clearTimeout(this.idleTimeoutId);
10914
+ timers.clearInterval(this.intervalId);
10915
+ this.idleTimeoutId = undefined;
10916
+ this.intervalId = undefined;
10917
+ };
10918
+ /**
10919
+ * Resumes a paused poller. Goes straight back to polling if it was already
10920
+ * past the quiet period, so a pause does not buy the ring another one.
10921
+ */
10922
+ resume = () => {
10923
+ if (this.stopped || !this.sessionId)
10924
+ return;
10925
+ if (this.idleTimeoutId !== undefined || this.intervalId !== undefined) {
10681
10926
  return;
10682
10927
  }
10683
- if (call.isCreatedByMe) {
10684
- const everyoneElseRejected = members
10685
- .filter((m) => m.user_id !== call.currentUserId)
10686
- .every((m) => rejectedBy[m.user_id]);
10687
- if (everyoneElseRejected) {
10688
- call.logger.info('everyone rejected, leaving the call');
10689
- await call.leave({
10690
- reject: true,
10691
- reason: 'cancel',
10692
- message: 'ring: everyone rejected',
10693
- });
10694
- }
10928
+ if (!this.wasPolling) {
10929
+ this.armIdleWindow();
10930
+ return;
10695
10931
  }
10696
- else {
10697
- if (rejectedBy[eventCall.created_by.id]) {
10698
- call.logger.info('call creator rejected, leaving call');
10699
- globalThis.streamRNVideoSDK?.callingX?.endCall(call, 'remote');
10700
- await call.leave({ message: 'ring: creator rejected' });
10932
+ this.intervalId = getTimers().setInterval(this.runTick, this.intervalMs);
10933
+ this.runTick();
10934
+ };
10935
+ /**
10936
+ * Stops polling. The poller cannot be restarted.
10937
+ */
10938
+ stop = () => {
10939
+ if (this.stopped)
10940
+ return;
10941
+ this.stopped = true;
10942
+ const timers = getTimers();
10943
+ timers.clearTimeout(this.idleTimeoutId);
10944
+ timers.clearInterval(this.intervalId);
10945
+ this.idleTimeoutId = undefined;
10946
+ this.intervalId = undefined;
10947
+ this.unsubscribe.forEach((off) => off());
10948
+ this.unsubscribe = [];
10949
+ };
10950
+ armIdleWindow = () => {
10951
+ if (this.stopped)
10952
+ return;
10953
+ const timers = getTimers();
10954
+ timers.clearTimeout(this.idleTimeoutId);
10955
+ timers.clearInterval(this.intervalId);
10956
+ this.intervalId = undefined;
10957
+ this.idleTimeoutId = timers.setTimeout(() => {
10958
+ this.idleTimeoutId = undefined;
10959
+ if (this.stopped)
10960
+ return;
10961
+ this.intervalId = timers.setInterval(this.runTick, this.intervalMs);
10962
+ this.runTick();
10963
+ }, this.startAfterMs);
10964
+ };
10965
+ runTick = () => {
10966
+ this.tick().catch((err) => {
10967
+ this.logger.warn('Failed to poll the ring state', err);
10968
+ });
10969
+ };
10970
+ tick = async () => {
10971
+ if (this.stopped || this.inFlight || !this.sessionId)
10972
+ return;
10973
+ const { callingState } = this.call.state;
10974
+ if (ringIsOver(callingState) || Date.now() >= this.deadlineAt) {
10975
+ this.stop();
10976
+ return;
10977
+ }
10978
+ // mid-join: nothing to reconcile until we know whether it succeeded
10979
+ if (callingState !== CallingState.RINGING)
10980
+ return;
10981
+ this.inFlight = true;
10982
+ try {
10983
+ const ringState = await this.call.getRingState(this.sessionId);
10984
+ if (this.stopped)
10985
+ return;
10986
+ this.call.state.updateFromRingState(ringState);
10987
+ if (await reconcileRingState(this.call, 'ring-poll-api'))
10988
+ this.stop();
10989
+ }
10990
+ catch (err) {
10991
+ // a missing session, or one of another call, will never resolve
10992
+ const status = err instanceof ErrorFromResponse ? err.status : undefined;
10993
+ if (status === 400 || status === 404) {
10994
+ this.logger.warn('Stopped polling the ring state', err);
10995
+ this.stop();
10996
+ }
10997
+ else {
10998
+ this.logger.debug('Failed to poll the ring state', err);
10701
10999
  }
10702
11000
  }
11001
+ finally {
11002
+ this.inFlight = false;
11003
+ }
11004
+ };
11005
+ }
11006
+
11007
+ /**
11008
+ * Drops a call that has been ringing for too long: the caller cancels after
11009
+ * `auto_cancel_timeout_ms`, a callee after `incoming_call_timeout_ms`.
11010
+ *
11011
+ * Either timeout being `0` means the call rings until something else settles it.
11012
+ */
11013
+ class RingTimeout {
11014
+ call;
11015
+ timeoutId;
11016
+ deadlineAt;
11017
+ stopped = false;
11018
+ constructor(call) {
11019
+ this.call = call;
11020
+ }
11021
+ /**
11022
+ * Schedules an auto-drop timeout based on the call settings.
11023
+ * Applicable only for ringing calls.
11024
+ */
11025
+ start = () => {
11026
+ if (this.stopped || this.timeoutId !== undefined)
11027
+ return;
11028
+ // ignore if the call is not ringing
11029
+ if (this.call.state.callingState !== CallingState.RINGING)
11030
+ return;
11031
+ const ring = this.call.state.settings?.ring;
11032
+ if (!ring)
11033
+ return;
11034
+ const isCaller = this.call.isCreatedByMe;
11035
+ const timeoutMs = isCaller
11036
+ ? ring.auto_cancel_timeout_ms
11037
+ : ring.incoming_call_timeout_ms;
11038
+ // 0 means no auto-drop
11039
+ if (timeoutMs <= 0)
11040
+ return;
11041
+ const now = Date.now();
11042
+ this.deadlineAt ??= now + timeoutMs;
11043
+ const delayMs = Math.max(0, this.deadlineAt - now);
11044
+ const timers = getTimers();
11045
+ this.timeoutId = timers.setTimeout(() => {
11046
+ this.timeoutId = undefined;
11047
+ // A failed timeout-triggered leave can arm a fresh retry window.
11048
+ this.deadlineAt = undefined;
11049
+ if (this.stopped)
11050
+ return;
11051
+ // the call might have stopped ringing by this point, e.g. it was already
11052
+ // accepted and joined
11053
+ if (this.call.state.callingState !== CallingState.RINGING)
11054
+ return;
11055
+ this.call
11056
+ .leave({
11057
+ reject: true,
11058
+ reason: 'timeout',
11059
+ message: `ringing timeout - ${isCaller
11060
+ ? 'no one accepted'
11061
+ : `user didn't interact with incoming call screen`}`,
11062
+ })
11063
+ .catch((err) => {
11064
+ videoLoggerSystem
11065
+ .getLogger('RingTimeout')
11066
+ .error('Failed to drop the call', err);
11067
+ });
11068
+ }, delayMs);
11069
+ };
11070
+ /**
11071
+ * Pauses the timeout while preserving its original deadline.
11072
+ */
11073
+ pause = () => {
11074
+ if (this.stopped || this.timeoutId === undefined)
11075
+ return;
11076
+ getTimers().clearTimeout(this.timeoutId);
11077
+ this.timeoutId = undefined;
11078
+ };
11079
+ /**
11080
+ * Cancels a scheduled auto-drop timeout. It cannot be armed again.
11081
+ */
11082
+ stop = () => {
11083
+ if (this.stopped)
11084
+ return;
11085
+ this.stopped = true;
11086
+ getTimers().clearTimeout(this.timeoutId);
11087
+ this.timeoutId = undefined;
11088
+ this.deadlineAt = undefined;
11089
+ };
11090
+ }
11091
+
11092
+ /**
11093
+ * Decides what a ringing call should do about the current user's own accept or
11094
+ * reject, which may have happened on another device.
11095
+ *
11096
+ * Accepting on *this* device also lands in `accepted_by`, so an acceptance only
11097
+ * means another device took the call when this one is still ringing.
11098
+ *
11099
+ * Only meaningful for a ringing call; the caller checks that.
11100
+ */
11101
+ const resolveOwnRingOutcome = ({ session, currentUserId, callingState, }) => {
11102
+ if (!currentUserId)
11103
+ return { settledByMe: false };
11104
+ const acceptedByMe = Boolean(session?.accepted_by[currentUserId]);
11105
+ const rejectedByMe = Boolean(session?.rejected_by[currentUserId]);
11106
+ if (!acceptedByMe && !rejectedByMe)
11107
+ return { settledByMe: false };
11108
+ const answeredElsewhere = acceptedByMe && callingState === CallingState.RINGING;
11109
+ return {
11110
+ settledByMe: true,
11111
+ leaveReason: answeredElsewhere
11112
+ ? 'answeredElsewhere'
11113
+ : rejectedByMe
11114
+ ? 'rejected'
11115
+ : undefined,
10703
11116
  };
10704
11117
  };
11118
+
10705
11119
  /**
10706
11120
  * Event handler that watches the delivery of `call.ended` Websocket event.
10707
11121
  */
@@ -11102,14 +11516,18 @@ const registerEventHandlers = (call, dispatcher) => {
11102
11516
  * @param call the call to register event handlers for.
11103
11517
  */
11104
11518
  const registerRingingCallEventHandlers = (call) => {
11105
- const coordinatorRingEvents = {
11106
- 'call.accepted': watchCallAccepted(call),
11107
- 'call.rejected': watchCallRejected(call),
11519
+ const reconcile = () => {
11520
+ reconcileRingState(call, 'ring-ws').catch((err) => {
11521
+ call.logger.error('Failed to reconcile the ring state', err);
11522
+ });
11108
11523
  };
11109
- const eventHandlers = Object.keys(coordinatorRingEvents).map((event) => {
11110
- const eventName = event;
11111
- return call.on(eventName, coordinatorRingEvents[eventName]);
11112
- });
11524
+ // each event needs its own closure. `call.missed` is deliberately absent:
11525
+ // nothing in the reconciler acts on `missed_by`, so the auto-drop owns the
11526
+ // "nobody answered" case until the server-owned ring timeout lands.
11527
+ const eventHandlers = [
11528
+ call.on('call.accepted', () => reconcile()),
11529
+ call.on('call.rejected', () => reconcile()),
11530
+ ];
11113
11531
  return () => {
11114
11532
  eventHandlers.forEach((unsubscribe) => unsubscribe());
11115
11533
  };
@@ -15157,7 +15575,8 @@ class Call {
15157
15575
  statsReporter;
15158
15576
  sfuStatsReporter;
15159
15577
  lastStatsOptions;
15160
- dropTimeout;
15578
+ ringTimeout;
15579
+ ringStatePoller;
15161
15580
  clientStore;
15162
15581
  streamClient;
15163
15582
  clientEventReporter;
@@ -15335,22 +15754,19 @@ class Call {
15335
15754
  createSubscription(this.state.session$, (session) => {
15336
15755
  if (!this.ringing)
15337
15756
  return;
15338
- const receiverId = this.clientStore.connectedUser?.id;
15339
- if (!receiverId)
15340
- return;
15341
- const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
15342
- const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
15343
- if (isAcceptedByMe || isRejectedByMe) {
15757
+ const { settledByMe, leaveReason } = resolveOwnRingOutcome({
15758
+ session,
15759
+ currentUserId: this.currentUserId,
15760
+ callingState: this.state.callingState,
15761
+ });
15762
+ if (settledByMe)
15344
15763
  this.cancelAutoDrop();
15345
- }
15346
- const isAcceptedElsewhere = isAcceptedByMe && this.state.callingState === CallingState.RINGING;
15347
- if ((isAcceptedElsewhere || isRejectedByMe) &&
15348
- !hasPending(this.joinLeaveConcurrencyTag)) {
15349
- globalThis.streamRNVideoSDK?.callingX?.endCall(this, isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected');
15350
- this.leave().catch(() => {
15351
- this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
15352
- });
15353
- }
15764
+ if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag))
15765
+ return;
15766
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
15767
+ this.leave().catch(() => {
15768
+ this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
15769
+ });
15354
15770
  }));
15355
15771
  };
15356
15772
  handleRingingCall = () => {
@@ -15396,6 +15812,7 @@ class Call {
15396
15812
  this.state.setCallingState(CallingState.RINGING);
15397
15813
  }
15398
15814
  this.scheduleAutoDrop();
15815
+ this.scheduleRingStatePolling();
15399
15816
  this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
15400
15817
  }
15401
15818
  };
@@ -15482,6 +15899,12 @@ class Call {
15482
15899
  if (this.state.callingState === CallingState.LEFT) {
15483
15900
  throw new Error('Cannot leave call that has already been left.');
15484
15901
  }
15902
+ // before the first await: the calling state stays RINGING well into the
15903
+ // teardown, so pause both watchdogs before they can race this leave. They
15904
+ // keep their deadlines, so a failed leave resumes them without handing the
15905
+ // ring another window.
15906
+ this.ringTimeout?.pause();
15907
+ this.ringStatePoller?.pause();
15485
15908
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
15486
15909
  const callingState = this.state.callingState;
15487
15910
  if (callingState === CallingState.LEFT) {
@@ -15548,6 +15971,11 @@ class Call {
15548
15971
  this.audioBindingsWatchdog?.dispose();
15549
15972
  await this.dynascaleManager?.dispose();
15550
15973
  this.state.setCallingState(CallingState.LEFT);
15974
+ // `ringingSubject` is cleared further down, so this still reads true for
15975
+ // a call that was ringing.
15976
+ if (this.ringing) {
15977
+ globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this);
15978
+ }
15551
15979
  this.state.setParticipants([]);
15552
15980
  this.state.dispose();
15553
15981
  // Reset reconnect-related accumulators so a future `call.join()` on the
@@ -15571,6 +15999,7 @@ class Call {
15571
15999
  this.unifiedSessionId = undefined;
15572
16000
  this.ringingSubject.next(false);
15573
16001
  this.cancelAutoDrop();
16002
+ this.cancelRingStatePolling();
15574
16003
  this.clientStore.unregisterCall(this);
15575
16004
  globalThis.streamRNVideoSDK?.callManager.stop({
15576
16005
  isRingingTypeCall: this.ringing,
@@ -15609,6 +16038,15 @@ class Call {
15609
16038
  this.logger.warn('Failed to dispose media engine', err);
15610
16039
  });
15611
16040
  }
16041
+ }).catch((err) => {
16042
+ if (!hasPending(this.joinLeaveConcurrencyTag) &&
16043
+ this.state.callingState === CallingState.RINGING) {
16044
+ // resume, never re-arm: a fresh watchdog would restart the ring
16045
+ // window this leave was already most of the way through
16046
+ this.ringTimeout?.start();
16047
+ this.ringStatePoller?.resume();
16048
+ }
16049
+ throw err;
15612
16050
  });
15613
16051
  };
15614
16052
  /**
@@ -15706,8 +16144,11 @@ class Call {
15706
16144
  * @param params.video if set to true, in a ringing scenario, mobile SDKs will show "incoming video call", audio only otherwise.
15707
16145
  */
15708
16146
  get = async (params) => {
16147
+ const getLeaveGeneration = this.leaveGeneration;
15709
16148
  await this.setup();
15710
16149
  const response = await this.streamClient.get(this.streamClientBasePath, params);
16150
+ if (this.leaveGeneration !== getLeaveGeneration)
16151
+ return response;
15711
16152
  this.updateFromCallStateResponse(response);
15712
16153
  if (params?.ring) {
15713
16154
  this.ringingSubject.next(true);
@@ -15759,6 +16200,21 @@ class Call {
15759
16200
  ring = async (data = {}) => {
15760
16201
  return this.streamClient.post(`${this.streamClientBasePath}/ring`, data);
15761
16202
  };
16203
+ /**
16204
+ * Returns who accepted, rejected or missed the ring for a call session.
16205
+ * Safe to poll: it performs no writes and emits no events.
16206
+ *
16207
+ * @param callSessionId the call session to read. Defaults to the current one.
16208
+ * Pass it explicitly to read a session that has already ended, as ending a
16209
+ * call clears its current session.
16210
+ */
16211
+ getRingState = async (callSessionId) => {
16212
+ const sessionId = callSessionId ?? this.state.session?.id;
16213
+ if (!sessionId) {
16214
+ throw new Error('Cannot read the ring state: the call has no session');
16215
+ }
16216
+ return this.streamClient.get(`${this.streamClientBasePath}/ring_state`, { call_session_id: sessionId });
16217
+ };
15762
16218
  /**
15763
16219
  * A shortcut for {@link Call.get} with `notify` parameter set to `true`.
15764
16220
  * Will send a `call.notification` event to the call members.
@@ -15797,42 +16253,80 @@ class Call {
15797
16253
  /**
15798
16254
  * Will start to watch for call related WebSocket events and initiate a call session with the server.
15799
16255
  *
16256
+ * One instance is one call flow: discard it after leaving, cancelling, or a
16257
+ * failed join, and create a fresh one for a later flow. Reconnection inside a
16258
+ * live call is handled here and needs no new instance.
16259
+ *
15800
16260
  * @returns a promise which resolves once the call join-flow has finished.
15801
16261
  */
15802
- join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
16262
+ join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, joinSource, ...data } = {}) => {
15803
16263
  const callingState = this.state.callingState;
16264
+ // Ahead of the failure boundary below on purpose: a duplicate join on a
16265
+ // live call is refused without tearing that call down.
15804
16266
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
15805
16267
  throw new Error(`Illegal State: call.join() shall be called only once`);
15806
16268
  }
16269
+ // snapshot before the first await: a leave() landing at any point from
16270
+ // here on supersedes this join, and the retry loop must bail out instead
16271
+ // of resurrecting a call that leave() already tore down.
16272
+ const joinLeaveGeneration = this.leaveGeneration;
16273
+ const supersededByLeave = () => this.leaveGeneration !== joinLeaveGeneration;
15807
16274
  // we need this to be set before the callingx.joinCall() is
15808
16275
  // called to avoid registering the test call in the CallKit/Telecom
15809
16276
  this.allowOwnTracksLoopback = allowOwnTracksLoopback;
15810
16277
  if (data?.ring) {
15811
16278
  this.ringingSubject.next(true);
15812
16279
  }
16280
+ // A ringing call is joined by the SDK rather than by app code, so React
16281
+ // Native prepares it from in here - there is no earlier point at which the
16282
+ // app holds the call. Read after `data.ring` above, which is what makes an
16283
+ // outgoing call ringing in the first place.
16284
+ const ringingLifecycle = this.ringing
16285
+ ? globalThis.streamRNVideoSDK?.ringingCallLifecycle
16286
+ : undefined;
15813
16287
  const callingX = globalThis.streamRNVideoSDK?.callingX;
15814
- if (callingX) {
15815
- // for Android/iOS, we need to start the call in the callingx library as soon as possible
15816
- await callingX.joinCall(this, this.clientStore.calls);
15817
- }
15818
- await this.setup();
15819
- this.clientEventReporter.registerCall(this.cid, {
15820
- callType: this.type,
15821
- callId: this.id,
15822
- getCallSessionId: () => this.state.session?.id ?? '',
15823
- getSfuId: () => this.credentials?.server.edge_name ?? '',
15824
- });
15825
- this.joinResponseTimeout = joinResponseTimeout;
15826
- this.rpcRequestTimeout = rpcRequestTimeout;
15827
- // we will count the number of join failures per SFU.
15828
- // once the number of failures reaches 2, we will piggyback on the `migrating_from`
15829
- // field to force the coordinator to provide us another SFU
15830
- const sfuJoinFailures = new Map();
15831
- const joinData = data;
15832
- maxJoinRetries = Math.max(maxJoinRetries, 1);
15833
16288
  try {
15834
- await this.clientEventReporter.withJoinLifecycle(this.cid, 'first-attempt', async () => {
16289
+ if (ringingLifecycle) {
16290
+ await ringingLifecycle.beforeJoin(this);
16291
+ if (supersededByLeave()) {
16292
+ this.logger.debug('Join superseded by leave; not joining');
16293
+ return;
16294
+ }
16295
+ }
16296
+ if (callingX) {
16297
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
16298
+ await callingX.joinCall(this, this.clientStore.calls, supersededByLeave);
16299
+ if (supersededByLeave()) {
16300
+ this.logger.debug('Join superseded by leave; not setting up');
16301
+ return;
16302
+ }
16303
+ }
16304
+ await this.setup();
16305
+ if (supersededByLeave()) {
16306
+ this.logger.debug('Join superseded by leave; not registering');
16307
+ return;
16308
+ }
16309
+ this.clientEventReporter.registerCall(this.cid, {
16310
+ callType: this.type,
16311
+ callId: this.id,
16312
+ getCallSessionId: () => this.state.session?.id ?? '',
16313
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
16314
+ });
16315
+ this.joinResponseTimeout = joinResponseTimeout;
16316
+ this.rpcRequestTimeout = rpcRequestTimeout;
16317
+ // we will count the number of join failures per SFU.
16318
+ // once the number of failures reaches 2, we will piggyback on the `migrating_from`
16319
+ // field to force the coordinator to provide us another SFU
16320
+ const sfuJoinFailures = new Map();
16321
+ const joinData = data;
16322
+ maxJoinRetries = Math.max(maxJoinRetries, 1);
16323
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'first-attempt', joinSource }, async () => {
15835
16324
  for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
16325
+ // Also covers a leave that lands during the backoff below.
16326
+ if (supersededByLeave()) {
16327
+ this.logger.debug('Join superseded by leave; not attempting');
16328
+ return;
16329
+ }
15836
16330
  try {
15837
16331
  this.logger.trace(`Joining call (${attempt})`, this.cid);
15838
16332
  await this.doJoin(data);
@@ -15841,6 +16335,10 @@ class Call {
15841
16335
  return;
15842
16336
  }
15843
16337
  catch (err) {
16338
+ if (supersededByLeave()) {
16339
+ this.logger.debug('Join superseded by leave; not retrying');
16340
+ return;
16341
+ }
15844
16342
  this.logger.warn(`Failed to join call (${attempt})`, this.cid);
15845
16343
  if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
15846
16344
  (err instanceof SfuJoinError && err.unrecoverable)) {
@@ -15870,6 +16368,9 @@ class Call {
15870
16368
  }
15871
16369
  catch (error) {
15872
16370
  callingX?.endCall(this, 'error');
16371
+ // Ends the failed ringing flow and releases what its setup installed.
16372
+ // Never rejects, so `error` is what the caller sees.
16373
+ await ringingLifecycle?.onJoinFailed(this);
15873
16374
  throw error;
15874
16375
  }
15875
16376
  });
@@ -15892,6 +16393,10 @@ class Call {
15892
16393
  // globals resolve to the call's factory. Idempotent across
15893
16394
  // reconnect/migration attempts.
15894
16395
  await this.ensureMediaFactory();
16396
+ if (supersededByLeave()) {
16397
+ this.logger.debug('Join superseded by leave; not wiring media');
16398
+ return;
16399
+ }
15895
16400
  const callingX = globalThis.streamRNVideoSDK?.callingX;
15896
16401
  if (callingX) {
15897
16402
  callingX.wireAudioEngineSubscription();
@@ -15907,11 +16412,17 @@ class Call {
15907
16412
  data?.migrating_from) {
15908
16413
  try {
15909
16414
  const joinResponse = await this.clientEventReporter.track(this.cid, 'CoordinatorJoin', () => this.doJoinRequest(data));
16415
+ if (!joinResponse || supersededByLeave()) {
16416
+ this.logger.debug('Join superseded by leave; not creating SFU client');
16417
+ return;
16418
+ }
15910
16419
  this.credentials = joinResponse.credentials;
15911
16420
  statsOptions = joinResponse.stats_options;
15912
16421
  this.lastStatsOptions = statsOptions;
15913
16422
  }
15914
16423
  catch (error) {
16424
+ if (supersededByLeave())
16425
+ return;
15915
16426
  // prevent triggering reconnect flow if the state is OFFLINE
15916
16427
  const avoidRestoreState = this.state.callingState === CallingState.OFFLINE;
15917
16428
  if (!avoidRestoreState) {
@@ -15959,6 +16470,10 @@ class Call {
15959
16470
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
15960
16471
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
15961
16472
  ]);
16473
+ if (supersededByLeave()) {
16474
+ this.logger.debug('Join superseded by leave; not joining SFU');
16475
+ return;
16476
+ }
15962
16477
  const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
15963
16478
  const reconnectDetails = isReconnecting
15964
16479
  ? this.getReconnectDetails(previousSfuClient?.edgeName, previousSessionId)
@@ -15984,6 +16499,10 @@ class Call {
15984
16499
  capabilities,
15985
16500
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
15986
16501
  }));
16502
+ if (supersededByLeave()) {
16503
+ this.logger.debug('Join superseded by leave; not applying SFU response');
16504
+ return;
16505
+ }
15987
16506
  this.currentPublishOptions = publishOptions;
15988
16507
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
15989
16508
  if (callState) {
@@ -15991,6 +16510,10 @@ class Call {
15991
16510
  }
15992
16511
  }
15993
16512
  catch (error) {
16513
+ if (supersededByLeave()) {
16514
+ this.logger.debug('Join superseded by leave; ignoring SFU failure');
16515
+ return;
16516
+ }
15994
16517
  this.logger.warn('Join SFU request failed', error);
15995
16518
  sfuClient.close(StreamSfuClient.JOIN_FAILED, 'Join request failed, connection considered unhealthy');
15996
16519
  // restore the previous call state if the join-flow fails
@@ -15998,8 +16521,8 @@ class Call {
15998
16521
  throw error;
15999
16522
  }
16000
16523
  }
16001
- // If the user left while this join was in flight, bail before re-setting JOINED and before
16002
- // peer-connection setup below (both run synchronously after this, so one check covers them).
16524
+ // If the user left while this join was in flight, bail before re-setting JOINED
16525
+ // or starting peer-connection setup below.
16003
16526
  if (supersededByLeave()) {
16004
16527
  this.logger.debug('Join superseded by leave; aborting join flow');
16005
16528
  return;
@@ -16028,6 +16551,10 @@ class Call {
16028
16551
  unifiedSessionId: this.unifiedSessionId,
16029
16552
  });
16030
16553
  }
16554
+ if (supersededByLeave()) {
16555
+ this.logger.debug('Join superseded by leave; not completing join flow');
16556
+ return;
16557
+ }
16031
16558
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
16032
16559
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
16033
16560
  this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
@@ -16173,15 +16700,20 @@ class Call {
16173
16700
  * @internal
16174
16701
  */
16175
16702
  initPublisherAndSubscriber = async (opts) => {
16703
+ const joinLeaveGeneration = this.leaveGeneration;
16176
16704
  const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
16177
16705
  const { enable_rtc_stats: enableTracing, reporting_interval_ms: reportingIntervalMs, } = statsOptions;
16178
16706
  // Flush the previous reporter's final sample while its peer connections are
16179
16707
  // still alive, before we dispose them below. Awaits only the sampling step.
16180
16708
  await this.sfuStatsReporter?.flush();
16709
+ if (this.leaveGeneration !== joinLeaveGeneration)
16710
+ return;
16181
16711
  this.sfuStatsReporter?.stop();
16182
16712
  this.sfuStatsReporter = undefined;
16183
16713
  if (closePreviousInstances && this.subscriber) {
16184
16714
  await this.subscriber.dispose();
16715
+ if (this.leaveGeneration !== joinLeaveGeneration)
16716
+ return;
16185
16717
  this.state.removeAllOrphanedTracks();
16186
16718
  }
16187
16719
  const basePeerConnectionOptions = {
@@ -16224,6 +16756,8 @@ class Call {
16224
16756
  if (!isAnonymous) {
16225
16757
  if (closePreviousInstances && this.publisher) {
16226
16758
  await this.publisher.dispose();
16759
+ if (this.leaveGeneration !== joinLeaveGeneration)
16760
+ return;
16227
16761
  }
16228
16762
  this.publisher = new Publisher(basePeerConnectionOptions, publishOptions, {
16229
16763
  selfSubEnabled: this.allowOwnTracksLoopback,
@@ -16260,12 +16794,22 @@ class Call {
16260
16794
  *
16261
16795
  * @internal
16262
16796
  * @param data the join call data.
16797
+ * @returns The coordinator response, or undefined if leave superseded the request before it was sent.
16263
16798
  */
16264
16799
  doJoinRequest = async (data) => {
16800
+ const joinLeaveGeneration = this.leaveGeneration;
16265
16801
  const location = await this.streamClient.getLocationHint();
16802
+ if (this.leaveGeneration !== joinLeaveGeneration) {
16803
+ this.logger.debug('Join superseded by leave; not sending coordinator request');
16804
+ return;
16805
+ }
16266
16806
  const e2ee = !!this.e2eeManager;
16267
16807
  const request = { ...data, location, e2ee };
16268
16808
  const joinResponse = await this.streamClient.post(`${this.streamClientBasePath}/join`, request);
16809
+ if (this.leaveGeneration !== joinLeaveGeneration) {
16810
+ this.logger.debug('Join superseded by leave; not applying coordinator response');
16811
+ return joinResponse;
16812
+ }
16269
16813
  this.state.updateFromCallResponse(joinResponse.call);
16270
16814
  this.state.setMembers(joinResponse.members);
16271
16815
  this.state.setOwnCapabilities(joinResponse.own_capabilities);
@@ -16276,6 +16820,8 @@ class Call {
16276
16820
  if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
16277
16821
  // signals other users that I have accepted the incoming call.
16278
16822
  await this.accept();
16823
+ if (this.leaveGeneration !== joinLeaveGeneration)
16824
+ return joinResponse;
16279
16825
  }
16280
16826
  if (this.streamClient._hasConnectionID()) {
16281
16827
  this.watching = true;
@@ -16365,6 +16911,7 @@ class Call {
16365
16911
  if (callingState === CallingState.JOINING ||
16366
16912
  callingState === CallingState.RECONNECTING ||
16367
16913
  callingState === CallingState.MIGRATING ||
16914
+ callingState === CallingState.LEFT ||
16368
16915
  callingState === CallingState.RECONNECTING_FAILED)
16369
16916
  return;
16370
16917
  // Drop redundant reconnect calls. If a reconnect is already queued or
@@ -16373,19 +16920,27 @@ class Call {
16373
16920
  // `POST /join` per entry) once the call is already healthy again.
16374
16921
  if (hasPending(this.reconnectConcurrencyTag))
16375
16922
  return;
16923
+ const reconnectLeaveGeneration = this.leaveGeneration;
16924
+ const supersededByLeave = () => this.leaveGeneration !== reconnectLeaveGeneration;
16376
16925
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
16926
+ if (supersededByLeave())
16927
+ return;
16377
16928
  const reconnectStartTime = Date.now();
16378
16929
  this.reconnectStrategy = strategy;
16379
16930
  this.reconnectReason = reason;
16380
16931
  const sfuRejoinFailures = new Map();
16381
16932
  const markAsReconnectingFailed = async () => {
16933
+ if (supersededByLeave())
16934
+ return;
16382
16935
  try {
16383
16936
  // attempt to fetch the call data from the server, as the call
16384
16937
  // state might have changed while we were reconnecting or were offline
16385
16938
  await this.get();
16386
16939
  }
16387
16940
  finally {
16388
- this.state.setCallingState(CallingState.RECONNECTING_FAILED);
16941
+ if (!supersededByLeave()) {
16942
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
16943
+ }
16389
16944
  }
16390
16945
  };
16391
16946
  const giveUpAndLeave = async (message) => {
@@ -16441,6 +16996,8 @@ class Call {
16441
16996
  try {
16442
16997
  // wait until the network is available
16443
16998
  await this.networkAvailableTask?.promise;
16999
+ if (supersededByLeave())
17000
+ return;
16444
17001
  this.logger.info(`[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[this.reconnectStrategy]}`);
16445
17002
  switch (this.reconnectStrategy) {
16446
17003
  case WebsocketReconnectStrategy.UNSPECIFIED:
@@ -16480,6 +17037,8 @@ class Call {
16480
17037
  break; // do-while loop, reconnection worked, exit the loop
16481
17038
  }
16482
17039
  catch (error) {
17040
+ if (supersededByLeave())
17041
+ return;
16483
17042
  if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
16484
17043
  const failedSfu = this.credentials?.server.edge_name;
16485
17044
  if (failedSfu) {
@@ -16510,6 +17069,8 @@ class Call {
16510
17069
  }
16511
17070
  // exponential backoff with jitter, capped at 5 s
16512
17071
  await sleep(retryInterval(attempt));
17072
+ if (supersededByLeave())
17073
+ return;
16513
17074
  const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
16514
17075
  const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
16515
17076
  this.fastReconnectDeadlineSeconds;
@@ -16528,7 +17089,8 @@ class Call {
16528
17089
  this.reconnectStrategy = nextStrategy;
16529
17090
  this.logger.info(`[Reconnect] ${currentStrategy} (${this.reconnectAttempts}) failed. Attempting with ${WebsocketReconnectStrategy[nextStrategy]}`, error);
16530
17091
  }
16531
- } while (this.state.callingState !== CallingState.JOINED &&
17092
+ } while (!supersededByLeave() &&
17093
+ this.state.callingState !== CallingState.JOINED &&
16532
17094
  this.state.callingState !== CallingState.RECONNECTING_FAILED &&
16533
17095
  this.state.callingState !== CallingState.LEFT);
16534
17096
  this.logger.info('[Reconnect] Reconnection flow finished');
@@ -16539,10 +17101,13 @@ class Call {
16539
17101
  * @internal
16540
17102
  */
16541
17103
  reconnectFast = async () => {
17104
+ const reconnectLeaveGeneration = this.leaveGeneration;
16542
17105
  const reconnectStartTime = Date.now();
16543
17106
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
16544
17107
  this.state.setCallingState(CallingState.RECONNECTING);
16545
17108
  await this.doJoin(this.joinCallData);
17109
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17110
+ return;
16546
17111
  await this.get(); // fetch the latest call state, as it might have changed
16547
17112
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
16548
17113
  };
@@ -16551,14 +17116,19 @@ class Call {
16551
17116
  * @internal
16552
17117
  */
16553
17118
  reconnectRejoin = async () => {
17119
+ const reconnectLeaveGeneration = this.leaveGeneration;
16554
17120
  const reconnectStartTime = Date.now();
16555
17121
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
16556
17122
  this.state.setCallingState(CallingState.RECONNECTING);
16557
17123
  const joinReason = this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
16558
17124
  ? 'network-available'
16559
17125
  : 'full-rejoin';
16560
- await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () => this.doJoin(this.joinCallData));
17126
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason }, () => this.doJoin(this.joinCallData));
17127
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17128
+ return;
16561
17129
  await this.restorePublishedTracks();
17130
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17131
+ return;
16562
17132
  this.restoreSubscribedTracks();
16563
17133
  this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
16564
17134
  };
@@ -16567,6 +17137,7 @@ class Call {
16567
17137
  * @internal
16568
17138
  */
16569
17139
  reconnectMigrate = async () => {
17140
+ const reconnectLeaveGeneration = this.leaveGeneration;
16570
17141
  const reconnectStartTime = Date.now();
16571
17142
  const currentSfuClient = this.sfuClient;
16572
17143
  if (!currentSfuClient) {
@@ -16580,26 +17151,32 @@ class Call {
16580
17151
  currentPublisher?.detachEventHandlers();
16581
17152
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
16582
17153
  try {
16583
- const currentSfu = currentSfuClient.edgeName;
16584
- await this.clientEventReporter.withJoinLifecycle(this.cid, 'migration', () => this.doJoin({
16585
- ...this.joinCallData,
16586
- migrating_from: currentSfu,
16587
- migrating_from_list: [currentSfu],
16588
- }));
16589
- }
16590
- finally {
16591
- // cleanup the migration_from field after the migration is complete or failed
16592
- // as we don't want to keep dirty data in the join call data
16593
- delete this.joinCallData?.migrating_from;
16594
- delete this.joinCallData?.migrating_from_list;
16595
- }
16596
- await this.restorePublishedTracks();
16597
- this.restoreSubscribedTracks();
16598
- try {
17154
+ try {
17155
+ const currentSfu = currentSfuClient.edgeName;
17156
+ await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'migration' }, () => this.doJoin({
17157
+ ...this.joinCallData,
17158
+ migrating_from: currentSfu,
17159
+ migrating_from_list: [currentSfu],
17160
+ }));
17161
+ }
17162
+ finally {
17163
+ // cleanup the migration_from field after the migration is complete or failed
17164
+ // as we don't want to keep dirty data in the join call data
17165
+ delete this.joinCallData?.migrating_from;
17166
+ delete this.joinCallData?.migrating_from_list;
17167
+ }
17168
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17169
+ return;
17170
+ await this.restorePublishedTracks();
17171
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17172
+ return;
17173
+ this.restoreSubscribedTracks();
16599
17174
  // Wait for the migration to complete, then close the previous SFU client
16600
17175
  // and the peer connection instances. In case of failure, the migration
16601
17176
  // task would throw an error and REJOIN would be attempted.
16602
17177
  await migrationTask();
17178
+ if (this.leaveGeneration !== reconnectLeaveGeneration)
17179
+ return;
16603
17180
  // in MIGRATE, we can consider the call as joined only after
16604
17181
  // `participantMigrationComplete` event is received, signaled by
16605
17182
  // the `migrationTask`
@@ -16884,10 +17461,11 @@ class Call {
16884
17461
  * Must be called before {@link join} so the RTCPeerConnection can be
16885
17462
  * configured for E2EE.
16886
17463
  *
16887
- * The manager is kept across {@link leave} so a rejoin of this same instance
16888
- * stays encrypted: do not dispose it while this call may be joined again.
16889
- * A disposed manager throws from `encrypt`/`decrypt` rather than silently
16890
- * publishing nothing, so re-attach a fresh one instead of reusing it.
17464
+ * The application owns the manager's lifetime - the SDK never disposes it, and
17465
+ * closing the peer connections does not release it. Discard the manager
17466
+ * together with this call instance once its flow has ended, and create a fresh
17467
+ * pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
17468
+ * rather than silently publishing nothing.
16891
17469
  *
16892
17470
  * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
16893
17471
  * built-in AES-GCM scheme, or pass your own implementation.
@@ -17354,40 +17932,36 @@ class Call {
17354
17932
  */
17355
17933
  scheduleAutoDrop = () => {
17356
17934
  this.cancelAutoDrop();
17357
- const settings = this.state.settings;
17358
- if (!settings)
17359
- return;
17360
- // ignore if the call is not ringing
17361
- if (this.state.callingState !== CallingState.RINGING)
17362
- return;
17363
- const timeoutInMs = this.isCreatedByMe
17364
- ? settings.ring.auto_cancel_timeout_ms
17365
- : settings.ring.incoming_call_timeout_ms;
17366
- // 0 means no auto-drop
17367
- if (timeoutInMs <= 0)
17368
- return;
17369
- this.dropTimeout = setTimeout(() => {
17370
- // the call might have stopped ringing by this point,
17371
- // e.g. it was already accepted and joined
17372
- if (this.state.callingState !== CallingState.RINGING)
17373
- return;
17374
- this.leave({
17375
- reject: true,
17376
- reason: 'timeout',
17377
- message: `ringing timeout - ${this.isCreatedByMe
17378
- ? 'no one accepted'
17379
- : `user didn't interact with incoming call screen`}`,
17380
- }).catch((err) => {
17381
- this.logger.error('Failed to drop call', err);
17382
- });
17383
- }, timeoutInMs);
17935
+ this.ringTimeout = new RingTimeout(this);
17936
+ this.ringTimeout.start();
17384
17937
  };
17385
17938
  /**
17386
17939
  * Cancels a scheduled auto-drop timeout.
17387
17940
  */
17388
17941
  cancelAutoDrop = () => {
17389
- clearTimeout(this.dropTimeout);
17390
- this.dropTimeout = undefined;
17942
+ this.ringTimeout?.stop();
17943
+ this.ringTimeout = undefined;
17944
+ };
17945
+ /**
17946
+ * Starts polling for the ring outcome. Applicable only to ringing calls the
17947
+ * current user created.
17948
+ */
17949
+ scheduleRingStatePolling = () => {
17950
+ this.cancelRingStatePolling();
17951
+ if (!this.isCreatedByMe)
17952
+ return;
17953
+ const options = this.streamClient.options.ringStatePolling;
17954
+ if (options === false)
17955
+ return;
17956
+ this.ringStatePoller = new RingStatePoller(this, options);
17957
+ this.ringStatePoller.start();
17958
+ };
17959
+ /**
17960
+ * Cancels the ring state polling.
17961
+ */
17962
+ cancelRingStatePolling = () => {
17963
+ this.ringStatePoller?.stop();
17964
+ this.ringStatePoller = undefined;
17391
17965
  };
17392
17966
  /**
17393
17967
  * Retrieves the list of recordings for the current call or call session.
@@ -19058,11 +19632,11 @@ class StreamClient {
19058
19632
  return await this.wsConnection.connect(this.defaultWSTimeout);
19059
19633
  };
19060
19634
  getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
19061
- "1.59.1";
19635
+ "1.61.0";
19062
19636
  getUserAgent = () => {
19063
19637
  if (!this.cachedUserAgent) {
19064
19638
  const { clientAppIdentifier = {} } = this.options;
19065
- const { sdkName = 'js', sdkVersion = "1.59.1", ...extras } = clientAppIdentifier;
19639
+ const { sdkName = 'js', sdkVersion = "1.61.0", ...extras } = clientAppIdentifier;
19066
19640
  this.cachedUserAgent = [
19067
19641
  `stream-video-${sdkName}-v${sdkVersion}`,
19068
19642
  ...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
@@ -19195,6 +19769,7 @@ class ClientEventReporter {
19195
19769
  callContexts = new Map();
19196
19770
  joinAttemptIds = new Map();
19197
19771
  joinReasons = new Map();
19772
+ joinSources = new Map();
19198
19773
  coordinatorPairs = new Map();
19199
19774
  wsPairs = new Map();
19200
19775
  peerConnectionPairs = new Map();
@@ -19309,6 +19884,7 @@ class ClientEventReporter {
19309
19884
  this.callContexts.delete(cid);
19310
19885
  this.joinAttemptIds.delete(cid);
19311
19886
  this.joinReasons.delete(cid);
19887
+ this.joinSources.delete(cid);
19312
19888
  this.coordinatorPairs.delete(cid);
19313
19889
  this.wsPairs.delete(cid);
19314
19890
  this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
@@ -19333,7 +19909,12 @@ class ClientEventReporter {
19333
19909
  this.logger.warn('Failed to start join correlation', err);
19334
19910
  }
19335
19911
  };
19336
- withJoinLifecycle = async (cid, joinReason, op) => {
19912
+ withJoinLifecycle = async (cid, options, op) => {
19913
+ const { joinReason, joinSource } = options;
19914
+ if (joinSource)
19915
+ this.joinSources.set(cid, joinSource);
19916
+ else
19917
+ this.joinSources.delete(cid);
19337
19918
  this.startCorrelation(cid, joinReason);
19338
19919
  try {
19339
19920
  return await op();
@@ -19342,6 +19923,9 @@ class ClientEventReporter {
19342
19923
  this.closeCallPairs(cid);
19343
19924
  throw err;
19344
19925
  }
19926
+ finally {
19927
+ this.joinSources.delete(cid);
19928
+ }
19345
19929
  };
19346
19930
  track = async (cid, stage, op) => {
19347
19931
  this.beginAttempt(cid, stage);
@@ -19465,6 +20049,7 @@ class ClientEventReporter {
19465
20049
  startedAt: Date.now(),
19466
20050
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
19467
20051
  joinReasonSnapshot: this.joinReasons.get(cid),
20052
+ joinSourceSnapshot: this.joinSources.get(cid),
19468
20053
  };
19469
20054
  this.coordinatorPairs.set(cid, pair);
19470
20055
  this.sendForCall(cid, {
@@ -19472,6 +20057,7 @@ class ClientEventReporter {
19472
20057
  ...(pair.joinReasonSnapshot && {
19473
20058
  join_reason: pair.joinReasonSnapshot,
19474
20059
  }),
20060
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
19475
20061
  event_type: 'initiated',
19476
20062
  });
19477
20063
  }
@@ -19486,6 +20072,7 @@ class ClientEventReporter {
19486
20072
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
19487
20073
  ...this.sessionIdField(cid),
19488
20074
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
20075
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
19489
20076
  event_type: 'completed',
19490
20077
  outcome: 'success',
19491
20078
  retry_count_attempt: pair.attempts - 1,
@@ -19504,6 +20091,7 @@ class ClientEventReporter {
19504
20091
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
19505
20092
  ...this.sessionIdField(cid),
19506
20093
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
20094
+ ...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
19507
20095
  event_type: 'completed',
19508
20096
  outcome: 'failure',
19509
20097
  retry_count_attempt: pair.attempts - 1,
@@ -20757,9 +21345,9 @@ class EncryptionManager extends TypedEventEmitter {
20757
21345
  }
20758
21346
  if ((this.piped ??= new WeakSet()).has(target))
20759
21347
  return;
20760
- this.piped.add(target);
20761
21348
  // @ts-expect-error createEncodedStreams is not in the standard typedefs
20762
21349
  const { readable, writable } = target.createEncodedStreams();
21350
+ this.piped.add(target);
20763
21351
  this.worker.postMessage({ type: 'cmd.setup_transform', ...options, readable, writable }, [readable, writable]);
20764
21352
  };
20765
21353
  /**
@@ -20848,5 +21436,5 @@ var e2eeWorker$1 = /*#__PURE__*/Object.freeze({
20848
21436
  e2eeWorker: e2eeWorker
20849
21437
  });
20850
21438
 
20851
- export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DEFAULT_LOOPBACK_RECORDING_DURATION_MS, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, EncryptionManager, EncryptionSettingsRequestModeEnum, EncryptionSettingsResponseModeEnum, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, HLSSettingsRequestQualityTracksEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsRequestOutputTypesEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, LayoutSettingsRequestNameEnum, LoopbackStreamsTimeoutError, MAX_LOOPBACK_RECORDING_DURATION_MS, MIN_LOOPBACK_RECORDING_DURATION_MS, MediaStreamRecorder, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, RequestPermissionRequestPermissionsEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SfuTimeoutError, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, TranslationSettingsLanguagesEnum, UpdateUserPermissionsRequestGrantPermissionsEnum, UpdateUserPermissionsRequestRevokePermissionsEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, WSAuthMessageProductsEnum, checkIfAudioOutputChangeSupported, clampLoopbackRecordingDuration, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, enableLoopbackAudio, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getCallMediaEngineProvider, getClientDetails, getDeviceState, getLoopbackStreams, getLoopbackTracks, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasInterruptedTrack, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishing, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setCallMediaEngineProvider, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, waitForLoopbackStreams, withLoopbackAudioEnabled, withParticipantSource };
21439
+ export { AudioSettingsRequestDefaultDeviceEnum, AudioSettingsResponseDefaultDeviceEnum, browsers as Browsers, Call, CallRecordingFailedEventRecordingTypeEnum, CallRecordingReadyEventRecordingTypeEnum, CallRecordingStartedEventRecordingTypeEnum, CallRecordingStoppedEventRecordingTypeEnum, CallState, CallType, CallTypes, CallingState, CameraManager, CameraManagerState, CreateDeviceRequestPushProviderEnum, DEFAULT_LOOPBACK_RECORDING_DURATION_MS, DebounceType, DeviceManager, DeviceManagerState, DynascaleManager, EncryptionManager, EncryptionSettingsRequestModeEnum, EncryptionSettingsResponseModeEnum, ErrorFromResponse, FrameRecordingSettingsRequestModeEnum, FrameRecordingSettingsRequestQualityEnum, FrameRecordingSettingsResponseModeEnum, HLSSettingsRequestQualityTracksEnum, IndividualRecordingSettingsRequestModeEnum, IndividualRecordingSettingsRequestOutputTypesEnum, IndividualRecordingSettingsResponseModeEnum, IngressAudioEncodingOptionsRequestChannelsEnum, IngressSourceRequestFpsEnum, IngressVideoLayerRequestCodecEnum, LOOPBACK_STREAMS_WAIT_TIMEOUT_MS, LayoutSettingsRequestNameEnum, LoopbackStreamsTimeoutError, MAX_LOOPBACK_RECORDING_DURATION_MS, MIN_LOOPBACK_RECORDING_DURATION_MS, MediaStreamRecorder, MicrophoneManager, MicrophoneManagerState, NoiseCancellationSettingsModeEnum, OwnCapability, RTMPBroadcastRequestQualityEnum, RTMPSettingsRequestQualityEnum, RawRecordingSettingsRequestModeEnum, RawRecordingSettingsResponseModeEnum, RecordSettingsRequestModeEnum, RecordSettingsRequestQualityEnum, RequestPermissionRequestPermissionsEnum, rxUtils as RxUtils, ScreenShareManager, ScreenShareState, events as SfuEvents, SfuJoinError, models as SfuModels, SfuTimeoutError, SpeakerManager, SpeakerState, StartClosedCaptionsRequestLanguageEnum, StartTranscriptionRequestLanguageEnum, StreamSfuClient, StreamVideoClient, StreamVideoReadOnlyStateStore, StreamVideoWriteableStateStore, TranscriptionSettingsRequestClosedCaptionModeEnum, TranscriptionSettingsRequestLanguageEnum, TranscriptionSettingsRequestModeEnum, TranscriptionSettingsResponseClosedCaptionModeEnum, TranscriptionSettingsResponseLanguageEnum, TranscriptionSettingsResponseModeEnum, TranslationSettingsLanguagesEnum, TypedEventEmitter, UpdateUserPermissionsRequestGrantPermissionsEnum, UpdateUserPermissionsRequestRevokePermissionsEnum, VideoSettingsRequestCameraFacingEnum, VideoSettingsResponseCameraFacingEnum, ViewportTracker, VisibilityState, WSAuthMessageProductsEnum, checkIfAudioOutputChangeSupported, clampLoopbackRecordingDuration, combineComparators, conditional, createSoundDetector, defaultSortPreset, descending, deviceIds$, disposeOfMediaStream, dominantSpeaker, enableLoopbackAudio, getAudioBrowserPermission, getAudioDevices, getAudioOutputDevices, getAudioStream, getCallMediaEngineProvider, getClientDetails, getDeviceState, getLoopbackStreams, getLoopbackTracks, getScreenShareStream, getSdkInfo, getVideoBrowserPermission, getVideoDevices, getVideoStream, getWebRTCInfo, hasAudio$1 as hasAudio, hasInterruptedTrack, hasPausedTrack, hasScreenShare, hasScreenShareAudio, hasVideo, humanize, isPinned, livestreamOrAudioRoomSortPreset, logToConsole, name, noopComparator, paginatedLayoutSortPreset, pinned, publishing, publishingAudio, publishingVideo, reactionType, resolveDeviceId, role, screenSharing, setCallMediaEngineProvider, setDeviceInfo, setOSInfo, setPowerState, setSdkInfo, setThermalState, setWebRTCInfo, speakerLayoutSortPreset, speaking, videoLoggerSystem, waitForLoopbackStreams, withLoopbackAudioEnabled, withParticipantSource };
20852
21440
  //# sourceMappingURL=index.es.js.map