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