@stream-io/video-client 1.61.2 → 1.62.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 +12 -0
- package/dist/index.browser.es.js +247 -29
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +247 -29
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +247 -29
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +21 -1
- package/dist/src/devices/DeviceManagerState.d.ts +17 -0
- package/dist/src/devices/MicrophoneManager.d.ts +11 -0
- package/dist/src/devices/devices.d.ts +5 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +13 -1
- package/dist/src/rtc/types.d.ts +7 -0
- package/package.json +1 -1
- package/src/Call.ts +110 -6
- package/src/devices/DeviceManager.ts +6 -0
- package/src/devices/DeviceManagerState.ts +24 -0
- package/src/devices/MicrophoneManager.ts +79 -17
- package/src/devices/SpeakerManager.ts +1 -1
- package/src/devices/__tests__/DeviceManager.test.ts +29 -0
- package/src/devices/__tests__/MicrophoneManager.test.ts +235 -0
- package/src/devices/__tests__/SpeakerManager.test.ts +27 -0
- package/src/devices/__tests__/devices.test.ts +10 -14
- package/src/devices/__tests__/mediaStreamTestHelpers.ts +1 -0
- package/src/devices/__tests__/mocks.ts +1 -0
- package/src/devices/devices.ts +7 -1
- package/src/helpers/DynascaleManager.ts +2 -2
- package/src/helpers/__tests__/DynascaleManager.test.ts +6 -4
- package/src/rtc/BasePeerConnection.ts +47 -9
- package/src/rtc/__tests__/Call.reconnect.test.ts +474 -0
- package/src/rtc/types.ts +8 -0
package/dist/index.es.js
CHANGED
|
@@ -5056,7 +5056,7 @@ const toIceCandidate = (iceTrickle) => {
|
|
|
5056
5056
|
}
|
|
5057
5057
|
};
|
|
5058
5058
|
|
|
5059
|
-
const version = "1.
|
|
5059
|
+
const version = "1.62.0";
|
|
5060
5060
|
const [major, minor, patch] = version.split('.');
|
|
5061
5061
|
let sdkInfo = {
|
|
5062
5062
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -8451,10 +8451,12 @@ class BasePeerConnection {
|
|
|
8451
8451
|
sfuClient;
|
|
8452
8452
|
onReconnectionNeeded;
|
|
8453
8453
|
onIceConnected;
|
|
8454
|
+
onNoLocalCandidates;
|
|
8454
8455
|
onPeerConnectionStateChange;
|
|
8455
8456
|
onRemoteTrackUnmute;
|
|
8456
8457
|
iceRestartDelay;
|
|
8457
8458
|
iceHasEverConnected = false;
|
|
8459
|
+
localCandidatesGathered = 0;
|
|
8458
8460
|
iceRestartTimeout;
|
|
8459
8461
|
preConnectStuckTimeout;
|
|
8460
8462
|
isIceRestarting = false;
|
|
@@ -8468,7 +8470,7 @@ class BasePeerConnection {
|
|
|
8468
8470
|
/**
|
|
8469
8471
|
* Constructs a new `BasePeerConnection` instance.
|
|
8470
8472
|
*/
|
|
8471
|
-
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
|
|
8473
|
+
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onNoLocalCandidates, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
|
|
8472
8474
|
this.peerType = peerType;
|
|
8473
8475
|
this.sfuClient = sfuClient;
|
|
8474
8476
|
this.state = state;
|
|
@@ -8479,6 +8481,7 @@ class BasePeerConnection {
|
|
|
8479
8481
|
this.tag = tag;
|
|
8480
8482
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
8481
8483
|
this.onIceConnected = onIceConnected;
|
|
8484
|
+
this.onNoLocalCandidates = onNoLocalCandidates;
|
|
8482
8485
|
this.onPeerConnectionStateChange = onPeerConnectionStateChange;
|
|
8483
8486
|
this.onRemoteTrackUnmute = onRemoteTrackUnmute;
|
|
8484
8487
|
this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
|
|
@@ -8519,6 +8522,7 @@ class BasePeerConnection {
|
|
|
8519
8522
|
this.preConnectStuckTimeout = undefined;
|
|
8520
8523
|
this.onReconnectionNeeded = undefined;
|
|
8521
8524
|
this.onIceConnected = undefined;
|
|
8525
|
+
this.onNoLocalCandidates = undefined;
|
|
8522
8526
|
this.onPeerConnectionStateChange = undefined;
|
|
8523
8527
|
this.onRemoteTrackUnmute = undefined;
|
|
8524
8528
|
this.isDisposed = true;
|
|
@@ -8622,6 +8626,17 @@ class BasePeerConnection {
|
|
|
8622
8626
|
getTrackType = (trackId) => {
|
|
8623
8627
|
return this.trackIdToTrackType.get(trackId);
|
|
8624
8628
|
};
|
|
8629
|
+
/**
|
|
8630
|
+
* Returns `true` once ICE has reached `connected`/`completed` at least
|
|
8631
|
+
* once during the lifetime of this peer connection.
|
|
8632
|
+
*/
|
|
8633
|
+
hasIceEverConnected = () => this.iceHasEverConnected;
|
|
8634
|
+
/**
|
|
8635
|
+
* Returns `true` when the latest ICE gathering round completed without
|
|
8636
|
+
* producing a single local candidate.
|
|
8637
|
+
*/
|
|
8638
|
+
hasNoLocalCandidates = () => this.pc.iceGatheringState === 'complete' &&
|
|
8639
|
+
this.localCandidatesGathered === 0;
|
|
8625
8640
|
/**
|
|
8626
8641
|
* Checks if the `RTCPeerConnection` is healthy.
|
|
8627
8642
|
* It checks the ICE connection state and the peer connection state.
|
|
@@ -8644,6 +8659,8 @@ class BasePeerConnection {
|
|
|
8644
8659
|
this.logger.debug('null ice candidate');
|
|
8645
8660
|
return;
|
|
8646
8661
|
}
|
|
8662
|
+
if (candidate.candidate)
|
|
8663
|
+
this.localCandidatesGathered++;
|
|
8647
8664
|
const iceCandidate = toJSON(candidate);
|
|
8648
8665
|
this.sfuClient
|
|
8649
8666
|
.iceTrickle({ peerType: this.peerType, iceCandidate })
|
|
@@ -8708,6 +8725,17 @@ class BasePeerConnection {
|
|
|
8708
8725
|
}
|
|
8709
8726
|
};
|
|
8710
8727
|
handleConnectionStateUpdate = (state) => {
|
|
8728
|
+
// Fire `onIceConnected` exactly once per peer-connection lifetime, the
|
|
8729
|
+
// first time ICE reaches `connected`/`completed` end-to-end. Recorded
|
|
8730
|
+
// ahead of the guards below so a connection established while offline,
|
|
8731
|
+
// reconnecting or restarting ICE is not missed. Used by `Call` to reset
|
|
8732
|
+
// the unsupported-network failure counter only after WebRTC has actually
|
|
8733
|
+
// recovered, not merely on SFU join.
|
|
8734
|
+
if ((state === 'connected' || state === 'completed') &&
|
|
8735
|
+
!this.iceHasEverConnected) {
|
|
8736
|
+
this.iceHasEverConnected = true;
|
|
8737
|
+
this.onIceConnected?.(this.peerType);
|
|
8738
|
+
}
|
|
8711
8739
|
const { callingState } = this.state;
|
|
8712
8740
|
if (callingState === CallingState.OFFLINE)
|
|
8713
8741
|
return;
|
|
@@ -8771,14 +8799,6 @@ class BasePeerConnection {
|
|
|
8771
8799
|
break;
|
|
8772
8800
|
case 'connected':
|
|
8773
8801
|
case 'completed':
|
|
8774
|
-
// Fire `onIceConnected` exactly once per peer-connection lifetime —
|
|
8775
|
-
// the first time ICE reaches `connected`/`completed` end-to-end.
|
|
8776
|
-
// Used by `Call` to reset the unsupported-network failure counter
|
|
8777
|
-
// only after WebRTC has actually recovered, not merely on SFU join.
|
|
8778
|
-
if (!this.iceHasEverConnected) {
|
|
8779
|
-
this.iceHasEverConnected = true;
|
|
8780
|
-
this.onIceConnected?.(this.peerType);
|
|
8781
|
-
}
|
|
8782
8802
|
// clear any scheduled restartICE since the connection is healthy
|
|
8783
8803
|
if (this.iceRestartTimeout) {
|
|
8784
8804
|
this.logger.info('connected connection, canceling restartICE');
|
|
@@ -8804,7 +8824,17 @@ class BasePeerConnection {
|
|
|
8804
8824
|
* Handles the ICE gathering state change event.
|
|
8805
8825
|
*/
|
|
8806
8826
|
onIceGatherChange = () => {
|
|
8807
|
-
|
|
8827
|
+
const state = this.pc.iceGatheringState;
|
|
8828
|
+
this.logger.debug(`ICE Gathering State`, state);
|
|
8829
|
+
if (state === 'gathering') {
|
|
8830
|
+
this.localCandidatesGathered = 0;
|
|
8831
|
+
}
|
|
8832
|
+
else if (state === 'complete' &&
|
|
8833
|
+
this.localCandidatesGathered === 0 &&
|
|
8834
|
+
!this.iceHasEverConnected) {
|
|
8835
|
+
this.logger.info('ICE gathering completed without local candidates');
|
|
8836
|
+
this.onNoLocalCandidates?.(this.peerType);
|
|
8837
|
+
}
|
|
8808
8838
|
};
|
|
8809
8839
|
/**
|
|
8810
8840
|
* Handles the signaling state change event.
|
|
@@ -12320,7 +12350,7 @@ class DynascaleManager {
|
|
|
12320
12350
|
this.traceAudioSessionState();
|
|
12321
12351
|
const participant$ = this.callState.participants$.pipe(map((ps) => ps.find((p) => p.sessionId === sessionId)), takeWhile((p) => !!p), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }));
|
|
12322
12352
|
const updateSinkId = (deviceId, audioContext) => {
|
|
12323
|
-
if (!deviceId)
|
|
12353
|
+
if (!deviceId || !this.speaker.state.isDeviceSelectionSupported)
|
|
12324
12354
|
return;
|
|
12325
12355
|
if ('setSinkId' in audioElement) {
|
|
12326
12356
|
audioElement.setSinkId(deviceId).catch((e) => {
|
|
@@ -12415,7 +12445,7 @@ class DynascaleManager {
|
|
|
12415
12445
|
updateSinkId(selectedDevice, audioContext);
|
|
12416
12446
|
});
|
|
12417
12447
|
});
|
|
12418
|
-
const sinkIdSubscription = !
|
|
12448
|
+
const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
|
|
12419
12449
|
? null
|
|
12420
12450
|
: this.speaker.state.selectedDevice$.subscribe((deviceId) => {
|
|
12421
12451
|
const audioContext = this.getOrCreateAudioContext();
|
|
@@ -13078,10 +13108,17 @@ const getDevices = (permission, kind, tracer) => {
|
|
|
13078
13108
|
/**
|
|
13079
13109
|
* Tells if the browser supports audio output change on 'audio' elements,
|
|
13080
13110
|
* see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
|
|
13111
|
+
*
|
|
13112
|
+
* WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
|
|
13113
|
+
* setSinkId requires a user gesture, a per-device output can run at a
|
|
13114
|
+
* different sample rate than the remote audio mixer (pitched/slowed audio),
|
|
13115
|
+
* and audio goes silent when the selected device disconnects.
|
|
13081
13116
|
*/
|
|
13082
13117
|
const checkIfAudioOutputChangeSupported = () => {
|
|
13083
13118
|
if (typeof document === 'undefined')
|
|
13084
13119
|
return false;
|
|
13120
|
+
if (isWebKit())
|
|
13121
|
+
return false;
|
|
13085
13122
|
const element = document.createElement('audio');
|
|
13086
13123
|
return 'setSinkId' in element;
|
|
13087
13124
|
};
|
|
@@ -13587,6 +13624,7 @@ class DeviceManager {
|
|
|
13587
13624
|
? forceStopOrOptions
|
|
13588
13625
|
: (forceStopOrOptions?.forceStop ?? false);
|
|
13589
13626
|
this.state.prevStatus = this.state.optimisticStatus;
|
|
13627
|
+
this.state.setError(undefined);
|
|
13590
13628
|
if (!forceStop && this.state.optimisticStatus === 'disabled') {
|
|
13591
13629
|
return;
|
|
13592
13630
|
}
|
|
@@ -13596,6 +13634,7 @@ class DeviceManager {
|
|
|
13596
13634
|
const stopTracks = forceStop || this.state.disableMode === 'stop-tracks';
|
|
13597
13635
|
await this.muteStream(stopTracks);
|
|
13598
13636
|
this.state.setStatus('disabled');
|
|
13637
|
+
this.state.setError(undefined);
|
|
13599
13638
|
}
|
|
13600
13639
|
finally {
|
|
13601
13640
|
if (!signal.aborted) {
|
|
@@ -13891,7 +13930,11 @@ class DeviceManager {
|
|
|
13891
13930
|
};
|
|
13892
13931
|
// the rootStream represents the stream coming from the actual device
|
|
13893
13932
|
// e.g. camera or microphone stream
|
|
13933
|
+
this.state.setError(undefined);
|
|
13894
13934
|
rootStreamPromise = this.getSelectedStream(constraints);
|
|
13935
|
+
rootStreamPromise.catch((error) => {
|
|
13936
|
+
this.state.setError(error);
|
|
13937
|
+
});
|
|
13895
13938
|
// we publish the last MediaStream of the chain
|
|
13896
13939
|
stream = await this.filters.reduce((parent, entry) => parent
|
|
13897
13940
|
.then((inputStream) => {
|
|
@@ -14129,6 +14172,7 @@ class DeviceManagerState {
|
|
|
14129
14172
|
rootMediaStreamSubject = new BehaviorSubject(undefined);
|
|
14130
14173
|
selectedDeviceSubject = new BehaviorSubject(undefined);
|
|
14131
14174
|
defaultConstraintsSubject = new BehaviorSubject(undefined);
|
|
14175
|
+
errorSubject = new BehaviorSubject(undefined);
|
|
14132
14176
|
/**
|
|
14133
14177
|
* @internal
|
|
14134
14178
|
*/
|
|
@@ -14163,6 +14207,13 @@ class DeviceManagerState {
|
|
|
14163
14207
|
* The default constraints for the device.
|
|
14164
14208
|
*/
|
|
14165
14209
|
defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
|
|
14210
|
+
/**
|
|
14211
|
+
* An Observable that emits the error of the last failed attempt to start
|
|
14212
|
+
* the device (e.g. the device is in use by another app), or `undefined`.
|
|
14213
|
+
* For screen share, this includes the user dismissing the picker.
|
|
14214
|
+
* Cleared when the device is started again or disabled.
|
|
14215
|
+
*/
|
|
14216
|
+
error$ = this.errorSubject.asObservable().pipe(distinctUntilChanged());
|
|
14166
14217
|
/**
|
|
14167
14218
|
* An observable that will emit `true` if browser/system permission
|
|
14168
14219
|
* is granted (or at least hasn't been denied), `false` otherwise.
|
|
@@ -14230,6 +14281,19 @@ class DeviceManagerState {
|
|
|
14230
14281
|
get rootMediaStream() {
|
|
14231
14282
|
return getCurrentValue(this.rootMediaStream$);
|
|
14232
14283
|
}
|
|
14284
|
+
/**
|
|
14285
|
+
* The error of the last failed attempt to start the device, or `undefined`.
|
|
14286
|
+
*/
|
|
14287
|
+
get error() {
|
|
14288
|
+
return getCurrentValue(this.error$);
|
|
14289
|
+
}
|
|
14290
|
+
/**
|
|
14291
|
+
* @internal
|
|
14292
|
+
* @param error the error to set.
|
|
14293
|
+
*/
|
|
14294
|
+
setError(error) {
|
|
14295
|
+
setCurrentValue(this.errorSubject, error);
|
|
14296
|
+
}
|
|
14233
14297
|
/**
|
|
14234
14298
|
* @internal
|
|
14235
14299
|
* @param status
|
|
@@ -14811,9 +14875,11 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
14811
14875
|
this.call.state.callingState$,
|
|
14812
14876
|
this.call.state.ownCapabilities$,
|
|
14813
14877
|
this.state.selectedDevice$,
|
|
14814
|
-
this.state.status$,
|
|
14815
14878
|
this.state.browserPermissionState$,
|
|
14816
|
-
|
|
14879
|
+
// only a trigger: queued handlers can run after the status changed
|
|
14880
|
+
// again, so the live status is read below
|
|
14881
|
+
this.state.status$,
|
|
14882
|
+
]), async ([callingState, ownCapabilities, deviceId, permissionState]) => {
|
|
14817
14883
|
try {
|
|
14818
14884
|
if (callingState === CallingState.LEFT) {
|
|
14819
14885
|
// The muted-recording-prepared mode is reset in `callManager.stop()`
|
|
@@ -14828,7 +14894,7 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
14828
14894
|
return;
|
|
14829
14895
|
if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
|
|
14830
14896
|
const hasPermission = await this.hasPermission(permissionState);
|
|
14831
|
-
if (hasPermission && status !== 'enabled') {
|
|
14897
|
+
if (hasPermission && this.state.status !== 'enabled') {
|
|
14832
14898
|
this.setMutedRecordingPrepared(true);
|
|
14833
14899
|
await this.startSpeakingWhileMutedDetection(deviceId);
|
|
14834
14900
|
}
|
|
@@ -15128,6 +15194,52 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15128
15194
|
await this.call.ensureMediaFactory();
|
|
15129
15195
|
return getAudioStream(constraints, this.call.tracer);
|
|
15130
15196
|
}
|
|
15197
|
+
/**
|
|
15198
|
+
* On mute, hands a clone of the live mic track to the speaking while muted
|
|
15199
|
+
* detection, so the device stays captured after the mic tracks are stopped.
|
|
15200
|
+
* Releasing and re-acquiring the mic flips Bluetooth headsets between audio
|
|
15201
|
+
* profiles.
|
|
15202
|
+
*/
|
|
15203
|
+
async muteStream(stopTracks = true) {
|
|
15204
|
+
const [track] = this.state.rootMediaStream?.getAudioTracks() ?? [];
|
|
15205
|
+
const clone = track?.readyState === 'live' &&
|
|
15206
|
+
this.state.optimisticStatus === 'disabled' &&
|
|
15207
|
+
this.canHandOffMic()
|
|
15208
|
+
? track.clone()
|
|
15209
|
+
: undefined;
|
|
15210
|
+
try {
|
|
15211
|
+
await super.muteStream(stopTracks);
|
|
15212
|
+
}
|
|
15213
|
+
catch (err) {
|
|
15214
|
+
clone?.stop();
|
|
15215
|
+
throw err;
|
|
15216
|
+
}
|
|
15217
|
+
if (!clone)
|
|
15218
|
+
return;
|
|
15219
|
+
// the mic, the call, or the selected device may have changed while
|
|
15220
|
+
// unpublishing
|
|
15221
|
+
const deviceId = resolveDeviceId(clone.getSettings().deviceId, 'audioinput');
|
|
15222
|
+
if (clone.readyState !== 'live' ||
|
|
15223
|
+
!this.canHandOffMic() ||
|
|
15224
|
+
deviceId !== this.state.selectedDevice) {
|
|
15225
|
+
clone.stop();
|
|
15226
|
+
return;
|
|
15227
|
+
}
|
|
15228
|
+
this.startSpeakingWhileMutedDetection(deviceId, clone).catch((err) => {
|
|
15229
|
+
clone.stop();
|
|
15230
|
+
this.logger.warn('Could not hand off mic to speaking while muted', err);
|
|
15231
|
+
});
|
|
15232
|
+
}
|
|
15233
|
+
/**
|
|
15234
|
+
* Whether a mute can hand the mic over to the speaking while muted detection.
|
|
15235
|
+
*/
|
|
15236
|
+
canHandOffMic = () => {
|
|
15237
|
+
const { callingState, ownCapabilities } = this.call.state;
|
|
15238
|
+
return (!isReactNative() &&
|
|
15239
|
+
this.speakingWhileMutedNotificationEnabled &&
|
|
15240
|
+
callingState === CallingState.JOINED &&
|
|
15241
|
+
ownCapabilities.includes(OwnCapability.SEND_AUDIO));
|
|
15242
|
+
};
|
|
15131
15243
|
doSetAudioBitrateProfile(profile) {
|
|
15132
15244
|
this.setDefaultConstraints({
|
|
15133
15245
|
...this.state.defaultConstraints,
|
|
@@ -15147,10 +15259,13 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15147
15259
|
}
|
|
15148
15260
|
}
|
|
15149
15261
|
}
|
|
15150
|
-
async startSpeakingWhileMutedDetection(deviceId) {
|
|
15262
|
+
async startSpeakingWhileMutedDetection(deviceId, track) {
|
|
15151
15263
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
15152
|
-
if (this.soundDetectorCleanup &&
|
|
15264
|
+
if (this.soundDetectorCleanup &&
|
|
15265
|
+
this.soundDetectorDeviceId === deviceId) {
|
|
15266
|
+
track?.stop();
|
|
15153
15267
|
return;
|
|
15268
|
+
}
|
|
15154
15269
|
await this.teardownSpeakingWhileMutedDetection();
|
|
15155
15270
|
if (isReactNative()) {
|
|
15156
15271
|
const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
|
|
@@ -15166,11 +15281,14 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15166
15281
|
};
|
|
15167
15282
|
}
|
|
15168
15283
|
else {
|
|
15169
|
-
//
|
|
15170
|
-
|
|
15171
|
-
|
|
15172
|
-
|
|
15173
|
-
|
|
15284
|
+
// Use the handed-off mic track, or start a new stream that's not
|
|
15285
|
+
// connected to publisher
|
|
15286
|
+
const stream = track
|
|
15287
|
+
? new MediaStream([track])
|
|
15288
|
+
: await this.getStream({
|
|
15289
|
+
...this.state.defaultConstraints,
|
|
15290
|
+
deviceId: { exact: deviceId },
|
|
15291
|
+
});
|
|
15174
15292
|
this.soundDetectorCleanup = createSoundDetector(stream, (event) => {
|
|
15175
15293
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
15176
15294
|
});
|
|
@@ -15450,7 +15568,7 @@ class SpeakerManager {
|
|
|
15450
15568
|
}
|
|
15451
15569
|
async applyWeb() {
|
|
15452
15570
|
const { enabled, storageKey } = this.devicePersistence;
|
|
15453
|
-
if (!enabled)
|
|
15571
|
+
if (!enabled || !this.state.isDeviceSelectionSupported)
|
|
15454
15572
|
return;
|
|
15455
15573
|
const preferences = readPreferences(storageKey);
|
|
15456
15574
|
const preferenceList = toPreferenceList(preferences.speaker);
|
|
@@ -15726,6 +15844,14 @@ class Call {
|
|
|
15726
15844
|
networkAvailableTask;
|
|
15727
15845
|
// (10 attempts per rolling 120 s window).
|
|
15728
15846
|
rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
15847
|
+
// Backstop for slow REJOIN loops that stay under the short window
|
|
15848
|
+
// (20 attempts per rolling 30 min window). Dropped once the integrator
|
|
15849
|
+
// takes over the budget through `setRejoinAttemptLimit`.
|
|
15850
|
+
rejoinLongWindowRateLimiter = new SlidingWindowRateLimiter(20, 30 * 60 * 1000);
|
|
15851
|
+
// In-flight `doJoin` runs, awaited before acting on peer connections that
|
|
15852
|
+
// gathered no local ICE candidates.
|
|
15853
|
+
joinTasks = new Set();
|
|
15854
|
+
noLocalCandidatesCheckScheduled = false;
|
|
15729
15855
|
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
15730
15856
|
// failures where ICE never reached `connected`/`completed`.
|
|
15731
15857
|
maxIceFailuresWithoutConnect = 2;
|
|
@@ -16119,6 +16245,7 @@ class Call {
|
|
|
16119
16245
|
// `joinFlow` never runs, leaving stale values that would make the next
|
|
16120
16246
|
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
16121
16247
|
this.rejoinRateLimiter.reset();
|
|
16248
|
+
this.rejoinLongWindowRateLimiter?.reset();
|
|
16122
16249
|
this.iceFailuresWithoutConnect = 0;
|
|
16123
16250
|
this.consecutiveNegotiationFailures = 0;
|
|
16124
16251
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
@@ -16514,6 +16641,16 @@ class Call {
|
|
|
16514
16641
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
16515
16642
|
*/
|
|
16516
16643
|
doJoin = async (data) => {
|
|
16644
|
+
const task = this.performJoin(data);
|
|
16645
|
+
this.joinTasks.add(task);
|
|
16646
|
+
try {
|
|
16647
|
+
await task;
|
|
16648
|
+
}
|
|
16649
|
+
finally {
|
|
16650
|
+
this.joinTasks.delete(task);
|
|
16651
|
+
}
|
|
16652
|
+
};
|
|
16653
|
+
performJoin = async (data) => {
|
|
16517
16654
|
const connectStartTime = Date.now();
|
|
16518
16655
|
const callingState = this.state.callingState;
|
|
16519
16656
|
const joinLeaveGeneration = this.leaveGeneration;
|
|
@@ -16871,6 +17008,9 @@ class Call {
|
|
|
16871
17008
|
// "ICE never connected" failure budget can be cleared.
|
|
16872
17009
|
this.iceFailuresWithoutConnect = 0;
|
|
16873
17010
|
},
|
|
17011
|
+
onNoLocalCandidates: () => {
|
|
17012
|
+
this.checkNoLocalCandidates();
|
|
17013
|
+
},
|
|
16874
17014
|
onPeerConnectionStateChange: (event) => {
|
|
16875
17015
|
this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
|
|
16876
17016
|
},
|
|
@@ -16919,6 +17059,59 @@ class Call {
|
|
|
16919
17059
|
this.sfuStatsReporter.start();
|
|
16920
17060
|
}
|
|
16921
17061
|
};
|
|
17062
|
+
/**
|
|
17063
|
+
* Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
|
|
17064
|
+
* connections never connected and one of them completed ICE gathering
|
|
17065
|
+
* without a single local candidate, which means WebRTC networking is
|
|
17066
|
+
* blocked on this device.
|
|
17067
|
+
*
|
|
17068
|
+
* `reconnect` ignores requests while a join or another reconnect is in
|
|
17069
|
+
* flight, so the check is postponed until those settle and then re-run
|
|
17070
|
+
* against the peer connections current at that time.
|
|
17071
|
+
*/
|
|
17072
|
+
checkNoLocalCandidates = () => {
|
|
17073
|
+
if (this.noLocalCandidatesCheckScheduled)
|
|
17074
|
+
return;
|
|
17075
|
+
if (this.joinTasks.size > 0 || hasPending(this.reconnectConcurrencyTag)) {
|
|
17076
|
+
this.noLocalCandidatesCheckScheduled = true;
|
|
17077
|
+
const leaveGeneration = this.leaveGeneration;
|
|
17078
|
+
this.waitForJoinsAndReconnects()
|
|
17079
|
+
.then(() => {
|
|
17080
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
17081
|
+
if (this.leaveGeneration !== leaveGeneration)
|
|
17082
|
+
return;
|
|
17083
|
+
this.checkNoLocalCandidates();
|
|
17084
|
+
})
|
|
17085
|
+
.catch((err) => {
|
|
17086
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
17087
|
+
this.logger.warn('[Reconnect] No-candidates check failed', err);
|
|
17088
|
+
});
|
|
17089
|
+
return;
|
|
17090
|
+
}
|
|
17091
|
+
if (this.state.callingState !== CallingState.JOINED)
|
|
17092
|
+
return;
|
|
17093
|
+
// without network, gathering legitimately yields no candidates
|
|
17094
|
+
if (typeof navigator !== 'undefined' && navigator.onLine === false)
|
|
17095
|
+
return;
|
|
17096
|
+
const peerConnections = [this.publisher, this.subscriber];
|
|
17097
|
+
if (peerConnections.some((pc) => pc?.hasIceEverConnected()))
|
|
17098
|
+
return;
|
|
17099
|
+
if (!peerConnections.some((pc) => pc?.hasNoLocalCandidates()))
|
|
17100
|
+
return;
|
|
17101
|
+
this.reconnect(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED).catch((err) => {
|
|
17102
|
+
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
17103
|
+
});
|
|
17104
|
+
};
|
|
17105
|
+
/**
|
|
17106
|
+
* Resolves once no `doJoin` run and no reconnect is in flight.
|
|
17107
|
+
*/
|
|
17108
|
+
waitForJoinsAndReconnects = async () => {
|
|
17109
|
+
while (this.joinTasks.size > 0 ||
|
|
17110
|
+
hasPending(this.reconnectConcurrencyTag)) {
|
|
17111
|
+
await Promise.allSettled(this.joinTasks);
|
|
17112
|
+
await settled(this.reconnectConcurrencyTag);
|
|
17113
|
+
}
|
|
17114
|
+
};
|
|
16922
17115
|
/**
|
|
16923
17116
|
* Retrieves credentials for joining the call.
|
|
16924
17117
|
*
|
|
@@ -17117,6 +17310,14 @@ class Call {
|
|
|
17117
17310
|
return;
|
|
17118
17311
|
}
|
|
17119
17312
|
}
|
|
17313
|
+
// MIGRATE is server-driven (e.g. SFU drains), so it doesn't count
|
|
17314
|
+
// toward the long-window backstop.
|
|
17315
|
+
if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN &&
|
|
17316
|
+
this.rejoinLongWindowRateLimiter &&
|
|
17317
|
+
!this.rejoinLongWindowRateLimiter.tryRegister()) {
|
|
17318
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
17319
|
+
return;
|
|
17320
|
+
}
|
|
17120
17321
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
17121
17322
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
17122
17323
|
this.reconnectAttempts++;
|
|
@@ -17335,7 +17536,13 @@ class Call {
|
|
|
17335
17536
|
this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
|
|
17336
17537
|
});
|
|
17337
17538
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
17338
|
-
|
|
17539
|
+
// Only the active SFU client may drive a reconnect: a superseded one can
|
|
17540
|
+
// still report errors about its own (replaced) session, and none may
|
|
17541
|
+
// while `leave()` tears the call down.
|
|
17542
|
+
const activeSfuTag = () => this.sfuClient?.tag ?? '*';
|
|
17543
|
+
const unregisterOnError = this.dispatcher.on('error', activeSfuTag, (e) => {
|
|
17544
|
+
if (!this.sfuClient)
|
|
17545
|
+
return;
|
|
17339
17546
|
const { reconnectStrategy: strategy, error } = e;
|
|
17340
17547
|
if (!SfuJoinError.isJoinErrorCode(e)) {
|
|
17341
17548
|
const code = error?.code ? ErrorCode[error.code] : undefined;
|
|
@@ -17358,7 +17565,16 @@ class Call {
|
|
|
17358
17565
|
});
|
|
17359
17566
|
}
|
|
17360
17567
|
else {
|
|
17361
|
-
|
|
17568
|
+
// A media transport failure on a session whose peer connections
|
|
17569
|
+
// never connected is an "ICE never connected" failure, and must
|
|
17570
|
+
// count toward the unsupported-network budget.
|
|
17571
|
+
const neverConnected = error?.code === ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE &&
|
|
17572
|
+
!this.publisher?.hasIceEverConnected() &&
|
|
17573
|
+
!this.subscriber?.hasIceEverConnected();
|
|
17574
|
+
const reason = neverConnected
|
|
17575
|
+
? ReconnectReason.ICE_NEVER_CONNECTED
|
|
17576
|
+
: error?.message || ReconnectReason.SFU_ERROR;
|
|
17577
|
+
this.reconnect(strategy, reason).catch((err) => {
|
|
17362
17578
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
17363
17579
|
});
|
|
17364
17580
|
}
|
|
@@ -18422,9 +18638,11 @@ class Call {
|
|
|
18422
18638
|
* `rejoin_attempt_limit_exceeded` leave message.
|
|
18423
18639
|
*
|
|
18424
18640
|
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
18425
|
-
* Both arguments are clamped to a minimum of 1.
|
|
18641
|
+
* Both arguments are clamped to a minimum of 1. Unless this method is
|
|
18642
|
+
* called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
|
|
18426
18643
|
*/
|
|
18427
18644
|
setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
|
|
18645
|
+
this.rejoinLongWindowRateLimiter = undefined;
|
|
18428
18646
|
this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
|
|
18429
18647
|
};
|
|
18430
18648
|
/**
|
|
@@ -19762,11 +19980,11 @@ class StreamClient {
|
|
|
19762
19980
|
return await this.wsConnection.connect(this.defaultWSTimeout);
|
|
19763
19981
|
};
|
|
19764
19982
|
getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
|
|
19765
|
-
"1.
|
|
19983
|
+
"1.62.0";
|
|
19766
19984
|
getUserAgent = () => {
|
|
19767
19985
|
if (!this.cachedUserAgent) {
|
|
19768
19986
|
const { clientAppIdentifier = {} } = this.options;
|
|
19769
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
19987
|
+
const { sdkName = 'js', sdkVersion = "1.62.0", ...extras } = clientAppIdentifier;
|
|
19770
19988
|
this.cachedUserAgent = [
|
|
19771
19989
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
19772
19990
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|