@stream-io/video-client 1.50.0 → 1.52.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 (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/index.browser.es.js +597 -70
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +597 -69
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +597 -70
  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/gen/video/sfu/event/events.d.ts +4 -0
  15. package/dist/src/gen/video/sfu/models/models.d.ts +204 -2
  16. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +9 -1
  17. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +67 -1
  18. package/dist/src/helpers/participantUtils.d.ts +10 -0
  19. package/dist/src/rtc/BasePeerConnection.d.ts +8 -3
  20. package/dist/src/rtc/Publisher.d.ts +21 -3
  21. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  22. package/dist/src/rtc/helpers/degradationPreference.d.ts +1 -0
  23. package/dist/src/rtc/types.d.ts +3 -0
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +2 -1
  25. package/dist/src/stats/utils.d.ts +1 -0
  26. package/package.json +14 -14
  27. package/src/Call.ts +27 -12
  28. package/src/devices/CameraManager.ts +9 -2
  29. package/src/devices/DeviceManager.ts +148 -8
  30. package/src/devices/DeviceManagerState.ts +4 -1
  31. package/src/devices/VirtualDevice.ts +69 -0
  32. package/src/devices/__tests__/CameraManager.test.ts +22 -1
  33. package/src/devices/__tests__/DeviceManager.test.ts +124 -2
  34. package/src/devices/__tests__/MicrophoneManager.test.ts +3 -1
  35. package/src/devices/__tests__/MicrophoneManagerRN.test.ts +3 -1
  36. package/src/devices/__tests__/ScreenShareManager.test.ts +3 -1
  37. package/src/devices/__tests__/web-audio.mocks.ts +3 -1
  38. package/src/devices/devicePersistence.ts +2 -1
  39. package/src/devices/index.ts +1 -0
  40. package/src/gen/video/sfu/event/events.ts +10 -0
  41. package/src/gen/video/sfu/models/models.ts +338 -0
  42. package/src/gen/video/sfu/signal_rpc/signal.client.ts +28 -2
  43. package/src/gen/video/sfu/signal_rpc/signal.ts +121 -15
  44. package/src/helpers/__tests__/DynascaleManager.test.ts +8 -7
  45. package/src/helpers/__tests__/browsers.test.ts +4 -4
  46. package/src/helpers/__tests__/participantUtils.test.ts +47 -0
  47. package/src/helpers/client-details.ts +4 -1
  48. package/src/helpers/participantUtils.ts +15 -0
  49. package/src/rtc/BasePeerConnection.ts +22 -4
  50. package/src/rtc/Publisher.ts +140 -41
  51. package/src/rtc/Subscriber.ts +1 -0
  52. package/src/rtc/TransceiverCache.ts +10 -3
  53. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  54. package/src/rtc/__tests__/Publisher.test.ts +659 -112
  55. package/src/rtc/__tests__/Subscriber.test.ts +7 -3
  56. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +16 -15
  57. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +33 -1
  58. package/src/rtc/helpers/degradationPreference.ts +18 -0
  59. package/src/rtc/types.ts +3 -0
  60. package/src/stats/rtc/StatsTracer.ts +25 -4
  61. package/src/stats/rtc/__tests__/StatsTracer.test.ts +155 -0
@@ -12,6 +12,7 @@ export declare class StatsTracer {
12
12
  private readonly pc;
13
13
  private readonly peerType;
14
14
  private readonly trackIdToTrackType;
15
+ private readonly driftThresholdMs;
15
16
  private costOverrides?;
16
17
  private previousStats;
17
18
  private frameTimeHistory;
@@ -19,7 +20,7 @@ export declare class StatsTracer {
19
20
  /**
20
21
  * Creates a new StatsTracer instance.
21
22
  */
22
- constructor(pc: RTCPeerConnection, peerType: PeerType, trackIdToTrackType: Map<string, TrackType>);
23
+ constructor(pc: RTCPeerConnection, peerType: PeerType, trackIdToTrackType: Map<string, TrackType>, statsTimestampDriftThresholdMs?: number);
23
24
  /**
24
25
  * Get the stats from the RTCPeerConnection.
25
26
  * When called, it will return the stats for the current connection.
@@ -23,6 +23,7 @@ export declare const getSdkSignature: (clientDetails: ClientDetails) => {
23
23
  os?: import("../gen/video/sfu/models/models").OS;
24
24
  browser?: import("../gen/video/sfu/models/models").Browser;
25
25
  device?: import("../gen/video/sfu/models/models").Device;
26
+ webrtcVersion: string;
26
27
  sdkName: string;
27
28
  sdkVersion: string;
28
29
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.50.0",
3
+ "version": "1.52.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -36,29 +36,29 @@
36
36
  "@protobuf-ts/twirp-transport": "^2.11.1",
37
37
  "@stream-io/logger": "^2.0.0",
38
38
  "@stream-io/worker-timer": "^1.2.5",
39
- "axios": "^1.12.2",
39
+ "axios": "^1.16.1",
40
40
  "rxjs": "~7.8.2",
41
41
  "sdp-transform": "^2.15.0",
42
42
  "ua-parser-js": "^1.0.41",
43
43
  "webrtc-adapter": "^8.2.4"
44
44
  },
45
45
  "devDependencies": {
46
- "@openapitools/openapi-generator-cli": "^2.25.0",
47
- "@rollup/plugin-replace": "^6.0.2",
48
- "@rollup/plugin-typescript": "^12.1.4",
49
- "@stream-io/audio-filters-web": "^0.8.0",
50
- "@stream-io/node-sdk": "^0.7.28",
46
+ "@openapitools/openapi-generator-cli": "^2.34.0",
47
+ "@rollup/plugin-replace": "^6.0.3",
48
+ "@rollup/plugin-typescript": "^12.3.0",
49
+ "@stream-io/audio-filters-web": "^0.8.2",
50
+ "@stream-io/node-sdk": "^0.7.59",
51
51
  "@total-typescript/shoehorn": "^0.1.2",
52
52
  "@types/sdp-transform": "^2.15.0",
53
53
  "@types/ua-parser-js": "^0.7.39",
54
- "@vitest/coverage-v8": "^3.2.4",
54
+ "@vitest/coverage-v8": "^4.1.7",
55
55
  "dotenv": "^16.6.1",
56
- "happy-dom": "^20.0.2",
57
- "prettier": "^3.6.2",
58
- "rimraf": "^6.0.1",
59
- "rollup": "^4.52.4",
56
+ "happy-dom": "^20.9.0",
57
+ "prettier": "^3.8.3",
58
+ "rimraf": "^6.1.3",
59
+ "rollup": "^4.60.4",
60
60
  "typescript": "^5.9.3",
61
- "vitest": "^3.2.4",
62
- "vitest-mock-extended": "^3.1.0"
61
+ "vitest": "^4.1.7",
62
+ "vitest-mock-extended": "^4.0.0"
63
63
  }
64
64
  }
package/src/Call.ts CHANGED
@@ -287,6 +287,7 @@ export class Call {
287
287
  private statsReportingIntervalInMs: number = 2000;
288
288
  private statsReporter?: StatsReporter;
289
289
  private sfuStatsReporter?: SfuStatsReporter;
290
+ private lastStatsOptions?: StatsOptions;
290
291
  private dropTimeout: ReturnType<typeof setTimeout> | undefined;
291
292
 
292
293
  private readonly clientStore: StreamVideoWriteableStateStore;
@@ -736,11 +737,12 @@ export class Call {
736
737
  this.sfuStatsReporter?.flush();
737
738
  this.sfuStatsReporter?.stop();
738
739
  this.sfuStatsReporter = undefined;
740
+ this.lastStatsOptions = undefined;
739
741
 
740
- this.subscriber?.dispose();
742
+ await this.subscriber?.dispose();
741
743
  this.subscriber = undefined;
742
744
 
743
- this.publisher?.dispose();
745
+ await this.publisher?.dispose();
744
746
  this.publisher = undefined;
745
747
 
746
748
  await this.sfuClient?.leaveAndClose(leaveReason);
@@ -1125,17 +1127,19 @@ export class Call {
1125
1127
  const performingFastReconnect =
1126
1128
  this.reconnectStrategy === WebsocketReconnectStrategy.FAST;
1127
1129
 
1128
- let statsOptions = this.sfuStatsReporter?.options;
1130
+ let statsOptions = this.lastStatsOptions;
1129
1131
  if (
1130
1132
  !this.credentials ||
1131
1133
  !statsOptions ||
1132
1134
  performingRejoin ||
1133
- performingMigration
1135
+ performingMigration ||
1136
+ data?.migrating_from
1134
1137
  ) {
1135
1138
  try {
1136
1139
  const joinResponse = await this.doJoinRequest(data);
1137
1140
  this.credentials = joinResponse.credentials;
1138
1141
  statsOptions = joinResponse.stats_options;
1142
+ this.lastStatsOptions = statsOptions;
1139
1143
  } catch (error) {
1140
1144
  // prevent triggering reconnect flow if the state is OFFLINE
1141
1145
  const avoidRestoreState =
@@ -1262,7 +1266,7 @@ export class Call {
1262
1266
  });
1263
1267
  } else {
1264
1268
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
1265
- this.initPublisherAndSubscriber({
1269
+ await this.initPublisherAndSubscriber({
1266
1270
  sfuClient,
1267
1271
  connectionConfig,
1268
1272
  clientDetails,
@@ -1436,7 +1440,7 @@ export class Call {
1436
1440
  * Initializes the Publisher and Subscriber Peer Connections.
1437
1441
  * @internal
1438
1442
  */
1439
- private initPublisherAndSubscriber = (opts: {
1443
+ private initPublisherAndSubscriber = async (opts: {
1440
1444
  sfuClient: StreamSfuClient;
1441
1445
  connectionConfig: RTCConfiguration;
1442
1446
  statsOptions: StatsOptions;
@@ -1454,9 +1458,12 @@ export class Call {
1454
1458
  closePreviousInstances,
1455
1459
  unifiedSessionId,
1456
1460
  } = opts;
1457
- const { enable_rtc_stats: enableTracing } = statsOptions;
1461
+ const {
1462
+ enable_rtc_stats: enableTracing,
1463
+ reporting_interval_ms: reportingIntervalMs,
1464
+ } = statsOptions;
1458
1465
  if (closePreviousInstances && this.subscriber) {
1459
- this.subscriber.dispose();
1466
+ await this.subscriber.dispose();
1460
1467
  }
1461
1468
  const basePeerConnectionOptions: BasePeerConnectionOpts = {
1462
1469
  sfuClient,
@@ -1465,6 +1472,7 @@ export class Call {
1465
1472
  connectionConfig,
1466
1473
  tag: sfuClient.tag,
1467
1474
  enableTracing,
1475
+ statsTimestampDriftThresholdMs: reportingIntervalMs / 2,
1468
1476
  clientPublishOptions: this.clientPublishOptions,
1469
1477
  onReconnectionNeeded: (kind, reason, peerType) => {
1470
1478
  this.reconnect(kind, reason).catch((err) => {
@@ -1487,7 +1495,7 @@ export class Call {
1487
1495
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
1488
1496
  if (!isAnonymous) {
1489
1497
  if (closePreviousInstances && this.publisher) {
1490
- this.publisher.dispose();
1498
+ await this.publisher.dispose();
1491
1499
  }
1492
1500
  this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
1493
1501
  }
@@ -1613,12 +1621,19 @@ export class Call {
1613
1621
  reason: ReconnectReason,
1614
1622
  ): Promise<void> => {
1615
1623
  if (
1624
+ this.state.callingState === CallingState.JOINING ||
1616
1625
  this.state.callingState === CallingState.RECONNECTING ||
1617
1626
  this.state.callingState === CallingState.MIGRATING ||
1618
1627
  this.state.callingState === CallingState.RECONNECTING_FAILED
1619
1628
  )
1620
1629
  return;
1621
1630
 
1631
+ // Drop redundant reconnect calls. If a reconnect is already queued or
1632
+ // running for this Call, that entry will resolve whatever broke;
1633
+ // queueing more entries just replays the full REJOIN cycle (one extra
1634
+ // `POST /join` per entry) once the call is already healthy again.
1635
+ if (hasPending(this.reconnectConcurrencyTag)) return;
1636
+
1622
1637
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
1623
1638
  const reconnectStartTime = Date.now();
1624
1639
  this.reconnectStrategy = strategy;
@@ -1881,8 +1896,8 @@ export class Call {
1881
1896
  // the `migrationTask`
1882
1897
  this.state.setCallingState(CallingState.JOINED);
1883
1898
  } finally {
1884
- currentSubscriber?.dispose();
1885
- currentPublisher?.dispose();
1899
+ await currentSubscriber?.dispose();
1900
+ await currentPublisher?.dispose();
1886
1901
 
1887
1902
  // and close the previous SFU client, without specifying close code
1888
1903
  currentSfuClient.close(StreamSfuClient.NORMAL_CLOSURE, 'Migrating away');
@@ -2109,7 +2124,7 @@ export class Call {
2109
2124
  */
2110
2125
  stopPublish = async (...trackTypes: TrackType[]) => {
2111
2126
  if (!this.sfuClient || !this.publisher) return;
2112
- this.publisher.stopTracks(...trackTypes);
2127
+ await this.publisher.stopTracks(...trackTypes);
2113
2128
  await this.updateLocalStreamState(undefined, ...trackTypes);
2114
2129
  };
2115
2130
 
@@ -176,9 +176,9 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
176
176
  return getVideoDevices(this.call.tracer);
177
177
  }
178
178
 
179
- protected override getStream(
179
+ protected override getResolvedConstraints(
180
180
  constraints: MediaTrackConstraints,
181
- ): Promise<MediaStream> {
181
+ ): MediaTrackConstraints {
182
182
  constraints.width = this.targetResolution.width;
183
183
  constraints.height = this.targetResolution.height;
184
184
  // We can't set both device id and facing mode
@@ -192,6 +192,13 @@ export class CameraManager extends DeviceManager<CameraManagerState> {
192
192
  constraints.facingMode =
193
193
  this.state.direction === 'front' ? 'user' : 'environment';
194
194
  }
195
+
196
+ return constraints;
197
+ }
198
+
199
+ protected override getStream(
200
+ constraints: MediaTrackConstraints,
201
+ ): Promise<MediaStream> {
195
202
  return getVideoStream(constraints, this.call.tracer);
196
203
  }
197
204
  }
@@ -1,9 +1,20 @@
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,
@@ -35,6 +46,13 @@ import {
35
46
  toPreferenceList,
36
47
  writePreferences,
37
48
  } from './devicePersistence';
49
+ import {
50
+ ActiveVirtualSession,
51
+ VirtualDevice,
52
+ VirtualDeviceEntry,
53
+ VirtualDeviceHandle,
54
+ } from './VirtualDevice';
55
+ import { generateUUIDv4 } from '../coordinator/connection/utils';
38
56
 
39
57
  export abstract class DeviceManager<
40
58
  S extends DeviceManagerState<C>,
@@ -56,6 +74,11 @@ export abstract class DeviceManager<
56
74
  protected areSubscriptionsSetUp = false;
57
75
  private isTrackStoppedDueToTrackEnd = false;
58
76
  private filters: MediaStreamFilterEntry[] = [];
77
+ private virtualDevicesSubject = new BehaviorSubject<VirtualDeviceEntry<C>[]>(
78
+ [],
79
+ );
80
+ private activeVirtualSession: ActiveVirtualSession | undefined;
81
+ private virtualDeviceConcurrencyTag = Symbol('virtualDeviceConcurrencyTag');
59
82
  private statusChangeConcurrencyTag = Symbol('statusChangeConcurrencyTag');
60
83
  private filterRegistrationConcurrencyTag = Symbol(
61
84
  'filterRegistrationConcurrencyTag',
@@ -119,8 +142,119 @@ export abstract class DeviceManager<
119
142
  *
120
143
  * @returns an Observable that will be updated if a device is connected or disconnected
121
144
  */
122
- listDevices() {
123
- 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
+ });
124
258
  }
125
259
 
126
260
  /**
@@ -299,6 +433,7 @@ export abstract class DeviceManager<
299
433
  this.subscriptions.forEach((s) => s());
300
434
  this.subscriptions = [];
301
435
  this.areSubscriptionsSetUp = false;
436
+ this.virtualDevicesSubject.next([]);
302
437
  };
303
438
 
304
439
  private runCurrentStreamCleanups = () => {
@@ -330,6 +465,10 @@ export abstract class DeviceManager<
330
465
 
331
466
  protected abstract getDevices(): Observable<MediaDeviceInfo[]>;
332
467
 
468
+ protected getResolvedConstraints(constraints: C): C {
469
+ return constraints;
470
+ }
471
+
333
472
  protected abstract getStream(constraints: C): Promise<MediaStream>;
334
473
 
335
474
  protected publishStream(
@@ -357,6 +496,7 @@ export abstract class DeviceManager<
357
496
  this.muteLocalStream(stopTracks);
358
497
  const allEnded = this.getTracks().every((t) => t.readyState === 'ended');
359
498
  if (allEnded) {
499
+ await this.stopActiveVirtualSession();
360
500
  // @ts-expect-error release() is present in react-native-webrtc
361
501
  if (typeof mediaStream.release === 'function') {
362
502
  // @ts-expect-error called to dispose the stream in RN
@@ -415,12 +555,12 @@ export abstract class DeviceManager<
415
555
  this.runCurrentStreamCleanups();
416
556
 
417
557
  const defaultConstraints = this.state.defaultConstraints;
418
- const constraints: MediaTrackConstraints = {
558
+ const constraints = this.getResolvedConstraints({
419
559
  ...defaultConstraints,
420
560
  deviceId: this.state.selectedDevice
421
561
  ? { exact: this.state.selectedDevice }
422
562
  : undefined,
423
- };
563
+ } as C);
424
564
 
425
565
  /**
426
566
  * Chains two media streams together.
@@ -481,7 +621,7 @@ export abstract class DeviceManager<
481
621
 
482
622
  // the rootStream represents the stream coming from the actual device
483
623
  // e.g. camera or microphone stream
484
- rootStreamPromise = this.getStream(constraints as C);
624
+ rootStreamPromise = this.getSelectedStream(constraints as C);
485
625
  // we publish the last MediaStream of the chain
486
626
  stream = await this.filters.reduce(
487
627
  (parent, entry) =>
@@ -581,7 +721,7 @@ export abstract class DeviceManager<
581
721
  });
582
722
  };
583
723
 
584
- private get mediaDeviceKind(): MediaDeviceKind {
724
+ private get mediaDeviceKind(): 'audioinput' | 'videoinput' {
585
725
  if (this.trackType === TrackType.AUDIO) return 'audioinput';
586
726
  if (this.trackType === TrackType.VIDEO) return 'videoinput';
587
727
  throw new Error('Invalid track type');
@@ -183,7 +183,10 @@ export abstract class DeviceManagerState<C = MediaTrackConstraints> {
183
183
  RxUtils.setCurrentValue(this.mediaStreamSubject, stream);
184
184
  RxUtils.setCurrentValue(this.rootMediaStreamSubject, rootStream);
185
185
  if (rootStream) {
186
- this.setDevice(this.getDeviceIdFromStream(rootStream));
186
+ const derived = this.getDeviceIdFromStream(rootStream);
187
+ if (derived) {
188
+ this.setDevice(derived);
189
+ }
187
190
  }
188
191
  }
189
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
+ }
@@ -50,7 +50,9 @@ vi.mock('../devices.ts', () => {
50
50
  vi.mock('../../Call.ts', () => {
51
51
  console.log('MOCKING Call');
52
52
  return {
53
- Call: vi.fn(() => mockCall()),
53
+ Call: vi.fn(function () {
54
+ return mockCall();
55
+ }),
54
56
  };
55
57
  });
56
58
 
@@ -210,6 +212,25 @@ describe('CameraManager', () => {
210
212
  });
211
213
  });
212
214
 
215
+ it('should pass resolved camera constraints to virtual devices', async () => {
216
+ const virtualStream = mockVideoStream();
217
+ const getUserMedia = vi.fn(() => ({ stream: virtualStream }));
218
+
219
+ const { deviceId } = manager.registerVirtualDevice({
220
+ label: 'Virtual camera',
221
+ getUserMedia,
222
+ });
223
+
224
+ await manager.select(deviceId);
225
+ await manager.enable();
226
+
227
+ expect(getUserMedia).toHaveBeenCalledWith({
228
+ deviceId: { exact: deviceId },
229
+ width: 1280,
230
+ height: 720,
231
+ });
232
+ });
233
+
213
234
  it(`should set target resolution, but shouldn't change device status`, async () => {
214
235
  manager['targetResolution'] = { width: 640, height: 480 };
215
236