@stream-io/video-client 1.48.0 → 1.50.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 (86) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/index.browser.es.js +1497 -677
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1497 -677
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1497 -677
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +77 -4
  9. package/dist/src/StreamSfuClient.d.ts +8 -1
  10. package/dist/src/coordinator/connection/client.d.ts +1 -1
  11. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  12. package/dist/src/coordinator/connection/types.d.ts +14 -0
  13. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +3 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +13 -1
  16. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  17. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  18. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  19. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  20. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  21. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  22. package/dist/src/helpers/SlidingWindowRateLimiter.d.ts +28 -0
  23. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  24. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  25. package/dist/src/helpers/browsers.d.ts +13 -0
  26. package/dist/src/helpers/concurrency.d.ts +6 -4
  27. package/dist/src/rtc/BasePeerConnection.d.ts +11 -2
  28. package/dist/src/rtc/Publisher.d.ts +17 -0
  29. package/dist/src/rtc/Subscriber.d.ts +1 -0
  30. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  31. package/dist/src/rtc/index.d.ts +1 -0
  32. package/dist/src/rtc/types.d.ts +33 -1
  33. package/dist/src/stats/rtc/types.d.ts +1 -1
  34. package/dist/src/store/rxUtils.d.ts +9 -0
  35. package/dist/src/types.d.ts +18 -0
  36. package/package.json +2 -2
  37. package/src/Call.ts +268 -40
  38. package/src/StreamSfuClient.ts +75 -12
  39. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  40. package/src/__tests__/Call.publishing.test.ts +103 -0
  41. package/src/__tests__/StreamSfuClient.test.ts +275 -0
  42. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  43. package/src/coordinator/connection/client.ts +1 -1
  44. package/src/coordinator/connection/connection.ts +149 -96
  45. package/src/coordinator/connection/types.ts +15 -0
  46. package/src/coordinator/connection/utils.ts +15 -0
  47. package/src/devices/DeviceManager.ts +92 -32
  48. package/src/devices/DeviceManagerState.ts +20 -1
  49. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  50. package/src/devices/__tests__/ScreenShareManager.test.ts +0 -2
  51. package/src/devices/__tests__/mocks.ts +2 -0
  52. package/src/devices/devices.ts +2 -1
  53. package/src/gen/video/sfu/event/events.ts +15 -0
  54. package/src/gen/video/sfu/models/models.ts +44 -0
  55. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  56. package/src/helpers/BlockedAudioTracker.ts +74 -0
  57. package/src/helpers/DynascaleManager.ts +46 -337
  58. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  59. package/src/helpers/SlidingWindowRateLimiter.ts +49 -0
  60. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  61. package/src/helpers/ViewportTracker.ts +74 -19
  62. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  63. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  64. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  65. package/src/helpers/__tests__/SlidingWindowRateLimiter.test.ts +43 -0
  66. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  67. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  68. package/src/helpers/__tests__/browsers.test.ts +85 -1
  69. package/src/helpers/browsers.ts +24 -0
  70. package/src/helpers/concurrency.ts +9 -10
  71. package/src/rpc/retryable.ts +0 -1
  72. package/src/rtc/BasePeerConnection.ts +96 -6
  73. package/src/rtc/Publisher.ts +49 -2
  74. package/src/rtc/Subscriber.ts +42 -14
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +761 -0
  76. package/src/rtc/__tests__/Publisher.test.ts +332 -10
  77. package/src/rtc/__tests__/Subscriber.test.ts +202 -1
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  80. package/src/rtc/helpers/degradationPreference.ts +22 -0
  81. package/src/rtc/index.ts +1 -0
  82. package/src/rtc/types.ts +38 -1
  83. package/src/stats/rtc/types.ts +1 -0
  84. package/src/store/__tests__/rxUtils.test.ts +276 -0
  85. package/src/store/rxUtils.ts +19 -0
  86. package/src/types.ts +19 -0
@@ -2,12 +2,15 @@ import './mocks/webrtc.mocks';
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { anyString } from 'vitest-mock-extended';
5
+ import { fromPartial } from '@total-typescript/shoehorn';
5
6
  import { NegotiationError } from '../NegotiationError';
6
7
  import { Publisher } from '../Publisher';
8
+ import { ReconnectReason } from '../types';
7
9
  import { CallState } from '../../store';
