@stream-io/video-client 1.49.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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +1404 -682
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1404 -682
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1404 -682
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +43 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/CameraManager.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +23 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  16. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  17. package/dist/src/devices/devicePersistence.d.ts +1 -1
  18. package/dist/src/devices/index.d.ts +1 -0
  19. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  20. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  21. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  22. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  23. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  24. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  25. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  26. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  27. package/dist/src/helpers/browsers.d.ts +13 -0
  28. package/dist/src/helpers/concurrency.d.ts +6 -4
  29. package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
  30. package/dist/src/rtc/Publisher.d.ts +38 -3
  31. package/dist/src/rtc/Subscriber.d.ts +1 -0
  32. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  33. package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
  34. package/dist/src/rtc/types.d.ts +2 -0
  35. package/dist/src/stats/rtc/types.d.ts +1 -1
  36. package/dist/src/store/rxUtils.d.ts +9 -0
  37. package/dist/src/types.d.ts +18 -0
  38. package/package.json +2 -2
  39. package/src/Call.ts +111 -33
  40. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  41. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  42. package/src/coordinator/connection/client.ts +1 -1
  43. package/src/coordinator/connection/connection.ts +149 -96
  44. package/src/coordinator/connection/types.ts +15 -0
  45. package/src/coordinator/connection/utils.ts +15 -0
  46. package/src/devices/CameraManager.ts +9 -2
  47. package/src/devices/DeviceManager.ts +239 -39
  48. package/src/devices/DeviceManagerState.ts +4 -2
  49. package/src/devices/VirtualDevice.ts +69 -0
  50. package/src/devices/__tests__/CameraManager.test.ts +19 -0
  51. package/src/devices/__tests__/DeviceManager.test.ts +404 -1
  52. package/src/devices/__tests__/mocks.ts +2 -0
  53. package/src/devices/devicePersistence.ts +2 -1
  54. package/src/devices/index.ts +1 -0
  55. package/src/gen/video/sfu/event/events.ts +15 -0
  56. package/src/gen/video/sfu/models/models.ts +44 -0
  57. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  58. package/src/helpers/BlockedAudioTracker.ts +74 -0
  59. package/src/helpers/DynascaleManager.ts +46 -337
  60. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  61. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  62. package/src/helpers/ViewportTracker.ts +74 -19
  63. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  64. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  65. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rtc/BasePeerConnection.ts +15 -3
  72. package/src/rtc/Publisher.ts +185 -40
  73. package/src/rtc/Subscriber.ts +42 -14
  74. package/src/rtc/TransceiverCache.ts +10 -3
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  76. package/src/rtc/__tests__/Publisher.test.ts +747 -88
  77. package/src/rtc/__tests__/Subscriber.test.ts +148 -3
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
  80. package/src/rtc/helpers/degradationPreference.ts +40 -0
  81. package/src/rtc/types.ts +2 -0
  82. package/src/stats/rtc/types.ts +1 -0
  83. package/src/store/__tests__/rxUtils.test.ts +276 -0
  84. package/src/store/rxUtils.ts +19 -0
  85. package/src/types.ts +19 -0
@@ -1,14 +1,26 @@
1
- import { combineLatest, firstValueFrom, Observable, pairwise } from 'rxjs';
1
+ import {
2
+ BehaviorSubject,
3
+ combineLatest,
4
+ firstValueFrom,
5
+ map,
6
+ Observable,
7
+ pairwise,
8
+ } from 'rxjs';
2
9
  import { Call } from '../Call';
3
10
  import type { DeviceDisconnectedEvent } from '../coordinator/connection/types';
4
11
  import { TrackPublishOptions } from '../rtc';
5
12
  import { CallingState } from '../store';
6
- import { createSubscription, getCurrentValue } from '../store/rxUtils';
13
+ import {
14
+ createSubscription,
15
+ getCurrentValue,
16
+ setCurrentValue,
17
+ } from '../store/rxUtils';
7
18
  import {
8
19
  DeviceManagerState,
9
20
  type InputDeviceStatus,
10
21
  } from './DeviceManagerState';
11
22
  import { isMobile } from '../helpers/compatibility';
