@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
@@ -103,6 +103,7 @@ export declare class Call {
103
103
  private statsReportingIntervalInMs;
104
104
  private statsReporter?;
105
105
  private sfuStatsReporter?;
106
+ private lastStatsOptions?;
106
107
  private dropTimeout;
107
108
  private readonly clientStore;
108
109
  readonly streamClient: StreamClient;
@@ -45,5 +45,6 @@ export declare class CameraManager extends DeviceManager<CameraManagerState> {
45
45
  */
46
46
  apply(settings: VideoSettingsResponse, publish: boolean): Promise<void>;
47
47
  protected getDevices(): Observable<MediaDeviceInfo[]>;
48
+ protected getResolvedConstraints(constraints: MediaTrackConstraints): MediaTrackConstraints;
48
49
  protected getStream(constraints: MediaTrackConstraints): Promise<MediaStream>;
49
50
  }
@@ -6,6 +6,7 @@ import { ScopedLogger } from '../logger';
6
6
  import { TrackType } from '../gen/video/sfu/models/models';
7
7
  import { MediaStreamFilter, MediaStreamFilterRegistrationResult } from './filters';
8
8
  import { DevicePersistenceOptions } from './devicePersistence';
9
+ import { VirtualDevice, VirtualDeviceEntry, VirtualDeviceHandle } from './VirtualDevice';
9
10
  export declare abstract class DeviceManager<S extends DeviceManagerState<C>, C = MediaTrackConstraints> {
10
11
  /**
11
12
  * if true, stops the media stream when call is left
@@ -21,6 +22,9 @@ export declare abstract class DeviceManager<S extends DeviceManagerState<C>, C =
21
22
  protected areSubscriptionsSetUp: boolean;
22
23
  private isTrackStoppedDueToTrackEnd;
23
24
  private filters;
25
+ private virtualDevicesSubject;
26
+ private activeVirtualSession;
27
+ private virtualDeviceConcurrencyTag;
24
28
  private statusChangeConcurrencyTag;
25
29
  private filterRegistrationConcurrencyTag;
26
30
  protected constructor(call: Call, state: S, trackType: TrackType, devicePersistence: Required<DevicePersistenceOptions>);
@@ -33,6 +37,21 @@ export declare abstract class DeviceManager<S extends DeviceManagerState<C>, C =
33
37
  * @returns an Observable that will be updated if a device is connected or disconnected
34
38
  */
35
39
  listDevices(): Observable<MediaDeviceInfo[]>;
40
+ /**
41
+ * Registers a virtual camera or microphone backed by a caller-supplied
42
+ * stream factory. The device appears in `listDevices()` and can be selected
43
+ * via `select()` like any real device.
44
+ *
45
+ * Web only. React Native is not supported.
46
+ *
47
+ * Only supported for camera and microphone managers; calling on any other
48
+ * manager throws.
49
+ */
50
+ registerVirtualDevice(virtualDevice: VirtualDevice<C>): VirtualDeviceHandle;
51
+ protected sanitizeVirtualStream(stream: MediaStream): MediaStream;
52
+ protected findVirtualDevice(deviceId: string | undefined): VirtualDeviceEntry<C> | undefined;
53
+ private stopActiveVirtualSession;
54
+ protected getSelectedStream(constraints: C): Promise<MediaStream>;
36
55
  /**
37
56
  * Returns `true` when this device is in enabled state.
38
57
  */
@@ -93,6 +112,7 @@ export declare abstract class DeviceManager<S extends DeviceManagerState<C>, C =
93
112
  private runCurrentStreamCleanups;
94
113
  protected applySettingsToStream(): Promise<void>;
95
114
  protected abstract getDevices(): Observable<MediaDeviceInfo[]>;
115
+ protected getResolvedConstraints(constraints: C): C;
96
116
  protected abstract getStream(constraints: C): Promise<MediaStream>;
97
117
  protected publishStream(stream: MediaStream, options?: TrackPublishOptions): Promise<void>;
98
118
  protected stopPublishStream(): Promise<void>;
@@ -0,0 +1,59 @@
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
+ * A virtual camera or microphone definition supplied by the integrator.
11
+ *
12
+ * Pass this to `camera.registerVirtualDevice()` /
13
+ * `microphone.registerVirtualDevice()` to make it appear in the device list
14
+ * and become selectable.
15
+ */
16
+ export interface VirtualDevice<C = MediaTrackConstraints> {
17
+ /**
18
+ * Human-readable label shown in device dropdowns.
19
+ */
20
+ label: string;
21
+ /**
22
+ * Called when the virtual device is selected and the SDK needs media.
23
+ * Returns the MediaStream to publish along with an optional `stop`
24
+ * callback that runs when the session is replaced, the tracks end, or
25
+ * the device is unregistered.
26
+ *
27
+ * `constraints` is the resolved set the SDK would otherwise pass to
28
+ * `getUserMedia` for a real device.
29
+ */
30
+ getUserMedia: (constraints: C) => VirtualDeviceSession | Promise<VirtualDeviceSession>;
31
+ }
32
+ /**
33
+ * @internal Internal entry stored in the device manager's registry.
34
+ */
35
+ export interface VirtualDeviceEntry<C = MediaTrackConstraints> extends VirtualDevice<C> {
36
+ readonly deviceId: string;
37
+ readonly kind: 'audioinput' | 'videoinput';
38
+ }
39
+ /**
40
+ * @internal Tracks the currently active virtual device session inside the
41
+ * device manager so its `stop` callback can be invoked when the session is
42
+ * replaced or torn down.
43
+ */
44
+ export interface ActiveVirtualSession {
45
+ deviceId: string;
46
+ stop?: () => void | Promise<void>;
47
+ }
48
+ export interface VirtualDeviceHandle {
49
+ /**
50
+ * The device id under which the virtual device was registered. Pass this
51
+ * to `camera.select()` / `microphone.select()` to switch to it.
52
+ */
53
+ readonly deviceId: string;
54
+ /**
55
+ * Removes the virtual device from the manager. If it is currently selected,
56
+ * the selection is reset so the SDK falls back to the default device.
57
+ */
58
+ unregister: () => Promise<void>;
59
+ }
@@ -21,7 +21,7 @@ export type LocalDevicePreferences = {
21
21
  };
22
22
  export declare const defaultDeviceId = "default";
23
23
  export declare const normalize: (options: DevicePersistenceOptions | undefined) => Required<DevicePersistenceOptions>;
24
- export declare const createSyntheticDevice: (deviceId: string, kind: MediaDeviceKind) => MediaDeviceInfo;
24
+ export declare const createSyntheticDevice: (deviceId: string, kind: MediaDeviceKind, label?: string) => MediaDeviceInfo;
25
25
  export declare const readPreferences: (storageKey: string) => LocalDevicePreferences;
26
26
  export declare const writePreferences: (currentDevice: MediaDeviceInfo | undefined, deviceKey: DevicePreferenceKey, muted: boolean | undefined, storageKey: string) => void;
27
27
  export declare const toPreferenceList: (preference?: LocalDevicePreference | LocalDevicePreference[]) => LocalDevicePreference[];
@@ -10,3 +10,4 @@ export * from './ScreenShareManager';
10
10
  export * from './ScreenShareState';
11
11
  export * from './SpeakerManager';
12
12
  export * from './SpeakerState';
13
+ export * from './VirtualDevice';
@@ -27,7 +27,7 @@ export declare abstract class BasePeerConnection {
27
27
  private iceRestartTimeout?;
28
28
  private preConnectStuckTimeout?;
29
29
  protected isIceRestarting: boolean;
30
- private isDisposed;
30
+ protected isDisposed: boolean;
31
31
  protected trackIdToTrackType: Map<string, TrackType>;
32
32
  readonly tracer?: Tracer;
33
33
  readonly stats: StatsTracer;
@@ -42,7 +42,7 @@ export declare abstract class BasePeerConnection {
42
42
  /**
43
43
  * Disposes the `RTCPeerConnection` instance.
44
44
  */
45
- dispose(): void;
45
+ dispose(): Promise<void>;
46
46
  /**
47
47
  * Detaches the event handlers from the `RTCPeerConnection`.
48
48
  */
@@ -62,6 +62,11 @@ export declare abstract class BasePeerConnection {
62
62
  * Consecutive events are queued and executed one after the other.
63
63
  */
64
64
  protected on: <E extends keyof AllSfuEvents>(event: E, fn: CallEventListener<E>) => void;
65
+ /**
66
+ * Returns the per-event `withoutConcurrency` tag used to serialize the
67
+ * dispatcher handler for `event` on this peer connection.
68
+ */
69
+ protected eventLockKey: (event: keyof AllSfuEvents) => string;
65
70
  /**
66
71
  * Appends the trickled ICE candidates to the `RTCPeerConnection`.
67
72
  */
@@ -17,7 +17,7 @@ export declare class Publisher extends BasePeerConnection {
17
17
  /**
18
18
  * Disposes this Publisher instance.
19
19
  */
20
- dispose(): void;
20
+ dispose(): Promise<void>;
21
21
  /**
22
22
  * Starts publishing the given track of the given media stream.
23
23
  *
@@ -71,11 +71,11 @@ export declare class Publisher extends BasePeerConnection {
71
71
  /**
72
72
  * Stops the cloned track that is being published to the SFU.
73
73
  */
74
- stopTracks: (...trackTypes: TrackType[]) => void;
74
+ stopTracks: (...trackTypes: TrackType[]) => Promise<void>;
75
75
  /**
76
76
  * Stops all the cloned tracks that are being published to the SFU.
77
77
  */
78
- stopAllTracks: () => void;
78
+ stopAllTracks: () => Promise<void>;
79
79
  private changePublishQuality;
80
80
  /**
81
81
  * Restarts the ICE connection and renegotiates with the SFU.
@@ -108,4 +108,22 @@ export declare class Publisher extends BasePeerConnection {
108
108
  private toTrackInfo;
109
109
  private cloneTrack;
110
110
  private stopTrack;
111
+ /**
112
+ * Silences a Firefox sender on the wire during unpublish.
113
+ *
114
+ * Firefox keeps emitting RTP after track.stop(), but the right lever
115
+ * differs by track type:
116
+ * - audio: `replaceTrack(null)` is the only reliable silencer;
117
+ * `setParameters({encodings:[...active:false]})` does NOT stop
118
+ * the Opus encoder.
119
+ * - video: `setParameters({encodings:[...active:false]})` pauses
120
+ * the encoder; `replaceTrack(null)` does NOT reliably stop the
121
+ * video encoder. The prior active=true configuration is captured
122
+ * onto `bundle.videoSender` so `updateTransceiver` can restore
123
+ * it on the next publish.
124
+ *
125
+ * No-op on non-Firefox browsers and during teardown.
126
+ */
127
+ private silenceSenderOnFirefox;
128
+ private disableAllEncodings;
111
129
  }
@@ -1,4 +1,4 @@
1
- import { PublishOption } from '../gen/video/sfu/models/models';
1
+ import { PublishOption, TrackType } from '../gen/video/sfu/models/models';
2
2
  import type { OptimalVideoLayer } from './layers';
3
3
  import type { PublishBundle } from './types';
4
4
  export declare class TransceiverCache {
@@ -18,6 +18,10 @@ export declare class TransceiverCache {
18
18
  * Gets the transceiver for the given publish option.
19
19
  */
20
20
  get: (publishOption: PublishOption) => PublishBundle | undefined;
21
+ /**
22
+ * Gets the transceiver for the given publish option id and track type.
23
+ */
24
+ getBy: (publishOptionId: number, trackType: TrackType) => PublishBundle | undefined;
21
25
  /**
22
26
  * Updates the cached bundle with the given patch.
23
27
  */
@@ -1,2 +1,3 @@
1
1
  import { DegradationPreference } from '../../gen/video/sfu/models/models';
2
2
  export declare const toRTCDegradationPreference: (preference: DegradationPreference) => RTCDegradationPreference | undefined;
3
+ export declare const fromRTCDegradationPreference: (preference: RTCDegradationPreference | undefined) => DegradationPreference;
@@ -4,6 +4,7 @@ import { CallState } from '../store';
4
4
  import { Dispatcher } from './Dispatcher';
5
5
  import type { OptimalVideoLayer } from './layers';
6
6
  import type { ClientPublishOptions } from '../types';
7
+ import type { VideoSender } from '../gen/video/sfu/event/events';
7
8
  /**
8
9
  * Canonical reasons the SDK uses to trigger a reconnection. Free-form strings
9
10
  * are still accepted at the callback boundary (e.g. when forwarding an SFU
@@ -55,6 +56,7 @@ export type PublishBundle = {
55
56
  publishOption: PublishOption;
56
57
  transceiver: RTCRtpTransceiver;
57
58
  options: TrackPublishOptions;
59
+ videoSender?: VideoSender;
58
60
  };
59
61
  export type TrackLayersCache = {
60
62
  publishOption: PublishOption;
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.51.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
@@ -46,7 +46,7 @@
46
46
  "@openapitools/openapi-generator-cli": "^2.25.0",
47
47
  "@rollup/plugin-replace": "^6.0.2",
48
48
  "@rollup/plugin-typescript": "^12.1.4",
49
- "@stream-io/audio-filters-web": "^0.8.0",
49
+ "@stream-io/audio-filters-web": "^0.8.1",
50
50
  "@stream-io/node-sdk": "^0.7.28",
51
51
  "@total-typescript/shoehorn": "^0.1.2",
52
52
  "@types/sdp-transform": "^2.15.0",
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;
@@ -1456,7 +1460,7 @@ export class Call {
1456
1460
  } = opts;
1457
1461
  const { enable_rtc_stats: enableTracing } = statsOptions;
1458
1462
  if (closePreviousInstances && this.subscriber) {
1459
- this.subscriber.dispose();
1463
+ await this.subscriber.dispose();
1460
1464
  }
1461
1465
  const basePeerConnectionOptions: BasePeerConnectionOpts = {
1462
1466
  sfuClient,
@@ -1487,7 +1491,7 @@ export class Call {
1487
1491
  const isAnonymous = this.streamClient.user?.type === 'anonymous';
1488
1492
  if (!isAnonymous) {
1489
1493
  if (closePreviousInstances && this.publisher) {
1490
- this.publisher.dispose();
1494
+ await this.publisher.dispose();
1491
1495
  }
1492
1496
  this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
1493
1497
  }
@@ -1613,12 +1617,19 @@ export class Call {
1613
1617
  reason: ReconnectReason,
1614
1618
  ): Promise<void> => {
1615
1619
  if (
1620
+ this.state.callingState === CallingState.JOINING ||
1616
1621
  this.state.callingState === CallingState.RECONNECTING ||
1617
1622
  this.state.callingState === CallingState.MIGRATING ||
1618
1623
  this.state.callingState === CallingState.RECONNECTING_FAILED
1619
1624
  )
1620
1625
  return;
1621
1626
 
1627
+ // Drop redundant reconnect calls. If a reconnect is already queued or
1628
+ // running for this Call, that entry will resolve whatever broke;
1629
+ // queueing more entries just replays the full REJOIN cycle (one extra
1630
+ // `POST /join` per entry) once the call is already healthy again.
1631
+ if (hasPending(this.reconnectConcurrencyTag)) return;
1632
+
1622
1633
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
1623
1634
  const reconnectStartTime = Date.now();
1624
1635
  this.reconnectStrategy = strategy;
@@ -1881,8 +1892,8 @@ export class Call {
1881
1892
  // the `migrationTask`
1882
1893
  this.state.setCallingState(CallingState.JOINED);
1883
1894
  } finally {
1884
- currentSubscriber?.dispose();
1885
- currentPublisher?.dispose();
1895
+ await currentSubscriber?.dispose();
1896
+ await currentPublisher?.dispose();
1886
1897
 
1887
1898
  // and close the previous SFU client, without specifying close code
1888
1899
  currentSfuClient.close(StreamSfuClient.NORMAL_CLOSURE, 'Migrating away');
@@ -2109,7 +2120,7 @@ export class Call {
2109
2120
  */
2110
2121
  stopPublish = async (...trackTypes: TrackType[]) => {
2111
2122
  if (!this.sfuClient || !this.publisher) return;
2112
- this.publisher.stopTracks(...trackTypes);
2123
+ await this.publisher.stopTracks(...trackTypes);
2113
2124
  await this.updateLocalStreamState(undefined, ...trackTypes);
2114
2125
  };
2115
2126
 
@@ -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