8
10
  import { StreamSfuClient } from '../../StreamSfuClient';
9
11
  import { DispatchableMessage, Dispatcher } from '../Dispatcher';
10
12
  import {
13
+ DegradationPreference,
11
14
  ErrorCode,
12
15
  PeerType,
13
16
  PublishOption,
@@ -81,12 +84,14 @@ describe('Publisher', () => {
81
84
  fps: 30,
82
85
  maxTemporalLayers: 3,
83
86
  maxSpatialLayers: 3,
87
+ degradationPreference: DegradationPreference.UNSPECIFIED,
84
88
  },
85
89
  ],
86
90
  );
87
91
  });
88
92
 
89
93
  afterEach(() => {
94
+ vi.useRealTimers();
90
95
  vi.clearAllMocks();
91
96
  vi.resetModules();
92
97
  publisher.dispose();
@@ -166,16 +171,19 @@ describe('Publisher', () => {
166
171
  changePublishQuality: {
167
172
  audioSenders: [],
168
173
  videoSenders: [
169
- {
174
+ fromPartial({
170
175
  publishOptionId: 1,
171
176
  trackType: TrackType.VIDEO,
172
177
  layers: [],
173
- },
174
- {
178
+ degradationPreference: DegradationPreference.BALANCED,
179
+ }),
180
+ fromPartial({
175
181
  publishOptionId: 2,
176
182
  trackType: TrackType.SCREEN_SHARE,
177
183
  layers: [],
178
- },
184
+ degradationPreference:
185
+ DegradationPreference.MAINTAIN_RESOLUTION,
186
+ }),
179
187
  ],
180
188
  },
181
189
  },
@@ -250,7 +258,14 @@ describe('Publisher', () => {
250
258
  expect(publisher['negotiate']).toHaveBeenCalled();
251
259
  });
252
260
 
261
+ const simulatePriorIceConnected = () => {
262
+ // @ts-expect-error private api
263
+ publisher['pc'].iceConnectionState = 'connected';
264
+ publisher['onIceConnectionStateChange']();
265
+ };
266
+
253
267
  it(`should perform ICE restart when connection state changes to 'failed'`, () => {
268
+ simulatePriorIceConnected();
254
269
  vi.spyOn(publisher, 'restartIce').mockResolvedValue();
255
270
  // @ts-expect-error private api
256
271
  publisher['pc'].iceConnectionState = 'failed';
@@ -259,6 +274,7 @@ describe('Publisher', () => {
259
274
  });
260
275
 
261
276
  it(`should perform rejoin when ICE restart fails after connection state changes to 'failed'`, async () => {
277
+ simulatePriorIceConnected();
262
278
  const { promise: lock, resolve: unlock } = promiseWithResolvers<void>();
263
279
  publisher['onReconnectionNeeded'] = vi
264
280
  .fn()
@@ -274,6 +290,7 @@ describe('Publisher', () => {
274
290
  });
275
291
 
276
292
  it(`should perform fast reconnect when ICE restart fails with SIGNAL_LOST error`, async () => {
293
+ simulatePriorIceConnected();
277
294
  const { promise: lock, resolve: unlock } = promiseWithResolvers<void>();
278
295
  publisher['onReconnectionNeeded'] = vi
279
296
  .fn()
@@ -325,6 +342,7 @@ describe('Publisher', () => {
325
342
  });
326
343
 
327
344
  it(`should perform REJOIN reconnect when ICE restart fails with any other error code`, async () => {
345
+ simulatePriorIceConnected();
328
346
  const { promise: lock, resolve: unlock } = promiseWithResolvers<void>();
329
347
  publisher['onReconnectionNeeded'] = vi
330
348
  .fn()
@@ -365,6 +383,7 @@ describe('Publisher', () => {
365
383
  });
366
384
 
367
385
  it(`should schedule ICE restart when connection state changes to 'disconnected'`, () => {
386
+ simulatePriorIceConnected();
368
387
  vi.spyOn(publisher, 'restartIce').mockResolvedValue();
369
388
  vi.useFakeTimers();
370
389
  // @ts-expect-error private api
@@ -376,6 +395,7 @@ describe('Publisher', () => {
376
395
  });
377
396
 
378
397
  it(`should perform rejoin when scheduled ICE restart fails`, async () => {
398
+ simulatePriorIceConnected();
379
399
  vi.spyOn(publisher, 'restartIce').mockRejectedValue('ICE restart failed');
380
400
  const { promise: lock, resolve } = promiseWithResolvers<void>();
381
401
  publisher['onReconnectionNeeded'] = vi
@@ -393,6 +413,202 @@ describe('Publisher', () => {
393
413
  expect(publisher['onReconnectionNeeded']).toHaveBeenCalled();
394
414
  });
395
415
 
416
+ it(`iceHasEverConnected is false before any connected state is observed`, () => {
417
+ expect(publisher['iceHasEverConnected']).toBe(false);
418
+ });
419
+
420
+ it(`iceHasEverConnected becomes true after 'connected' ICE state`, () => {
421
+ // @ts-expect-error private api
422
+ publisher['pc'].iceConnectionState = 'connected';
423
+ publisher['onIceConnectionStateChange']();
424
+ expect(publisher['iceHasEverConnected']).toBe(true);
425
+ });
426
+
427
+ it(`iceHasEverConnected also flips on 'completed' ICE state`, () => {
428
+ // @ts-expect-error private api
429
+ publisher['pc'].iceConnectionState = 'completed';
430
+ publisher['onIceConnectionStateChange']();
431
+ expect(publisher['iceHasEverConnected']).toBe(true);
432
+ });
433
+
434
+ it(`does NOT call restartIce when ICE never connected and state goes to 'failed' — emits REJOIN with 'ice_never_connected'`, () => {
435
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
436
+ publisher['onReconnectionNeeded'] = vi.fn();
437
+ // @ts-expect-error private api
438
+ publisher['pc'].iceConnectionState = 'failed';
439
+ publisher['onIceConnectionStateChange']();
440
+ expect(publisher.restartIce).not.toHaveBeenCalled();
441
+ expect(publisher['onReconnectionNeeded']).toHaveBeenCalledWith(
442
+ WebsocketReconnectStrategy.REJOIN,
443
+ ReconnectReason.ICE_NEVER_CONNECTED,
444
+ PeerType.PUBLISHER_UNSPECIFIED,
445
+ );
446
+ });
447
+
448
+ it(`pre-connect 'disconnected' does not restart or escalate immediately`, () => {
449
+ // ICE has never reached `connected`. A `disconnected` transition at
450
+ // this point is just the browser's checking phase wobbling; the
451
+ // browser may yet move back to checking/connected. The SDK should
452
+ // wait it out — no synchronous restart, no synchronous REJOIN. Only
453
+ // a terminal `failed` before connect, or the pre-connect watchdog
454
+ // expiring, should escalate via `ICE_NEVER_CONNECTED`.
455
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
456
+ publisher['onReconnectionNeeded'] = vi.fn();
457
+ // @ts-expect-error private api
458
+ publisher['pc'].iceConnectionState = 'disconnected';
459
+ publisher['onIceConnectionStateChange']();
460
+ expect(publisher.restartIce).not.toHaveBeenCalled();
461
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
462
+ });
463
+
464
+ it(`pre-connect 'disconnected' watchdog escalates to REJOIN if state stays stuck`, () => {
465
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
466
+ publisher['onReconnectionNeeded'] = vi.fn();
467
+ vi.useFakeTimers();
468
+ const watchdogMs = publisher['iceRestartDelay'] * 2;
469
+
470
+ // @ts-expect-error private api
471
+ publisher['pc'].iceConnectionState = 'disconnected';
472
+ publisher['onIceConnectionStateChange']();
473
+ // before the watchdog fires, no escalation
474
+ vi.advanceTimersByTime(watchdogMs - 1);
475
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
476
+
477
+ // watchdog fires; still stuck in disconnected → escalate
478
+ vi.advanceTimersByTime(2);
479
+ expect(publisher.restartIce).not.toHaveBeenCalled();
480
+ expect(publisher['onReconnectionNeeded']).toHaveBeenCalledWith(
481
+ WebsocketReconnectStrategy.REJOIN,
482
+ ReconnectReason.ICE_NEVER_CONNECTED,
483
+ PeerType.PUBLISHER_UNSPECIFIED,
484
+ );
485
+ });
486
+
487
+ it(`pre-connect 'disconnected' watchdog is canceled when ICE recovers to 'connected'`, () => {
488
+ publisher['onReconnectionNeeded'] = vi.fn();
489
+ vi.useFakeTimers();
490
+ const watchdogMs = publisher['iceRestartDelay'] * 2;
491
+
492
+ // @ts-expect-error private api
493
+ publisher['pc'].iceConnectionState = 'disconnected';
494
+ publisher['onIceConnectionStateChange']();
495
+ // recover before the watchdog window expires
496
+ // @ts-expect-error private api
497
+ publisher['pc'].iceConnectionState = 'connected';
498
+ publisher['onIceConnectionStateChange']();
499
+
500
+ // advance past the original watchdog window — must NOT fire now
501
+ vi.advanceTimersByTime(watchdogMs + 100);
502
+
503
+ expect(publisher['iceHasEverConnected']).toBe(true);
504
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
505
+ });
506
+
507
+ it(`pre-connect 'disconnected' that recovers to 'connected' continues normally`, () => {
508
+ publisher['onReconnectionNeeded'] = vi.fn();
509
+ // @ts-expect-error private api
510
+ publisher['pc'].iceConnectionState = 'disconnected';
511
+ publisher['onIceConnectionStateChange']();
512
+ // browser now reaches connected — the normal connected branch runs
513
+ // and `iceHasEverConnected` flips to true.
514
+ // @ts-expect-error private api
515
+ publisher['pc'].iceConnectionState = 'connected';
516
+ publisher['onIceConnectionStateChange']();
517
+ expect(publisher['iceHasEverConnected']).toBe(true);
518
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
519
+ });
520
+
521
+ it(`isStable() returns false when ICE is 'new'`, () => {
522
+ // @ts-expect-error private api
523
+ publisher['pc'].iceConnectionState = 'new';
524
+ // default connectionState in mock is 'connected'
525
+ expect(publisher.isStable()).toBe(false);
526
+ });
527
+
528
+ it(`isStable() returns true when ICE is 'connected' and connectionState is 'connected'`, () => {
529
+ // @ts-expect-error private api
530
+ publisher['pc'].iceConnectionState = 'connected';
531
+ // @ts-expect-error private api
532
+ publisher['pc'].connectionState = 'connected';
533
+ expect(publisher.isStable()).toBe(true);
534
+ });
535
+
536
+ it(`isStable() returns true when ICE is 'completed' and connectionState is 'connected'`, () => {
537
+ // @ts-expect-error private api
538
+ publisher['pc'].iceConnectionState = 'completed';
539
+ // @ts-expect-error private api
540
+ publisher['pc'].connectionState = 'connected';
541
+ expect(publisher.isStable()).toBe(true);
542
+ });
543
+
544
+ it(`isStable() returns false when ICE is 'disconnected'`, () => {
545
+ // @ts-expect-error private api
546
+ publisher['pc'].iceConnectionState = 'disconnected';
547
+ // @ts-expect-error private api
548
+ publisher['pc'].connectionState = 'connected';
549
+ expect(publisher.isStable()).toBe(false);
550
+ });
551
+
552
+ it(`after connected→disconnected→connected cycle, subsequent 'failed' DOES trigger ICE restart (flag stays true)`, () => {
553
+ // @ts-expect-error private api
554
+ publisher['pc'].iceConnectionState = 'connected';
555
+ publisher['onIceConnectionStateChange']();
556
+ // @ts-expect-error private api
557
+ publisher['pc'].iceConnectionState = 'disconnected';
558
+ publisher['onIceConnectionStateChange']();
559
+ // @ts-expect-error private api
560
+ publisher['pc'].iceConnectionState = 'connected';
561
+ publisher['onIceConnectionStateChange']();
562
+
563
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
564
+ publisher['onReconnectionNeeded'] = vi.fn();
565
+ // @ts-expect-error private api
566
+ publisher['pc'].iceConnectionState = 'failed';
567
+ publisher['onIceConnectionStateChange']();
568
+ expect(publisher.restartIce).toHaveBeenCalled();
569
+ // the reason here is the regular restart path, NOT 'ice_never_connected'
570
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalledWith(
571
+ WebsocketReconnectStrategy.REJOIN,
572
+ ReconnectReason.ICE_NEVER_CONNECTED,
573
+ PeerType.PUBLISHER_UNSPECIFIED,
574
+ );
575
+ });
576
+
577
+ it(`connection-state 'failed' (distinct from ICE state) still fires REJOIN even after ICE was connected`, () => {
578
+ // mark ICE as connected first
579
+ // @ts-expect-error private api
580
+ publisher['pc'].iceConnectionState = 'connected';
581
+ publisher['onIceConnectionStateChange']();
582
+
583
+ publisher['onReconnectionNeeded'] = vi.fn();
584
+ // @ts-expect-error private api
585
+ publisher['pc'].connectionState = 'failed';
586
+ publisher['onConnectionStateChange']();
587
+ expect(publisher['onReconnectionNeeded']).toHaveBeenCalledWith(
588
+ WebsocketReconnectStrategy.REJOIN,
589
+ ReconnectReason.CONNECTION_FAILED,
590
+ PeerType.PUBLISHER_UNSPECIFIED,
591
+ );
592
+ });
593
+
594
+ it(`'completed' state is treated as connectivity — subsequent 'failed' DOES trigger ICE restart`, () => {
595
+ // @ts-expect-error private api
596
+ publisher['pc'].iceConnectionState = 'completed';
597
+ publisher['onIceConnectionStateChange']();
598
+
599
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
600
+ publisher['onReconnectionNeeded'] = vi.fn();
601
+ // @ts-expect-error private api
602
+ publisher['pc'].iceConnectionState = 'failed';
603
+ publisher['onIceConnectionStateChange']();
604
+ expect(publisher.restartIce).toHaveBeenCalled();
605
+ expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalledWith(
606
+ WebsocketReconnectStrategy.REJOIN,
607
+ ReconnectReason.ICE_NEVER_CONNECTED,
608
+ PeerType.PUBLISHER_UNSPECIFIED,
609
+ );
610
+ });
611
+
396
612
  it(`should schedule ICE restart but cancel it if connection recovers in the meantime`, () => {
397
613
  vi.spyOn(publisher, 'restartIce').mockResolvedValue();
398
614
  vi.useFakeTimers();
@@ -447,6 +663,7 @@ describe('Publisher', () => {
447
663
  await publisher['changePublishQuality']({
448
664
  publishOptionId: 1,
449
665
  trackType: TrackType.VIDEO,
666
+ degradationPreference: DegradationPreference.UNSPECIFIED,
450
667
  layers: [
451
668
  {
452
669
  name: 'q',
@@ -523,6 +740,7 @@ describe('Publisher', () => {
523
740
  await publisher['changePublishQuality']({
524
741
  publishOptionId: 1,
525
742
  trackType: TrackType.VIDEO,
743
+ degradationPreference: DegradationPreference.UNSPECIFIED,
526
744
  layers: [
527
745
  {
528
746
  name: 'q',
@@ -586,6 +804,7 @@ describe('Publisher', () => {
586
804
  await publisher['changePublishQuality']({
587
805
  publishOptionId: 1,
588
806
  trackType: TrackType.VIDEO,
807
+ degradationPreference: DegradationPreference.UNSPECIFIED,
589
808
  layers: [
590
809
  {
591
810
  name: 'q',
@@ -645,6 +864,7 @@ describe('Publisher', () => {
645
864
  await publisher['changePublishQuality']({
646
865
  publishOptionId: 1,
647
866
  trackType: TrackType.VIDEO,
867
+ degradationPreference: DegradationPreference.UNSPECIFIED,
648
868
  layers: [
649
869
  {
650
870
  name: 'q',
@@ -669,6 +889,93 @@ describe('Publisher', () => {
669
889
  },
670
890
  ]);
671
891
  });
892
+
893
+ it('applies degradationPreference from the SFU event', async () => {
894
+ const transceiver = new RTCRtpTransceiver();
895
+ const setParametersSpy = vi
896
+ .spyOn(transceiver.sender, 'setParameters')
897
+ .mockResolvedValue();
898
+ vi.spyOn(transceiver.sender, 'getParameters').mockReturnValue({
899
+ // @ts-expect-error incomplete data
900
+ codecs: [{ mimeType: 'video/VP8' }],
901
+ encodings: [{ rid: 'q', active: true }],
902
+ degradationPreference: 'maintain-framerate',
903
+ });
904
+
905
+ publisher['transceiverCache'].add({
906
+ // @ts-expect-error incomplete data
907
+ publishOption: { trackType: TrackType.VIDEO, id: 1 },
908
+ transceiver,
909
+ options: {},
910
+ });
911
+
912
+ await publisher['changePublishQuality']({
913
+ publishOptionId: 1,
914
+ trackType: TrackType.VIDEO,
915
+ degradationPreference: DegradationPreference.BALANCED,
916
+ layers: [
917
+ {
918
+ name: 'q',
919
+ active: true,
920
+ maxBitrate: 100,
921
+ scaleResolutionDownBy: 1,
922
+ maxFramerate: 30,
923
+ scalabilityMode: '',
924
+ },
925
+ ],
926
+ });
927
+
928
+ expect(setParametersSpy).toHaveBeenCalled();
929
+ expect(setParametersSpy.mock.calls[0][0].degradationPreference).toBe(
930
+ 'balanced',
931
+ );
932
+ });
933
+
934
+ it('does not call setParameters when nothing changes and degradationPreference is UNSPECIFIED', async () => {
935
+ const transceiver = new RTCRtpTransceiver();
936
+ const setParametersSpy = vi
937
+ .spyOn(transceiver.sender, 'setParameters')
938
+ .mockResolvedValue();
939
+ vi.spyOn(transceiver.sender, 'getParameters').mockReturnValue({
940
+ // @ts-expect-error incomplete data
941
+ codecs: [{ mimeType: 'video/VP8' }],
942
+ encodings: [
943
+ {
944
+ rid: 'q',
945
+ active: true,
946
+ maxBitrate: 100,
947
+ scaleResolutionDownBy: 1,
948
+ maxFramerate: 30,
949
+ },
950
+ ],
951
+ degradationPreference: 'maintain-framerate',
952
+ });
953
+
954
+ publisher['transceiverCache'].add({
955
+ // @ts-expect-error incomplete data
956
+ publishOption: { trackType: TrackType.VIDEO, id: 1 },
957
+ transceiver,
958
+ options: {},
959
+ });
960
+
961
+ await publisher['changePublishQuality']({
962
+ publishOptionId: 1,
963
+ trackType: TrackType.VIDEO,
964
+ degradationPreference: DegradationPreference.UNSPECIFIED,
965
+ layers: [
966
+ {
967
+ name: 'q',
968
+ active: true,
969
+ maxBitrate: 100,
970
+ scaleResolutionDownBy: 1,
971
+ maxFramerate: 30,
972
+ scalabilityMode: '',
973
+ },
974
+ ],
975
+ });
976
+
977
+ expect(setParametersSpy).not.toHaveBeenCalled();
978
+ });
672
979
  });
673
980
 
674
981
  describe('changePublishOptions', () => {
@@ -683,12 +990,27 @@ describe('Publisher', () => {
683
990
  vi.spyOn(publisher, 'negotiate').mockResolvedValue();
684
991
 
685
992
  publisher['publishOptions'] = [
686
- // @ts-expect-error incomplete data
687
- { trackType: TrackType.VIDEO, id: 0, codec: { name: 'vp8' } },
688
- // @ts-expect-error incomplete data
689
- { trackType: TrackType.VIDEO, id: 1, codec: { name: 'av1' } },
690
- // @ts-expect-error incomplete data
691
- { trackType: TrackType.VIDEO, id: 2, codec: { name: 'vp9' } },
993
+ {
994
+ trackType: TrackType.VIDEO,
995
+ id: 0,
996
+ // @ts-expect-error incomplete data
997
+ codec: { name: 'vp8' },
998
+ degradationPreference: DegradationPreference.UNSPECIFIED,
999
+ },
1000
+ {
1001
+ trackType: TrackType.VIDEO,
1002
+ id: 1,
1003
+ // @ts-expect-error incomplete data
1004
+ codec: { name: 'av1' },
1005
+ degradationPreference: DegradationPreference.UNSPECIFIED,
1006
+ },
1007
+ {
1008
+ trackType: TrackType.VIDEO,
1009
+ id: 2,
1010
+ // @ts-expect-error incomplete data
1011
+ codec: { name: 'vp9' },
1012
+ degradationPreference: DegradationPreference.UNSPECIFIED,
1013
+ },
692
1014
  ];
693
1015
 
694
1016
  publisher['transceiverCache'].add({