23
+ import { isWebKit } from '../helpers/browsers';
12
24
  import { isReactNative } from '../helpers/platforms';
13
25
  import { ScopedLogger, videoLoggerSystem } from '../logger';
14
26
  import { TrackType } from '../gen/video/sfu/models/models';
@@ -24,6 +36,7 @@ import {
24
36
  MediaStreamFilterEntry,
25
37
  MediaStreamFilterRegistrationResult,
26
38
  } from './filters';
39
+ import { pushToIfMissing, removeFromIfPresent } from '../helpers/array';
27
40
  import {
28
41
  createSyntheticDevice,
29
42
  defaultDeviceId,
@@ -33,6 +46,13 @@ import {
33
46
  toPreferenceList,
34
47
  writePreferences,
35
48
  } from './devicePersistence';
49
+ import {
50
+ ActiveVirtualSession,
51
+ VirtualDevice,
52
+ VirtualDeviceEntry,
53
+ VirtualDeviceHandle,
54
+ } from './VirtualDevice';
55
+ import { generateUUIDv4 } from '../coordinator/connection/utils';
36
56
 
37
57
  export abstract class DeviceManager<
38
58
  S extends DeviceManagerState<C>,
@@ -49,10 +69,16 @@ export abstract class DeviceManager<
49
69
  protected readonly call: Call;
50
70
  protected readonly trackType: TrackType;
51
71
  protected subscriptions: (() => void)[] = [];
72
+ protected currentStreamCleanups: (() => void)[] = [];
52
73
  protected devicePersistence: Required<DevicePersistenceOptions>;
53
74
  protected areSubscriptionsSetUp = false;
54
75
  private isTrackStoppedDueToTrackEnd = false;
55
76
  private filters: MediaStreamFilterEntry[] = [];
77
+ private virtualDevicesSubject = new BehaviorSubject<VirtualDeviceEntry<C>[]>(
78
+ [],
79
+ );
80
+ private activeVirtualSession: ActiveVirtualSession | undefined;
81
+ private virtualDeviceConcurrencyTag = Symbol('virtualDeviceConcurrencyTag');
56
82
  private statusChangeConcurrencyTag = Symbol('statusChangeConcurrencyTag');
57
83
  private filterRegistrationConcurrencyTag = Symbol(
58
84
  'filterRegistrationConcurrencyTag',
@@ -116,8 +142,119 @@ export abstract class DeviceManager<
116
142
  *
117
143
  * @returns an Observable that will be updated if a device is connected or disconnected
118
144
  */
119
- listDevices() {
120
- return this.getDevices();
145
+ listDevices(): Observable<MediaDeviceInfo[]> {
146
+ return combineLatest([this.getDevices(), this.virtualDevicesSubject]).pipe(
147
+ map(([real, virtual]) => [
148
+ ...real,
149
+ ...virtual.map((d) =>
150
+ createSyntheticDevice(d.deviceId, d.kind, d.label),
151
+ ),
152
+ ]),
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Registers a virtual camera or microphone backed by a caller-supplied
158
+ * stream factory. The device appears in `listDevices()` and can be selected
159
+ * via `select()` like any real device.
160
+ *
161
+ * Web only. React Native is not supported.
162
+ *
163
+ * Only supported for camera and microphone managers; calling on any other
164
+ * manager throws.
165
+ */
166
+ registerVirtualDevice(virtualDevice: VirtualDevice<C>): VirtualDeviceHandle {
167
+ if (isReactNative()) {
168
+ throw new Error('Virtual devices are not supported on React Native.');
169
+ }
170
+ if (
171
+ this.trackType !== TrackType.AUDIO &&
172
+ this.trackType !== TrackType.VIDEO
173
+ ) {
174
+ throw new Error(
175
+ 'Virtual devices are only supported for camera and microphone.',
176
+ );
177
+ }
178
+
179
+ const deviceId = `stream-virtual:${generateUUIDv4()}`;
180
+ const entry: VirtualDeviceEntry<C> = {
181
+ deviceId,
182
+ kind: this.mediaDeviceKind,
183
+ ...virtualDevice,
184
+ };
185
+
186
+ setCurrentValue(this.virtualDevicesSubject, (current) => [
187
+ ...current,
188
+ entry,
189
+ ]);
190
+
191
+ return {
192
+ deviceId: entry.deviceId,
193
+ unregister: async () => {
194
+ await withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
195
+ setCurrentValue(this.virtualDevicesSubject, (current) =>
196
+ current.filter((d) => d !== entry),
197
+ );
198
+ if (this.activeVirtualSession?.deviceId === deviceId) {
199
+ await this.stopActiveVirtualSession();
200
+ }
201
+ });
202
+
203
+ if (this.state.selectedDevice === deviceId) {
204
+ await this.statusChangeSettled();
205
+
206
+ await this.disable({ forceStop: true });
207
+ await this.select(undefined);
208
+ }
209
+ },
210
+ };
211
+ }
212
+
213
+ protected sanitizeVirtualStream(stream: MediaStream): MediaStream {
214
+ stream.getTracks().forEach((track) => {
215
+ const originalGetSettings = track.getSettings.bind(track);
216
+ track.getSettings = () => {
217
+ const settings = originalGetSettings();
218
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
219
+ const { deviceId, ...rest } = settings;
220
+ return rest;
221
+ };
222
+ });
223
+
224
+ return stream;
225
+ }
226
+
227
+ protected findVirtualDevice(deviceId: string | undefined) {
228
+ if (!deviceId) return undefined;
229
+ return getCurrentValue(this.virtualDevicesSubject).find(
230
+ (d) => d.deviceId === deviceId,
231
+ );
232
+ }
233
+
234
+ private async stopActiveVirtualSession() {
235
+ const session = this.activeVirtualSession;
236
+ this.activeVirtualSession = undefined;
237
+ await session?.stop?.();
238
+ }
239
+
240
+ protected async getSelectedStream(constraints: C): Promise<MediaStream> {
241
+ const deviceId = this.state.selectedDevice;
242
+ if (!deviceId?.startsWith('stream-virtual')) {
243
+ return this.getStream(constraints);
244
+ }
245
+
246
+ return withoutConcurrency(this.virtualDeviceConcurrencyTag, async () => {
247
+ const virtualDevice = this.findVirtualDevice(deviceId);
248
+ if (!virtualDevice) {
249
+ throw new Error(`Virtual device is not registered: ${deviceId}`);
250
+ }
251
+
252
+ await this.stopActiveVirtualSession();
253
+ const { stream, stop } = await virtualDevice.getUserMedia(constraints);
254
+ this.activeVirtualSession = { deviceId, stop };
255
+
256
+ return this.sanitizeVirtualStream(stream);
257
+ });
121
258
  }
122
259
 
123
260
  /**
@@ -292,9 +429,16 @@ export abstract class DeviceManager<
292
429
  * @internal
293
430
  */
294
431
  dispose = () => {
432
+ this.runCurrentStreamCleanups();
295
433
  this.subscriptions.forEach((s) => s());
296
434
  this.subscriptions = [];
297
435
  this.areSubscriptionsSetUp = false;
436
+ this.virtualDevicesSubject.next([]);
437
+ };
438
+
439
+ private runCurrentStreamCleanups = () => {
440
+ this.currentStreamCleanups.forEach((c) => c());
441
+ this.currentStreamCleanups = [];
298
442
  };
299
443
 
300
444
  protected async applySettingsToStream() {
@@ -321,6 +465,10 @@ export abstract class DeviceManager<
321
465
 
322
466
  protected abstract getDevices(): Observable<MediaDeviceInfo[]>;
323
467
 
468
+ protected getResolvedConstraints(constraints: C): C {
469
+ return constraints;
470
+ }
471
+
324
472
  protected abstract getStream(constraints: C): Promise<MediaStream>;
325
473
 
326
474
  protected publishStream(
@@ -348,12 +496,15 @@ export abstract class DeviceManager<
348
496
  this.muteLocalStream(stopTracks);
349
497
  const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
350
498
  if (allEnded) {
499
+ await this.stopActiveVirtualSession();
351
500
  // @ts-expect-error release() is present in react-native-webrtc
352
501
  if (typeof mediaStream.release === 'function') {
353
502
  // @ts-expect-error called to dispose the stream in RN
354
503
  mediaStream.release();
355
504
  }
505
+ this.runCurrentStreamCleanups();
356
506
  this.state.setMediaStream(undefined, undefined);
507
+ this.setLocalInterrupted(false);
357
508
  this.filters.forEach((entry) => entry.stop?.());
358
509
  }
359
510
  }
@@ -390,7 +541,7 @@ export abstract class DeviceManager<
390
541
  protected async unmuteStream() {
391
542
  this.logger.debug('Starting stream');
392
543
  let stream: MediaStream;
393
- let rootStream: Promise<MediaStream> | undefined;
544
+ let rootStreamPromise: Promise<MediaStream> | undefined;
394
545
  if (
395
546
  this.state.mediaStream &&
396
547
  this.getTracks().every((t) => t.readyState === 'live')
@@ -398,13 +549,18 @@ export abstract class DeviceManager<
398
549
  stream = this.state.mediaStream;
399
550
  this.enableTracks();
400
551
  } else {
552
+ // We are about to compose a fresh filter chain and acquire a new
553
+ // root stream. Drop any listeners bound to the previous root stream
554
+ // before chainWith below registers new ones for the new chain.
555
+ this.runCurrentStreamCleanups();
556
+
401
557
  const defaultConstraints = this.state.defaultConstraints;
402
- const constraints: MediaTrackConstraints = {
558
+ const constraints = this.getResolvedConstraints({
403
559
  ...defaultConstraints,
404
560
  deviceId: this.state.selectedDevice
405
561
  ? { exact: this.state.selectedDevice }
406
562
  : undefined,
407
- };
563
+ } as C);
408
564
 
409
565
  /**
410
566
  * Chains two media streams together.
@@ -455,7 +611,7 @@ export abstract class DeviceManager<
455
611
  });
456
612
  };
457
613
  parentTrack.addEventListener('ended', handleParentTrackEnded);
458
- this.subscriptions.push(() => {
614
+ this.currentStreamCleanups.push(() => {
459
615
  parentTrack.removeEventListener('ended', handleParentTrackEnded);
460
616
  });
461
617
  });
@@ -465,7 +621,7 @@ export abstract class DeviceManager<
465
621
 
466
622
  // the rootStream represents the stream coming from the actual device
467
623
  // e.g. camera or microphone stream
468
- rootStream = this.getStream(constraints as C);
624
+ rootStreamPromise = this.getSelectedStream(constraints as C);
469
625
  // we publish the last MediaStream of the chain
470
626
  stream = await this.filters.reduce(
471
627
  (parent, entry) =>
@@ -482,46 +638,90 @@ export abstract class DeviceManager<
482
638
  );
483
639
  return parent;
484
640
  }),
485
- rootStream,
641
+ rootStreamPromise,
486
642
  );
487
643
  }
488
644
  if (this.call.state.callingState === CallingState.JOINED) {
489
645
  await this.publishStream(stream);
490
646
  }
491
647
  if (this.state.mediaStream !== stream) {
492
- this.state.setMediaStream(stream, await rootStream);
493
- const handleTrackEnded = async () => {
494
- await this.statusChangeSettled();
495
- if (this.enabled) {
496
- this.isTrackStoppedDueToTrackEnd = true;
497
- setTimeout(() => {
498
- this.isTrackStoppedDueToTrackEnd = false;
499
- }, 2000);
500
- await this.disable();
501
- }
502
- };
503
- const createTrackMuteHandler = (muted: boolean) => () => {
504
- if (!isMobile() || this.trackType !== TrackType.VIDEO) return;
505
- this.call.notifyTrackMuteState(muted, this.trackType).catch((err) => {
506
- this.logger.warn('Error while notifying track mute state', err);
507
- });
508
- };
509
- stream.getTracks().forEach((track) => {
510
- const muteHandler = createTrackMuteHandler(true);
511
- const unmuteHandler = createTrackMuteHandler(false);
512
- track.addEventListener('mute', muteHandler);
513
- track.addEventListener('unmute', unmuteHandler);
514
- track.addEventListener('ended', handleTrackEnded);
515
- this.subscriptions.push(() => {
516
- track.removeEventListener('mute', muteHandler);
517
- track.removeEventListener('unmute', unmuteHandler);
518
- track.removeEventListener('ended', handleTrackEnded);
648
+ const rootStream = await rootStreamPromise;
649
+ this.state.setMediaStream(stream, rootStream);
650
+ if (rootStream) {
651
+ const handleTrackEnded = async () => {
652
+ this.setLocalInterrupted(false);
653
+ await this.statusChangeSettled();
654
+ if (this.enabled) {
655
+ this.isTrackStoppedDueToTrackEnd = true;
656
+ setTimeout(() => {
657
+ this.isTrackStoppedDueToTrackEnd = false;
658
+ }, 2000);
659
+ await this.disable();
660
+ }
661
+ };
662
+ const createTrackMuteHandler = (muted: boolean) => () => {
663
+ this.setLocalInterrupted(muted);
664
+
665
+ // WebKit's RTCRtpSender encoder can stay stalled after an iOS /
666
+ // macOS audio session interruption even though the track is
667
+ // unmuted. Re-arm the sender on every unmute for any WebKit
668
+ // runtime (Safari + plain iOS WKWebViews). Skipped when the
669
+ // page is hidden because the encoder won't resume until
670
+ // foreground anyway.
671
+ if (!muted && isWebKit() && document.visibilityState !== 'hidden') {
672
+ this.call.refreshPublishedTrack(this.trackType).catch((err) => {
673
+ this.logger.warn('Failed to refresh track on system unmute', err);
674
+ });
675
+ }
676
+
677
+ // report all tracks on mobile, and only Video on desktop browsers
678
+ if (isMobile() || this.trackType == TrackType.VIDEO) {
679
+ this.call.tracer.trace('navigator.mediaDevices.muteStateUpdated', {
680
+ trackType: TrackType[this.trackType],
681
+ muted,
682
+ });
683
+ this.call
684
+ .notifyTrackMuteState(muted, this.trackType)
685
+ .catch((err) => {
686
+ this.logger.warn('Error while notifying track mute state', err);
687
+ });
688
+ }
689
+ };
690
+ rootStream.getTracks().forEach((track) => {
691
+ const muteHandler = createTrackMuteHandler(true);
692
+ const unmuteHandler = createTrackMuteHandler(false);
693
+ track.addEventListener('mute', muteHandler);
694
+ track.addEventListener('unmute', unmuteHandler);
695
+ track.addEventListener('ended', handleTrackEnded);
696
+ this.currentStreamCleanups.push(() => {
697
+ track.removeEventListener('mute', muteHandler);
698
+ track.removeEventListener('unmute', unmuteHandler);
699
+ track.removeEventListener('ended', handleTrackEnded);
700
+ });
519
701
  });
520
- });
702
+ const initialMuted = rootStream.getTracks().some((t) => t.muted);
703
+ this.setLocalInterrupted(initialMuted);
704
+ } else {
705
+ this.setLocalInterrupted(false);
706
+ }
521
707
  }
522
708
  }
523
709
 
524
- private get mediaDeviceKind(): MediaDeviceKind {
710
+ private setLocalInterrupted = (interrupted: boolean) => {
711
+ const localParticipant = this.call.state.localParticipant;
712
+ if (!localParticipant) return;
713
+ this.call.state.updateParticipant(localParticipant.sessionId, (p) => {
714
+ const current = p.interruptedTracks ?? [];
715
+ const has = current.includes(this.trackType);
716
+ if (interrupted === has) return {};
717
+ const next = interrupted
718
+ ? pushToIfMissing([...current], this.trackType)
719
+ : removeFromIfPresent([...current], this.trackType);
720
+ return { interruptedTracks: next };
721
+ });
722
+ };
723
+
724
+ private get mediaDeviceKind(): 'audioinput' | 'videoinput' {
525
725
  if (this.trackType === TrackType.AUDIO) return 'audioinput';
526
726
  if (this.trackType === TrackType.VIDEO) return 'videoinput';
527
727
  throw new Error('Invalid track type');
@@ -36,7 +36,6 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
36
36
 
37
37
  /**
38
38
  * An Observable that emits the current media stream, or `undefined` if the device is currently disabled.
39
- *
40
39
  */
41
40
  mediaStream$ = this.mediaStreamSubject.asObservable();
42
41
 
@@ -184,7 +183,10 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
184
183
  RxUtils.setCurrentValue(this.mediaStreamSubject, stream);
185
184
  RxUtils.setCurrentValue(this.rootMediaStreamSubject, rootStream);
186
185
  if (rootStream) {
187
- this.setDevice(this.getDeviceIdFromStream(rootStream));
186
+ const derived = this.getDeviceIdFromStream(rootStream);
187
+ if (derived) {
188
+ this.setDevice(derived);
189
+ }
188
190
  }
189
191
  }
190
192
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A MediaStream produced for a virtual device session, along with an optional
3
+ * cleanup callback. Returned by {@link VirtualDevice.getUserMedia}.
4
+ */
5
+ export interface VirtualDeviceSession {
6
+ readonly stream: MediaStream;
7
+ readonly stop?: () => void | Promise<void>;
8
+ }
9
+
10
+ /**
11
+ * A virtual camera or microphone definition supplied by the integrator.
12
+ *
13
+ * Pass this to `camera.registerVirtualDevice()` /
14
+ * `microphone.registerVirtualDevice()` to make it appear in the device list
15
+ * and become selectable.
16
+ */
17
+ export interface VirtualDevice<C = MediaTrackConstraints> {
18
+ /**
19
+ * Human-readable label shown in device dropdowns.
20
+ */
21
+ label: string;
22
+
23
+ /**
24
+ * Called when the virtual device is selected and the SDK needs media.
25
+ * Returns the MediaStream to publish along with an optional `stop`
26
+ * callback that runs when the session is replaced, the tracks end, or
27
+ * the device is unregistered.
28
+ *
29
+ * `constraints` is the resolved set the SDK would otherwise pass to
30
+ * `getUserMedia` for a real device.
31
+ */
32
+ getUserMedia: (
33
+ constraints: C,
34
+ ) => VirtualDeviceSession | Promise<VirtualDeviceSession>;
35
+ }
36
+
37
+ /**
38
+ * @internal Internal entry stored in the device manager's registry.
39
+ */
40
+ export interface VirtualDeviceEntry<
41
+ C = MediaTrackConstraints,
42
+ > extends VirtualDevice<C> {
43
+ readonly deviceId: string;
44
+ readonly kind: 'audioinput' | 'videoinput';
45
+ }
46
+
47
+ /**
48
+ * @internal Tracks the currently active virtual device session inside the
49
+ * device manager so its `stop` callback can be invoked when the session is
50
+ * replaced or torn down.
51
+ */
52
+ export interface ActiveVirtualSession {
53
+ deviceId: string;
54
+ stop?: () => void | Promise<void>;
55
+ }
56
+
57
+ export interface VirtualDeviceHandle {
58
+ /**
59
+ * The device id under which the virtual device was registered. Pass this
60
+ * to `camera.select()` / `microphone.select()` to switch to it.
61
+ */
62
+ readonly deviceId: string;
63
+
64
+ /**
65
+ * Removes the virtual device from the manager. If it is currently selected,
66
+ * the selection is reset so the SDK falls back to the default device.
67
+ */
68
+ unregister: () => Promise<void>;
69
+ }
@@ -210,6 +210,25 @@ describe('CameraManager', () => {
210
210
  });
211
211
  });
212
212
 
213
+ it('should pass resolved camera constraints to virtual devices', async () => {
214
+ const virtualStream = mockVideoStream();
215
+ const getUserMedia = vi.fn(() => ({ stream: virtualStream }));
216
+
217
+ const { deviceId } = manager.registerVirtualDevice({
218
+ label: 'Virtual camera',
219
+ getUserMedia,
220
+ });
221
+
222
+ await manager.select(deviceId);
223
+ await manager.enable();
224
+
225
+ expect(getUserMedia).toHaveBeenCalledWith({
226
+ deviceId: { exact: deviceId },
227
+ width: 1280,
228
+ height: 720,
229
+ });
230
+ });
231
+
213
232
  it(`should set target resolution, but shouldn't change device status`, async () => {
214
233
  manager['targetResolution'] = { width: 640, height: 480 };
215
234