@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.
- package/CHANGELOG.md +22 -0
- package/dist/index.browser.es.js +1404 -682
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1404 -682
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1404 -682
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +43 -3
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/CameraManager.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +23 -0
- package/dist/src/devices/DeviceManagerState.d.ts +0 -1
- 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 +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
- package/dist/src/rtc/Publisher.d.ts +38 -3
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/TransceiverCache.d.ts +5 -1
- package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +111 -33
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/CameraManager.ts +9 -2
- package/src/devices/DeviceManager.ts +239 -39
- package/src/devices/DeviceManagerState.ts +4 -2
- package/src/devices/VirtualDevice.ts +69 -0
- package/src/devices/__tests__/CameraManager.test.ts +19 -0
- package/src/devices/__tests__/DeviceManager.test.ts +404 -1
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/devices/devicePersistence.ts +2 -1
- package/src/devices/index.ts +1 -0
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rtc/BasePeerConnection.ts +15 -3
- package/src/rtc/Publisher.ts +185 -40
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/TransceiverCache.ts +10 -3
- package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
- package/src/rtc/__tests__/Publisher.test.ts +747 -88
- package/src/rtc/__tests__/Subscriber.test.ts +148 -3
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
- package/src/rtc/helpers/degradationPreference.ts +40 -0
- package/src/rtc/types.ts +2 -0
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- package/src/types.ts +19 -0
|
@@ -1,14 +1,26 @@
|
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
this.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
|
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.
|
|
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
|
|