@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.cjs.js
CHANGED
|
@@ -5101,7 +5101,7 @@ const toIceCandidate = (iceTrickle) => {
|
|
|
5101
5101
|
}
|
|
5102
5102
|
};
|
|
5103
5103
|
|
|
5104
|
-
const version = "1.
|
|
5104
|
+
const version = "1.61.0";
|
|
5105
5105
|
const [major, minor, patch] = version.split('.');
|
|
5106
5106
|
let sdkInfo = {
|
|
5107
5107
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -7134,6 +7134,30 @@ class CallState {
|
|
|
7134
7134
|
this.setCurrentValue(this.captioningSubject, call.captioning);
|
|
7135
7135
|
this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
|
|
7136
7136
|
};
|
|
7137
|
+
/**
|
|
7138
|
+
* Merges a polled ring state into the current session. Only the ring fields
|
|
7139
|
+
* are touched: the endpoint does not return the session roster.
|
|
7140
|
+
*
|
|
7141
|
+
* @internal
|
|
7142
|
+
*
|
|
7143
|
+
* @param ringState the ring state as returned by the coordinator.
|
|
7144
|
+
*/
|
|
7145
|
+
updateFromRingState = (ringState) => {
|
|
7146
|
+
this.setCurrentValue(this.sessionSubject, (session) => {
|
|
7147
|
+
if (!session || session.id !== ringState.session_id)
|
|
7148
|
+
return session;
|
|
7149
|
+
return {
|
|
7150
|
+
...session,
|
|
7151
|
+
accepted_by: ringState.accepted_by,
|
|
7152
|
+
rejected_by: ringState.rejected_by,
|
|
7153
|
+
missed_by: ringState.missed_by,
|
|
7154
|
+
ended_at: ringState.session_ended_at ?? session.ended_at,
|
|
7155
|
+
};
|
|
7156
|
+
});
|
|
7157
|
+
if (ringState.call_ended_at) {
|
|
7158
|
+
this.setEndedAt(new Date(ringState.call_ended_at));
|
|
7159
|
+
}
|
|
7160
|
+
};
|
|
7137
7161
|
/**
|
|
7138
7162
|
* Updates the call state with the data received from the SFU server.
|
|
7139
7163
|
*
|
|
@@ -7445,6 +7469,19 @@ const getSdkName = (sdk) => {
|
|
|
7445
7469
|
const getSdkVersion = (sdk) => {
|
|
7446
7470
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
7447
7471
|
};
|
|
7472
|
+
/**
|
|
7473
|
+
* Returns the identifier the SFU receives in the `X-Stream-Client` header,
|
|
7474
|
+
* e.g. `stream-video-react-v1.43.1`. Matches the format the coordinator
|
|
7475
|
+
* already gets through the client's user agent.
|
|
7476
|
+
*/
|
|
7477
|
+
const getStreamClientId = (sdk) => {
|
|
7478
|
+
const sdkPlatforms = {
|
|
7479
|
+
[SdkType.REACT]: 'react',
|
|
7480
|
+
[SdkType.REACT_NATIVE]: 'react-native',
|
|
7481
|
+
};
|
|
7482
|
+
const platform = (sdk && sdkPlatforms[sdk.type]) || 'js';
|
|
7483
|
+
return `stream-video-${platform}-v${getSdkVersion(sdk)}`;
|
|
7484
|
+
};
|
|
7448
7485
|
|
|
7449
7486
|
/**
|
|
7450
7487
|
* Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store
|
|
@@ -9129,6 +9166,25 @@ class TransceiverCache {
|
|
|
9129
9166
|
this.cache.push(bundle);
|
|
9130
9167
|
this.transceiverOrder.push(bundle.transceiver);
|
|
9131
9168
|
};
|
|
9169
|
+
/**
|
|
9170
|
+
* Retires a failed sender from the reusable cache.
|
|
9171
|
+
*
|
|
9172
|
+
* A transceiver that never reached an m-section also leaves the creation
|
|
9173
|
+
* history: once stopped, it is left out of every later offer, so counting
|
|
9174
|
+
* it would shift the `mid` index of every transceiver added after it. One
|
|
9175
|
+
* that is already associated keeps its slot, since its m-section survives
|
|
9176
|
+
* as a rejected one.
|
|
9177
|
+
*/
|
|
9178
|
+
remove = (transceiver) => {
|
|
9179
|
+
const index = this.cache.findIndex((b) => b.transceiver === transceiver);
|
|
9180
|
+
if (index !== -1)
|
|
9181
|
+
this.cache.splice(index, 1);
|
|
9182
|
+
if (transceiver.mid)
|
|
9183
|
+
return;
|
|
9184
|
+
const orderIndex = this.transceiverOrder.indexOf(transceiver);
|
|
9185
|
+
if (orderIndex !== -1)
|
|
9186
|
+
this.transceiverOrder.splice(orderIndex, 1);
|
|
9187
|
+
};
|
|
9132
9188
|
/**
|
|
9133
9189
|
* Gets the transceiver for the given publish option.
|
|
9134
9190
|
*/
|
|
@@ -9300,7 +9356,9 @@ class Publisher extends BasePeerConnection {
|
|
|
9300
9356
|
const sendEncodings = isSvcCodec(publishOption.codec?.name)
|
|
9301
9357
|
? toSvcEncodings(encodings)
|
|
9302
9358
|
: encodings;
|
|
9303
|
-
|
|
9359
|
+
// An E2EE sender must never have media before its transform is attached,
|
|
9360
|
+
// including when another track or an ICE restart negotiates this PC.
|
|
9361
|
+
const transceiver = this.pc.addTransceiver(this.e2ee ? track.kind : track, {
|
|
9304
9362
|
direction: 'sendonly',
|
|
9305
9363
|
sendEncodings,
|
|
9306
9364
|
});
|
|
@@ -9310,16 +9368,63 @@ class Publisher extends BasePeerConnection {
|
|
|
9310
9368
|
this.transceiverCache.add({ publishOption, transceiver, options });
|
|
9311
9369
|
this.trackIdToTrackType.set(track.id, trackType);
|
|
9312
9370
|
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
9313
|
-
|
|
9371
|
+
if (this.e2ee) {
|
|
9372
|
+
await this.initEncryptedSender(transceiver, track, publishOption);
|
|
9373
|
+
}
|
|
9374
|
+
else {
|
|
9375
|
+
await this.setDegradationPreference(transceiver.sender, publishOption);
|
|
9376
|
+
}
|
|
9377
|
+
await this.negotiate();
|
|
9378
|
+
};
|
|
9379
|
+
/**
|
|
9380
|
+
* Attaches the encryptor, applies sender parameters, then attaches the track.
|
|
9381
|
+
*
|
|
9382
|
+
* `encrypt` must run before the first await: Chrome short-circuits a sender
|
|
9383
|
+
* whose encoded streams aren't created in the task that created it, and the
|
|
9384
|
+
* track goes on last so the sender has no media until both steps succeed.
|
|
9385
|
+
*/
|
|
9386
|
+
initEncryptedSender = async (transceiver, track, publishOption) => {
|
|
9387
|
+
try {
|
|
9388
|
+
const { sender } = transceiver;
|
|
9389
|
+
const codec = publishOption.codec?.name.toLowerCase();
|
|
9390
|
+
this.e2ee.encrypt(sender, codec, TrackType[publishOption.trackType]);
|
|
9391
|
+
this.logger.debug('E2EE encryptor attached to sender');
|
|
9392
|
+
await this.setDegradationPreference(sender, publishOption);
|
|
9393
|
+
await sender.replaceTrack(track);
|
|
9394
|
+
}
|
|
9395
|
+
catch (err) {
|
|
9396
|
+
this.retireTransceiver(transceiver, track);
|
|
9397
|
+
throw err;
|
|
9398
|
+
}
|
|
9399
|
+
};
|
|
9400
|
+
setDegradationPreference = async (sender, publishOption) => {
|
|
9401
|
+
const params = sender.getParameters();
|
|
9314
9402
|
params.degradationPreference =
|
|
9315
9403
|
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
9316
9404
|
'maintain-framerate';
|
|
9317
|
-
await
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
|
|
9405
|
+
await sender.setParameters(params);
|
|
9406
|
+
};
|
|
9407
|
+
/**
|
|
9408
|
+
* Discards a sender whose E2EE initialization failed.
|
|
9409
|
+
*
|
|
9410
|
+
* A partially initialized sender may carry no transform, so publish() must
|
|
9411
|
+
* not reuse it: retiring it from the cache is what keeps a later publish or
|
|
9412
|
+
* ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
|
|
9413
|
+
* the initialization failure is what reaches the caller.
|
|
9414
|
+
*/
|
|
9415
|
+
retireTransceiver = (transceiver, track) => {
|
|
9416
|
+
this.transceiverCache.remove(transceiver);
|
|
9417
|
+
this.trackIdToTrackType.delete(track.id);
|
|
9418
|
+
// on React Native a clone shares its native source, so stopping it here
|
|
9419
|
+
// would stop the track the caller passed in. dispose() releases it.
|
|
9420
|
+
if (!isReactNative())
|
|
9421
|
+
this.stopTrack(track);
|
|
9422
|
+
try {
|
|
9423
|
+
transceiver.stop();
|
|
9424
|
+
}
|
|
9425
|
+
catch (err) {
|
|
9426
|
+
this.logger.debug('Failed to stop a retired transceiver', err);
|
|
9321
9427
|
}
|
|
9322
|
-
await this.negotiate();
|
|
9323
9428
|
};
|
|
9324
9429
|
/**
|
|
9325
9430
|
* Updates the transceiver with the given track and track type.
|
|
@@ -10326,7 +10431,10 @@ class StreamSfuClient {
|
|
|
10326
10431
|
this.rpc = createSignalClient({
|
|
10327
10432
|
baseUrl: server.url,
|
|
10328
10433
|
interceptors: [
|
|
10329
|
-
withHeaders({
|
|
10434
|
+
withHeaders({
|
|
10435
|
+
Authorization: `Bearer ${token}`,
|
|
10436
|
+
'X-Stream-Client': getStreamClientId(getSdkInfo()),
|
|
10437
|
+
}),
|
|
10330
10438
|
this.tracer && withRequestTracer(this.tracer.trace),
|
|
10331
10439
|
this.logger.getLogLevel() === 'trace' && withRequestLogger(this.logger),
|
|
10332
10440
|
withTimeout(rpcRequestTimeout, this.tracer?.trace),
|
|
@@ -10663,64 +10771,370 @@ class StreamSfuClient {
|
|
|
10663
10771
|
}
|
|
10664
10772
|
|
|
10665
10773
|
/**
|
|
10666
|
-
*
|
|
10667
|
-
*
|
|
10668
|
-
|
|
10669
|
-
|
|
10670
|
-
|
|
10671
|
-
|
|
10672
|
-
|
|
10673
|
-
|
|
10674
|
-
|
|
10675
|
-
|
|
10676
|
-
|
|
10677
|
-
|
|
10774
|
+
* Decides what a ringing call should do next, based on the current call state.
|
|
10775
|
+
*
|
|
10776
|
+
* The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
|
|
10777
|
+
* state poller both run this. They differ only in how the state got there: the
|
|
10778
|
+
* handlers rely on `CallState.updateFromEvent`, which runs before them, and the
|
|
10779
|
+
* poller applies the polled ring state itself.
|
|
10780
|
+
*
|
|
10781
|
+
* @param call the call to reconcile.
|
|
10782
|
+
* @param joinSource which of the two triggered this run, reported on the
|
|
10783
|
+
* caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
|
|
10784
|
+
* poller.
|
|
10785
|
+
* @returns whether the ring reached a terminal state. A failed join is not
|
|
10786
|
+
* terminal: the caller should keep trying while the ring is open.
|
|
10787
|
+
*/
|
|
10788
|
+
const reconcileRingState = async (call, joinSource) => {
|
|
10789
|
+
if (call.state.callingState !== exports.CallingState.RINGING)
|
|
10790
|
+
return true;
|
|
10791
|
+
return call.isCreatedByMe
|
|
10792
|
+
? reconcileAsCaller(call, joinSource)
|
|
10793
|
+
: reconcileAsCallee(call);
|
|
10794
|
+
};
|
|
10795
|
+
const reconcileAsCaller = async (call, joinSource) => {
|
|
10796
|
+
const { session, members, endedAt } = call.state;
|
|
10797
|
+
const currentUserId = call.currentUserId;
|
|
10798
|
+
// checked before `accepted_by`: an ended session cannot be joined
|
|
10799
|
+
if (endedAt || session?.ended_at) {
|
|
10800
|
+
call.logger.info('ring: the call has ended, leaving');
|
|
10801
|
+
// `leave` reports the remote end to callingx off `reason: 'ended'`
|
|
10802
|
+
return leave(call, {
|
|
10803
|
+
reject: false,
|
|
10804
|
+
reason: 'ended',
|
|
10805
|
+
message: 'ring: call ended',
|
|
10806
|
+
});
|
|
10807
|
+
}
|
|
10808
|
+
const acceptedBy = session?.accepted_by ?? {};
|
|
10809
|
+
if (Object.keys(acceptedBy).some((userId) => userId !== currentUserId)) {
|
|
10810
|
+
call.logger.info('ring: the call was accepted, joining');
|
|
10811
|
+
try {
|
|
10812
|
+
await call.join({ joinSource });
|
|
10678
10813
|
}
|
|
10679
|
-
|
|
10814
|
+
catch (err) {
|
|
10815
|
+
// `doJoin` restores the ringing state when a join fails, so the ring is
|
|
10816
|
+
// still open. Report it unsettled and let the next poll retry.
|
|
10817
|
+
call.logger.error('Failed to join an accepted call', err);
|
|
10818
|
+
return false;
|
|
10819
|
+
}
|
|
10820
|
+
return true;
|
|
10821
|
+
}
|
|
10822
|
+
const otherMembers = members
|
|
10823
|
+
.filter((member) => member.user_id !== currentUserId)
|
|
10824
|
+
.map((member) => member.user_id);
|
|
10825
|
+
if (otherMembers.length === 0)
|
|
10826
|
+
return false;
|
|
10827
|
+
const rejectedBy = session?.rejected_by ?? {};
|
|
10828
|
+
if (otherMembers.every((userId) => rejectedBy[userId])) {
|
|
10829
|
+
call.logger.info('ring: everyone rejected, leaving');
|
|
10830
|
+
return leave(call, {
|
|
10831
|
+
reject: true,
|
|
10832
|
+
reason: 'cancel',
|
|
10833
|
+
message: 'ring: everyone rejected',
|
|
10834
|
+
});
|
|
10835
|
+
}
|
|
10836
|
+
return false;
|
|
10837
|
+
};
|
|
10838
|
+
// the current user's own accept or reject, on this or another device, is
|
|
10839
|
+
// handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
|
|
10840
|
+
const reconcileAsCallee = async (call) => {
|
|
10841
|
+
const createdById = call.state.createdBy?.id;
|
|
10842
|
+
const rejectedBy = call.state.session?.rejected_by ?? {};
|
|
10843
|
+
if (createdById && rejectedBy[createdById]) {
|
|
10844
|
+
call.logger.info('ring: the caller cancelled, leaving');
|
|
10845
|
+
return leave(call, {
|
|
10846
|
+
reason: 'ended',
|
|
10847
|
+
message: 'ring: creator rejected',
|
|
10848
|
+
});
|
|
10849
|
+
}
|
|
10850
|
+
return false;
|
|
10680
10851
|
};
|
|
10852
|
+
// `false` when the call could not be left, so the ring stays open for a retry.
|
|
10853
|
+
const leave = async (call, options) => {
|
|
10854
|
+
try {
|
|
10855
|
+
await call.leave(options);
|
|
10856
|
+
return true;
|
|
10857
|
+
}
|
|
10858
|
+
catch (err) {
|
|
10859
|
+
call.logger.error('Failed to leave a ringing call', err);
|
|
10860
|
+
return false;
|
|
10861
|
+
}
|
|
10862
|
+
};
|
|
10863
|
+
|
|
10864
|
+
// `doJoin` restores the previous calling state when a join fails, so JOINING can
|
|
10865
|
+
// go back to RINGING. Only a state the ring cannot return from ends the poller.
|
|
10866
|
+
const ringIsOver = (callingState) => callingState !== exports.CallingState.RINGING &&
|
|
10867
|
+
callingState !== exports.CallingState.JOINING;
|
|
10681
10868
|
/**
|
|
10682
|
-
*
|
|
10683
|
-
*
|
|
10869
|
+
* Polls the coordinator for the outcome of a ring the current user started.
|
|
10870
|
+
*
|
|
10871
|
+
* `call.accepted`, `call.rejected` and `call.missed` are delivered best-effort,
|
|
10872
|
+
* with no store-and-forward, so a caller that drops one is left on a ringing
|
|
10873
|
+
* screen while the callee is already in the call. After a quiet period this
|
|
10874
|
+
* reads the ring state until a terminal outcome or the end of the ring window.
|
|
10684
10875
|
*/
|
|
10685
|
-
|
|
10686
|
-
|
|
10687
|
-
|
|
10688
|
-
|
|
10876
|
+
class RingStatePoller {
|
|
10877
|
+
logger = videoLoggerSystem.getLogger('RingStatePoller');
|
|
10878
|
+
call;
|
|
10879
|
+
startAfterMs;
|
|
10880
|
+
intervalMs;
|
|
10881
|
+
sessionId;
|
|
10882
|
+
deadlineAt = 0;
|
|
10883
|
+
idleTimeoutId;
|
|
10884
|
+
intervalId;
|
|
10885
|
+
stopped = false;
|
|
10886
|
+
wasPolling = false;
|
|
10887
|
+
inFlight = false;
|
|
10888
|
+
unsubscribe = [];
|
|
10889
|
+
constructor(call, options = {}) {
|
|
10890
|
+
this.call = call;
|
|
10891
|
+
this.startAfterMs = options.startAfterMs ?? 15_000;
|
|
10892
|
+
this.intervalMs = options.intervalMs ?? 5_000;
|
|
10893
|
+
}
|
|
10894
|
+
/**
|
|
10895
|
+
* Starts polling. Does nothing when the call has no session yet.
|
|
10896
|
+
*/
|
|
10897
|
+
start = () => {
|
|
10898
|
+
if (this.stopped || this.sessionId)
|
|
10899
|
+
return;
|
|
10900
|
+
if (this.call.state.callingState !== exports.CallingState.RINGING)
|
|
10689
10901
|
return;
|
|
10690
|
-
|
|
10691
|
-
const
|
|
10692
|
-
if (!
|
|
10693
|
-
|
|
10902
|
+
// captured once: `call.ended` clears the call's current session
|
|
10903
|
+
const sessionId = this.call.state.session?.id;
|
|
10904
|
+
if (!sessionId) {
|
|
10905
|
+
this.logger.warn('the call has no session');
|
|
10694
10906
|
return;
|
|
10695
10907
|
}
|
|
10696
|
-
|
|
10697
|
-
const
|
|
10698
|
-
|
|
10699
|
-
|
|
10908
|
+
this.sessionId = sessionId;
|
|
10909
|
+
const ring = this.call.state.settings?.ring;
|
|
10910
|
+
const maxDurationMs = ring?.auto_cancel_timeout_ms || ring?.missed_call_timeout_ms || 30_000;
|
|
10911
|
+
this.deadlineAt = Date.now() + maxDurationMs;
|
|
10912
|
+
// an incoming event proves the socket is alive, so the quiet period starts
|
|
10913
|
+
// over: in a group ring a single rejection does not settle the ring.
|
|
10914
|
+
this.unsubscribe.push(this.call.on('call.accepted', () => this.armIdleWindow()), this.call.on('call.rejected', () => this.armIdleWindow()), this.call.on('call.missed', () => this.armIdleWindow()),
|
|
10915
|
+
// the ring is over, whichever way it went. Joining an accepted call
|
|
10916
|
+
// never goes through `leave`, so this is the only signal for it.
|
|
10917
|
+
createSubscription(this.call.state.callingState$, (callingState) => {
|
|
10918
|
+
if (ringIsOver(callingState))
|
|
10919
|
+
this.stop();
|
|
10920
|
+
}));
|
|
10921
|
+
this.armIdleWindow();
|
|
10922
|
+
};
|
|
10923
|
+
/**
|
|
10924
|
+
* Pauses polling, keeping the captured session, the deadline and the event
|
|
10925
|
+
* subscriptions. Resuming does not extend the ring window.
|
|
10926
|
+
*/
|
|
10927
|
+
pause = () => {
|
|
10928
|
+
if (this.stopped)
|
|
10929
|
+
return;
|
|
10930
|
+
this.wasPolling = this.intervalId !== undefined;
|
|
10931
|
+
const timers = getTimers();
|
|
10932
|
+
timers.clearTimeout(this.idleTimeoutId);
|
|
10933
|
+
timers.clearInterval(this.intervalId);
|
|
10934
|
+
this.idleTimeoutId = undefined;
|
|
10935
|
+
this.intervalId = undefined;
|
|
10936
|
+
};
|
|
10937
|
+
/**
|
|
10938
|
+
* Resumes a paused poller. Goes straight back to polling if it was already
|
|
10939
|
+
* past the quiet period, so a pause does not buy the ring another one.
|
|
10940
|
+
*/
|
|
10941
|
+
resume = () => {
|
|
10942
|
+
if (this.stopped || !this.sessionId)
|
|
10943
|
+
return;
|
|
10944
|
+
if (this.idleTimeoutId !== undefined || this.intervalId !== undefined) {
|
|
10700
10945
|
return;
|
|
10701
10946
|
}
|
|
10702
|
-
if (
|
|
10703
|
-
|
|
10704
|
-
|
|
10705
|
-
.every((m) => rejectedBy[m.user_id]);
|
|
10706
|
-
if (everyoneElseRejected) {
|
|
10707
|
-
call.logger.info('everyone rejected, leaving the call');
|
|
10708
|
-
await call.leave({
|
|
10709
|
-
reject: true,
|
|
10710
|
-
reason: 'cancel',
|
|
10711
|
-
message: 'ring: everyone rejected',
|
|
10712
|
-
});
|
|
10713
|
-
}
|
|
10947
|
+
if (!this.wasPolling) {
|
|
10948
|
+
this.armIdleWindow();
|
|
10949
|
+
return;
|
|
10714
10950
|
}
|
|
10715
|
-
|
|
10716
|
-
|
|
10717
|
-
|
|
10718
|
-
|
|
10719
|
-
|
|
10951
|
+
this.intervalId = getTimers().setInterval(this.runTick, this.intervalMs);
|
|
10952
|
+
this.runTick();
|
|
10953
|
+
};
|
|
10954
|
+
/**
|
|
10955
|
+
* Stops polling. The poller cannot be restarted.
|
|
10956
|
+
*/
|
|
10957
|
+
stop = () => {
|
|
10958
|
+
if (this.stopped)
|
|
10959
|
+
return;
|
|
10960
|
+
this.stopped = true;
|
|
10961
|
+
const timers = getTimers();
|
|
10962
|
+
timers.clearTimeout(this.idleTimeoutId);
|
|
10963
|
+
timers.clearInterval(this.intervalId);
|
|
10964
|
+
this.idleTimeoutId = undefined;
|
|
10965
|
+
this.intervalId = undefined;
|
|
10966
|
+
this.unsubscribe.forEach((off) => off());
|
|
10967
|
+
this.unsubscribe = [];
|
|
10968
|
+
};
|
|
10969
|
+
armIdleWindow = () => {
|
|
10970
|
+
if (this.stopped)
|
|
10971
|
+
return;
|
|
10972
|
+
const timers = getTimers();
|
|
10973
|
+
timers.clearTimeout(this.idleTimeoutId);
|
|
10974
|
+
timers.clearInterval(this.intervalId);
|
|
10975
|
+
this.intervalId = undefined;
|
|
10976
|
+
this.idleTimeoutId = timers.setTimeout(() => {
|
|
10977
|
+
this.idleTimeoutId = undefined;
|
|
10978
|
+
if (this.stopped)
|
|
10979
|
+
return;
|
|
10980
|
+
this.intervalId = timers.setInterval(this.runTick, this.intervalMs);
|
|
10981
|
+
this.runTick();
|
|
10982
|
+
}, this.startAfterMs);
|
|
10983
|
+
};
|
|
10984
|
+
runTick = () => {
|
|
10985
|
+
this.tick().catch((err) => {
|
|
10986
|
+
this.logger.warn('Failed to poll the ring state', err);
|
|
10987
|
+
});
|
|
10988
|
+
};
|
|
10989
|
+
tick = async () => {
|
|
10990
|
+
if (this.stopped || this.inFlight || !this.sessionId)
|
|
10991
|
+
return;
|
|
10992
|
+
const { callingState } = this.call.state;
|
|
10993
|
+
if (ringIsOver(callingState) || Date.now() >= this.deadlineAt) {
|
|
10994
|
+
this.stop();
|
|
10995
|
+
return;
|
|
10996
|
+
}
|
|
10997
|
+
// mid-join: nothing to reconcile until we know whether it succeeded
|
|
10998
|
+
if (callingState !== exports.CallingState.RINGING)
|
|
10999
|
+
return;
|
|
11000
|
+
this.inFlight = true;
|
|
11001
|
+
try {
|
|
11002
|
+
const ringState = await this.call.getRingState(this.sessionId);
|
|
11003
|
+
if (this.stopped)
|
|
11004
|
+
return;
|
|
11005
|
+
this.call.state.updateFromRingState(ringState);
|
|
11006
|
+
if (await reconcileRingState(this.call, 'ring-poll-api'))
|
|
11007
|
+
this.stop();
|
|
11008
|
+
}
|
|
11009
|
+
catch (err) {
|
|
11010
|
+
// a missing session, or one of another call, will never resolve
|
|
11011
|
+
const status = err instanceof ErrorFromResponse ? err.status : undefined;
|
|
11012
|
+
if (status === 400 || status === 404) {
|
|
11013
|
+
this.logger.warn('Stopped polling the ring state', err);
|
|
11014
|
+
this.stop();
|
|
11015
|
+
}
|
|
11016
|
+
else {
|
|
11017
|
+
this.logger.debug('Failed to poll the ring state', err);
|
|
10720
11018
|
}
|
|
10721
11019
|
}
|
|
11020
|
+
finally {
|
|
11021
|
+
this.inFlight = false;
|
|
11022
|
+
}
|
|
11023
|
+
};
|
|
11024
|
+
}
|
|
11025
|
+
|
|
11026
|
+
/**
|
|
11027
|
+
* Drops a call that has been ringing for too long: the caller cancels after
|
|
11028
|
+
* `auto_cancel_timeout_ms`, a callee after `incoming_call_timeout_ms`.
|
|
11029
|
+
*
|
|
11030
|
+
* Either timeout being `0` means the call rings until something else settles it.
|
|
11031
|
+
*/
|
|
11032
|
+
class RingTimeout {
|
|
11033
|
+
call;
|
|
11034
|
+
timeoutId;
|
|
11035
|
+
deadlineAt;
|
|
11036
|
+
stopped = false;
|
|
11037
|
+
constructor(call) {
|
|
11038
|
+
this.call = call;
|
|
11039
|
+
}
|
|
11040
|
+
/**
|
|
11041
|
+
* Schedules an auto-drop timeout based on the call settings.
|
|
11042
|
+
* Applicable only for ringing calls.
|
|
11043
|
+
*/
|
|
11044
|
+
start = () => {
|
|
11045
|
+
if (this.stopped || this.timeoutId !== undefined)
|
|
11046
|
+
return;
|
|
11047
|
+
// ignore if the call is not ringing
|
|
11048
|
+
if (this.call.state.callingState !== exports.CallingState.RINGING)
|
|
11049
|
+
return;
|
|
11050
|
+
const ring = this.call.state.settings?.ring;
|
|
11051
|
+
if (!ring)
|
|
11052
|
+
return;
|
|
11053
|
+
const isCaller = this.call.isCreatedByMe;
|
|
11054
|
+
const timeoutMs = isCaller
|
|
11055
|
+
? ring.auto_cancel_timeout_ms
|
|
11056
|
+
: ring.incoming_call_timeout_ms;
|
|
11057
|
+
// 0 means no auto-drop
|
|
11058
|
+
if (timeoutMs <= 0)
|
|
11059
|
+
return;
|
|
11060
|
+
const now = Date.now();
|
|
11061
|
+
this.deadlineAt ??= now + timeoutMs;
|
|
11062
|
+
const delayMs = Math.max(0, this.deadlineAt - now);
|
|
11063
|
+
const timers = getTimers();
|
|
11064
|
+
this.timeoutId = timers.setTimeout(() => {
|
|
11065
|
+
this.timeoutId = undefined;
|
|
11066
|
+
// A failed timeout-triggered leave can arm a fresh retry window.
|
|
11067
|
+
this.deadlineAt = undefined;
|
|
11068
|
+
if (this.stopped)
|
|
11069
|
+
return;
|
|
11070
|
+
// the call might have stopped ringing by this point, e.g. it was already
|
|
11071
|
+
// accepted and joined
|
|
11072
|
+
if (this.call.state.callingState !== exports.CallingState.RINGING)
|
|
11073
|
+
return;
|
|
11074
|
+
this.call
|
|
11075
|
+
.leave({
|
|
11076
|
+
reject: true,
|
|
11077
|
+
reason: 'timeout',
|
|
11078
|
+
message: `ringing timeout - ${isCaller
|
|
11079
|
+
? 'no one accepted'
|
|
11080
|
+
: `user didn't interact with incoming call screen`}`,
|
|
11081
|
+
})
|
|
11082
|
+
.catch((err) => {
|
|
11083
|
+
videoLoggerSystem
|
|
11084
|
+
.getLogger('RingTimeout')
|
|
11085
|
+
.error('Failed to drop the call', err);
|
|
11086
|
+
});
|
|
11087
|
+
}, delayMs);
|
|
11088
|
+
};
|
|
11089
|
+
/**
|
|
11090
|
+
* Pauses the timeout while preserving its original deadline.
|
|
11091
|
+
*/
|
|
11092
|
+
pause = () => {
|
|
11093
|
+
if (this.stopped || this.timeoutId === undefined)
|
|
11094
|
+
return;
|
|
11095
|
+
getTimers().clearTimeout(this.timeoutId);
|
|
11096
|
+
this.timeoutId = undefined;
|
|
11097
|
+
};
|
|
11098
|
+
/**
|
|
11099
|
+
* Cancels a scheduled auto-drop timeout. It cannot be armed again.
|
|
11100
|
+
*/
|
|
11101
|
+
stop = () => {
|
|
11102
|
+
if (this.stopped)
|
|
11103
|
+
return;
|
|
11104
|
+
this.stopped = true;
|
|
11105
|
+
getTimers().clearTimeout(this.timeoutId);
|
|
11106
|
+
this.timeoutId = undefined;
|
|
11107
|
+
this.deadlineAt = undefined;
|
|
11108
|
+
};
|
|
11109
|
+
}
|
|
11110
|
+
|
|
11111
|
+
/**
|
|
11112
|
+
* Decides what a ringing call should do about the current user's own accept or
|
|
11113
|
+
* reject, which may have happened on another device.
|
|
11114
|
+
*
|
|
11115
|
+
* Accepting on *this* device also lands in `accepted_by`, so an acceptance only
|
|
11116
|
+
* means another device took the call when this one is still ringing.
|
|
11117
|
+
*
|
|
11118
|
+
* Only meaningful for a ringing call; the caller checks that.
|
|
11119
|
+
*/
|
|
11120
|
+
const resolveOwnRingOutcome = ({ session, currentUserId, callingState, }) => {
|
|
11121
|
+
if (!currentUserId)
|
|
11122
|
+
return { settledByMe: false };
|
|
11123
|
+
const acceptedByMe = Boolean(session?.accepted_by[currentUserId]);
|
|
11124
|
+
const rejectedByMe = Boolean(session?.rejected_by[currentUserId]);
|
|
11125
|
+
if (!acceptedByMe && !rejectedByMe)
|
|
11126
|
+
return { settledByMe: false };
|
|
11127
|
+
const answeredElsewhere = acceptedByMe && callingState === exports.CallingState.RINGING;
|
|
11128
|
+
return {
|
|
11129
|
+
settledByMe: true,
|
|
11130
|
+
leaveReason: answeredElsewhere
|
|
11131
|
+
? 'answeredElsewhere'
|
|
11132
|
+
: rejectedByMe
|
|
11133
|
+
? 'rejected'
|
|
11134
|
+
: undefined,
|
|
10722
11135
|
};
|
|
10723
11136
|
};
|
|
11137
|
+
|
|
10724
11138
|
/**
|
|
10725
11139
|
* Event handler that watches the delivery of `call.ended` Websocket event.
|
|
10726
11140
|
*/
|
|
@@ -11121,14 +11535,18 @@ const registerEventHandlers = (call, dispatcher) => {
|
|
|
11121
11535
|
* @param call the call to register event handlers for.
|
|
11122
11536
|
*/
|
|
11123
11537
|
const registerRingingCallEventHandlers = (call) => {
|
|
11124
|
-
const
|
|
11125
|
-
'
|
|
11126
|
-
|
|
11538
|
+
const reconcile = () => {
|
|
11539
|
+
reconcileRingState(call, 'ring-ws').catch((err) => {
|
|
11540
|
+
call.logger.error('Failed to reconcile the ring state', err);
|
|
11541
|
+
});
|
|
11127
11542
|
};
|
|
11128
|
-
|
|
11129
|
-
|
|
11130
|
-
|
|
11131
|
-
|
|
11543
|
+
// each event needs its own closure. `call.missed` is deliberately absent:
|
|
11544
|
+
// nothing in the reconciler acts on `missed_by`, so the auto-drop owns the
|
|
11545
|
+
// "nobody answered" case until the server-owned ring timeout lands.
|
|
11546
|
+
const eventHandlers = [
|
|
11547
|
+
call.on('call.accepted', () => reconcile()),
|
|
11548
|
+
call.on('call.rejected', () => reconcile()),
|
|
11549
|
+
];
|
|
11132
11550
|
return () => {
|
|
11133
11551
|
eventHandlers.forEach((unsubscribe) => unsubscribe());
|
|
11134
11552
|
};
|
|
@@ -15176,7 +15594,8 @@ class Call {
|
|
|
15176
15594
|
statsReporter;
|
|
15177
15595
|
sfuStatsReporter;
|
|
15178
15596
|
lastStatsOptions;
|
|
15179
|
-
|
|
15597
|
+
ringTimeout;
|
|
15598
|
+
ringStatePoller;
|
|
15180
15599
|
clientStore;
|
|
15181
15600
|
streamClient;
|
|
15182
15601
|
clientEventReporter;
|
|
@@ -15354,22 +15773,19 @@ class Call {
|
|
|
15354
15773
|
createSubscription(this.state.session$, (session) => {
|
|
15355
15774
|
if (!this.ringing)
|
|
15356
15775
|
return;
|
|
15357
|
-
const
|
|
15358
|
-
|
|
15359
|
-
|
|
15360
|
-
|
|
15361
|
-
|
|
15362
|
-
if (
|
|
15776
|
+
const { settledByMe, leaveReason } = resolveOwnRingOutcome({
|
|
15777
|
+
session,
|
|
15778
|
+
currentUserId: this.currentUserId,
|
|
15779
|
+
callingState: this.state.callingState,
|
|
15780
|
+
});
|
|
15781
|
+
if (settledByMe)
|
|
15363
15782
|
this.cancelAutoDrop();
|
|
15364
|
-
|
|
15365
|
-
|
|
15366
|
-
|
|
15367
|
-
|
|
15368
|
-
|
|
15369
|
-
|
|
15370
|
-
this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
|
|
15371
|
-
});
|
|
15372
|
-
}
|
|
15783
|
+
if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag))
|
|
15784
|
+
return;
|
|
15785
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
|
|
15786
|
+
this.leave().catch(() => {
|
|
15787
|
+
this.logger.error('Could not leave a call that was accepted or rejected elsewhere');
|
|
15788
|
+
});
|
|
15373
15789
|
}));
|
|
15374
15790
|
};
|
|
15375
15791
|
handleRingingCall = () => {
|
|
@@ -15415,6 +15831,7 @@ class Call {
|
|
|
15415
15831
|
this.state.setCallingState(exports.CallingState.RINGING);
|
|
15416
15832
|
}
|
|
15417
15833
|
this.scheduleAutoDrop();
|
|
15834
|
+
this.scheduleRingStatePolling();
|
|
15418
15835
|
this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
|
|
15419
15836
|
}
|
|
15420
15837
|
};
|
|
@@ -15501,6 +15918,12 @@ class Call {
|
|
|
15501
15918
|
if (this.state.callingState === exports.CallingState.LEFT) {
|
|
15502
15919
|
throw new Error('Cannot leave call that has already been left.');
|
|
15503
15920
|
}
|
|
15921
|
+
// before the first await: the calling state stays RINGING well into the
|
|
15922
|
+
// teardown, so pause both watchdogs before they can race this leave. They
|
|
15923
|
+
// keep their deadlines, so a failed leave resumes them without handing the
|
|
15924
|
+
// ring another window.
|
|
15925
|
+
this.ringTimeout?.pause();
|
|
15926
|
+
this.ringStatePoller?.pause();
|
|
15504
15927
|
await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
|
|
15505
15928
|
const callingState = this.state.callingState;
|
|
15506
15929
|
if (callingState === exports.CallingState.LEFT) {
|
|
@@ -15567,6 +15990,11 @@ class Call {
|
|
|
15567
15990
|
this.audioBindingsWatchdog?.dispose();
|
|
15568
15991
|
await this.dynascaleManager?.dispose();
|
|
15569
15992
|
this.state.setCallingState(exports.CallingState.LEFT);
|
|
15993
|
+
// `ringingSubject` is cleared further down, so this still reads true for
|
|
15994
|
+
// a call that was ringing.
|
|
15995
|
+
if (this.ringing) {
|
|
15996
|
+
globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this);
|
|
15997
|
+
}
|
|
15570
15998
|
this.state.setParticipants([]);
|
|
15571
15999
|
this.state.dispose();
|
|
15572
16000
|
// Reset reconnect-related accumulators so a future `call.join()` on the
|
|
@@ -15590,6 +16018,7 @@ class Call {
|
|
|
15590
16018
|
this.unifiedSessionId = undefined;
|
|
15591
16019
|
this.ringingSubject.next(false);
|
|
15592
16020
|
this.cancelAutoDrop();
|
|
16021
|
+
this.cancelRingStatePolling();
|
|
15593
16022
|
this.clientStore.unregisterCall(this);
|
|
15594
16023
|
globalThis.streamRNVideoSDK?.callManager.stop({
|
|
15595
16024
|
isRingingTypeCall: this.ringing,
|
|
@@ -15628,6 +16057,15 @@ class Call {
|
|
|
15628
16057
|
this.logger.warn('Failed to dispose media engine', err);
|
|
15629
16058
|
});
|
|
15630
16059
|
}
|
|
16060
|
+
}).catch((err) => {
|
|
16061
|
+
if (!hasPending(this.joinLeaveConcurrencyTag) &&
|
|
16062
|
+
this.state.callingState === exports.CallingState.RINGING) {
|
|
16063
|
+
// resume, never re-arm: a fresh watchdog would restart the ring
|
|
16064
|
+
// window this leave was already most of the way through
|
|
16065
|
+
this.ringTimeout?.start();
|
|
16066
|
+
this.ringStatePoller?.resume();
|
|
16067
|
+
}
|
|
16068
|
+
throw err;
|
|
15631
16069
|
});
|
|
15632
16070
|
};
|
|
15633
16071
|
/**
|
|
@@ -15725,8 +16163,11 @@ class Call {
|
|
|
15725
16163
|
* @param params.video if set to true, in a ringing scenario, mobile SDKs will show "incoming video call", audio only otherwise.
|
|
15726
16164
|
*/
|
|
15727
16165
|
get = async (params) => {
|
|
16166
|
+
const getLeaveGeneration = this.leaveGeneration;
|
|
15728
16167
|
await this.setup();
|
|
15729
16168
|
const response = await this.streamClient.get(this.streamClientBasePath, params);
|
|
16169
|
+
if (this.leaveGeneration !== getLeaveGeneration)
|
|
16170
|
+
return response;
|
|
15730
16171
|
this.updateFromCallStateResponse(response);
|
|
15731
16172
|
if (params?.ring) {
|
|
15732
16173
|
this.ringingSubject.next(true);
|
|
@@ -15778,6 +16219,21 @@ class Call {
|
|
|
15778
16219
|
ring = async (data = {}) => {
|
|
15779
16220
|
return this.streamClient.post(`${this.streamClientBasePath}/ring`, data);
|
|
15780
16221
|
};
|
|
16222
|
+
/**
|
|
16223
|
+
* Returns who accepted, rejected or missed the ring for a call session.
|
|
16224
|
+
* Safe to poll: it performs no writes and emits no events.
|
|
16225
|
+
*
|
|
16226
|
+
* @param callSessionId the call session to read. Defaults to the current one.
|
|
16227
|
+
* Pass it explicitly to read a session that has already ended, as ending a
|
|
16228
|
+
* call clears its current session.
|
|
16229
|
+
*/
|
|
16230
|
+
getRingState = async (callSessionId) => {
|
|
16231
|
+
const sessionId = callSessionId ?? this.state.session?.id;
|
|
16232
|
+
if (!sessionId) {
|
|
16233
|
+
throw new Error('Cannot read the ring state: the call has no session');
|
|
16234
|
+
}
|
|
16235
|
+
return this.streamClient.get(`${this.streamClientBasePath}/ring_state`, { call_session_id: sessionId });
|
|
16236
|
+
};
|
|
15781
16237
|
/**
|
|
15782
16238
|
* A shortcut for {@link Call.get} with `notify` parameter set to `true`.
|
|
15783
16239
|
* Will send a `call.notification` event to the call members.
|
|
@@ -15816,42 +16272,80 @@ class Call {
|
|
|
15816
16272
|
/**
|
|
15817
16273
|
* Will start to watch for call related WebSocket events and initiate a call session with the server.
|
|
15818
16274
|
*
|
|
16275
|
+
* One instance is one call flow: discard it after leaving, cancelling, or a
|
|
16276
|
+
* failed join, and create a fresh one for a later flow. Reconnection inside a
|
|
16277
|
+
* live call is handled here and needs no new instance.
|
|
16278
|
+
*
|
|
15819
16279
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
15820
16280
|
*/
|
|
15821
|
-
join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, ...data } = {}) => {
|
|
16281
|
+
join = singleFlight(async ({ maxJoinRetries = 3, joinResponseTimeout, rpcRequestTimeout, allowOwnTracksLoopback = false, joinSource, ...data } = {}) => {
|
|
15822
16282
|
const callingState = this.state.callingState;
|
|
16283
|
+
// Ahead of the failure boundary below on purpose: a duplicate join on a
|
|
16284
|
+
// live call is refused without tearing that call down.
|
|
15823
16285
|
if ([exports.CallingState.JOINED, exports.CallingState.JOINING].includes(callingState)) {
|
|
15824
16286
|
throw new Error(`Illegal State: call.join() shall be called only once`);
|
|
15825
16287
|
}
|
|
16288
|
+
// snapshot before the first await: a leave() landing at any point from
|
|
16289
|
+
// here on supersedes this join, and the retry loop must bail out instead
|
|
16290
|
+
// of resurrecting a call that leave() already tore down.
|
|
16291
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
16292
|
+
const supersededByLeave = () => this.leaveGeneration !== joinLeaveGeneration;
|
|
15826
16293
|
// we need this to be set before the callingx.joinCall() is
|
|
15827
16294
|
// called to avoid registering the test call in the CallKit/Telecom
|
|
15828
16295
|
this.allowOwnTracksLoopback = allowOwnTracksLoopback;
|
|
15829
16296
|
if (data?.ring) {
|
|
15830
16297
|
this.ringingSubject.next(true);
|
|
15831
16298
|
}
|
|
16299
|
+
// A ringing call is joined by the SDK rather than by app code, so React
|
|
16300
|
+
// Native prepares it from in here - there is no earlier point at which the
|
|
16301
|
+
// app holds the call. Read after `data.ring` above, which is what makes an
|
|
16302
|
+
// outgoing call ringing in the first place.
|
|
16303
|
+
const ringingLifecycle = this.ringing
|
|
16304
|
+
? globalThis.streamRNVideoSDK?.ringingCallLifecycle
|
|
16305
|
+
: undefined;
|
|
15832
16306
|
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
15833
|
-
if (callingX) {
|
|
15834
|
-
// for Android/iOS, we need to start the call in the callingx library as soon as possible
|
|
15835
|
-
await callingX.joinCall(this, this.clientStore.calls);
|
|
15836
|
-
}
|
|
15837
|
-
await this.setup();
|
|
15838
|
-
this.clientEventReporter.registerCall(this.cid, {
|
|
15839
|
-
callType: this.type,
|
|
15840
|
-
callId: this.id,
|
|
15841
|
-
getCallSessionId: () => this.state.session?.id ?? '',
|
|
15842
|
-
getSfuId: () => this.credentials?.server.edge_name ?? '',
|
|
15843
|
-
});
|
|
15844
|
-
this.joinResponseTimeout = joinResponseTimeout;
|
|
15845
|
-
this.rpcRequestTimeout = rpcRequestTimeout;
|
|
15846
|
-
// we will count the number of join failures per SFU.
|
|
15847
|
-
// once the number of failures reaches 2, we will piggyback on the `migrating_from`
|
|
15848
|
-
// field to force the coordinator to provide us another SFU
|
|
15849
|
-
const sfuJoinFailures = new Map();
|
|
15850
|
-
const joinData = data;
|
|
15851
|
-
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
15852
16307
|
try {
|
|
15853
|
-
|
|
16308
|
+
if (ringingLifecycle) {
|
|
16309
|
+
await ringingLifecycle.beforeJoin(this);
|
|
16310
|
+
if (supersededByLeave()) {
|
|
16311
|
+
this.logger.debug('Join superseded by leave; not joining');
|
|
16312
|
+
return;
|
|
16313
|
+
}
|
|
16314
|
+
}
|
|
16315
|
+
if (callingX) {
|
|
16316
|
+
// for Android/iOS, we need to start the call in the callingx library as soon as possible
|
|
16317
|
+
await callingX.joinCall(this, this.clientStore.calls, supersededByLeave);
|
|
16318
|
+
if (supersededByLeave()) {
|
|
16319
|
+
this.logger.debug('Join superseded by leave; not setting up');
|
|
16320
|
+
return;
|
|
16321
|
+
}
|
|
16322
|
+
}
|
|
16323
|
+
await this.setup();
|
|
16324
|
+
if (supersededByLeave()) {
|
|
16325
|
+
this.logger.debug('Join superseded by leave; not registering');
|
|
16326
|
+
return;
|
|
16327
|
+
}
|
|
16328
|
+
this.clientEventReporter.registerCall(this.cid, {
|
|
16329
|
+
callType: this.type,
|
|
16330
|
+
callId: this.id,
|
|
16331
|
+
getCallSessionId: () => this.state.session?.id ?? '',
|
|
16332
|
+
getSfuId: () => this.credentials?.server.edge_name ?? '',
|
|
16333
|
+
});
|
|
16334
|
+
this.joinResponseTimeout = joinResponseTimeout;
|
|
16335
|
+
this.rpcRequestTimeout = rpcRequestTimeout;
|
|
16336
|
+
// we will count the number of join failures per SFU.
|
|
16337
|
+
// once the number of failures reaches 2, we will piggyback on the `migrating_from`
|
|
16338
|
+
// field to force the coordinator to provide us another SFU
|
|
16339
|
+
const sfuJoinFailures = new Map();
|
|
16340
|
+
const joinData = data;
|
|
16341
|
+
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
16342
|
+
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'first-attempt', joinSource }, async () => {
|
|
15854
16343
|
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
|
|
16344
|
+
// Also covers a leave that lands during the backoff below.
|
|
16345
|
+
if (supersededByLeave()) {
|
|
16346
|
+
this.logger.debug('Join superseded by leave; not attempting');
|
|
16347
|
+
return;
|
|
16348
|
+
}
|
|
15855
16349
|
try {
|
|
15856
16350
|
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
15857
16351
|
await this.doJoin(data);
|
|
@@ -15860,6 +16354,10 @@ class Call {
|
|
|
15860
16354
|
return;
|
|
15861
16355
|
}
|
|
15862
16356
|
catch (err) {
|
|
16357
|
+
if (supersededByLeave()) {
|
|
16358
|
+
this.logger.debug('Join superseded by leave; not retrying');
|
|
16359
|
+
return;
|
|
16360
|
+
}
|
|
15863
16361
|
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
15864
16362
|
if ((err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
15865
16363
|
(err instanceof SfuJoinError && err.unrecoverable)) {
|
|
@@ -15889,6 +16387,9 @@ class Call {
|
|
|
15889
16387
|
}
|
|
15890
16388
|
catch (error) {
|
|
15891
16389
|
callingX?.endCall(this, 'error');
|
|
16390
|
+
// Ends the failed ringing flow and releases what its setup installed.
|
|
16391
|
+
// Never rejects, so `error` is what the caller sees.
|
|
16392
|
+
await ringingLifecycle?.onJoinFailed(this);
|
|
15892
16393
|
throw error;
|
|
15893
16394
|
}
|
|
15894
16395
|
});
|
|
@@ -15911,6 +16412,10 @@ class Call {
|
|
|
15911
16412
|
// globals resolve to the call's factory. Idempotent across
|
|
15912
16413
|
// reconnect/migration attempts.
|
|
15913
16414
|
await this.ensureMediaFactory();
|
|
16415
|
+
if (supersededByLeave()) {
|
|
16416
|
+
this.logger.debug('Join superseded by leave; not wiring media');
|
|
16417
|
+
return;
|
|
16418
|
+
}
|
|
15914
16419
|
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
15915
16420
|
if (callingX) {
|
|
15916
16421
|
callingX.wireAudioEngineSubscription();
|
|
@@ -15926,11 +16431,17 @@ class Call {
|
|
|
15926
16431
|
data?.migrating_from) {
|
|
15927
16432
|
try {
|
|
15928
16433
|
const joinResponse = await this.clientEventReporter.track(this.cid, 'CoordinatorJoin', () => this.doJoinRequest(data));
|
|
16434
|
+
if (!joinResponse || supersededByLeave()) {
|
|
16435
|
+
this.logger.debug('Join superseded by leave; not creating SFU client');
|
|
16436
|
+
return;
|
|
16437
|
+
}
|
|
15929
16438
|
this.credentials = joinResponse.credentials;
|
|
15930
16439
|
statsOptions = joinResponse.stats_options;
|
|
15931
16440
|
this.lastStatsOptions = statsOptions;
|
|
15932
16441
|
}
|
|
15933
16442
|
catch (error) {
|
|
16443
|
+
if (supersededByLeave())
|
|
16444
|
+
return;
|
|
15934
16445
|
// prevent triggering reconnect flow if the state is OFFLINE
|
|
15935
16446
|
const avoidRestoreState = this.state.callingState === exports.CallingState.OFFLINE;
|
|
15936
16447
|
if (!avoidRestoreState) {
|
|
@@ -15978,6 +16489,10 @@ class Call {
|
|
|
15978
16489
|
getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
|
|
15979
16490
|
getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
|
|
15980
16491
|
]);
|
|
16492
|
+
if (supersededByLeave()) {
|
|
16493
|
+
this.logger.debug('Join superseded by leave; not joining SFU');
|
|
16494
|
+
return;
|
|
16495
|
+
}
|
|
15981
16496
|
const isReconnecting = this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
|
|
15982
16497
|
const reconnectDetails = isReconnecting
|
|
15983
16498
|
? this.getReconnectDetails(previousSfuClient?.edgeName, previousSessionId)
|
|
@@ -16003,6 +16518,10 @@ class Call {
|
|
|
16003
16518
|
capabilities,
|
|
16004
16519
|
source: ParticipantSource.WEBRTC_UNSPECIFIED,
|
|
16005
16520
|
}));
|
|
16521
|
+
if (supersededByLeave()) {
|
|
16522
|
+
this.logger.debug('Join superseded by leave; not applying SFU response');
|
|
16523
|
+
return;
|
|
16524
|
+
}
|
|
16006
16525
|
this.currentPublishOptions = publishOptions;
|
|
16007
16526
|
this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
|
|
16008
16527
|
if (callState) {
|
|
@@ -16010,6 +16529,10 @@ class Call {
|
|
|
16010
16529
|
}
|
|
16011
16530
|
}
|
|
16012
16531
|
catch (error) {
|
|
16532
|
+
if (supersededByLeave()) {
|
|
16533
|
+
this.logger.debug('Join superseded by leave; ignoring SFU failure');
|
|
16534
|
+
return;
|
|
16535
|
+
}
|
|
16013
16536
|
this.logger.warn('Join SFU request failed', error);
|
|
16014
16537
|
sfuClient.close(StreamSfuClient.JOIN_FAILED, 'Join request failed, connection considered unhealthy');
|
|
16015
16538
|
// restore the previous call state if the join-flow fails
|
|
@@ -16017,8 +16540,8 @@ class Call {
|
|
|
16017
16540
|
throw error;
|
|
16018
16541
|
}
|
|
16019
16542
|
}
|
|
16020
|
-
// If the user left while this join was in flight, bail before re-setting JOINED
|
|
16021
|
-
// peer-connection setup below
|
|
16543
|
+
// If the user left while this join was in flight, bail before re-setting JOINED
|
|
16544
|
+
// or starting peer-connection setup below.
|
|
16022
16545
|
if (supersededByLeave()) {
|
|
16023
16546
|
this.logger.debug('Join superseded by leave; aborting join flow');
|
|
16024
16547
|
return;
|
|
@@ -16047,6 +16570,10 @@ class Call {
|
|
|
16047
16570
|
unifiedSessionId: this.unifiedSessionId,
|
|
16048
16571
|
});
|
|
16049
16572
|
}
|
|
16573
|
+
if (supersededByLeave()) {
|
|
16574
|
+
this.logger.debug('Join superseded by leave; not completing join flow');
|
|
16575
|
+
return;
|
|
16576
|
+
}
|
|
16050
16577
|
// make sure we only track connection timing if we are not calling this method as part of a reconnection flow
|
|
16051
16578
|
if (!performingRejoin && !performingFastReconnect && !performingMigration) {
|
|
16052
16579
|
this.sfuStatsReporter?.sendConnectionTime((Date.now() - connectStartTime) / 1000);
|
|
@@ -16192,15 +16719,20 @@ class Call {
|
|
|
16192
16719
|
* @internal
|
|
16193
16720
|
*/
|
|
16194
16721
|
initPublisherAndSubscriber = async (opts) => {
|
|
16722
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
16195
16723
|
const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
|
|
16196
16724
|
const { enable_rtc_stats: enableTracing, reporting_interval_ms: reportingIntervalMs, } = statsOptions;
|
|
16197
16725
|
// Flush the previous reporter's final sample while its peer connections are
|
|
16198
16726
|
// still alive, before we dispose them below. Awaits only the sampling step.
|
|
16199
16727
|
await this.sfuStatsReporter?.flush();
|
|
16728
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16729
|
+
return;
|
|
16200
16730
|
this.sfuStatsReporter?.stop();
|
|
16201
16731
|
this.sfuStatsReporter = undefined;
|
|
16202
16732
|
if (closePreviousInstances && this.subscriber) {
|
|
16203
16733
|
await this.subscriber.dispose();
|
|
16734
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16735
|
+
return;
|
|
16204
16736
|
this.state.removeAllOrphanedTracks();
|
|
16205
16737
|
}
|
|
16206
16738
|
const basePeerConnectionOptions = {
|
|
@@ -16243,6 +16775,8 @@ class Call {
|
|
|
16243
16775
|
if (!isAnonymous) {
|
|
16244
16776
|
if (closePreviousInstances && this.publisher) {
|
|
16245
16777
|
await this.publisher.dispose();
|
|
16778
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16779
|
+
return;
|
|
16246
16780
|
}
|
|
16247
16781
|
this.publisher = new Publisher(basePeerConnectionOptions, publishOptions, {
|
|
16248
16782
|
selfSubEnabled: this.allowOwnTracksLoopback,
|
|
@@ -16279,12 +16813,22 @@ class Call {
|
|
|
16279
16813
|
*
|
|
16280
16814
|
* @internal
|
|
16281
16815
|
* @param data the join call data.
|
|
16816
|
+
* @returns The coordinator response, or undefined if leave superseded the request before it was sent.
|
|
16282
16817
|
*/
|
|
16283
16818
|
doJoinRequest = async (data) => {
|
|
16819
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
16284
16820
|
const location = await this.streamClient.getLocationHint();
|
|
16821
|
+
if (this.leaveGeneration !== joinLeaveGeneration) {
|
|
16822
|
+
this.logger.debug('Join superseded by leave; not sending coordinator request');
|
|
16823
|
+
return;
|
|
16824
|
+
}
|
|
16285
16825
|
const e2ee = !!this.e2eeManager;
|
|
16286
16826
|
const request = { ...data, location, e2ee };
|
|
16287
16827
|
const joinResponse = await this.streamClient.post(`${this.streamClientBasePath}/join`, request);
|
|
16828
|
+
if (this.leaveGeneration !== joinLeaveGeneration) {
|
|
16829
|
+
this.logger.debug('Join superseded by leave; not applying coordinator response');
|
|
16830
|
+
return joinResponse;
|
|
16831
|
+
}
|
|
16288
16832
|
this.state.updateFromCallResponse(joinResponse.call);
|
|
16289
16833
|
this.state.setMembers(joinResponse.members);
|
|
16290
16834
|
this.state.setOwnCapabilities(joinResponse.own_capabilities);
|
|
@@ -16295,6 +16839,8 @@ class Call {
|
|
|
16295
16839
|
if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
|
|
16296
16840
|
// signals other users that I have accepted the incoming call.
|
|
16297
16841
|
await this.accept();
|
|
16842
|
+
if (this.leaveGeneration !== joinLeaveGeneration)
|
|
16843
|
+
return joinResponse;
|
|
16298
16844
|
}
|
|
16299
16845
|
if (this.streamClient._hasConnectionID()) {
|
|
16300
16846
|
this.watching = true;
|
|
@@ -16384,6 +16930,7 @@ class Call {
|
|
|
16384
16930
|
if (callingState === exports.CallingState.JOINING ||
|
|
16385
16931
|
callingState === exports.CallingState.RECONNECTING ||
|
|
16386
16932
|
callingState === exports.CallingState.MIGRATING ||
|
|
16933
|
+
callingState === exports.CallingState.LEFT ||
|
|
16387
16934
|
callingState === exports.CallingState.RECONNECTING_FAILED)
|
|
16388
16935
|
return;
|
|
16389
16936
|
// Drop redundant reconnect calls. If a reconnect is already queued or
|
|
@@ -16392,19 +16939,27 @@ class Call {
|
|
|
16392
16939
|
// `POST /join` per entry) once the call is already healthy again.
|
|
16393
16940
|
if (hasPending(this.reconnectConcurrencyTag))
|
|
16394
16941
|
return;
|
|
16942
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16943
|
+
const supersededByLeave = () => this.leaveGeneration !== reconnectLeaveGeneration;
|
|
16395
16944
|
return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
|
|
16945
|
+
if (supersededByLeave())
|
|
16946
|
+
return;
|
|
16396
16947
|
const reconnectStartTime = Date.now();
|
|
16397
16948
|
this.reconnectStrategy = strategy;
|
|
16398
16949
|
this.reconnectReason = reason;
|
|
16399
16950
|
const sfuRejoinFailures = new Map();
|
|
16400
16951
|
const markAsReconnectingFailed = async () => {
|
|
16952
|
+
if (supersededByLeave())
|
|
16953
|
+
return;
|
|
16401
16954
|
try {
|
|
16402
16955
|
// attempt to fetch the call data from the server, as the call
|
|
16403
16956
|
// state might have changed while we were reconnecting or were offline
|
|
16404
16957
|
await this.get();
|
|
16405
16958
|
}
|
|
16406
16959
|
finally {
|
|
16407
|
-
|
|
16960
|
+
if (!supersededByLeave()) {
|
|
16961
|
+
this.state.setCallingState(exports.CallingState.RECONNECTING_FAILED);
|
|
16962
|
+
}
|
|
16408
16963
|
}
|
|
16409
16964
|
};
|
|
16410
16965
|
const giveUpAndLeave = async (message) => {
|
|
@@ -16460,6 +17015,8 @@ class Call {
|
|
|
16460
17015
|
try {
|
|
16461
17016
|
// wait until the network is available
|
|
16462
17017
|
await this.networkAvailableTask?.promise;
|
|
17018
|
+
if (supersededByLeave())
|
|
17019
|
+
return;
|
|
16463
17020
|
this.logger.info(`[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[this.reconnectStrategy]}`);
|
|
16464
17021
|
switch (this.reconnectStrategy) {
|
|
16465
17022
|
case WebsocketReconnectStrategy.UNSPECIFIED:
|
|
@@ -16499,6 +17056,8 @@ class Call {
|
|
|
16499
17056
|
break; // do-while loop, reconnection worked, exit the loop
|
|
16500
17057
|
}
|
|
16501
17058
|
catch (error) {
|
|
17059
|
+
if (supersededByLeave())
|
|
17060
|
+
return;
|
|
16502
17061
|
if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
|
|
16503
17062
|
const failedSfu = this.credentials?.server.edge_name;
|
|
16504
17063
|
if (failedSfu) {
|
|
@@ -16529,6 +17088,8 @@ class Call {
|
|
|
16529
17088
|
}
|
|
16530
17089
|
// exponential backoff with jitter, capped at 5 s
|
|
16531
17090
|
await sleep(retryInterval(attempt));
|
|
17091
|
+
if (supersededByLeave())
|
|
17092
|
+
return;
|
|
16532
17093
|
const wasMigrating = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
16533
17094
|
const mustPerformRejoin = (Date.now() - reconnectStartTime) / 1000 >
|
|
16534
17095
|
this.fastReconnectDeadlineSeconds;
|
|
@@ -16547,7 +17108,8 @@ class Call {
|
|
|
16547
17108
|
this.reconnectStrategy = nextStrategy;
|
|
16548
17109
|
this.logger.info(`[Reconnect] ${currentStrategy} (${this.reconnectAttempts}) failed. Attempting with ${WebsocketReconnectStrategy[nextStrategy]}`, error);
|
|
16549
17110
|
}
|
|
16550
|
-
} while (
|
|
17111
|
+
} while (!supersededByLeave() &&
|
|
17112
|
+
this.state.callingState !== exports.CallingState.JOINED &&
|
|
16551
17113
|
this.state.callingState !== exports.CallingState.RECONNECTING_FAILED &&
|
|
16552
17114
|
this.state.callingState !== exports.CallingState.LEFT);
|
|
16553
17115
|
this.logger.info('[Reconnect] Reconnection flow finished');
|
|
@@ -16558,10 +17120,13 @@ class Call {
|
|
|
16558
17120
|
* @internal
|
|
16559
17121
|
*/
|
|
16560
17122
|
reconnectFast = async () => {
|
|
17123
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16561
17124
|
const reconnectStartTime = Date.now();
|
|
16562
17125
|
this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
|
|
16563
17126
|
this.state.setCallingState(exports.CallingState.RECONNECTING);
|
|
16564
17127
|
await this.doJoin(this.joinCallData);
|
|
17128
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17129
|
+
return;
|
|
16565
17130
|
await this.get(); // fetch the latest call state, as it might have changed
|
|
16566
17131
|
this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.FAST, (Date.now() - reconnectStartTime) / 1000);
|
|
16567
17132
|
};
|
|
@@ -16570,14 +17135,19 @@ class Call {
|
|
|
16570
17135
|
* @internal
|
|
16571
17136
|
*/
|
|
16572
17137
|
reconnectRejoin = async () => {
|
|
17138
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16573
17139
|
const reconnectStartTime = Date.now();
|
|
16574
17140
|
this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
|
|
16575
17141
|
this.state.setCallingState(exports.CallingState.RECONNECTING);
|
|
16576
17142
|
const joinReason = this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
|
|
16577
17143
|
? 'network-available'
|
|
16578
17144
|
: 'full-rejoin';
|
|
16579
|
-
await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () => this.doJoin(this.joinCallData));
|
|
17145
|
+
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason }, () => this.doJoin(this.joinCallData));
|
|
17146
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17147
|
+
return;
|
|
16580
17148
|
await this.restorePublishedTracks();
|
|
17149
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17150
|
+
return;
|
|
16581
17151
|
this.restoreSubscribedTracks();
|
|
16582
17152
|
this.sfuStatsReporter?.sendReconnectionTime(WebsocketReconnectStrategy.REJOIN, (Date.now() - reconnectStartTime) / 1000);
|
|
16583
17153
|
};
|
|
@@ -16586,6 +17156,7 @@ class Call {
|
|
|
16586
17156
|
* @internal
|
|
16587
17157
|
*/
|
|
16588
17158
|
reconnectMigrate = async () => {
|
|
17159
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
16589
17160
|
const reconnectStartTime = Date.now();
|
|
16590
17161
|
const currentSfuClient = this.sfuClient;
|
|
16591
17162
|
if (!currentSfuClient) {
|
|
@@ -16599,26 +17170,32 @@ class Call {
|
|
|
16599
17170
|
currentPublisher?.detachEventHandlers();
|
|
16600
17171
|
const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
|
|
16601
17172
|
try {
|
|
16602
|
-
|
|
16603
|
-
|
|
16604
|
-
|
|
16605
|
-
|
|
16606
|
-
|
|
16607
|
-
|
|
16608
|
-
|
|
16609
|
-
|
|
16610
|
-
|
|
16611
|
-
|
|
16612
|
-
|
|
16613
|
-
|
|
16614
|
-
|
|
16615
|
-
|
|
16616
|
-
|
|
16617
|
-
|
|
17173
|
+
try {
|
|
17174
|
+
const currentSfu = currentSfuClient.edgeName;
|
|
17175
|
+
await this.clientEventReporter.withJoinLifecycle(this.cid, { joinReason: 'migration' }, () => this.doJoin({
|
|
17176
|
+
...this.joinCallData,
|
|
17177
|
+
migrating_from: currentSfu,
|
|
17178
|
+
migrating_from_list: [currentSfu],
|
|
17179
|
+
}));
|
|
17180
|
+
}
|
|
17181
|
+
finally {
|
|
17182
|
+
// cleanup the migration_from field after the migration is complete or failed
|
|
17183
|
+
// as we don't want to keep dirty data in the join call data
|
|
17184
|
+
delete this.joinCallData?.migrating_from;
|
|
17185
|
+
delete this.joinCallData?.migrating_from_list;
|
|
17186
|
+
}
|
|
17187
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17188
|
+
return;
|
|
17189
|
+
await this.restorePublishedTracks();
|
|
17190
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17191
|
+
return;
|
|
17192
|
+
this.restoreSubscribedTracks();
|
|
16618
17193
|
// Wait for the migration to complete, then close the previous SFU client
|
|
16619
17194
|
// and the peer connection instances. In case of failure, the migration
|
|
16620
17195
|
// task would throw an error and REJOIN would be attempted.
|
|
16621
17196
|
await migrationTask();
|
|
17197
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration)
|
|
17198
|
+
return;
|
|
16622
17199
|
// in MIGRATE, we can consider the call as joined only after
|
|
16623
17200
|
// `participantMigrationComplete` event is received, signaled by
|
|
16624
17201
|
// the `migrationTask`
|
|
@@ -16903,10 +17480,11 @@ class Call {
|
|
|
16903
17480
|
* Must be called before {@link join} so the RTCPeerConnection can be
|
|
16904
17481
|
* configured for E2EE.
|
|
16905
17482
|
*
|
|
16906
|
-
* The
|
|
16907
|
-
*
|
|
16908
|
-
*
|
|
16909
|
-
*
|
|
17483
|
+
* The application owns the manager's lifetime - the SDK never disposes it, and
|
|
17484
|
+
* closing the peer connections does not release it. Discard the manager
|
|
17485
|
+
* together with this call instance once its flow has ended, and create a fresh
|
|
17486
|
+
* pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
|
|
17487
|
+
* rather than silently publishing nothing.
|
|
16910
17488
|
*
|
|
16911
17489
|
* @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
|
|
16912
17490
|
* built-in AES-GCM scheme, or pass your own implementation.
|
|
@@ -17373,40 +17951,36 @@ class Call {
|
|
|
17373
17951
|
*/
|
|
17374
17952
|
scheduleAutoDrop = () => {
|
|
17375
17953
|
this.cancelAutoDrop();
|
|
17376
|
-
|
|
17377
|
-
|
|
17378
|
-
return;
|
|
17379
|
-
// ignore if the call is not ringing
|
|
17380
|
-
if (this.state.callingState !== exports.CallingState.RINGING)
|
|
17381
|
-
return;
|
|
17382
|
-
const timeoutInMs = this.isCreatedByMe
|
|
17383
|
-
? settings.ring.auto_cancel_timeout_ms
|
|
17384
|
-
: settings.ring.incoming_call_timeout_ms;
|
|
17385
|
-
// 0 means no auto-drop
|
|
17386
|
-
if (timeoutInMs <= 0)
|
|
17387
|
-
return;
|
|
17388
|
-
this.dropTimeout = setTimeout(() => {
|
|
17389
|
-
// the call might have stopped ringing by this point,
|
|
17390
|
-
// e.g. it was already accepted and joined
|
|
17391
|
-
if (this.state.callingState !== exports.CallingState.RINGING)
|
|
17392
|
-
return;
|
|
17393
|
-
this.leave({
|
|
17394
|
-
reject: true,
|
|
17395
|
-
reason: 'timeout',
|
|
17396
|
-
message: `ringing timeout - ${this.isCreatedByMe
|
|
17397
|
-
? 'no one accepted'
|
|
17398
|
-
: `user didn't interact with incoming call screen`}`,
|
|
17399
|
-
}).catch((err) => {
|
|
17400
|
-
this.logger.error('Failed to drop call', err);
|
|
17401
|
-
});
|
|
17402
|
-
}, timeoutInMs);
|
|
17954
|
+
this.ringTimeout = new RingTimeout(this);
|
|
17955
|
+
this.ringTimeout.start();
|
|
17403
17956
|
};
|
|
17404
17957
|
/**
|
|
17405
17958
|
* Cancels a scheduled auto-drop timeout.
|
|
17406
17959
|
*/
|
|
17407
17960
|
cancelAutoDrop = () => {
|
|
17408
|
-
|
|
17409
|
-
this.
|
|
17961
|
+
this.ringTimeout?.stop();
|
|
17962
|
+
this.ringTimeout = undefined;
|
|
17963
|
+
};
|
|
17964
|
+
/**
|
|
17965
|
+
* Starts polling for the ring outcome. Applicable only to ringing calls the
|
|
17966
|
+
* current user created.
|
|
17967
|
+
*/
|
|
17968
|
+
scheduleRingStatePolling = () => {
|
|
17969
|
+
this.cancelRingStatePolling();
|
|
17970
|
+
if (!this.isCreatedByMe)
|
|
17971
|
+
return;
|
|
17972
|
+
const options = this.streamClient.options.ringStatePolling;
|
|
17973
|
+
if (options === false)
|
|
17974
|
+
return;
|
|
17975
|
+
this.ringStatePoller = new RingStatePoller(this, options);
|
|
17976
|
+
this.ringStatePoller.start();
|
|
17977
|
+
};
|
|
17978
|
+
/**
|
|
17979
|
+
* Cancels the ring state polling.
|
|
17980
|
+
*/
|
|
17981
|
+
cancelRingStatePolling = () => {
|
|
17982
|
+
this.ringStatePoller?.stop();
|
|
17983
|
+
this.ringStatePoller = undefined;
|
|
17410
17984
|
};
|
|
17411
17985
|
/**
|
|
17412
17986
|
* Retrieves the list of recordings for the current call or call session.
|
|
@@ -19077,11 +19651,11 @@ class StreamClient {
|
|
|
19077
19651
|
return await this.wsConnection.connect(this.defaultWSTimeout);
|
|
19078
19652
|
};
|
|
19079
19653
|
getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
|
|
19080
|
-
"1.
|
|
19654
|
+
"1.61.0";
|
|
19081
19655
|
getUserAgent = () => {
|
|
19082
19656
|
if (!this.cachedUserAgent) {
|
|
19083
19657
|
const { clientAppIdentifier = {} } = this.options;
|
|
19084
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
19658
|
+
const { sdkName = 'js', sdkVersion = "1.61.0", ...extras } = clientAppIdentifier;
|
|
19085
19659
|
this.cachedUserAgent = [
|
|
19086
19660
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
19087
19661
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|
|
@@ -19214,6 +19788,7 @@ class ClientEventReporter {
|
|
|
19214
19788
|
callContexts = new Map();
|
|
19215
19789
|
joinAttemptIds = new Map();
|
|
19216
19790
|
joinReasons = new Map();
|
|
19791
|
+
joinSources = new Map();
|
|
19217
19792
|
coordinatorPairs = new Map();
|
|
19218
19793
|
wsPairs = new Map();
|
|
19219
19794
|
peerConnectionPairs = new Map();
|
|
@@ -19328,6 +19903,7 @@ class ClientEventReporter {
|
|
|
19328
19903
|
this.callContexts.delete(cid);
|
|
19329
19904
|
this.joinAttemptIds.delete(cid);
|
|
19330
19905
|
this.joinReasons.delete(cid);
|
|
19906
|
+
this.joinSources.delete(cid);
|
|
19331
19907
|
this.coordinatorPairs.delete(cid);
|
|
19332
19908
|
this.wsPairs.delete(cid);
|
|
19333
19909
|
this.firstFrameReported.delete(`${cid}:FirstVideoFrame`);
|
|
@@ -19352,7 +19928,12 @@ class ClientEventReporter {
|
|
|
19352
19928
|
this.logger.warn('Failed to start join correlation', err);
|
|
19353
19929
|
}
|
|
19354
19930
|
};
|
|
19355
|
-
withJoinLifecycle = async (cid,
|
|
19931
|
+
withJoinLifecycle = async (cid, options, op) => {
|
|
19932
|
+
const { joinReason, joinSource } = options;
|
|
19933
|
+
if (joinSource)
|
|
19934
|
+
this.joinSources.set(cid, joinSource);
|
|
19935
|
+
else
|
|
19936
|
+
this.joinSources.delete(cid);
|
|
19356
19937
|
this.startCorrelation(cid, joinReason);
|
|
19357
19938
|
try {
|
|
19358
19939
|
return await op();
|
|
@@ -19361,6 +19942,9 @@ class ClientEventReporter {
|
|
|
19361
19942
|
this.closeCallPairs(cid);
|
|
19362
19943
|
throw err;
|
|
19363
19944
|
}
|
|
19945
|
+
finally {
|
|
19946
|
+
this.joinSources.delete(cid);
|
|
19947
|
+
}
|
|
19364
19948
|
};
|
|
19365
19949
|
track = async (cid, stage, op) => {
|
|
19366
19950
|
this.beginAttempt(cid, stage);
|
|
@@ -19484,6 +20068,7 @@ class ClientEventReporter {
|
|
|
19484
20068
|
startedAt: Date.now(),
|
|
19485
20069
|
joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
|
|
19486
20070
|
joinReasonSnapshot: this.joinReasons.get(cid),
|
|
20071
|
+
joinSourceSnapshot: this.joinSources.get(cid),
|
|
19487
20072
|
};
|
|
19488
20073
|
this.coordinatorPairs.set(cid, pair);
|
|
19489
20074
|
this.sendForCall(cid, {
|
|
@@ -19491,6 +20076,7 @@ class ClientEventReporter {
|
|
|
19491
20076
|
...(pair.joinReasonSnapshot && {
|
|
19492
20077
|
join_reason: pair.joinReasonSnapshot,
|
|
19493
20078
|
}),
|
|
20079
|
+
...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
|
|
19494
20080
|
event_type: 'initiated',
|
|
19495
20081
|
});
|
|
19496
20082
|
}
|
|
@@ -19505,6 +20091,7 @@ class ClientEventReporter {
|
|
|
19505
20091
|
...this.buildCommon(cid, 'CoordinatorJoin', pair),
|
|
19506
20092
|
...this.sessionIdField(cid),
|
|
19507
20093
|
...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
|
|
20094
|
+
...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
|
|
19508
20095
|
event_type: 'completed',
|
|
19509
20096
|
outcome: 'success',
|
|
19510
20097
|
retry_count_attempt: pair.attempts - 1,
|
|
@@ -19523,6 +20110,7 @@ class ClientEventReporter {
|
|
|
19523
20110
|
...this.buildCommon(cid, 'CoordinatorJoin', pair),
|
|
19524
20111
|
...this.sessionIdField(cid),
|
|
19525
20112
|
...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
|
|
20113
|
+
...(pair.joinSourceSnapshot && { source: pair.joinSourceSnapshot }),
|
|
19526
20114
|
event_type: 'completed',
|
|
19527
20115
|
outcome: 'failure',
|
|
19528
20116
|
retry_count_attempt: pair.attempts - 1,
|
|
@@ -20776,9 +21364,9 @@ class EncryptionManager extends TypedEventEmitter {
|
|
|
20776
21364
|
}
|
|
20777
21365
|
if ((this.piped ??= new WeakSet()).has(target))
|
|
20778
21366
|
return;
|
|
20779
|
-
this.piped.add(target);
|
|
20780
21367
|
// @ts-expect-error createEncodedStreams is not in the standard typedefs
|
|
20781
21368
|
const { readable, writable } = target.createEncodedStreams();
|
|
21369
|
+
this.piped.add(target);
|
|
20782
21370
|
this.worker.postMessage({ type: 'cmd.setup_transform', ...options, readable, writable }, [readable, writable]);
|
|
20783
21371
|
};
|
|
20784
21372
|
/**
|
|
@@ -20946,6 +21534,7 @@ exports.TranscriptionSettingsResponseClosedCaptionModeEnum = TranscriptionSettin
|
|
|
20946
21534
|
exports.TranscriptionSettingsResponseLanguageEnum = TranscriptionSettingsResponseLanguageEnum;
|
|
20947
21535
|
exports.TranscriptionSettingsResponseModeEnum = TranscriptionSettingsResponseModeEnum;
|
|
20948
21536
|
exports.TranslationSettingsLanguagesEnum = TranslationSettingsLanguagesEnum;
|
|
21537
|
+
exports.TypedEventEmitter = TypedEventEmitter;
|
|
20949
21538
|
exports.UpdateUserPermissionsRequestGrantPermissionsEnum = UpdateUserPermissionsRequestGrantPermissionsEnum;
|
|
20950
21539
|
exports.UpdateUserPermissionsRequestRevokePermissionsEnum = UpdateUserPermissionsRequestRevokePermissionsEnum;
|
|
20951
21540
|
exports.VideoSettingsRequestCameraFacingEnum = VideoSettingsRequestCameraFacingEnum;
|