@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.
- package/CHANGELOG.md +11 -0
- package/dist/index.browser.es.js +288 -58
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +288 -58
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +288 -58
- 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/rtc/BasePeerConnection.d.ts +7 -2
- 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 +2 -0
- package/package.json +2 -2
- package/src/Call.ts +22 -11
- 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 +19 -0
- package/src/devices/__tests__/DeviceManager.test.ts +121 -1
- package/src/devices/devicePersistence.ts +2 -1
- package/src/devices/index.ts +1 -0
- package/src/rtc/BasePeerConnection.ts +15 -3
- package/src/rtc/Publisher.ts +140 -41
- 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 +2 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +33 -1
- package/src/rtc/helpers/degradationPreference.ts +18 -0
- package/src/rtc/types.ts +2 -0
package/dist/src/Call.d.ts
CHANGED
|
@@ -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[];
|
|
@@ -27,7 +27,7 @@ export declare abstract class BasePeerConnection {
|
|
|
27
27
|
private iceRestartTimeout?;
|
|
28
28
|
private preConnectStuckTimeout?;
|
|
29
29
|
protected isIceRestarting: boolean;
|
|
30
|
-
|
|
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;
|
package/dist/src/rtc/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|