@stream-io/video-client 1.50.0 → 1.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.browser.es.js +288 -58
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +288 -58
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +288 -58
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +1 -0
  9. package/dist/src/devices/CameraManager.d.ts +1 -0
  10. package/dist/src/devices/DeviceManager.d.ts +20 -0
  11. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  12. package/dist/src/devices/devicePersistence.d.ts +1 -1
  13. package/dist/src/devices/index.d.ts +1 -0
  14. package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
  15. package/dist/src/rtc/Publisher.d.ts +21 -3
  16. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  17. package/dist/src/rtc/helpers/degradationPreference.d.ts +1 -0
  18. package/dist/src/rtc/types.d.ts +2 -0
  19. package/package.json +2 -2
  20. package/src/Call.ts +22 -11
  21. package/src/devices/CameraManager.ts +9 -2
  22. package/src/devices/DeviceManager.ts +148 -8
  23. package/src/devices/DeviceManagerState.ts +4 -1
  24. package/src/devices/VirtualDevice.ts +69 -0
  25. package/src/devices/__tests__/CameraManager.test.ts +19 -0
  26. package/src/devices/__tests__/DeviceManager.test.ts +121 -1
  27. package/src/devices/devicePersistence.ts +2 -1
  28. package/src/devices/index.ts +1 -0
  29. package/src/rtc/BasePeerConnection.ts +15 -3
  30. package/src/rtc/Publisher.ts +140 -41
  31. package/src/rtc/TransceiverCache.ts +10 -3
  32. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  33. package/src/rtc/__tests__/Publisher.test.ts +659 -112
  34. package/src/rtc/__tests__/Subscriber.test.ts +2 -2
  35. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +33 -1
  36. package/src/rtc/helpers/degradationPreference.ts +18 -0
  37. package/src/rtc/types.ts +2 -0
@@ -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: '', groupId: '' } as MediaDeviceInfo;
53
+ return { deviceId, kind, label, groupId: '' } as MediaDeviceInfo;
53
54
  };
54
55
 
55
56
  export const readPreferences = (storageKey: string): LocalDevicePreferences => {
@@ -10,3 +10,4 @@ export * from './ScreenShareManager';
10
10
  export * from './ScreenShareState';
11
11
  export * from './SpeakerManager';
12
12
  export * from './SpeakerState';
13
+ export * from './VirtualDevice';
@@ -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
- private isDisposed = false;
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 = `pc.${this.lock}.${event}`;
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
  */
@@ -20,11 +20,15 @@ import {
20
20
  toVideoLayers,
21
21
  } from './layers';
22
22
  import { isSvcCodec } from './codecs';
23
- import { toRTCDegradationPreference } from './helpers/degradationPreference';
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
- await this.changePublishQuality(videoSender);
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
- this.stopAllTracks();
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 { transceiver } = this.transceiverCache.get(publishOption) || {};
102
- if (!transceiver) {
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
- transceiver: RTCRtpTransceiver,
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) this.trackIdToTrackType.set(track.id, trackType);
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(transceiver, null, publishOption.trackType);
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
- for (const item of this.transceiverCache.items()) {
291
- const { publishOption, transceiver } = item;
292
- if (!trackTypes.includes(publishOption.trackType)) continue;
293
- this.stopTrack(transceiver.sender.track);
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
- for (const { transceiver } of this.transceiverCache.items()) {
302
- this.stopTrack(transceiver.sender.track);
303
- }
304
- for (const track of this.clonedTracks) {
305
- this.stopTrack(track);
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 (videoSender: VideoSender) => {
310
- const { trackType, layers, publishOptionId } = videoSender;
311
- const enabledLayers = layers.filter((l) => l.active);
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 transceiverId = this.transceiverCache.find(
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 = transceiverId?.publishOption.codec?.name;
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 === publishOption.id &&
31
- bundle.publishOption.trackType === publishOption.trackType,
37
+ bundle.publishOption.id === publishOptionId &&
38
+ bundle.publishOption.trackType === trackType,
32
39
  );
33
40
  };
34
41