@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
|
@@ -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
|
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from './mocks';
|
|
18
18
|
import { DeviceManager } from '../DeviceManager';
|
|
19
19
|
import { DeviceManagerState } from '../DeviceManagerState';
|
|
20
|
-
import { of } from 'rxjs';
|
|
20
|
+
import { firstValueFrom, of } from 'rxjs';
|
|
21
21
|
import { TrackType } from '../../gen/video/sfu/models/models';
|
|
22
22
|
import { PermissionsContext } from '../../permissions';
|
|
23
23
|
import { readPreferences } from '../devicePersistence';
|
|
@@ -221,6 +221,126 @@ describe('Device Manager', () => {
|
|
|
221
221
|
expect(spy.mock.calls.length).toBe(1);
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
it('should use a virtual device stream factory instead of requesting a real device stream', async () => {
|
|
225
|
+
const virtualStream = mockVideoStream();
|
|
226
|
+
const getUserMedia = vi.fn(() => ({ stream: virtualStream }));
|
|
227
|
+
|
|
228
|
+
const { deviceId } = manager.registerVirtualDevice({
|
|
229
|
+
label: 'Virtual camera',
|
|
230
|
+
getUserMedia,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await manager.select(deviceId);
|
|
234
|
+
await manager.enable();
|
|
235
|
+
|
|
236
|
+
expect(getUserMedia).toHaveBeenCalledOnce();
|
|
237
|
+
expect(getUserMedia).toHaveBeenCalledWith({
|
|
238
|
+
deviceId: { exact: deviceId },
|
|
239
|
+
});
|
|
240
|
+
expect(manager.getStream).not.toHaveBeenCalled();
|
|
241
|
+
expect(manager.state.mediaStream).toBe(virtualStream);
|
|
242
|
+
expect(manager.state.selectedDevice).toBe(deviceId);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should call virtual device stop when switching away from it', async () => {
|
|
246
|
+
const stop = vi.fn();
|
|
247
|
+
const virtualStream = mockVideoStream();
|
|
248
|
+
|
|
249
|
+
const { deviceId } = manager.registerVirtualDevice({
|
|
250
|
+
label: 'Virtual camera',
|
|
251
|
+
getUserMedia: vi.fn(() => ({ stream: virtualStream, stop })),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await manager.select(deviceId);
|
|
255
|
+
await manager.enable();
|
|
256
|
+
await manager.select(mockVideoDevices[1].deviceId);
|
|
257
|
+
|
|
258
|
+
expect(stop).toHaveBeenCalledTimes(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should support an async getUserMedia returning a Promise', async () => {
|
|
262
|
+
const virtualStream = mockVideoStream();
|
|
263
|
+
const getUserMedia = vi.fn(() =>
|
|
264
|
+
Promise.resolve({ stream: virtualStream }),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const { deviceId } = manager.registerVirtualDevice({
|
|
268
|
+
label: 'Async virtual camera',
|
|
269
|
+
getUserMedia,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await manager.select(deviceId);
|
|
273
|
+
await manager.enable();
|
|
274
|
+
|
|
275
|
+
expect(getUserMedia).toHaveBeenCalledOnce();
|
|
276
|
+
expect(manager.state.mediaStream).toBe(virtualStream);
|
|
277
|
+
expect(manager.state.selectedDevice).toBe(deviceId);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should roll back selection when getUserMedia rejects', async () => {
|
|
281
|
+
const failure = new Error('factory boom');
|
|
282
|
+
const getUserMedia = vi.fn(() => Promise.reject(failure));
|
|
283
|
+
|
|
284
|
+
await manager.enable();
|
|
285
|
+
const previousDevice = manager.state.selectedDevice;
|
|
286
|
+
|
|
287
|
+
const { deviceId } = manager.registerVirtualDevice({
|
|
288
|
+
label: 'Failing camera',
|
|
289
|
+
getUserMedia,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await expect(manager.select(deviceId)).rejects.toThrow(failure);
|
|
293
|
+
|
|
294
|
+
expect(manager.state.selectedDevice).toBe(previousDevice);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should stop the active session and clear selection on unregister', async () => {
|
|
298
|
+
const stop = vi.fn();
|
|
299
|
+
const virtualStream = mockVideoStream();
|
|
300
|
+
|
|
301
|
+
const { deviceId, unregister } = manager.registerVirtualDevice({
|
|
302
|
+
label: 'Virtual camera',
|
|
303
|
+
getUserMedia: vi.fn(() => ({ stream: virtualStream, stop })),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await manager.select(deviceId);
|
|
307
|
+
await manager.enable();
|
|
308
|
+
|
|
309
|
+
await unregister();
|
|
310
|
+
|
|
311
|
+
expect(stop).toHaveBeenCalledTimes(1);
|
|
312
|
+
expect(manager.state.selectedDevice).not.toBe(deviceId);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should remove the entry on unregister without stopping when not selected', async () => {
|
|
316
|
+
const stop = vi.fn();
|
|
317
|
+
const getUserMedia = vi.fn(() => ({ stream: mockVideoStream(), stop }));
|
|
318
|
+
|
|
319
|
+
const { unregister } = manager.registerVirtualDevice({
|
|
320
|
+
label: 'Unused virtual camera',
|
|
321
|
+
getUserMedia,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await unregister();
|
|
325
|
+
|
|
326
|
+
expect(stop).not.toHaveBeenCalled();
|
|
327
|
+
expect(getUserMedia).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should expose virtual devices via listDevices() with the provided label', async () => {
|
|
331
|
+
manager.registerVirtualDevice({
|
|
332
|
+
label: 'My virtual camera',
|
|
333
|
+
getUserMedia: vi.fn(() => ({ stream: mockVideoStream() })),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const devices = await firstValueFrom(manager.listDevices());
|
|
337
|
+
|
|
338
|
+
expect(devices.length).toBe(mockVideoDevices.length + 1);
|
|
339
|
+
const virtual = devices.find((d) => d.label === 'My virtual camera');
|
|
340
|
+
expect(virtual).toBeDefined();
|
|
341
|
+
expect(virtual?.kind).toBe('videoinput');
|
|
342
|
+
});
|
|
343
|
+
|
|
224
344
|
it('should resume previously enabled state', async () => {
|
|
225
345
|
vi.spyOn(manager, 'enable');
|
|
226
346
|
|
|
@@ -48,8 +48,9 @@ export const normalize = (
|
|
|
48
48
|
export const createSyntheticDevice = (
|
|
49
49
|
deviceId: string,
|
|
50
50
|
kind: MediaDeviceKind,
|
|
51
|
+
label = '',
|
|
51
52
|
): MediaDeviceInfo => {
|
|
52
|
-
return { deviceId, kind, label
|
|
53
|
+
return { deviceId, kind, label, groupId: '' } as MediaDeviceInfo;
|
|
53
54
|
};
|
|
54
55
|
|
|
55
56
|
export const readPreferences = (storageKey: string): LocalDevicePreferences => {
|
package/src/devices/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ export abstract class BasePeerConnection {
|
|
|
42
42
|
private iceRestartTimeout?: NodeJS.Timeout;
|
|
43
43
|
private preConnectStuckTimeout?: NodeJS.Timeout;
|
|
44
44
|
protected isIceRestarting = false;
|
|
45
|
-
|
|
45
|
+
protected isDisposed = false;
|
|
46
46
|
|
|
47
47
|
protected trackIdToTrackType = new Map<string, TrackType>();
|
|
48
48
|
|
|
@@ -115,7 +115,7 @@ export abstract class BasePeerConnection {
|
|
|
115
115
|
/**
|
|
116
116
|
* Disposes the `RTCPeerConnection` instance.
|
|
117
117
|
*/
|
|
118
|
-
dispose() {
|
|
118
|
+
async dispose(): Promise<void> {
|
|
119
119
|
clearTimeout(this.iceRestartTimeout);
|
|
120
120
|
this.iceRestartTimeout = undefined;
|
|
121
121
|
clearTimeout(this.preConnectStuckTimeout);
|
|
@@ -141,6 +141,10 @@ export abstract class BasePeerConnection {
|
|
|
141
141
|
this.onIceConnectionStateChange,
|
|
142
142
|
);
|
|
143
143
|
pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
|
|
144
|
+
pc.removeEventListener(
|
|
145
|
+
'connectionstatechange',
|
|
146
|
+
this.onConnectionStateChange,
|
|
147
|
+
);
|
|
144
148
|
this.unsubscribeIceTrickle?.();
|
|
145
149
|
this.subscriptions.forEach((unsubscribe) => unsubscribe());
|
|
146
150
|
this.subscriptions = [];
|
|
@@ -183,7 +187,7 @@ export abstract class BasePeerConnection {
|
|
|
183
187
|
const getTag = () => this.tag;
|
|
184
188
|
this.subscriptions.push(
|
|
185
189
|
this.dispatcher.on(event, getTag, (e) => {
|
|
186
|
-
const lockKey =
|
|
190
|
+
const lockKey = this.eventLockKey(event);
|
|
187
191
|
withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
|
|
188
192
|
if (this.isDisposed) return;
|
|
189
193
|
this.logger.warn(`Error handling ${event}`, err);
|
|
@@ -192,6 +196,14 @@ export abstract class BasePeerConnection {
|
|
|
192
196
|
);
|
|
193
197
|
};
|
|
194
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Returns the per-event `withoutConcurrency` tag used to serialize the
|
|
201
|
+
* dispatcher handler for `event` on this peer connection.
|
|
202
|
+
*/
|
|
203
|
+
protected eventLockKey = (event: keyof AllSfuEvents): string => {
|
|
204
|
+
return `pc.${this.lock}.${event}`;
|
|
205
|
+
};
|
|
206
|
+
|
|
195
207
|
/**
|
|
196
208
|
* Appends the trickled ICE candidates to the `RTCPeerConnection`.
|
|
197
209
|
*/
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -20,11 +20,15 @@ import {
|
|
|
20
20
|
toVideoLayers,
|
|
21
21
|
} from './layers';
|
|
22
22
|
import { isSvcCodec } from './codecs';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
fromRTCDegradationPreference,
|
|
25
|
+
toRTCDegradationPreference,
|
|
26
|
+
} from './helpers/degradationPreference';
|
|
24
27
|
import { isAudioTrackType } from './helpers/tracks';
|
|
25
28
|
import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
|
|
26
29
|
import { withoutConcurrency } from '../helpers/concurrency';
|
|
27
30
|
import { isReactNative } from '../helpers/platforms';
|
|
31
|
+
import { isFirefox } from '../helpers/browsers';
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
34
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|
|
@@ -53,7 +57,16 @@ export class Publisher extends BasePeerConnection {
|
|
|
53
57
|
|
|
54
58
|
this.on('changePublishQuality', async (event) => {
|
|
55
59
|
for (const videoSender of event.videoSenders) {
|
|
56
|
-
|
|
60
|
+
// if not publishing, update the encodingConfigCache and don't modify the state.
|
|
61
|
+
// we'll apply this config on the next publish/unmute.
|
|
62
|
+
const { trackType, publishOptionId } = videoSender;
|
|
63
|
+
const bundle = this.transceiverCache.getBy(publishOptionId, trackType);
|
|
64
|
+
if (bundle) {
|
|
65
|
+
this.transceiverCache.update(bundle.publishOption, { videoSender });
|
|
66
|
+
}
|
|
67
|
+
if (isFirefox() && !this.isPublishing(trackType)) continue;
|
|
68
|
+
|
|
69
|
+
await this.changePublishQuality(videoSender, bundle);
|
|
57
70
|
}
|
|
58
71
|
});
|
|
59
72
|
|
|
@@ -66,9 +79,13 @@ export class Publisher extends BasePeerConnection {
|
|
|
66
79
|
/**
|
|
67
80
|
* Disposes this Publisher instance.
|
|
68
81
|
*/
|
|
69
|
-
dispose() {
|
|
70
|
-
super.dispose();
|
|
71
|
-
|
|
82
|
+
async dispose(): Promise<void> {
|
|
83
|
+
await super.dispose();
|
|
84
|
+
try {
|
|
85
|
+
await this.stopAllTracks();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
this.logger.warn('Failed to stop tracks during dispose', err);
|
|
88
|
+
}
|
|
72
89
|
this.clonedTracks.clear();
|
|
73
90
|
}
|
|
74
91
|
|
|
@@ -98,17 +115,12 @@ export class Publisher extends BasePeerConnection {
|
|
|
98
115
|
// appear in the SDP in multiple transceivers
|
|
99
116
|
const trackToPublish = this.cloneTrack(track);
|
|
100
117
|
|
|
101
|
-
const
|
|
102
|
-
if (!
|
|
118
|
+
const bundle = this.transceiverCache.get(publishOption);
|
|
119
|
+
if (!bundle) {
|
|
103
120
|
await this.addTransceiver(trackToPublish, publishOption, options);
|
|
104
121
|
} else {
|
|
105
|
-
const previousTrack = transceiver.sender.track;
|
|
106
|
-
await this.updateTransceiver(
|
|
107
|
-
transceiver,
|
|
108
|
-
trackToPublish,
|
|
109
|
-
trackType,
|
|
110
|
-
options,
|
|
111
|
-
);
|
|
122
|
+
const previousTrack = bundle.transceiver.sender.track;
|
|
123
|
+
await this.updateTransceiver(bundle, trackToPublish, options);
|
|
112
124
|
if (!isReactNative()) {
|
|
113
125
|
this.stopTrack(previousTrack);
|
|
114
126
|
}
|
|
@@ -153,15 +165,22 @@ export class Publisher extends BasePeerConnection {
|
|
|
153
165
|
* Updates the transceiver with the given track and track type.
|
|
154
166
|
*/
|
|
155
167
|
private updateTransceiver = async (
|
|
156
|
-
|
|
168
|
+
bundle: PublishBundle,
|
|
157
169
|
track: MediaStreamTrack | null,
|
|
158
|
-
trackType: TrackType,
|
|
159
170
|
options: TrackPublishOptions = {},
|
|
160
171
|
) => {
|
|
172
|
+
const { transceiver, publishOption } = bundle;
|
|
173
|
+
const trackType = publishOption.trackType;
|
|
161
174
|
const sender = transceiver.sender;
|
|
162
175
|
if (sender.track) this.trackIdToTrackType.delete(sender.track.id);
|
|
163
176
|
await sender.replaceTrack(track);
|
|
164
|
-
if (track)
|
|
177
|
+
if (track) {
|
|
178
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
179
|
+
if (isFirefox() && bundle.videoSender) {
|
|
180
|
+
// restore the encoding config from the cache, if any
|
|
181
|
+
await this.changePublishQuality(bundle.videoSender, bundle);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
165
184
|
if (isAudioTrackType(trackType)) {
|
|
166
185
|
await this.updateAudioPublishOptions(trackType, options);
|
|
167
186
|
}
|
|
@@ -230,7 +249,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
230
249
|
if (hasPublishOption) continue;
|
|
231
250
|
// it is safe to stop the track here, it is a clone
|
|
232
251
|
this.stopTrack(transceiver.sender.track);
|
|
233
|
-
await this.updateTransceiver(
|
|
252
|
+
await this.updateTransceiver(item, null);
|
|
234
253
|
}
|
|
235
254
|
};
|
|
236
255
|
|
|
@@ -286,39 +305,50 @@ export class Publisher extends BasePeerConnection {
|
|
|
286
305
|
/**
|
|
287
306
|
* Stops the cloned track that is being published to the SFU.
|
|
288
307
|
*/
|
|
289
|
-
stopTracks = (...trackTypes: TrackType[]) => {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
308
|
+
stopTracks = async (...trackTypes: TrackType[]) => {
|
|
309
|
+
return withoutConcurrency(
|
|
310
|
+
this.eventLockKey('changePublishQuality'),
|
|
311
|
+
async () => {
|
|
312
|
+
for (const item of this.transceiverCache.items()) {
|
|
313
|
+
const { publishOption, transceiver } = item;
|
|
314
|
+
if (!trackTypes.includes(publishOption.trackType)) continue;
|
|
315
|
+
const track = transceiver.sender.track;
|
|
316
|
+
await this.silenceSenderOnFirefox(item);
|
|
317
|
+
this.stopTrack(track);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
);
|
|
295
321
|
};
|
|
296
322
|
|
|
297
323
|
/**
|
|
298
324
|
* Stops all the cloned tracks that are being published to the SFU.
|
|
299
325
|
*/
|
|
300
|
-
stopAllTracks = () => {
|
|
301
|
-
|
|
302
|
-
this.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
326
|
+
stopAllTracks = async () => {
|
|
327
|
+
return withoutConcurrency(
|
|
328
|
+
this.eventLockKey('changePublishQuality'),
|
|
329
|
+
async () => {
|
|
330
|
+
for (const item of this.transceiverCache.items()) {
|
|
331
|
+
const track = item.transceiver.sender.track;
|
|
332
|
+
await this.silenceSenderOnFirefox(item);
|
|
333
|
+
this.stopTrack(track);
|
|
334
|
+
}
|
|
335
|
+
for (const track of this.clonedTracks) {
|
|
336
|
+
this.stopTrack(track);
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
);
|
|
307
340
|
};
|
|
308
341
|
|
|
309
|
-
private changePublishQuality = async (
|
|
310
|
-
|
|
311
|
-
|
|
342
|
+
private changePublishQuality = async (
|
|
343
|
+
videoSender: VideoSender,
|
|
344
|
+
bundle: PublishBundle | undefined,
|
|
345
|
+
) => {
|
|
346
|
+
const enabledLayers = videoSender.layers.filter((l) => l.active);
|
|
312
347
|
|
|
313
348
|
const tag = 'Update publish quality:';
|
|
314
349
|
this.logger.info(`${tag} requested layers by SFU:`, enabledLayers);
|
|
315
350
|
|
|
316
|
-
const
|
|
317
|
-
(t) =>
|
|
318
|
-
t.publishOption.id === publishOptionId &&
|
|
319
|
-
t.publishOption.trackType === trackType,
|
|
320
|
-
);
|
|
321
|
-
const sender = transceiverId?.transceiver.sender;
|
|
351
|
+
const sender = bundle?.transceiver.sender;
|
|
322
352
|
if (!sender) {
|
|
323
353
|
return this.logger.warn(`${tag} no video sender found.`);
|
|
324
354
|
}
|
|
@@ -328,7 +358,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
328
358
|
return this.logger.warn(`${tag} there are no encodings set.`);
|
|
329
359
|
}
|
|
330
360
|
|
|
331
|
-
const codecInUse =
|
|
361
|
+
const codecInUse = bundle?.publishOption.codec?.name;
|
|
332
362
|
const usesSvcCodec = codecInUse && isSvcCodec(codecInUse);
|
|
333
363
|
|
|
334
364
|
let changed = false;
|
|
@@ -560,4 +590,73 @@ export class Publisher extends BasePeerConnection {
|
|
|
560
590
|
track.stop();
|
|
561
591
|
this.clonedTracks.delete(track);
|
|
562
592
|
};
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Silences a Firefox sender on the wire during unpublish.
|
|
596
|
+
*
|
|
597
|
+
* Firefox keeps emitting RTP after track.stop(), but the right lever
|
|
598
|
+
* differs by track type:
|
|
599
|
+
* - audio: `replaceTrack(null)` is the only reliable silencer;
|
|
600
|
+
* `setParameters({encodings:[...active:false]})` does NOT stop
|
|
601
|
+
* the Opus encoder.
|
|
602
|
+
* - video: `setParameters({encodings:[...active:false]})` pauses
|
|
603
|
+
* the encoder; `replaceTrack(null)` does NOT reliably stop the
|
|
604
|
+
* video encoder. The prior active=true configuration is captured
|
|
605
|
+
* onto `bundle.videoSender` so `updateTransceiver` can restore
|
|
606
|
+
* it on the next publish.
|
|
607
|
+
*
|
|
608
|
+
* No-op on non-Firefox browsers and during teardown.
|
|
609
|
+
*/
|
|
610
|
+
private silenceSenderOnFirefox = async (bundle: PublishBundle) => {
|
|
611
|
+
if (this.isDisposed || !isFirefox()) return;
|
|
612
|
+
const { transceiver, publishOption } = bundle;
|
|
613
|
+
if (isAudioTrackType(publishOption.trackType)) {
|
|
614
|
+
await transceiver.sender.replaceTrack(null).catch((err) => {
|
|
615
|
+
this.logger.warn('Failed to clear audio sender track', err);
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
await this.disableAllEncodings(bundle);
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
private disableAllEncodings = async (bundle: PublishBundle) => {
|
|
623
|
+
const { transceiver, publishOption } = bundle;
|
|
624
|
+
const sender = transceiver.sender;
|
|
625
|
+
const params = sender.getParameters();
|
|
626
|
+
if (!params.encodings || params.encodings.length === 0) return;
|
|
627
|
+
|
|
628
|
+
if (!bundle.videoSender) {
|
|
629
|
+
this.transceiverCache.update(publishOption, {
|
|
630
|
+
videoSender: {
|
|
631
|
+
trackType: publishOption.trackType,
|
|
632
|
+
publishOptionId: publishOption.id,
|
|
633
|
+
codec: publishOption.codec,
|
|
634
|
+
degradationPreference: fromRTCDegradationPreference(
|
|
635
|
+
params.degradationPreference,
|
|
636
|
+
),
|
|
637
|
+
layers: params.encodings.map((e) => ({
|
|
638
|
+
name: e.rid ?? 'q',
|
|
639
|
+
active: e.active ?? true,
|
|
640
|
+
maxBitrate: e.maxBitrate ?? 0,
|
|
641
|
+
scaleResolutionDownBy: e.scaleResolutionDownBy ?? 0,
|
|
642
|
+
maxFramerate: e.maxFramerate ?? 0,
|
|
643
|
+
// @ts-expect-error scalabilityMode is not in the typedefs yet
|
|
644
|
+
scalabilityMode: e.scalabilityMode ?? '',
|
|
645
|
+
})),
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
let changed = false;
|
|
651
|
+
for (const encoding of params.encodings) {
|
|
652
|
+
if (encoding.active !== false) {
|
|
653
|
+
encoding.active = false;
|
|
654
|
+
changed = true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (!changed) return;
|
|
658
|
+
await sender.setParameters(params).catch((err) => {
|
|
659
|
+
this.logger.error('Failed to disable video sender encodings:', err);
|
|
660
|
+
});
|
|
661
|
+
};
|
|
563
662
|
}
|
|
@@ -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, TrackLayersCache } from './types';
|
|
4
4
|
|
|
@@ -25,10 +25,17 @@ export class TransceiverCache {
|
|
|
25
25
|
* Gets the transceiver for the given publish option.
|
|
26
26
|
*/
|
|
27
27
|
get = (publishOption: PublishOption): PublishBundle | undefined => {
|
|
28
|
+
return this.getBy(publishOption.id, publishOption.trackType);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Gets the transceiver for the given publish option id and track type.
|
|
33
|
+
*/
|
|
34
|
+
getBy = (publishOptionId: number, trackType: TrackType) => {
|
|
28
35
|
return this.cache.find(
|
|
29
36
|
(bundle) =>
|
|
30
|
-
bundle.publishOption.id ===
|
|
31
|
-
bundle.publishOption.trackType ===
|
|
37
|
+
bundle.publishOption.id === publishOptionId &&
|
|
38
|
+
bundle.publishOption.trackType === trackType,
|
|
32
39
|
);
|
|
33
40
|
};
|
|
34
41
|
|