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