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