@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.cjs.js
CHANGED
|
@@ -5075,7 +5075,7 @@ const toIceCandidate = (iceTrickle) => {
|
|
|
5075
5075
|
}
|
|
5076
5076
|
};
|
|
5077
5077
|
|
|
5078
|
-
const version = "1.
|
|
5078
|
+
const version = "1.62.0";
|
|
5079
5079
|
const [major, minor, patch] = version.split('.');
|
|
5080
5080
|
let sdkInfo = {
|
|
5081
5081
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -8470,10 +8470,12 @@ class BasePeerConnection {
|
|
|
8470
8470
|
sfuClient;
|
|
8471
8471
|
onReconnectionNeeded;
|
|
8472
8472
|
onIceConnected;
|
|
8473
|
+
onNoLocalCandidates;
|
|
8473
8474
|
onPeerConnectionStateChange;
|
|
8474
8475
|
onRemoteTrackUnmute;
|
|
8475
8476
|
iceRestartDelay;
|
|
8476
8477
|
iceHasEverConnected = false;
|
|
8478
|
+
localCandidatesGathered = 0;
|
|
8477
8479
|
iceRestartTimeout;
|
|
8478
8480
|
preConnectStuckTimeout;
|
|
8479
8481
|
isIceRestarting = false;
|
|
@@ -8487,7 +8489,7 @@ class BasePeerConnection {
|
|
|
8487
8489
|
/**
|
|
8488
8490
|
* Constructs a new `BasePeerConnection` instance.
|
|
8489
8491
|
*/
|
|
8490
|
-
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
|
|
8492
|
+
constructor(peerType, { sfuClient, connectionConfig, state, dispatcher, onReconnectionNeeded, onIceConnected, onNoLocalCandidates, onPeerConnectionStateChange, onRemoteTrackUnmute, tag, enableTracing, clientPublishOptions, e2ee, iceRestartDelay = 2500, statsTimestampDriftThresholdMs = 0, }) {
|
|
8491
8493
|
this.peerType = peerType;
|
|
8492
8494
|
this.sfuClient = sfuClient;
|
|
8493
8495
|
this.state = state;
|
|
@@ -8498,6 +8500,7 @@ class BasePeerConnection {
|
|
|
8498
8500
|
this.tag = tag;
|
|
8499
8501
|
this.onReconnectionNeeded = onReconnectionNeeded;
|
|
8500
8502
|
this.onIceConnected = onIceConnected;
|
|
8503
|
+
this.onNoLocalCandidates = onNoLocalCandidates;
|
|
8501
8504
|
this.onPeerConnectionStateChange = onPeerConnectionStateChange;
|
|
8502
8505
|
this.onRemoteTrackUnmute = onRemoteTrackUnmute;
|
|
8503
8506
|
this.logger = videoLoggerSystem.getLogger(peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher', { tags: [tag] });
|
|
@@ -8538,6 +8541,7 @@ class BasePeerConnection {
|
|
|
8538
8541
|
this.preConnectStuckTimeout = undefined;
|
|
8539
8542
|
this.onReconnectionNeeded = undefined;
|
|
8540
8543
|
this.onIceConnected = undefined;
|
|
8544
|
+
this.onNoLocalCandidates = undefined;
|
|
8541
8545
|
this.onPeerConnectionStateChange = undefined;
|
|
8542
8546
|
this.onRemoteTrackUnmute = undefined;
|
|
8543
8547
|
this.isDisposed = true;
|
|
@@ -8641,6 +8645,17 @@ class BasePeerConnection {
|
|
|
8641
8645
|
getTrackType = (trackId) => {
|
|
8642
8646
|
return this.trackIdToTrackType.get(trackId);
|
|
8643
8647
|
};
|
|
8648
|
+
/**
|
|
8649
|
+
* Returns `true` once ICE has reached `connected`/`completed` at least
|
|
8650
|
+
* once during the lifetime of this peer connection.
|
|
8651
|
+
*/
|
|
8652
|
+
hasIceEverConnected = () => this.iceHasEverConnected;
|
|
8653
|
+
/**
|
|
8654
|
+
* Returns `true` when the latest ICE gathering round completed without
|
|
8655
|
+
* producing a single local candidate.
|
|
8656
|
+
*/
|
|
8657
|
+
hasNoLocalCandidates = () => this.pc.iceGatheringState === 'complete' &&
|
|
8658
|
+
this.localCandidatesGathered === 0;
|
|
8644
8659
|
/**
|
|
8645
8660
|
* Checks if the `RTCPeerConnection` is healthy.
|
|
8646
8661
|
* It checks the ICE connection state and the peer connection state.
|
|
@@ -8663,6 +8678,8 @@ class BasePeerConnection {
|
|
|
8663
8678
|
this.logger.debug('null ice candidate');
|
|
8664
8679
|
return;
|
|
8665
8680
|
}
|
|
8681
|
+
if (candidate.candidate)
|
|
8682
|
+
this.localCandidatesGathered++;
|
|
8666
8683
|
const iceCandidate = toJSON(candidate);
|
|
8667
8684
|
this.sfuClient
|
|
8668
8685
|
.iceTrickle({ peerType: this.peerType, iceCandidate })
|
|
@@ -8727,6 +8744,17 @@ class BasePeerConnection {
|
|
|
8727
8744
|
}
|
|
8728
8745
|
};
|
|
8729
8746
|
handleConnectionStateUpdate = (state) => {
|
|
8747
|
+
// Fire `onIceConnected` exactly once per peer-connection lifetime, the
|
|
8748
|
+
// first time ICE reaches `connected`/`completed` end-to-end. Recorded
|
|
8749
|
+
// ahead of the guards below so a connection established while offline,
|
|
8750
|
+
// reconnecting or restarting ICE is not missed. Used by `Call` to reset
|
|
8751
|
+
// the unsupported-network failure counter only after WebRTC has actually
|
|
8752
|
+
// recovered, not merely on SFU join.
|
|
8753
|
+
if ((state === 'connected' || state === 'completed') &&
|
|
8754
|
+
!this.iceHasEverConnected) {
|
|
8755
|
+
this.iceHasEverConnected = true;
|
|
8756
|
+
this.onIceConnected?.(this.peerType);
|
|
8757
|
+
}
|
|
8730
8758
|
const { callingState } = this.state;
|
|
8731
8759
|
if (callingState === exports.CallingState.OFFLINE)
|
|
8732
8760
|
return;
|
|
@@ -8790,14 +8818,6 @@ class BasePeerConnection {
|
|
|
8790
8818
|
break;
|
|
8791
8819
|
case 'connected':
|
|
8792
8820
|
case 'completed':
|
|
8793
|
-
// Fire `onIceConnected` exactly once per peer-connection lifetime —
|
|
8794
|
-
// the first time ICE reaches `connected`/`completed` end-to-end.
|
|
8795
|
-
// Used by `Call` to reset the unsupported-network failure counter
|
|
8796
|
-
// only after WebRTC has actually recovered, not merely on SFU join.
|
|
8797
|
-
if (!this.iceHasEverConnected) {
|
|
8798
|
-
this.iceHasEverConnected = true;
|
|
8799
|
-
this.onIceConnected?.(this.peerType);
|
|
8800
|
-
}
|
|
8801
8821
|
// clear any scheduled restartICE since the connection is healthy
|
|
8802
8822
|
if (this.iceRestartTimeout) {
|
|
8803
8823
|
this.logger.info('connected connection, canceling restartICE');
|
|
@@ -8823,7 +8843,17 @@ class BasePeerConnection {
|
|
|
8823
8843
|
* Handles the ICE gathering state change event.
|
|
8824
8844
|
*/
|
|
8825
8845
|
onIceGatherChange = () => {
|
|
8826
|
-
|
|
8846
|
+
const state = this.pc.iceGatheringState;
|
|
8847
|
+
this.logger.debug(`ICE Gathering State`, state);
|
|
8848
|
+
if (state === 'gathering') {
|
|
8849
|
+
this.localCandidatesGathered = 0;
|
|
8850
|
+
}
|
|
8851
|
+
else if (state === 'complete' &&
|
|
8852
|
+
this.localCandidatesGathered === 0 &&
|
|
8853
|
+
!this.iceHasEverConnected) {
|
|
8854
|
+
this.logger.info('ICE gathering completed without local candidates');
|
|
8855
|
+
this.onNoLocalCandidates?.(this.peerType);
|
|
8856
|
+
}
|
|
8827
8857
|
};
|
|
8828
8858
|
/**
|
|
8829
8859
|
* Handles the signaling state change event.
|
|
@@ -12339,7 +12369,7 @@ class DynascaleManager {
|
|
|
12339
12369
|
this.traceAudioSessionState();
|
|
12340
12370
|
const participant$ = this.callState.participants$.pipe(rxjs.map((ps) => ps.find((p) => p.sessionId === sessionId)), rxjs.takeWhile((p) => !!p), rxjs.distinctUntilChanged(), rxjs.shareReplay({ bufferSize: 1, refCount: true }));
|
|
12341
12371
|
const updateSinkId = (deviceId, audioContext) => {
|
|
12342
|
-
if (!deviceId)
|
|
12372
|
+
if (!deviceId || !this.speaker.state.isDeviceSelectionSupported)
|
|
12343
12373
|
return;
|
|
12344
12374
|
if ('setSinkId' in audioElement) {
|
|
12345
12375
|
audioElement.setSinkId(deviceId).catch((e) => {
|
|
@@ -12434,7 +12464,7 @@ class DynascaleManager {
|
|
|
12434
12464
|
updateSinkId(selectedDevice, audioContext);
|
|
12435
12465
|
});
|
|
12436
12466
|
});
|
|
12437
|
-
const sinkIdSubscription = !
|
|
12467
|
+
const sinkIdSubscription = !this.speaker.state.isDeviceSelectionSupported
|
|
12438
12468
|
? null
|
|
12439
12469
|
: this.speaker.state.selectedDevice$.subscribe((deviceId) => {
|
|
12440
12470
|
const audioContext = this.getOrCreateAudioContext();
|
|
@@ -13097,10 +13127,17 @@ const getDevices = (permission, kind, tracer) => {
|
|
|
13097
13127
|
/**
|
|
13098
13128
|
* Tells if the browser supports audio output change on 'audio' elements,
|
|
13099
13129
|
* see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId.
|
|
13130
|
+
*
|
|
13131
|
+
* WebKit (Safari, iOS browsers, WKWebView) is reported as unsupported:
|
|
13132
|
+
* setSinkId requires a user gesture, a per-device output can run at a
|
|
13133
|
+
* different sample rate than the remote audio mixer (pitched/slowed audio),
|
|
13134
|
+
* and audio goes silent when the selected device disconnects.
|
|
13100
13135
|
*/
|
|
13101
13136
|
const checkIfAudioOutputChangeSupported = () => {
|
|
13102
13137
|
if (typeof document === 'undefined')
|
|
13103
13138
|
return false;
|
|
13139
|
+
if (isWebKit())
|
|
13140
|
+
return false;
|
|
13104
13141
|
const element = document.createElement('audio');
|
|
13105
13142
|
return 'setSinkId' in element;
|
|
13106
13143
|
};
|
|
@@ -13606,6 +13643,7 @@ class DeviceManager {
|
|
|
13606
13643
|
? forceStopOrOptions
|
|
13607
13644
|
: (forceStopOrOptions?.forceStop ?? false);
|
|
13608
13645
|
this.state.prevStatus = this.state.optimisticStatus;
|
|
13646
|
+
this.state.setError(undefined);
|
|
13609
13647
|
if (!forceStop && this.state.optimisticStatus === 'disabled') {
|
|
13610
13648
|
return;
|
|
13611
13649
|
}
|
|
@@ -13615,6 +13653,7 @@ class DeviceManager {
|
|
|
13615
13653
|
const stopTracks = forceStop || this.state.disableMode === 'stop-tracks';
|
|
13616
13654
|
await this.muteStream(stopTracks);
|
|
13617
13655
|
this.state.setStatus('disabled');
|
|
13656
|
+
this.state.setError(undefined);
|
|
13618
13657
|
}
|
|
13619
13658
|
finally {
|
|
13620
13659
|
if (!signal.aborted) {
|
|
@@ -13910,7 +13949,11 @@ class DeviceManager {
|
|
|
13910
13949
|
};
|
|
13911
13950
|
// the rootStream represents the stream coming from the actual device
|
|
13912
13951
|
// e.g. camera or microphone stream
|
|
13952
|
+
this.state.setError(undefined);
|
|
13913
13953
|
rootStreamPromise = this.getSelectedStream(constraints);
|
|
13954
|
+
rootStreamPromise.catch((error) => {
|
|
13955
|
+
this.state.setError(error);
|
|
13956
|
+
});
|
|
13914
13957
|
// we publish the last MediaStream of the chain
|
|
13915
13958
|
stream = await this.filters.reduce((parent, entry) => parent
|
|
13916
13959
|
.then((inputStream) => {
|
|
@@ -14148,6 +14191,7 @@ class DeviceManagerState {
|
|
|
14148
14191
|
rootMediaStreamSubject = new rxjs.BehaviorSubject(undefined);
|
|
14149
14192
|
selectedDeviceSubject = new rxjs.BehaviorSubject(undefined);
|
|
14150
14193
|
defaultConstraintsSubject = new rxjs.BehaviorSubject(undefined);
|
|
14194
|
+
errorSubject = new rxjs.BehaviorSubject(undefined);
|
|
14151
14195
|
/**
|
|
14152
14196
|
* @internal
|
|
14153
14197
|
*/
|
|
@@ -14182,6 +14226,13 @@ class DeviceManagerState {
|
|
|
14182
14226
|
* The default constraints for the device.
|
|
14183
14227
|
*/
|
|
14184
14228
|
defaultConstraints$ = this.defaultConstraintsSubject.asObservable();
|
|
14229
|
+
/**
|
|
14230
|
+
* An Observable that emits the error of the last failed attempt to start
|
|
14231
|
+
* the device (e.g. the device is in use by another app), or `undefined`.
|
|
14232
|
+
* For screen share, this includes the user dismissing the picker.
|
|
14233
|
+
* Cleared when the device is started again or disabled.
|
|
14234
|
+
*/
|
|
14235
|
+
error$ = this.errorSubject.asObservable().pipe(rxjs.distinctUntilChanged());
|
|
14185
14236
|
/**
|
|
14186
14237
|
* An observable that will emit `true` if browser/system permission
|
|
14187
14238
|
* is granted (or at least hasn't been denied), `false` otherwise.
|
|
@@ -14249,6 +14300,19 @@ class DeviceManagerState {
|
|
|
14249
14300
|
get rootMediaStream() {
|
|
14250
14301
|
return getCurrentValue(this.rootMediaStream$);
|
|
14251
14302
|
}
|
|
14303
|
+
/**
|
|
14304
|
+
* The error of the last failed attempt to start the device, or `undefined`.
|
|
14305
|
+
*/
|
|
14306
|
+
get error() {
|
|
14307
|
+
return getCurrentValue(this.error$);
|
|
14308
|
+
}
|
|
14309
|
+
/**
|
|
14310
|
+
* @internal
|
|
14311
|
+
* @param error the error to set.
|
|
14312
|
+
*/
|
|
14313
|
+
setError(error) {
|
|
14314
|
+
setCurrentValue(this.errorSubject, error);
|
|
14315
|
+
}
|
|
14252
14316
|
/**
|
|
14253
14317
|
* @internal
|
|
14254
14318
|
* @param status
|
|
@@ -14830,9 +14894,11 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
14830
14894
|
this.call.state.callingState$,
|
|
14831
14895
|
this.call.state.ownCapabilities$,
|
|
14832
14896
|
this.state.selectedDevice$,
|
|
14833
|
-
this.state.status$,
|
|
14834
14897
|
this.state.browserPermissionState$,
|
|
14835
|
-
|
|
14898
|
+
// only a trigger: queued handlers can run after the status changed
|
|
14899
|
+
// again, so the live status is read below
|
|
14900
|
+
this.state.status$,
|
|
14901
|
+
]), async ([callingState, ownCapabilities, deviceId, permissionState]) => {
|
|
14836
14902
|
try {
|
|
14837
14903
|
if (callingState === exports.CallingState.LEFT) {
|
|
14838
14904
|
// The muted-recording-prepared mode is reset in `callManager.stop()`
|
|
@@ -14847,7 +14913,7 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
14847
14913
|
return;
|
|
14848
14914
|
if (ownCapabilities.includes(OwnCapability.SEND_AUDIO)) {
|
|
14849
14915
|
const hasPermission = await this.hasPermission(permissionState);
|
|
14850
|
-
if (hasPermission && status !== 'enabled') {
|
|
14916
|
+
if (hasPermission && this.state.status !== 'enabled') {
|
|
14851
14917
|
this.setMutedRecordingPrepared(true);
|
|
14852
14918
|
await this.startSpeakingWhileMutedDetection(deviceId);
|
|
14853
14919
|
}
|
|
@@ -15147,6 +15213,52 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15147
15213
|
await this.call.ensureMediaFactory();
|
|
15148
15214
|
return getAudioStream(constraints, this.call.tracer);
|
|
15149
15215
|
}
|
|
15216
|
+
/**
|
|
15217
|
+
* On mute, hands a clone of the live mic track to the speaking while muted
|
|
15218
|
+
* detection, so the device stays captured after the mic tracks are stopped.
|
|
15219
|
+
* Releasing and re-acquiring the mic flips Bluetooth headsets between audio
|
|
15220
|
+
* profiles.
|
|
15221
|
+
*/
|
|
15222
|
+
async muteStream(stopTracks = true) {
|
|
15223
|
+
const [track] = this.state.rootMediaStream?.getAudioTracks() ?? [];
|
|
15224
|
+
const clone = track?.readyState === 'live' &&
|
|
15225
|
+
this.state.optimisticStatus === 'disabled' &&
|
|
15226
|
+
this.canHandOffMic()
|
|
15227
|
+
? track.clone()
|
|
15228
|
+
: undefined;
|
|
15229
|
+
try {
|
|
15230
|
+
await super.muteStream(stopTracks);
|
|
15231
|
+
}
|
|
15232
|
+
catch (err) {
|
|
15233
|
+
clone?.stop();
|
|
15234
|
+
throw err;
|
|
15235
|
+
}
|
|
15236
|
+
if (!clone)
|
|
15237
|
+
return;
|
|
15238
|
+
// the mic, the call, or the selected device may have changed while
|
|
15239
|
+
// unpublishing
|
|
15240
|
+
const deviceId = resolveDeviceId(clone.getSettings().deviceId, 'audioinput');
|
|
15241
|
+
if (clone.readyState !== 'live' ||
|
|
15242
|
+
!this.canHandOffMic() ||
|
|
15243
|
+
deviceId !== this.state.selectedDevice) {
|
|
15244
|
+
clone.stop();
|
|
15245
|
+
return;
|
|
15246
|
+
}
|
|
15247
|
+
this.startSpeakingWhileMutedDetection(deviceId, clone).catch((err) => {
|
|
15248
|
+
clone.stop();
|
|
15249
|
+
this.logger.warn('Could not hand off mic to speaking while muted', err);
|
|
15250
|
+
});
|
|
15251
|
+
}
|
|
15252
|
+
/**
|
|
15253
|
+
* Whether a mute can hand the mic over to the speaking while muted detection.
|
|
15254
|
+
*/
|
|
15255
|
+
canHandOffMic = () => {
|
|
15256
|
+
const { callingState, ownCapabilities } = this.call.state;
|
|
15257
|
+
return (!isReactNative() &&
|
|
15258
|
+
this.speakingWhileMutedNotificationEnabled &&
|
|
15259
|
+
callingState === exports.CallingState.JOINED &&
|
|
15260
|
+
ownCapabilities.includes(OwnCapability.SEND_AUDIO));
|
|
15261
|
+
};
|
|
15150
15262
|
doSetAudioBitrateProfile(profile) {
|
|
15151
15263
|
this.setDefaultConstraints({
|
|
15152
15264
|
...this.state.defaultConstraints,
|
|
@@ -15166,10 +15278,13 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15166
15278
|
}
|
|
15167
15279
|
}
|
|
15168
15280
|
}
|
|
15169
|
-
async startSpeakingWhileMutedDetection(deviceId) {
|
|
15281
|
+
async startSpeakingWhileMutedDetection(deviceId, track) {
|
|
15170
15282
|
await withoutConcurrency(this.soundDetectorConcurrencyTag, async () => {
|
|
15171
|
-
if (this.soundDetectorCleanup &&
|
|
15283
|
+
if (this.soundDetectorCleanup &&
|
|
15284
|
+
this.soundDetectorDeviceId === deviceId) {
|
|
15285
|
+
track?.stop();
|
|
15172
15286
|
return;
|
|
15287
|
+
}
|
|
15173
15288
|
await this.teardownSpeakingWhileMutedDetection();
|
|
15174
15289
|
if (isReactNative()) {
|
|
15175
15290
|
const speechActivity = globalThis.streamRNVideoSDK?.nativeEvents?.speechActivity;
|
|
@@ -15185,11 +15300,14 @@ class MicrophoneManager extends AudioDeviceManager {
|
|
|
15185
15300
|
};
|
|
15186
15301
|
}
|
|
15187
15302
|
else {
|
|
15188
|
-
//
|
|
15189
|
-
|
|
15190
|
-
|
|
15191
|
-
|
|
15192
|
-
|
|
15303
|
+
// Use the handed-off mic track, or start a new stream that's not
|
|
15304
|
+
// connected to publisher
|
|
15305
|
+
const stream = track
|
|
15306
|
+
? new MediaStream([track])
|
|
15307
|
+
: await this.getStream({
|
|
15308
|
+
...this.state.defaultConstraints,
|
|
15309
|
+
deviceId: { exact: deviceId },
|
|
15310
|
+
});
|
|
15193
15311
|
this.soundDetectorCleanup = createSoundDetector(stream, (event) => {
|
|
15194
15312
|
this.state.setSpeakingWhileMuted(event.isSoundDetected);
|
|
15195
15313
|
});
|
|
@@ -15469,7 +15587,7 @@ class SpeakerManager {
|
|
|
15469
15587
|
}
|
|
15470
15588
|
async applyWeb() {
|
|
15471
15589
|
const { enabled, storageKey } = this.devicePersistence;
|
|
15472
|
-
if (!enabled)
|
|
15590
|
+
if (!enabled || !this.state.isDeviceSelectionSupported)
|
|
15473
15591
|
return;
|
|
15474
15592
|
const preferences = readPreferences(storageKey);
|
|
15475
15593
|
const preferenceList = toPreferenceList(preferences.speaker);
|
|
@@ -15745,6 +15863,14 @@ class Call {
|
|
|
15745
15863
|
networkAvailableTask;
|
|
15746
15864
|
// (10 attempts per rolling 120 s window).
|
|
15747
15865
|
rejoinRateLimiter = new SlidingWindowRateLimiter(10, 120000);
|
|
15866
|
+
// Backstop for slow REJOIN loops that stay under the short window
|
|
15867
|
+
// (20 attempts per rolling 30 min window). Dropped once the integrator
|
|
15868
|
+
// takes over the budget through `setRejoinAttemptLimit`.
|
|
15869
|
+
rejoinLongWindowRateLimiter = new SlidingWindowRateLimiter(20, 30 * 60 * 1000);
|
|
15870
|
+
// In-flight `doJoin` runs, awaited before acting on peer connections that
|
|
15871
|
+
// gathered no local ICE candidates.
|
|
15872
|
+
joinTasks = new Set();
|
|
15873
|
+
noLocalCandidatesCheckScheduled = false;
|
|
15748
15874
|
// "Network doesn't support WebRTC" detector: counts peer-connection
|
|
15749
15875
|
// failures where ICE never reached `connected`/`completed`.
|
|
15750
15876
|
maxIceFailuresWithoutConnect = 2;
|
|
@@ -16138,6 +16264,7 @@ class Call {
|
|
|
16138
16264
|
// `joinFlow` never runs, leaving stale values that would make the next
|
|
16139
16265
|
// fresh `join()` send a stale `ReconnectDetails` to the SFU.
|
|
16140
16266
|
this.rejoinRateLimiter.reset();
|
|
16267
|
+
this.rejoinLongWindowRateLimiter?.reset();
|
|
16141
16268
|
this.iceFailuresWithoutConnect = 0;
|
|
16142
16269
|
this.consecutiveNegotiationFailures = 0;
|
|
16143
16270
|
this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
|
|
@@ -16533,6 +16660,16 @@ class Call {
|
|
|
16533
16660
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
16534
16661
|
*/
|
|
16535
16662
|
doJoin = async (data) => {
|
|
16663
|
+
const task = this.performJoin(data);
|
|
16664
|
+
this.joinTasks.add(task);
|
|
16665
|
+
try {
|
|
16666
|
+
await task;
|
|
16667
|
+
}
|
|
16668
|
+
finally {
|
|
16669
|
+
this.joinTasks.delete(task);
|
|
16670
|
+
}
|
|
16671
|
+
};
|
|
16672
|
+
performJoin = async (data) => {
|
|
16536
16673
|
const connectStartTime = Date.now();
|
|
16537
16674
|
const callingState = this.state.callingState;
|
|
16538
16675
|
const joinLeaveGeneration = this.leaveGeneration;
|
|
@@ -16890,6 +17027,9 @@ class Call {
|
|
|
16890
17027
|
// "ICE never connected" failure budget can be cleared.
|
|
16891
17028
|
this.iceFailuresWithoutConnect = 0;
|
|
16892
17029
|
},
|
|
17030
|
+
onNoLocalCandidates: () => {
|
|
17031
|
+
this.checkNoLocalCandidates();
|
|
17032
|
+
},
|
|
16893
17033
|
onPeerConnectionStateChange: (event) => {
|
|
16894
17034
|
this.clientEventReporter.onPeerConnectionStateChange(this.cid, event);
|
|
16895
17035
|
},
|
|
@@ -16938,6 +17078,59 @@ class Call {
|
|
|
16938
17078
|
this.sfuStatsReporter.start();
|
|
16939
17079
|
}
|
|
16940
17080
|
};
|
|
17081
|
+
/**
|
|
17082
|
+
* Escalates to REJOIN with `ICE_NEVER_CONNECTED` when the current peer
|
|
17083
|
+
* connections never connected and one of them completed ICE gathering
|
|
17084
|
+
* without a single local candidate, which means WebRTC networking is
|
|
17085
|
+
* blocked on this device.
|
|
17086
|
+
*
|
|
17087
|
+
* `reconnect` ignores requests while a join or another reconnect is in
|
|
17088
|
+
* flight, so the check is postponed until those settle and then re-run
|
|
17089
|
+
* against the peer connections current at that time.
|
|
17090
|
+
*/
|
|
17091
|
+
checkNoLocalCandidates = () => {
|
|
17092
|
+
if (this.noLocalCandidatesCheckScheduled)
|
|
17093
|
+
return;
|
|
17094
|
+
if (this.joinTasks.size > 0 || hasPending(this.reconnectConcurrencyTag)) {
|
|
17095
|
+
this.noLocalCandidatesCheckScheduled = true;
|
|
17096
|
+
const leaveGeneration = this.leaveGeneration;
|
|
17097
|
+
this.waitForJoinsAndReconnects()
|
|
17098
|
+
.then(() => {
|
|
17099
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
17100
|
+
if (this.leaveGeneration !== leaveGeneration)
|
|
17101
|
+
return;
|
|
17102
|
+
this.checkNoLocalCandidates();
|
|
17103
|
+
})
|
|
17104
|
+
.catch((err) => {
|
|
17105
|
+
this.noLocalCandidatesCheckScheduled = false;
|
|
17106
|
+
this.logger.warn('[Reconnect] No-candidates check failed', err);
|
|
17107
|
+
});
|
|
17108
|
+
return;
|
|
17109
|
+
}
|
|
17110
|
+
if (this.state.callingState !== exports.CallingState.JOINED)
|
|
17111
|
+
return;
|
|
17112
|
+
// without network, gathering legitimately yields no candidates
|
|
17113
|
+
if (typeof navigator !== 'undefined' && navigator.onLine === false)
|
|
17114
|
+
return;
|
|
17115
|
+
const peerConnections = [this.publisher, this.subscriber];
|
|
17116
|
+
if (peerConnections.some((pc) => pc?.hasIceEverConnected()))
|
|
17117
|
+
return;
|
|
17118
|
+
if (!peerConnections.some((pc) => pc?.hasNoLocalCandidates()))
|
|
17119
|
+
return;
|
|
17120
|
+
this.reconnect(WebsocketReconnectStrategy.REJOIN, ReconnectReason.ICE_NEVER_CONNECTED).catch((err) => {
|
|
17121
|
+
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
17122
|
+
});
|
|
17123
|
+
};
|
|
17124
|
+
/**
|
|
17125
|
+
* Resolves once no `doJoin` run and no reconnect is in flight.
|
|
17126
|
+
*/
|
|
17127
|
+
waitForJoinsAndReconnects = async () => {
|
|
17128
|
+
while (this.joinTasks.size > 0 ||
|
|
17129
|
+
hasPending(this.reconnectConcurrencyTag)) {
|
|
17130
|
+
await Promise.allSettled(this.joinTasks);
|
|
17131
|
+
await settled(this.reconnectConcurrencyTag);
|
|
17132
|
+
}
|
|
17133
|
+
};
|
|
16941
17134
|
/**
|
|
16942
17135
|
* Retrieves credentials for joining the call.
|
|
16943
17136
|
*
|
|
@@ -17136,6 +17329,14 @@ class Call {
|
|
|
17136
17329
|
return;
|
|
17137
17330
|
}
|
|
17138
17331
|
}
|
|
17332
|
+
// MIGRATE is server-driven (e.g. SFU drains), so it doesn't count
|
|
17333
|
+
// toward the long-window backstop.
|
|
17334
|
+
if (this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN &&
|
|
17335
|
+
this.rejoinLongWindowRateLimiter &&
|
|
17336
|
+
!this.rejoinLongWindowRateLimiter.tryRegister()) {
|
|
17337
|
+
await giveUpAndLeave('rejoin_attempt_limit_exceeded');
|
|
17338
|
+
return;
|
|
17339
|
+
}
|
|
17139
17340
|
// we don't increment reconnect attempts for the FAST strategy.
|
|
17140
17341
|
if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
|
|
17141
17342
|
this.reconnectAttempts++;
|
|
@@ -17354,7 +17555,13 @@ class Call {
|
|
|
17354
17555
|
this.reconnect(WebsocketReconnectStrategy.MIGRATE, ReconnectReason.GO_AWAY).catch((err) => this.logger.warn('[Reconnect] Error reconnecting', err));
|
|
17355
17556
|
});
|
|
17356
17557
|
// handles the "error" event, through which the SFU can request a reconnect
|
|
17357
|
-
|
|
17558
|
+
// Only the active SFU client may drive a reconnect: a superseded one can
|
|
17559
|
+
// still report errors about its own (replaced) session, and none may
|
|
17560
|
+
// while `leave()` tears the call down.
|
|
17561
|
+
const activeSfuTag = () => this.sfuClient?.tag ?? '*';
|
|
17562
|
+
const unregisterOnError = this.dispatcher.on('error', activeSfuTag, (e) => {
|
|
17563
|
+
if (!this.sfuClient)
|
|
17564
|
+
return;
|
|
17358
17565
|
const { reconnectStrategy: strategy, error } = e;
|
|
17359
17566
|
if (!SfuJoinError.isJoinErrorCode(e)) {
|
|
17360
17567
|
const code = error?.code ? ErrorCode[error.code] : undefined;
|
|
@@ -17377,7 +17584,16 @@ class Call {
|
|
|
17377
17584
|
});
|
|
17378
17585
|
}
|
|
17379
17586
|
else {
|
|
17380
|
-
|
|
17587
|
+
// A media transport failure on a session whose peer connections
|
|
17588
|
+
// never connected is an "ICE never connected" failure, and must
|
|
17589
|
+
// count toward the unsupported-network budget.
|
|
17590
|
+
const neverConnected = error?.code === ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE &&
|
|
17591
|
+
!this.publisher?.hasIceEverConnected() &&
|
|
17592
|
+
!this.subscriber?.hasIceEverConnected();
|
|
17593
|
+
const reason = neverConnected
|
|
17594
|
+
? ReconnectReason.ICE_NEVER_CONNECTED
|
|
17595
|
+
: error?.message || ReconnectReason.SFU_ERROR;
|
|
17596
|
+
this.reconnect(strategy, reason).catch((err) => {
|
|
17381
17597
|
this.logger.warn('[Reconnect] Error reconnecting', err);
|
|
17382
17598
|
});
|
|
17383
17599
|
}
|
|
@@ -18441,9 +18657,11 @@ class Call {
|
|
|
18441
18657
|
* `rejoin_attempt_limit_exceeded` leave message.
|
|
18442
18658
|
*
|
|
18443
18659
|
* Defaults: 10 attempts per 120 seconds (aligned with the Swift SDK).
|
|
18444
|
-
* Both arguments are clamped to a minimum of 1.
|
|
18660
|
+
* Both arguments are clamped to a minimum of 1. Unless this method is
|
|
18661
|
+
* called, an additional cap of 20 REJOIN attempts per 30 minutes applies.
|
|
18445
18662
|
*/
|
|
18446
18663
|
setRejoinAttemptLimit = (maxAttempts, windowSeconds) => {
|
|
18664
|
+
this.rejoinLongWindowRateLimiter = undefined;
|
|
18447
18665
|
this.rejoinRateLimiter.setLimits(Math.max(1, maxAttempts), Math.max(1, windowSeconds) * 1000);
|
|
18448
18666
|
};
|
|
18449
18667
|
/**
|
|
@@ -19781,11 +19999,11 @@ class StreamClient {
|
|
|
19781
19999
|
return await this.wsConnection.connect(this.defaultWSTimeout);
|
|
19782
20000
|
};
|
|
19783
20001
|
getSdkVersion = () => this.options.clientAppIdentifier?.sdkVersion ||
|
|
19784
|
-
"1.
|
|
20002
|
+
"1.62.0";
|
|
19785
20003
|
getUserAgent = () => {
|
|
19786
20004
|
if (!this.cachedUserAgent) {
|
|
19787
20005
|
const { clientAppIdentifier = {} } = this.options;
|
|
19788
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
20006
|
+
const { sdkName = 'js', sdkVersion = "1.62.0", ...extras } = clientAppIdentifier;
|
|
19789
20007
|
this.cachedUserAgent = [
|
|
19790
20008
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
19791
20009
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|