@stream-io/video-client 1.50.0 → 1.51.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 +11 -0
- package/dist/index.browser.es.js +288 -58
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +288 -58
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +288 -58
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +1 -0
- package/dist/src/devices/CameraManager.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +20 -0
- package/dist/src/devices/VirtualDevice.d.ts +59 -0
- package/dist/src/devices/devicePersistence.d.ts +1 -1
- package/dist/src/devices/index.d.ts +1 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
- package/dist/src/rtc/Publisher.d.ts +21 -3
- package/dist/src/rtc/TransceiverCache.d.ts +5 -1
- package/dist/src/rtc/helpers/degradationPreference.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/package.json +2 -2
- package/src/Call.ts +22 -11
- package/src/devices/CameraManager.ts +9 -2
- package/src/devices/DeviceManager.ts +148 -8
- package/src/devices/DeviceManagerState.ts +4 -1
- package/src/devices/VirtualDevice.ts +69 -0
- package/src/devices/__tests__/CameraManager.test.ts +19 -0
- package/src/devices/__tests__/DeviceManager.test.ts +121 -1
- package/src/devices/devicePersistence.ts +2 -1
- package/src/devices/index.ts +1 -0
- package/src/rtc/BasePeerConnection.ts +15 -3
- package/src/rtc/Publisher.ts +140 -41
- package/src/rtc/TransceiverCache.ts +10 -3
- package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
- package/src/rtc/__tests__/Publisher.test.ts +659 -112
- package/src/rtc/__tests__/Subscriber.test.ts +2 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +33 -1
- package/src/rtc/helpers/degradationPreference.ts +18 -0
- package/src/rtc/types.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
|
|
4
4
|
|
|
5
|
+
## [1.51.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.50.0...@stream-io/video-client-1.51.0) (2026-05-26)
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- **client:** Register virtual devices ([#2220](https://github.com/GetStream/stream-video-js/issues/2220)) ([c663e2d](https://github.com/GetStream/stream-video-js/commit/c663e2df9f82cf64c38a9d3e6a1e86282107b27d))
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
- **client:** bail reconnects during in-flight lifecycles and clean up listeners ([#2257](https://github.com/GetStream/stream-video-js/issues/2257)) ([f6fa17e](https://github.com/GetStream/stream-video-js/commit/f6fa17e041cef1aebeba38b06d6cfba5c085e5a6))
|
|
14
|
+
- **client:** stop sending RTP after track.stop() on Firefox ([#2237](https://github.com/GetStream/stream-video-js/issues/2237)) ([5b7e9b8](https://github.com/GetStream/stream-video-js/commit/5b7e9b8bd17c43f17d66586dd88617ae91bac609))
|
|
15
|
+
|
|
5
16
|
## [1.50.0](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.49.0...@stream-io/video-client-1.50.0) (2026-05-18)
|
|
6
17
|
|
|
7
18
|
### Features
|
package/dist/index.browser.es.js
CHANGED
|
@@ -6359,7 +6359,7 @@ const getSdkVersion = (sdk) => {
|
|
|
6359
6359
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
6360
6360
|
};
|
|
6361
6361
|
|
|
6362
|
-
const version = "1.
|
|
6362
|
+
const version = "1.51.0";
|
|
6363
6363
|
const [major, minor, patch] = version.split('.');
|
|
6364
6364
|
let sdkInfo = {
|
|
6365
6365
|
type: SdkType.PLAIN_JAVASCRIPT,
|
|
@@ -7478,7 +7478,7 @@ class BasePeerConnection {
|
|
|
7478
7478
|
this.on = (event, fn) => {
|
|
7479
7479
|
const getTag = () => this.tag;
|
|
7480
7480
|
this.subscriptions.push(this.dispatcher.on(event, getTag, (e) => {
|
|
7481
|
-
const lockKey =
|
|
7481
|
+
const lockKey = this.eventLockKey(event);
|
|
7482
7482
|
withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
|
|
7483
7483
|
if (this.isDisposed)
|
|
7484
7484
|
return;
|
|
@@ -7486,6 +7486,13 @@ class BasePeerConnection {
|
|
|
7486
7486
|
});
|
|
7487
7487
|
}));
|
|
7488
7488
|
};
|
|
7489
|
+
/**
|
|
7490
|
+
* Returns the per-event `withoutConcurrency` tag used to serialize the
|
|
7491
|
+
* dispatcher handler for `event` on this peer connection.
|
|
7492
|
+
*/
|
|
7493
|
+
this.eventLockKey = (event) => {
|
|
7494
|
+
return `pc.${this.lock}.${event}`;
|
|
7495
|
+
};
|
|
7489
7496
|
/**
|
|
7490
7497
|
* Appends the trickled ICE candidates to the `RTCPeerConnection`.
|
|
7491
7498
|
*/
|
|
@@ -7739,7 +7746,7 @@ class BasePeerConnection {
|
|
|
7739
7746
|
/**
|
|
7740
7747
|
* Disposes the `RTCPeerConnection` instance.
|
|
7741
7748
|
*/
|
|
7742
|
-
dispose() {
|
|
7749
|
+
async dispose() {
|
|
7743
7750
|
clearTimeout(this.iceRestartTimeout);
|
|
7744
7751
|
this.iceRestartTimeout = undefined;
|
|
7745
7752
|
clearTimeout(this.preConnectStuckTimeout);
|
|
@@ -7761,6 +7768,7 @@ class BasePeerConnection {
|
|
|
7761
7768
|
pc.removeEventListener('signalingstatechange', this.onSignalingChange);
|
|
7762
7769
|
pc.removeEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
|
|
7763
7770
|
pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
|
|
7771
|
+
pc.removeEventListener('connectionstatechange', this.onConnectionStateChange);
|
|
7764
7772
|
this.unsubscribeIceTrickle?.();
|
|
7765
7773
|
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
7766
7774
|
this.subscriptions = [];
|
|
@@ -7788,8 +7796,14 @@ class TransceiverCache {
|
|
|
7788
7796
|
* Gets the transceiver for the given publish option.
|
|
7789
7797
|
*/
|
|
7790
7798
|
this.get = (publishOption) => {
|
|
7791
|
-
return this.
|
|
7792
|
-
|
|
7799
|
+
return this.getBy(publishOption.id, publishOption.trackType);
|
|
7800
|
+
};
|
|
7801
|
+
/**
|
|
7802
|
+
* Gets the transceiver for the given publish option id and track type.
|
|
7803
|
+
*/
|
|
7804
|
+
this.getBy = (publishOptionId, trackType) => {
|
|
7805
|
+
return this.cache.find((bundle) => bundle.publishOption.id === publishOptionId &&
|
|
7806
|
+
bundle.publishOption.trackType === trackType);
|
|
7793
7807
|
};
|
|
7794
7808
|
/**
|
|
7795
7809
|
* Updates the cached bundle with the given patch.
|
|
@@ -8074,6 +8088,21 @@ const toRTCDegradationPreference = (preference) => {
|
|
|
8074
8088
|
ensureExhausted(preference, 'Unknown degradation preference');
|
|
8075
8089
|
}
|
|
8076
8090
|
};
|
|
8091
|
+
const fromRTCDegradationPreference = (preference) => {
|
|
8092
|
+
switch (preference) {
|
|
8093
|
+
case 'balanced':
|
|
8094
|
+
return DegradationPreference.BALANCED;
|
|
8095
|
+
case 'maintain-framerate':
|
|
8096
|
+
return DegradationPreference.MAINTAIN_FRAMERATE;
|
|
8097
|
+
case 'maintain-resolution':
|
|
8098
|
+
return DegradationPreference.MAINTAIN_RESOLUTION;
|
|
8099
|
+
// @ts-expect-error not in the typedefs yet
|
|
8100
|
+
case 'maintain-framerate-and-resolution':
|
|
8101
|
+
return DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION;
|
|
8102
|
+
default:
|
|
8103
|
+
return DegradationPreference.UNSPECIFIED;
|
|
8104
|
+
}
|
|
8105
|
+
};
|
|
8077
8106
|
|
|
8078
8107
|
/**
|
|
8079
8108
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|
|
@@ -8108,13 +8137,13 @@ class Publisher extends BasePeerConnection {
|
|
|
8108
8137
|
// create a clone of the track as otherwise the same trackId will
|
|
8109
8138
|
// appear in the SDP in multiple transceivers
|
|
8110
8139
|
const trackToPublish = this.cloneTrack(track);
|
|
8111
|
-
const
|
|
8112
|
-
if (!
|
|
8140
|
+
const bundle = this.transceiverCache.get(publishOption);
|
|
8141
|
+
if (!bundle) {
|
|
8113
8142
|
await this.addTransceiver(trackToPublish, publishOption, options);
|
|
8114
8143
|
}
|
|
8115
8144
|
else {
|
|
8116
|
-
const previousTrack = transceiver.sender.track;
|
|
8117
|
-
await this.updateTransceiver(
|
|
8145
|
+
const previousTrack = bundle.transceiver.sender.track;
|
|
8146
|
+
await this.updateTransceiver(bundle, trackToPublish, options);
|
|
8118
8147
|
if (!isReactNative()) {
|
|
8119
8148
|
this.stopTrack(previousTrack);
|
|
8120
8149
|
}
|
|
@@ -8149,13 +8178,20 @@ class Publisher extends BasePeerConnection {
|
|
|
8149
8178
|
/**
|
|
8150
8179
|
* Updates the transceiver with the given track and track type.
|
|
8151
8180
|
*/
|
|
8152
|
-
this.updateTransceiver = async (
|
|
8181
|
+
this.updateTransceiver = async (bundle, track, options = {}) => {
|
|
8182
|
+
const { transceiver, publishOption } = bundle;
|
|
8183
|
+
const trackType = publishOption.trackType;
|
|
8153
8184
|
const sender = transceiver.sender;
|
|
8154
8185
|
if (sender.track)
|
|
8155
8186
|
this.trackIdToTrackType.delete(sender.track.id);
|
|
8156
8187
|
await sender.replaceTrack(track);
|
|
8157
|
-
if (track)
|
|
8188
|
+
if (track) {
|
|
8158
8189
|
this.trackIdToTrackType.set(track.id, trackType);
|
|
8190
|
+
if (isFirefox() && bundle.videoSender) {
|
|
8191
|
+
// restore the encoding config from the cache, if any
|
|
8192
|
+
await this.changePublishQuality(bundle.videoSender, bundle);
|
|
8193
|
+
}
|
|
8194
|
+
}
|
|
8159
8195
|
if (isAudioTrackType(trackType)) {
|
|
8160
8196
|
await this.updateAudioPublishOptions(trackType, options);
|
|
8161
8197
|
}
|
|
@@ -8215,7 +8251,7 @@ class Publisher extends BasePeerConnection {
|
|
|
8215
8251
|
continue;
|
|
8216
8252
|
// it is safe to stop the track here, it is a clone
|
|
8217
8253
|
this.stopTrack(transceiver.sender.track);
|
|
8218
|
-
await this.updateTransceiver(
|
|
8254
|
+
await this.updateTransceiver(item, null);
|
|
8219
8255
|
}
|
|
8220
8256
|
};
|
|
8221
8257
|
/**
|
|
@@ -8272,33 +8308,38 @@ class Publisher extends BasePeerConnection {
|
|
|
8272
8308
|
/**
|
|
8273
8309
|
* Stops the cloned track that is being published to the SFU.
|
|
8274
8310
|
*/
|
|
8275
|
-
this.stopTracks = (...trackTypes) => {
|
|
8276
|
-
|
|
8277
|
-
const
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8311
|
+
this.stopTracks = async (...trackTypes) => {
|
|
8312
|
+
return withoutConcurrency(this.eventLockKey('changePublishQuality'), async () => {
|
|
8313
|
+
for (const item of this.transceiverCache.items()) {
|
|
8314
|
+
const { publishOption, transceiver } = item;
|
|
8315
|
+
if (!trackTypes.includes(publishOption.trackType))
|
|
8316
|
+
continue;
|
|
8317
|
+
const track = transceiver.sender.track;
|
|
8318
|
+
await this.silenceSenderOnFirefox(item);
|
|
8319
|
+
this.stopTrack(track);
|
|
8320
|
+
}
|
|
8321
|
+
});
|
|
8282
8322
|
};
|
|
8283
8323
|
/**
|
|
8284
8324
|
* Stops all the cloned tracks that are being published to the SFU.
|
|
8285
8325
|
*/
|
|
8286
|
-
this.stopAllTracks = () => {
|
|
8287
|
-
|
|
8288
|
-
this.
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
8292
|
-
|
|
8326
|
+
this.stopAllTracks = async () => {
|
|
8327
|
+
return withoutConcurrency(this.eventLockKey('changePublishQuality'), async () => {
|
|
8328
|
+
for (const item of this.transceiverCache.items()) {
|
|
8329
|
+
const track = item.transceiver.sender.track;
|
|
8330
|
+
await this.silenceSenderOnFirefox(item);
|
|
8331
|
+
this.stopTrack(track);
|
|
8332
|
+
}
|
|
8333
|
+
for (const track of this.clonedTracks) {
|
|
8334
|
+
this.stopTrack(track);
|
|
8335
|
+
}
|
|
8336
|
+
});
|
|
8293
8337
|
};
|
|
8294
|
-
this.changePublishQuality = async (videoSender) => {
|
|
8295
|
-
const
|
|
8296
|
-
const enabledLayers = layers.filter((l) => l.active);
|
|
8338
|
+
this.changePublishQuality = async (videoSender, bundle) => {
|
|
8339
|
+
const enabledLayers = videoSender.layers.filter((l) => l.active);
|
|
8297
8340
|
const tag = 'Update publish quality:';
|
|
8298
8341
|
this.logger.info(`${tag} requested layers by SFU:`, enabledLayers);
|
|
8299
|
-
const
|
|
8300
|
-
t.publishOption.trackType === trackType);
|
|
8301
|
-
const sender = transceiverId?.transceiver.sender;
|
|
8342
|
+
const sender = bundle?.transceiver.sender;
|
|
8302
8343
|
if (!sender) {
|
|
8303
8344
|
return this.logger.warn(`${tag} no video sender found.`);
|
|
8304
8345
|
}
|
|
@@ -8306,7 +8347,7 @@ class Publisher extends BasePeerConnection {
|
|
|
8306
8347
|
if (params.encodings.length === 0) {
|
|
8307
8348
|
return this.logger.warn(`${tag} there are no encodings set.`);
|
|
8308
8349
|
}
|
|
8309
|
-
const codecInUse =
|
|
8350
|
+
const codecInUse = bundle?.publishOption.codec?.name;
|
|
8310
8351
|
const usesSvcCodec = codecInUse && isSvcCodec(codecInUse);
|
|
8311
8352
|
let changed = false;
|
|
8312
8353
|
for (const encoder of params.encodings) {
|
|
@@ -8506,6 +8547,72 @@ class Publisher extends BasePeerConnection {
|
|
|
8506
8547
|
track.stop();
|
|
8507
8548
|
this.clonedTracks.delete(track);
|
|
8508
8549
|
};
|
|
8550
|
+
/**
|
|
8551
|
+
* Silences a Firefox sender on the wire during unpublish.
|
|
8552
|
+
*
|
|
8553
|
+
* Firefox keeps emitting RTP after track.stop(), but the right lever
|
|
8554
|
+
* differs by track type:
|
|
8555
|
+
* - audio: `replaceTrack(null)` is the only reliable silencer;
|
|
8556
|
+
* `setParameters({encodings:[...active:false]})` does NOT stop
|
|
8557
|
+
* the Opus encoder.
|
|
8558
|
+
* - video: `setParameters({encodings:[...active:false]})` pauses
|
|
8559
|
+
* the encoder; `replaceTrack(null)` does NOT reliably stop the
|
|
8560
|
+
* video encoder. The prior active=true configuration is captured
|
|
8561
|
+
* onto `bundle.videoSender` so `updateTransceiver` can restore
|
|
8562
|
+
* it on the next publish.
|
|
8563
|
+
*
|
|
8564
|
+
* No-op on non-Firefox browsers and during teardown.
|
|
8565
|
+
*/
|
|
8566
|
+
this.silenceSenderOnFirefox = async (bundle) => {
|
|
8567
|
+
if (this.isDisposed || !isFirefox())
|
|
8568
|
+
return;
|
|
8569
|
+
const { transceiver, publishOption } = bundle;
|
|
8570
|
+
if (isAudioTrackType(publishOption.trackType)) {
|
|
8571
|
+
await transceiver.sender.replaceTrack(null).catch((err) => {
|
|
8572
|
+
this.logger.warn('Failed to clear audio sender track', err);
|
|
8573
|
+
});
|
|
8574
|
+
return;
|
|
8575
|
+
}
|
|
8576
|
+
await this.disableAllEncodings(bundle);
|
|
8577
|
+
};
|
|
8578
|
+
this.disableAllEncodings = async (bundle) => {
|
|
8579
|
+
const { transceiver, publishOption } = bundle;
|
|
8580
|
+
const sender = transceiver.sender;
|
|
8581
|
+
const params = sender.getParameters();
|
|
8582
|
+
if (!params.encodings || params.encodings.length === 0)
|
|
8583
|
+
return;
|
|
8584
|
+
if (!bundle.videoSender) {
|
|
8585
|
+
this.transceiverCache.update(publishOption, {
|
|
8586
|
+
videoSender: {
|
|
8587
|
+
trackType: publishOption.trackType,
|
|
8588
|
+
publishOptionId: publishOption.id,
|
|
8589
|
+
codec: publishOption.codec,
|
|
8590
|
+
degradationPreference: fromRTCDegradationPreference(params.degradationPreference),
|
|
8591
|
+
layers: params.encodings.map((e) => ({
|
|
8592
|
+
name: e.rid ?? 'q',
|
|
8593
|
+
active: e.active ?? true,
|
|
8594
|
+
maxBitrate: e.maxBitrate ?? 0,
|
|
8595
|
+
scaleResolutionDownBy: e.scaleResolutionDownBy ?? 0,
|
|
8596
|
+
maxFramerate: e.maxFramerate ?? 0,
|
|
8597
|
+
// @ts-expect-error scalabilityMode is not in the typedefs yet
|
|
8598
|
+
scalabilityMode: e.scalabilityMode ?? '',
|
|
8599
|
+
})),
|
|
8600
|
+
},
|
|
8601
|
+
});
|
|
8602
|
+
}
|
|
8603
|
+
let changed = false;
|
|
8604
|
+
for (const encoding of params.encodings) {
|
|
8605
|
+
if (encoding.active !== false) {
|
|
8606
|
+
encoding.active = false;
|
|
8607
|
+
changed = true;
|
|
8608
|
+
}
|
|
8609
|
+
}
|
|
8610
|
+
if (!changed)
|
|
8611
|
+
return;
|
|
8612
|
+
await sender.setParameters(params).catch((err) => {
|
|
8613
|
+
this.logger.error('Failed to disable video sender encodings:', err);
|
|
8614
|
+
});
|
|
8615
|
+
};
|
|
8509
8616
|
this.publishOptions = publishOptions;
|
|
8510
8617
|
this.on('iceRestart', (iceRestart) => {
|
|
8511
8618
|
if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED)
|
|
@@ -8514,7 +8621,16 @@ class Publisher extends BasePeerConnection {
|
|
|
8514
8621
|
});
|
|
8515
8622
|
this.on('changePublishQuality', async (event) => {
|
|
8516
8623
|
for (const videoSender of event.videoSenders) {
|
|
8517
|
-
|
|
8624
|
+
// if not publishing, update the encodingConfigCache and don't modify the state.
|
|
8625
|
+
// we'll apply this config on the next publish/unmute.
|
|
8626
|
+
const { trackType, publishOptionId } = videoSender;
|
|
8627
|
+
const bundle = this.transceiverCache.getBy(publishOptionId, trackType);
|
|
8628
|
+
if (bundle) {
|
|
8629
|
+
this.transceiverCache.update(bundle.publishOption, { videoSender });
|
|
8630
|
+
}
|
|
8631
|
+
if (isFirefox() && !this.isPublishing(trackType))
|
|
8632
|
+
continue;
|
|
8633
|
+
await this.changePublishQuality(videoSender, bundle);
|
|
8518
8634
|
}
|
|
8519
8635
|
});
|
|
8520
8636
|
this.on('changePublishOptions', (event) => {
|
|
@@ -8525,9 +8641,14 @@ class Publisher extends BasePeerConnection {
|
|
|
8525
8641
|
/**
|
|
8526
8642
|
* Disposes this Publisher instance.
|
|
8527
8643
|
*/
|
|
8528
|
-
dispose() {
|
|
8529
|
-
super.dispose();
|
|
8530
|
-
|
|
8644
|
+
async dispose() {
|
|
8645
|
+
await super.dispose();
|
|
8646
|
+
try {
|
|
8647
|
+
await this.stopAllTracks();
|
|
8648
|
+
}
|
|
8649
|
+
catch (err) {
|
|
8650
|
+
this.logger.warn('Failed to stop tracks during dispose', err);
|
|
8651
|
+
}
|
|
8531
8652
|
this.clonedTracks.clear();
|
|
8532
8653
|
}
|
|
8533
8654
|
}
|
|
@@ -11311,8 +11432,8 @@ const normalize = (options) => {
|
|
|
11311
11432
|
: false,
|
|
11312
11433
|
};
|
|
11313
11434
|
};
|
|
11314
|
-
const createSyntheticDevice = (deviceId, kind) => {
|
|
11315
|
-
return { deviceId, kind, label
|
|
11435
|
+
const createSyntheticDevice = (deviceId, kind, label = '') => {
|
|
11436
|
+
return { deviceId, kind, label, groupId: '' };
|
|
11316
11437
|
};
|
|
11317
11438
|
const readPreferences = (storageKey) => {
|
|
11318
11439
|
try {
|
|
@@ -11366,6 +11487,8 @@ class DeviceManager {
|
|
|
11366
11487
|
this.areSubscriptionsSetUp = false;
|
|
11367
11488
|
this.isTrackStoppedDueToTrackEnd = false;
|
|
11368
11489
|
this.filters = [];
|
|
11490
|
+
this.virtualDevicesSubject = new BehaviorSubject([]);
|
|
11491
|
+
this.virtualDeviceConcurrencyTag = Symbol('virtualDeviceConcurrencyTag');
|
|
11369
11492
|
this.statusChangeConcurrencyTag = Symbol('statusChangeConcurrencyTag');
|
|
11370
11493
|
this.filterRegistrationConcurrencyTag = Symbol('filterRegistrationConcurrencyTag');
|
|
11371
11494
|
/**
|
|
@@ -11378,6 +11501,7 @@ class DeviceManager {
|
|
|
11378
11501
|
this.subscriptions.forEach((s) => s());
|
|
11379
11502
|
this.subscriptions = [];
|
|
11380
11503
|
this.areSubscriptionsSetUp = false;
|
|
11504
|
+
this.virtualDevicesSubject.next([]);
|
|
11381
11505
|
};
|
|
11382
11506
|
this.runCurrentStreamCleanups = () => {
|
|
11383
11507
|
this.currentStreamCleanups.forEach((c) => c());
|
|
@@ -11436,7 +11560,93 @@ class DeviceManager {
|
|
|
11436
11560
|
* @returns an Observable that will be updated if a device is connected or disconnected
|
|
11437
11561
|
*/
|
|
11438
11562
|
listDevices() {
|
|
11439
|
-
return this.getDevices()
|
|
11563
|
+
return combineLatest([this.getDevices(), this.virtualDevicesSubject]).pipe(map(([real, virtual]) => [
|
|
11564
|
+
...real,
|
|
11565
|
+
...virtual.map((d) => createSyntheticDevice(d.deviceId, d.kind, d.label)),
|
|
11566
|
+
]));
|
|
11567
|
+
}
|
|
11568
|
+
/**
|
|
11569
|
+
* Registers a virtual camera or microphone backed by a caller-supplied
|
|
11570
|
+
* stream factory. The device appears in `listDevices()` and can be selected
|
|
11571
|
+
* via `select()` like any real device.
|
|
11572
|
+
*
|
|
11573
|
+
* Web only. React Native is not supported.
|
|
11574
|
+
*
|
|
11575
|
+
* Only supported for camera and microphone managers; calling on any other
|
|
11576
|
+
* manager throws.
|
|
11577
|
+
*/
|
|
11578
|
+
registerVirtualDevice(virtualDevice) {
|
|
11579
|
+
if (isReactNative()) {
|
|
11580
|
+
throw new Error('Virtual devices are not supported on React Native.');
|
|
11581
|
+
}
|
|
11582
|
+
if (this.trackType !== TrackType.AUDIO &&
|
|
11583
|
+
this.trackType !== TrackType.VIDEO) {
|
|
11584
|
+
throw new Error('Virtual devices are only supported for camera and microphone.');
|
|
11585
|
+
}
|
|
11586
|
+
const deviceId = `stream-virtual:${generateUUIDv4()}`;
|
|
11587
|
+
const entry = {
|
|
11588
|
+
deviceId,
|
|
11589
|
+
kind: this.mediaDeviceKind,
|
|
11590
|
+
...virtualDevice,
|
|
11591
|
+
};
|
|
11592
|
+
setCurrentValue(this.virtualDevicesSubject, (current) => [
|
|
11593
|
+
...current,
|
|
11594
|
+
entry,
|
|
11595
|
+
]);
|
|
11596
|
+
return {
|
|
11597
|
+
deviceId: entry.deviceId,
|
|
11598
|
+
unregister: async () => {
|
|
11599
|
+
await withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
|
|
11600
|
+
setCurrentValue(this.virtualDevicesSubject, (current) => current.filter((d) => d !== entry));
|
|
11601
|
+
if (this.activeVirtualSession?.deviceId === deviceId) {
|
|
11602
|
+
await this.stopActiveVirtualSession();
|
|
11603
|
+
}
|
|
11604
|
+
});
|
|
11605
|
+
if (this.state.selectedDevice === deviceId) {
|
|
11606
|
+
await this.statusChangeSettled();
|
|
11607
|
+
await this.disable({ forceStop: true });
|
|
11608
|
+
await this.select(undefined);
|
|
11609
|
+
}
|
|
11610
|
+
},
|
|
11611
|
+
};
|
|
11612
|
+
}
|
|
11613
|
+
sanitizeVirtualStream(stream) {
|
|
11614
|
+
stream.getTracks().forEach((track) => {
|
|
11615
|
+
const originalGetSettings = track.getSettings.bind(track);
|
|
11616
|
+
track.getSettings = () => {
|
|
11617
|
+
const settings = originalGetSettings();
|
|
11618
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
11619
|
+
const { deviceId, ...rest } = settings;
|
|
11620
|
+
return rest;
|
|
11621
|
+
};
|
|
11622
|
+
});
|
|
11623
|
+
return stream;
|
|
11624
|
+
}
|
|
11625
|
+
findVirtualDevice(deviceId) {
|
|
11626
|
+
if (!deviceId)
|
|
11627
|
+
return undefined;
|
|
11628
|
+
return getCurrentValue(this.virtualDevicesSubject).find((d) => d.deviceId === deviceId);
|
|
11629
|
+
}
|
|
11630
|
+
async stopActiveVirtualSession() {
|
|
11631
|
+
const session = this.activeVirtualSession;
|
|
11632
|
+
this.activeVirtualSession = undefined;
|
|
11633
|
+
await session?.stop?.();
|
|
11634
|
+
}
|
|
11635
|
+
async getSelectedStream(constraints) {
|
|
11636
|
+
const deviceId = this.state.selectedDevice;
|
|
11637
|
+
if (!deviceId?.startsWith('stream-virtual')) {
|
|
11638
|
+
return this.getStream(constraints);
|
|
11639
|
+
}
|
|
11640
|
+
return withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
|
|
11641
|
+
const virtualDevice = this.findVirtualDevice(deviceId);
|
|
11642
|
+
if (!virtualDevice) {
|
|
11643
|
+
throw new Error(`Virtual device is not registered: ${deviceId}`);
|
|
11644
|
+
}
|
|
11645
|
+
await this.stopActiveVirtualSession();
|
|
11646
|
+
const { stream, stop } = await virtualDevice.getUserMedia(constraints);
|
|
11647
|
+
this.activeVirtualSession = { deviceId, stop };
|
|
11648
|
+
return this.sanitizeVirtualStream(stream);
|
|
11649
|
+
});
|
|
11440
11650
|
}
|
|
11441
11651
|
/**
|
|
11442
11652
|
* Returns `true` when this device is in enabled state.
|
|
@@ -11596,6 +11806,9 @@ class DeviceManager {
|
|
|
11596
11806
|
}
|
|
11597
11807
|
});
|
|
11598
11808
|
}
|
|
11809
|
+
getResolvedConstraints(constraints) {
|
|
11810
|
+
return constraints;
|
|
11811
|
+
}
|
|
11599
11812
|
publishStream(stream, options) {
|
|
11600
11813
|
return this.call.publish(stream, this.trackType, options);
|
|
11601
11814
|
}
|
|
@@ -11616,6 +11829,7 @@ class DeviceManager {
|
|
|
11616
11829
|
this.muteLocalStream(stopTracks);
|
|
11617
11830
|
const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
|
|
11618
11831
|
if (allEnded) {
|
|
11832
|
+
await this.stopActiveVirtualSession();
|
|
11619
11833
|
// @ts-expect-error release() is present in react-native-webrtc
|
|
11620
11834
|
if (typeof mediaStream.release === 'function') {
|
|
11621
11835
|
// @ts-expect-error called to dispose the stream in RN
|
|
@@ -11671,12 +11885,12 @@ class DeviceManager {
|
|
|
11671
11885
|
// before chainWith below registers new ones for the new chain.
|
|
11672
11886
|
this.runCurrentStreamCleanups();
|
|
11673
11887
|
const defaultConstraints = this.state.defaultConstraints;
|
|
11674
|
-
const constraints = {
|
|
11888
|
+
const constraints = this.getResolvedConstraints({
|
|
11675
11889
|
...defaultConstraints,
|
|
11676
11890
|
deviceId: this.state.selectedDevice
|
|
11677
11891
|
? { exact: this.state.selectedDevice }
|
|
11678
11892
|
: undefined,
|
|
11679
|
-
};
|
|
11893
|
+
});
|
|
11680
11894
|
/**
|
|
11681
11895
|
* Chains two media streams together.
|
|
11682
11896
|
*
|
|
@@ -11733,7 +11947,7 @@ class DeviceManager {
|
|
|
11733
11947
|
};
|
|
11734
11948
|
// the rootStream represents the stream coming from the actual device
|
|
11735
11949
|
// e.g. camera or microphone stream
|
|
11736
|
-
rootStreamPromise = this.
|
|
11950
|
+
rootStreamPromise = this.getSelectedStream(constraints);
|
|
11737
11951
|
// we publish the last MediaStream of the chain
|
|
11738
11952
|
stream = await this.filters.reduce((parent, entry) => parent
|
|
11739
11953
|
.then((inputStream) => {
|
|
@@ -12050,7 +12264,10 @@ class DeviceManagerState {
|
|
|
12050
12264
|
setCurrentValue(this.mediaStreamSubject, stream);
|
|
12051
12265
|
setCurrentValue(this.rootMediaStreamSubject, rootStream);
|
|
12052
12266
|
if (rootStream) {
|
|
12053
|
-
this.
|
|
12267
|
+
const derived = this.getDeviceIdFromStream(rootStream);
|
|
12268
|
+
if (derived) {
|
|
12269
|
+
this.setDevice(derived);
|
|
12270
|
+
}
|
|
12054
12271
|
}
|
|
12055
12272
|
}
|
|
12056
12273
|
/**
|
|
@@ -12263,7 +12480,7 @@ class CameraManager extends DeviceManager {
|
|
|
12263
12480
|
getDevices() {
|
|
12264
12481
|
return getVideoDevices(this.call.tracer);
|
|
12265
12482
|
}
|
|
12266
|
-
|
|
12483
|
+
getResolvedConstraints(constraints) {
|
|
12267
12484
|
constraints.width = this.targetResolution.width;
|
|
12268
12485
|
constraints.height = this.targetResolution.height;
|
|
12269
12486
|
// We can't set both device id and facing mode
|
|
@@ -12274,6 +12491,9 @@ class CameraManager extends DeviceManager {
|
|
|
12274
12491
|
constraints.facingMode =
|
|
12275
12492
|
this.state.direction === 'front' ? 'user' : 'environment';
|
|
12276
12493
|
}
|
|
12494
|
+
return constraints;
|
|
12495
|
+
}
|
|
12496
|
+
getStream(constraints) {
|
|
12277
12497
|
return getVideoStream(constraints, this.call.tracer);
|
|
12278
12498
|
}
|
|
12279
12499
|
}
|
|
@@ -13601,9 +13821,10 @@ class Call {
|
|
|
13601
13821
|
this.sfuStatsReporter?.flush();
|
|
13602
13822
|
this.sfuStatsReporter?.stop();
|
|
13603
13823
|
this.sfuStatsReporter = undefined;
|
|
13604
|
-
this.
|
|
13824
|
+
this.lastStatsOptions = undefined;
|
|
13825
|
+
await this.subscriber?.dispose();
|
|
13605
13826
|
this.subscriber = undefined;
|
|
13606
|
-
this.publisher?.dispose();
|
|
13827
|
+
await this.publisher?.dispose();
|
|
13607
13828
|
this.publisher = undefined;
|
|
13608
13829
|
await this.sfuClient?.leaveAndClose(leaveReason);
|
|
13609
13830
|
this.sfuClient = undefined;
|
|
@@ -13879,15 +14100,17 @@ class Call {
|
|
|
13879
14100
|
const performingMigration = this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
13880
14101
|
const performingRejoin = this.reconnectStrategy === WebsocketReconnectStrategy.REJOIN;
|
|
13881
14102
|
const performingFastReconnect = this.reconnectStrategy === WebsocketReconnectStrategy.FAST;
|
|
13882
|
-
let statsOptions = this.
|
|
14103
|
+
let statsOptions = this.lastStatsOptions;
|
|
13883
14104
|
if (!this.credentials ||
|
|
13884
14105
|
!statsOptions ||
|
|
13885
14106
|
performingRejoin ||
|
|
13886
|
-
performingMigration
|
|
14107
|
+
performingMigration ||
|
|
14108
|
+
data?.migrating_from) {
|
|
13887
14109
|
try {
|
|
13888
14110
|
const joinResponse = await this.doJoinRequest(data);
|
|
13889
14111
|
this.credentials = joinResponse.credentials;
|
|
13890
14112
|
statsOptions = joinResponse.stats_options;
|
|
14113
|
+
this.lastStatsOptions = statsOptions;
|
|
13891
14114
|
}
|
|
13892
14115
|
catch (error) {
|
|
13893
14116
|
// prevent triggering reconnect flow if the state is OFFLINE
|
|
@@ -13994,7 +14217,7 @@ class Call {
|
|
|
13994
14217
|
}
|
|
13995
14218
|
else {
|
|
13996
14219
|
const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
|
|
13997
|
-
this.initPublisherAndSubscriber({
|
|
14220
|
+
await this.initPublisherAndSubscriber({
|
|
13998
14221
|
sfuClient,
|
|
13999
14222
|
connectionConfig,
|
|
14000
14223
|
clientDetails,
|
|
@@ -14139,11 +14362,11 @@ class Call {
|
|
|
14139
14362
|
* Initializes the Publisher and Subscriber Peer Connections.
|
|
14140
14363
|
* @internal
|
|
14141
14364
|
*/
|
|
14142
|
-
this.initPublisherAndSubscriber = (opts) => {
|
|
14365
|
+
this.initPublisherAndSubscriber = async (opts) => {
|
|
14143
14366
|
const { sfuClient, connectionConfig, clientDetails, statsOptions, publishOptions, closePreviousInstances, unifiedSessionId, } = opts;
|
|
14144
14367
|
const { enable_rtc_stats: enableTracing } = statsOptions;
|
|
14145
14368
|
if (closePreviousInstances && this.subscriber) {
|
|
14146
|
-
this.subscriber.dispose();
|
|
14369
|
+
await this.subscriber.dispose();
|
|
14147
14370
|
}
|
|
14148
14371
|
const basePeerConnectionOptions = {
|
|
14149
14372
|
sfuClient,
|
|
@@ -14172,7 +14395,7 @@ class Call {
|
|
|
14172
14395
|
const isAnonymous = this.streamClient.user?.type === 'anonymous';
|
|
14173
14396
|
if (!isAnonymous) {
|
|
14174
14397
|
if (closePreviousInstances && this.publisher) {
|
|
14175
|
-
this.publisher.dispose();
|
|
14398
|
+
await this.publisher.dispose();
|
|
14176
14399
|
}
|
|
14177
14400
|
this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
|
|
14178
14401
|
}
|
|
@@ -14275,10 +14498,17 @@ class Call {
|
|
|
14275
14498
|
* `ICE_NEVER_CONNECTED` increments the unsupported-network counter).
|
|
14276
14499
|
*/
|
|
14277
14500
|
this.reconnect = async (strategy, reason) => {
|
|
14278
|
-
if (this.state.callingState === CallingState.
|
|
14501
|
+
if (this.state.callingState === CallingState.JOINING ||
|
|
14502
|
+
this.state.callingState === CallingState.RECONNECTING ||
|
|
14279
14503
|
this.state.callingState === CallingState.MIGRATING ||
|
|
14280
14504
|
this.state.callingState === CallingState.RECONNECTING_FAILED)
|
|
14281
14505
|
return;
|
|
14506
|
+
// Drop redundant reconnect calls. If a reconnect is already queued or
|
|
14507
|
+
// running for this Call, that entry will resolve whatever broke;
|
|
14508
|
+
// queueing more entries just replays the full REJOIN cycle (one extra
|
|
14509
|
+
// `POST /join` per entry) once the call is already healthy again.
|
|
14510
|
+
if (hasPending(this.reconnectConcurrencyTag))
|
|
14511
|
+
return;
|
|
14282
14512
|
return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
|
|
14283
14513
|
const reconnectStartTime = Date.now();
|
|
14284
14514
|
this.reconnectStrategy = strategy;
|
|
@@ -14483,8 +14713,8 @@ class Call {
|
|
|
14483
14713
|
this.state.setCallingState(CallingState.JOINED);
|
|
14484
14714
|
}
|
|
14485
14715
|
finally {
|
|
14486
|
-
currentSubscriber?.dispose();
|
|
14487
|
-
currentPublisher?.dispose();
|
|
14716
|
+
await currentSubscriber?.dispose();
|
|
14717
|
+
await currentPublisher?.dispose();
|
|
14488
14718
|
// and close the previous SFU client, without specifying close code
|
|
14489
14719
|
currentSfuClient.close(StreamSfuClient.NORMAL_CLOSURE, 'Migrating away');
|
|
14490
14720
|
}
|
|
@@ -14673,7 +14903,7 @@ class Call {
|
|
|
14673
14903
|
this.stopPublish = async (...trackTypes) => {
|
|
14674
14904
|
if (!this.sfuClient || !this.publisher)
|
|
14675
14905
|
return;
|
|
14676
|
-
this.publisher.stopTracks(...trackTypes);
|
|
14906
|
+
await this.publisher.stopTracks(...trackTypes);
|
|
14677
14907
|
await this.updateLocalStreamState(undefined, ...trackTypes);
|
|
14678
14908
|
};
|
|
14679
14909
|
/**
|
|
@@ -16788,7 +17018,7 @@ class StreamClient {
|
|
|
16788
17018
|
this.getUserAgent = () => {
|
|
16789
17019
|
if (!this.cachedUserAgent) {
|
|
16790
17020
|
const { clientAppIdentifier = {} } = this.options;
|
|
16791
|
-
const { sdkName = 'js', sdkVersion = "1.
|
|
17021
|
+
const { sdkName = 'js', sdkVersion = "1.51.0", ...extras } = clientAppIdentifier;
|
|
16792
17022
|
this.cachedUserAgent = [
|
|
16793
17023
|
`stream-video-${sdkName}-v${sdkVersion}`,
|
|
16794
17024
|
...Object.entries(extras).map(([key, value]) => `${key}=${value}`),
|