@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.
- package/CHANGELOG.md +24 -0
- package/dist/index.browser.es.js +597 -70
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +597 -69
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +597 -70
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +1 -0
- package/dist/src/devices/CameraManager.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +20 -0
- package/dist/src/devices/VirtualDevice.d.ts +59 -0
- package/dist/src/devices/devicePersistence.d.ts +1 -1
- package/dist/src/devices/index.d.ts +1 -0
- package/dist/src/gen/video/sfu/event/events.d.ts +4 -0
- package/dist/src/gen/video/sfu/models/models.d.ts +204 -2
- package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +9 -1
- package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +67 -1
- package/dist/src/helpers/participantUtils.d.ts +10 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +8 -3
- package/dist/src/rtc/Publisher.d.ts +21 -3
- package/dist/src/rtc/TransceiverCache.d.ts +5 -1
- package/dist/src/rtc/helpers/degradationPreference.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +3 -0
- package/dist/src/stats/rtc/StatsTracer.d.ts +2 -1
- package/dist/src/stats/utils.d.ts +1 -0
- package/package.json +14 -14
- package/src/Call.ts +27 -12
- package/src/devices/CameraManager.ts +9 -2
- package/src/devices/DeviceManager.ts +148 -8
- package/src/devices/DeviceManagerState.ts +4 -1
- package/src/devices/VirtualDevice.ts +69 -0
- package/src/devices/__tests__/CameraManager.test.ts +22 -1
- package/src/devices/__tests__/DeviceManager.test.ts +124 -2
- package/src/devices/__tests__/MicrophoneManager.test.ts +3 -1
- package/src/devices/__tests__/MicrophoneManagerRN.test.ts +3 -1
- package/src/devices/__tests__/ScreenShareManager.test.ts +3 -1
- package/src/devices/__tests__/web-audio.mocks.ts +3 -1
- package/src/devices/devicePersistence.ts +2 -1
- package/src/devices/index.ts +1 -0
- package/src/gen/video/sfu/event/events.ts +10 -0
- package/src/gen/video/sfu/models/models.ts +338 -0
- package/src/gen/video/sfu/signal_rpc/signal.client.ts +28 -2
- package/src/gen/video/sfu/signal_rpc/signal.ts +121 -15
- package/src/helpers/__tests__/DynascaleManager.test.ts +8 -7
- package/src/helpers/__tests__/browsers.test.ts +4 -4
- package/src/helpers/__tests__/participantUtils.test.ts +47 -0
- package/src/helpers/client-details.ts +4 -1
- package/src/helpers/participantUtils.ts +15 -0
- package/src/rtc/BasePeerConnection.ts +22 -4
- package/src/rtc/Publisher.ts +140 -41
- package/src/rtc/Subscriber.ts +1 -0
- package/src/rtc/TransceiverCache.ts +10 -3
- package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
- package/src/rtc/__tests__/Publisher.test.ts +659 -112
- package/src/rtc/__tests__/Subscriber.test.ts +7 -3
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +16 -15
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +33 -1
- package/src/rtc/helpers/degradationPreference.ts +18 -0
- package/src/rtc/types.ts +3 -0
- package/src/stats/rtc/StatsTracer.ts +25 -4
- 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.
|
|
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.
|
|
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.
|
|
47
|
-
"@rollup/plugin-replace": "^6.0.
|
|
48
|
-
"@rollup/plugin-typescript": "^12.
|
|
49
|
-
"@stream-io/audio-filters-web": "^0.8.
|
|
50
|
-
"@stream-io/node-sdk": "^0.7.
|
|
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": "^
|
|
54
|
+
"@vitest/coverage-v8": "^4.1.7",
|
|
55
55
|
"dotenv": "^16.6.1",
|
|
56
|
-
"happy-dom": "^20.0
|
|
57
|
-
"prettier": "^3.
|
|
58
|
-
"rimraf": "^6.
|
|
59
|
-
"rollup": "^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": "^
|
|
62
|
-
"vitest-mock-extended": "^
|
|
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.
|
|
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 {
|
|
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
|
|
179
|
+
protected override getResolvedConstraints(
|
|
180
180
|
constraints: MediaTrackConstraints,
|
|
181
|
-
):
|
|
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 {
|
|
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 {
|
|
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
|
|
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.
|
|
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():
|
|
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.
|
|
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(()
|
|
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
|
|