@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.
- package/CHANGELOG.md +18 -0
- package/dist/index.browser.es.js +744 -156
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +744 -155
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +744 -156
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +34 -8
- package/dist/src/coordinator/connection/types.d.ts +16 -0
- package/dist/src/events/call.d.ts +0 -11
- package/dist/src/gen/coordinator/index.d.ts +73 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
- package/dist/src/ringing/RingStatePoller.d.ts +46 -0
- package/dist/src/ringing/RingTimeout.d.ts +27 -0
- package/dist/src/ringing/index.d.ts +4 -0
- package/dist/src/ringing/reconcileRingState.d.ts +18 -0
- package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
- package/dist/src/rtc/Publisher.d.ts +18 -0
- package/dist/src/rtc/TransceiverCache.d.ts +10 -0
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
- package/dist/src/stats/utils.d.ts +6 -0
- package/dist/src/store/CallState.d.ts +10 -1
- package/dist/src/types.d.ts +30 -1
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/Call.ts +272 -108
- package/src/StreamSfuClient.ts +6 -2
- package/src/__tests__/Call.autodrop.test.ts +116 -64
- package/src/__tests__/Call.getRingState.test.ts +87 -0
- package/src/__tests__/Call.lifecycle.test.ts +498 -5
- package/src/__tests__/Call.ringSettled.test.ts +71 -0
- package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
- package/src/__tests__/StreamSfuClient.test.ts +48 -0
- package/src/coordinator/connection/types.ts +19 -0
- package/src/events/__tests__/call.test.ts +2 -217
- package/src/events/call.ts +1 -73
- package/src/events/callEventHandlers.ts +12 -23
- package/src/gen/coordinator/index.ts +67 -0
- package/src/reporting/ClientEventReporter.ts +30 -4
- package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
- package/src/ringing/RingStatePoller.ts +177 -0
- package/src/ringing/RingTimeout.ts +90 -0
- package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
- package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
- package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
- package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
- package/src/ringing/index.ts +4 -0
- package/src/ringing/reconcileRingState.ts +108 -0
- package/src/ringing/resolveOwnRingOutcome.ts +58 -0
- package/src/rtc/Publisher.ts +66 -12
- package/src/rtc/TransceiverCache.ts +17 -0
- package/src/rtc/__tests__/Publisher.test.ts +209 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
- package/src/rtc/e2ee/E2EEManager.ts +10 -1
- package/src/rtc/e2ee/EncryptionManager.ts +1 -1
- package/src/rtc/e2ee/SPEC.md +1 -1
- package/src/stats/__tests__/utils.test.ts +33 -0
- package/src/stats/utils.ts +15 -0
- package/src/store/CallState.ts +25 -0
- package/src/store/__tests__/CallState.test.ts +59 -0
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9299
|
-
|
|
9300
|
-
|
|
9301
|
-
|
|
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({
|
|
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
|
-
*
|
|
10648
|
-
*
|
|
10649
|
-
|
|
10650
|
-
|
|
10651
|
-
|
|
10652
|
-
|
|
10653
|
-
|
|
10654
|
-
|
|
10655
|
-
|
|
10656
|
-
|
|
10657
|
-
|
|
10658
|
-
|
|
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
|
-
*
|
|
10664
|
-
*
|
|
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
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
|
|
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
|
-
|
|
10672
|
-
const
|
|
10673
|
-
if (!
|
|
10674
|
-
|
|
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
|
-
|
|
10678
|
-
const
|
|
10679
|
-
|
|
10680
|
-
|
|
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 (
|
|
10684
|
-
|
|
10685
|
-
|
|
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
|
-
|
|
10697
|
-
|
|
10698
|
-
|
|
10699
|
-
|
|
10700
|
-
|
|
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
|
|
11106
|
-
'
|
|
11107
|
-
|
|
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
|
-
|
|
11110
|
-
|
|
11111
|
-
|
|
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
|
-
|
|
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
|
|
15339
|
-
|
|
15340
|
-
|
|
15341
|
-
|
|
15342
|
-
|
|
15343
|
-
if (
|
|
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
|
-
|
|
15347
|
-
|
|
15348
|
-
|
|
15349
|
-
|
|
15350
|
-
|
|
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
|
-
|
|
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
|
|
16002
|
-
// peer-connection setup below
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
16584
|
-
|
|
16585
|
-
|
|
16586
|
-
|
|
16587
|
-
|
|
16588
|
-
|
|
16589
|
-
|
|
16590
|
-
|
|
16591
|
-
|
|
16592
|
-
|
|
16593
|
-
|
|
16594
|
-
|
|
16595
|
-
|
|
16596
|
-
|
|
16597
|
-
|
|
16598
|
-
|
|
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
|
|
16888
|
-
*
|
|
16889
|
-
*
|
|
16890
|
-
*
|
|
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
|
-
|
|
17358
|
-
|
|
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
|
-
|
|
17390
|
-
this.
|
|
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.
|
|
19635
|
+
"1.61.0";
|
|
19062
19636
|
getUserAgent = () => {
|
|
19063
19637
|
if (!this.cachedUserAgent) {
|
|
19064
19638
|
const { clientAppIdentifier = {} } = this.options;
|
|
19065
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
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,
|
|
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
|