@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.browser.es.js +288 -58
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +288 -58
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +288 -58
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +1 -0
  9. package/dist/src/devices/CameraManager.d.ts +1 -0
  10. package/dist/src/devices/DeviceManager.d.ts +20 -0
  11. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  12. package/dist/src/devices/devicePersistence.d.ts +1 -1
  13. package/dist/src/devices/index.d.ts +1 -0
  14. package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
  15. package/dist/src/rtc/Publisher.d.ts +21 -3
  16. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  17. package/dist/src/rtc/helpers/degradationPreference.d.ts +1 -0
  18. package/dist/src/rtc/types.d.ts +2 -0
  19. package/package.json +2 -2
  20. package/src/Call.ts +22 -11
  21. package/src/devices/CameraManager.ts +9 -2
  22. package/src/devices/DeviceManager.ts +148 -8
  23. package/src/devices/DeviceManagerState.ts +4 -1
  24. package/src/devices/VirtualDevice.ts +69 -0
  25. package/src/devices/__tests__/CameraManager.test.ts +19 -0
  26. package/src/devices/__tests__/DeviceManager.test.ts +121 -1
  27. package/src/devices/devicePersistence.ts +2 -1
  28. package/src/devices/index.ts +1 -0
  29. package/src/rtc/BasePeerConnection.ts +15 -3
  30. package/src/rtc/Publisher.ts +140 -41
  31. package/src/rtc/TransceiverCache.ts +10 -3
  32. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  33. package/src/rtc/__tests__/Publisher.test.ts +659 -112
  34. package/src/rtc/__tests__/Subscriber.test.ts +2 -2
  35. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +33 -1
  36. package/src/rtc/helpers/degradationPreference.ts +18 -0
  37. 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
@@ -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.50.0";
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 = `pc.${this.lock}.${event}`;
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.cache.find((bundle) => bundle.publishOption.id === publishOption.id &&
7792
- bundle.publishOption.trackType === publishOption.trackType);
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 { transceiver } = this.transceiverCache.get(publishOption) || {};
8112
- if (!transceiver) {
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(transceiver, trackToPublish, trackType, options);
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 (transceiver, track, trackType, options = {}) => {
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(transceiver, null, publishOption.trackType);
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
- for (const item of this.transceiverCache.items()) {
8277
- const { publishOption, transceiver } = item;
8278
- if (!trackTypes.includes(publishOption.trackType))
8279
- continue;
8280
- this.stopTrack(transceiver.sender.track);
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
- for (const { transceiver } of this.transceiverCache.items()) {
8288
- this.stopTrack(transceiver.sender.track);
8289
- }
8290
- for (const track of this.clonedTracks) {
8291
- this.stopTrack(track);
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 { trackType, layers, publishOptionId } = videoSender;
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 transceiverId = this.transceiverCache.find((t) => t.publishOption.id === publishOptionId &&
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 = transceiverId?.publishOption.codec?.name;
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
- await this.changePublishQuality(videoSender);
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
- this.stopAllTracks();
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: '', groupId: '' };
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.getStream(constraints);
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.setDevice(this.getDeviceIdFromStream(rootStream));
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
- getStream(constraints) {
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.subscriber?.dispose();
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.sfuStatsReporter?.options;
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.RECONNECTING ||
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.50.0", ...extras } = clientAppIdentifier;
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}`),