@stream-io/video-client 1.49.0 → 1.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.browser.es.js +1404 -682
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1404 -682
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1404 -682
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +43 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/CameraManager.d.ts +1 -0
  14. package/dist/src/devices/DeviceManager.d.ts +23 -0
  15. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  16. package/dist/src/devices/VirtualDevice.d.ts +59 -0
  17. package/dist/src/devices/devicePersistence.d.ts +1 -1
  18. package/dist/src/devices/index.d.ts +1 -0
  19. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  20. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  21. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  22. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  23. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  24. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  25. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  26. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  27. package/dist/src/helpers/browsers.d.ts +13 -0
  28. package/dist/src/helpers/concurrency.d.ts +6 -4
  29. package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
  30. package/dist/src/rtc/Publisher.d.ts +38 -3
  31. package/dist/src/rtc/Subscriber.d.ts +1 -0
  32. package/dist/src/rtc/TransceiverCache.d.ts +5 -1
  33. package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
  34. package/dist/src/rtc/types.d.ts +2 -0
  35. package/dist/src/stats/rtc/types.d.ts +1 -1
  36. package/dist/src/store/rxUtils.d.ts +9 -0
  37. package/dist/src/types.d.ts +18 -0
  38. package/package.json +2 -2
  39. package/src/Call.ts +111 -33
  40. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  41. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  42. package/src/coordinator/connection/client.ts +1 -1
  43. package/src/coordinator/connection/connection.ts +149 -96
  44. package/src/coordinator/connection/types.ts +15 -0
  45. package/src/coordinator/connection/utils.ts +15 -0
  46. package/src/devices/CameraManager.ts +9 -2
  47. package/src/devices/DeviceManager.ts +239 -39
  48. package/src/devices/DeviceManagerState.ts +4 -2
  49. package/src/devices/VirtualDevice.ts +69 -0
  50. package/src/devices/__tests__/CameraManager.test.ts +19 -0
  51. package/src/devices/__tests__/DeviceManager.test.ts +404 -1
  52. package/src/devices/__tests__/mocks.ts +2 -0
  53. package/src/devices/devicePersistence.ts +2 -1
  54. package/src/devices/index.ts +1 -0
  55. package/src/gen/video/sfu/event/events.ts +15 -0
  56. package/src/gen/video/sfu/models/models.ts +44 -0
  57. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  58. package/src/helpers/BlockedAudioTracker.ts +74 -0
  59. package/src/helpers/DynascaleManager.ts +46 -337
  60. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  61. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  62. package/src/helpers/ViewportTracker.ts +74 -19
  63. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  64. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  65. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -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/rtc/BasePeerConnection.ts +15 -3
  72. package/src/rtc/Publisher.ts +185 -40
  73. package/src/rtc/Subscriber.ts +42 -14
  74. package/src/rtc/TransceiverCache.ts +10 -3
  75. package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
  76. package/src/rtc/__tests__/Publisher.test.ts +747 -88
  77. package/src/rtc/__tests__/Subscriber.test.ts +148 -3
  78. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  79. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
  80. package/src/rtc/helpers/degradationPreference.ts +40 -0
  81. package/src/rtc/types.ts +2 -0
  82. package/src/stats/rtc/types.ts +1 -0
  83. package/src/store/__tests__/rxUtils.test.ts +276 -0
  84. package/src/store/rxUtils.ts +19 -0
  85. package/src/types.ts +19 -0
@@ -2,6 +2,7 @@ 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';
7
8
  import { ReconnectReason } from '../types';
@@ -9,6 +10,7 @@ import { CallState } from '../../store';
9
10
  import { StreamSfuClient } from '../../StreamSfuClient';
10
11
  import { DispatchableMessage, Dispatcher } from '../Dispatcher';
11
12
  import {
13
+ DegradationPreference,
12
14
  ErrorCode,
13
15
  PeerType,
14
16
  PublishOption,
@@ -22,6 +24,7 @@ import { IceTrickleBuffer } from '../IceTrickleBuffer';
22
24
  import { StreamClient } from '../../coordinator/connection/client';
23
25
  import { TransceiverCache } from '../TransceiverCache';
24
26
  import { promiseWithResolvers } from '../../helpers/promise';
27
+ import { isFirefox } from '../../helpers/browsers';
25
28
 
26
29
  vi.mock('../../StreamSfuClient', () => {
27
30
  console.log('MOCKING StreamSfuClient');
@@ -30,6 +33,12 @@ vi.mock('../../StreamSfuClient', () => {
30
33
  };
31
34
  });
32
35
 
36
+ vi.mock('../../helpers/browsers', async (importOriginal) => {
37
+ const actual =
38
+ await importOriginal<typeof import('../../helpers/browsers')>();
39
+ return { ...actual, isFirefox: vi.fn().mockReturnValue(false) };
40
+ });
41
+
33
42
  describe('Publisher', () => {
34
43
  const sessionId = 'session-id-test';
35
44
  let publisher: Publisher;
@@ -82,16 +91,17 @@ describe('Publisher', () => {
82
91
  fps: 30,
83
92
  maxTemporalLayers: 3,
84
93
  maxSpatialLayers: 3,
94
+ degradationPreference: DegradationPreference.UNSPECIFIED,
85
95
  },
86
96
  ],
87
97
  );
88
98
  });
89
99
 
90
- afterEach(() => {
100
+ afterEach(async () => {
91
101
  vi.useRealTimers();
92
102
  vi.clearAllMocks();
93
103
  vi.resetModules();
94
- publisher.dispose();
104
+ await publisher.dispose();
95
105
  });
96
106
 
97
107
  describe('Publishing', () => {
@@ -168,16 +178,19 @@ describe('Publisher', () => {
168
178
  changePublishQuality: {
169
179
  audioSenders: [],
170
180
  videoSenders: [
171
- {
181
+ fromPartial({
172
182
  publishOptionId: 1,
173
183
  trackType: TrackType.VIDEO,
174
184
  layers: [],
175
- },
176
- {
185
+ degradationPreference: DegradationPreference.BALANCED,
186
+ }),
187
+ fromPartial({
177
188
  publishOptionId: 2,
178
189
  trackType: TrackType.SCREEN_SHARE,
179
190
  layers: [],
180
- },
191
+ degradationPreference:
192
+ DegradationPreference.MAINTAIN_RESOLUTION,
193
+ }),
181
194
  ],
182
195
  },
183
196
  },
@@ -654,36 +667,40 @@ describe('Publisher', () => {
654
667
  options: {},
655
668
  });
656
669
 
657
- await publisher['changePublishQuality']({
658
- publishOptionId: 1,
659
- trackType: TrackType.VIDEO,
660
- layers: [
661
- {
662
- name: 'q',
663
- active: true,
664
- maxBitrate: 100,
665
- scaleResolutionDownBy: 4,
666
- maxFramerate: 30,
667
- scalabilityMode: '',
668
- },
669
- {
670
- name: 'h',
671
- active: false,
672
- maxBitrate: 150,
673
- scaleResolutionDownBy: 2,
674
- maxFramerate: 30,
675
- scalabilityMode: '',
676
- },
677
- {
678
- name: 'f',
679
- active: true,
680
- maxBitrate: 200,
681
- scaleResolutionDownBy: 1,
682
- maxFramerate: 30,
683
- scalabilityMode: '',
684
- },
685
- ],
686
- });
670
+ await publisher['changePublishQuality'](
671
+ {
672
+ publishOptionId: 1,
673
+ trackType: TrackType.VIDEO,
674
+ degradationPreference: DegradationPreference.UNSPECIFIED,
675
+ layers: [
676
+ {
677
+ name: 'q',
678
+ active: true,
679
+ maxBitrate: 100,
680
+ scaleResolutionDownBy: 4,
681
+ maxFramerate: 30,
682
+ scalabilityMode: '',
683
+ },
684
+ {
685
+ name: 'h',
686
+ active: false,
687
+ maxBitrate: 150,
688
+ scaleResolutionDownBy: 2,
689
+ maxFramerate: 30,
690
+ scalabilityMode: '',
691
+ },
692
+ {
693
+ name: 'f',
694
+ active: true,
695
+ maxBitrate: 200,
696
+ scaleResolutionDownBy: 1,
697
+ maxFramerate: 30,
698
+ scalabilityMode: '',
699
+ },
700
+ ],
701
+ },
702
+ publisher['transceiverCache'].getBy(1, TrackType.VIDEO),
703
+ );
687
704
 
688
705
  expect(getParametersSpy).toHaveBeenCalled();
689
706
  expect(setParametersSpy).toHaveBeenCalled();
@@ -730,20 +747,24 @@ describe('Publisher', () => {
730
747
  options: {},
731
748
  });
732
749
 
733
- await publisher['changePublishQuality']({
734
- publishOptionId: 1,
735
- trackType: TrackType.VIDEO,
736
- layers: [
737
- {
738
- name: 'q',
739
- active: true,
740
- maxBitrate: 100,
741
- scaleResolutionDownBy: 4,
742
- maxFramerate: 30,
743
- scalabilityMode: '',
744
- },
745
- ],
746
- });
750
+ await publisher['changePublishQuality'](
751
+ {
752
+ publishOptionId: 1,
753
+ trackType: TrackType.VIDEO,
754
+ degradationPreference: DegradationPreference.UNSPECIFIED,
755
+ layers: [
756
+ {
757
+ name: 'q',
758
+ active: true,
759
+ maxBitrate: 100,
760
+ scaleResolutionDownBy: 4,
761
+ maxFramerate: 30,
762
+ scalabilityMode: '',
763
+ },
764
+ ],
765
+ },
766
+ publisher['transceiverCache'].getBy(1, TrackType.VIDEO),
767
+ );
747
768
 
748
769
  expect(getParametersSpy).toHaveBeenCalled();
749
770
  expect(setParametersSpy).toHaveBeenCalled();
@@ -793,20 +814,24 @@ describe('Publisher', () => {
793
814
  transceiver,
794
815
  options: {},
795
816
  });
796
- await publisher['changePublishQuality']({
797
- publishOptionId: 1,
798
- trackType: TrackType.VIDEO,
799
- layers: [
800
- {
801
- name: 'q',
802
- active: true,
803
- maxBitrate: 50,
804
- scaleResolutionDownBy: 1,
805
- maxFramerate: 30,
806
- scalabilityMode: 'L1T3',
807
- },
808
- ],
809
- });
817
+ await publisher['changePublishQuality'](
818
+ {
819
+ publishOptionId: 1,
820
+ trackType: TrackType.VIDEO,
821
+ degradationPreference: DegradationPreference.UNSPECIFIED,
822
+ layers: [
823
+ {
824
+ name: 'q',
825
+ active: true,
826
+ maxBitrate: 50,
827
+ scaleResolutionDownBy: 1,
828
+ maxFramerate: 30,
829
+ scalabilityMode: 'L1T3',
830
+ },
831
+ ],
832
+ },
833
+ publisher['transceiverCache'].getBy(1, TrackType.VIDEO),
834
+ );
810
835
 
811
836
  expect(getParametersSpy).toHaveBeenCalled();
812
837
  expect(setParametersSpy).toHaveBeenCalled();
@@ -852,20 +877,24 @@ describe('Publisher', () => {
852
877
  options: {},
853
878
  });
854
879
 
855
- await publisher['changePublishQuality']({
856
- publishOptionId: 1,
857
- trackType: TrackType.VIDEO,
858
- layers: [
859
- {
860
- name: 'q',
861
- active: true,
862
- maxBitrate: 50,
863
- scaleResolutionDownBy: 1,
864
- maxFramerate: 30,
865
- scalabilityMode: 'L1T3',
866
- },
867
- ],
868
- });
880
+ await publisher['changePublishQuality'](
881
+ {
882
+ publishOptionId: 1,
883
+ trackType: TrackType.VIDEO,
884
+ degradationPreference: DegradationPreference.UNSPECIFIED,
885
+ layers: [
886
+ {
887
+ name: 'q',
888
+ active: true,
889
+ maxBitrate: 50,
890
+ scaleResolutionDownBy: 1,
891
+ maxFramerate: 30,
892
+ scalabilityMode: 'L1T3',
893
+ },
894
+ ],
895
+ },
896
+ publisher['transceiverCache'].getBy(1, TrackType.VIDEO),
897
+ );
869
898
 
870
899
  expect(getParametersSpy).toHaveBeenCalled();
871
900
  expect(setParametersSpy).toHaveBeenCalled();
@@ -879,6 +908,99 @@ describe('Publisher', () => {
879
908
  },
880
909
  ]);
881
910
  });
911
+
912
+ it('applies degradationPreference from the SFU event', async () => {
913
+ const transceiver = new RTCRtpTransceiver();
914
+ const setParametersSpy = vi
915
+ .spyOn(transceiver.sender, 'setParameters')
916
+ .mockResolvedValue();
917
+ vi.spyOn(transceiver.sender, 'getParameters').mockReturnValue({
918
+ // @ts-expect-error incomplete data
919
+ codecs: [{ mimeType: 'video/VP8' }],
920
+ encodings: [{ rid: 'q', active: true }],
921
+ degradationPreference: 'maintain-framerate',
922
+ });
923
+
924
+ publisher['transceiverCache'].add({
925
+ // @ts-expect-error incomplete data
926
+ publishOption: { trackType: TrackType.VIDEO, id: 1 },
927
+ transceiver,
928
+ options: {},
929
+ });
930
+
931
+ await publisher['changePublishQuality'](
932
+ {
933
+ publishOptionId: 1,
934
+ trackType: TrackType.VIDEO,
935
+ degradationPreference: DegradationPreference.BALANCED,
936
+ layers: [
937
+ {
938
+ name: 'q',
939
+ active: true,
940
+ maxBitrate: 100,
941
+ scaleResolutionDownBy: 1,
942
+ maxFramerate: 30,
943
+ scalabilityMode: '',
944
+ },
945
+ ],
946
+ },
947
+ publisher['transceiverCache'].getBy(1, TrackType.VIDEO),
948
+ );
949
+
950
+ expect(setParametersSpy).toHaveBeenCalled();
951
+ expect(setParametersSpy.mock.calls[0][0].degradationPreference).toBe(
952
+ 'balanced',
953
+ );
954
+ });
955
+
956
+ it('does not call setParameters when nothing changes and degradationPreference is UNSPECIFIED', async () => {
957
+ const transceiver = new RTCRtpTransceiver();
958
+ const setParametersSpy = vi
959
+ .spyOn(transceiver.sender, 'setParameters')
960
+ .mockResolvedValue();
961
+ vi.spyOn(transceiver.sender, 'getParameters').mockReturnValue({
962
+ // @ts-expect-error incomplete data
963
+ codecs: [{ mimeType: 'video/VP8' }],
964
+ encodings: [
965
+ {
966
+ rid: 'q',
967
+ active: true,
968
+ maxBitrate: 100,
969
+ scaleResolutionDownBy: 1,
970
+ maxFramerate: 30,
971
+ },
972
+ ],
973
+ degradationPreference: 'maintain-framerate',
974
+ });
975
+
976
+ publisher['transceiverCache'].add({
977
+ // @ts-expect-error incomplete data
978
+ publishOption: { trackType: TrackType.VIDEO, id: 1 },
979
+ transceiver,
980
+ options: {},
981
+ });
982
+
983
+ await publisher['changePublishQuality'](
984
+ {
985
+ publishOptionId: 1,
986
+ trackType: TrackType.VIDEO,
987
+ degradationPreference: DegradationPreference.UNSPECIFIED,
988
+ layers: [
989
+ {
990
+ name: 'q',
991
+ active: true,
992
+ maxBitrate: 100,
993
+ scaleResolutionDownBy: 1,
994
+ maxFramerate: 30,
995
+ scalabilityMode: '',
996
+ },
997
+ ],
998
+ },
999
+ publisher['transceiverCache'].getBy(1, TrackType.VIDEO),
1000
+ );
1001
+
1002
+ expect(setParametersSpy).not.toHaveBeenCalled();
1003
+ });
882
1004
  });
883
1005
 
884
1006
  describe('changePublishOptions', () => {
@@ -893,12 +1015,27 @@ describe('Publisher', () => {
893
1015
  vi.spyOn(publisher, 'negotiate').mockResolvedValue();
894
1016
 
895
1017
  publisher['publishOptions'] = [
896
- // @ts-expect-error incomplete data
897
- { trackType: TrackType.VIDEO, id: 0, codec: { name: 'vp8' } },
898
- // @ts-expect-error incomplete data
899
- { trackType: TrackType.VIDEO, id: 1, codec: { name: 'av1' } },
900
- // @ts-expect-error incomplete data
901
- { trackType: TrackType.VIDEO, id: 2, codec: { name: 'vp9' } },
1018
+ {
1019
+ trackType: TrackType.VIDEO,
1020
+ id: 0,
1021
+ // @ts-expect-error incomplete data
1022
+ codec: { name: 'vp8' },
1023
+ degradationPreference: DegradationPreference.UNSPECIFIED,
1024
+ },
1025
+ {
1026
+ trackType: TrackType.VIDEO,
1027
+ id: 1,
1028
+ // @ts-expect-error incomplete data
1029
+ codec: { name: 'av1' },
1030
+ degradationPreference: DegradationPreference.UNSPECIFIED,
1031
+ },
1032
+ {
1033
+ trackType: TrackType.VIDEO,
1034
+ id: 2,
1035
+ // @ts-expect-error incomplete data
1036
+ codec: { name: 'vp9' },
1037
+ degradationPreference: DegradationPreference.UNSPECIFIED,
1038
+ },
902
1039
  ];
903
1040
 
904
1041
  publisher['transceiverCache'].add({
@@ -1123,22 +1260,544 @@ describe('Publisher', () => {
1123
1260
  expect(publisher.getTrackType('unknown')).toBeUndefined();
1124
1261
  });
1125
1262
 
1126
- it('stopTracks should stop tracks', () => {
1263
+ it('stopTracks should stop tracks', async () => {
1127
1264
  const track = cache['cache'][0].transceiver.sender.track!;
1128
1265
  vi.spyOn(track, 'stop');
1129
1266
  expect(publisher['clonedTracks'].size).toBe(3);
1130
- publisher.stopTracks(TrackType.VIDEO);
1267
+ await publisher.stopTracks(TrackType.VIDEO);
1131
1268
  expect(track!.stop).toHaveBeenCalled();
1132
1269
  expect(publisher['clonedTracks'].size).toBe(1);
1133
1270
  });
1134
1271
 
1135
- it('stopAllTracks should stop all tracks', () => {
1272
+ it('stopAllTracks should stop all tracks', async () => {
1136
1273
  const track = cache['cache'][0].transceiver.sender.track!;
1137
1274
  vi.spyOn(track, 'stop');
1138
1275
  expect(publisher['clonedTracks'].size).toBe(3);
1139
- publisher.stopAllTracks();
1276
+ await publisher.stopAllTracks();
1140
1277
  expect(track!.stop).toHaveBeenCalled();
1141
1278
  expect(publisher['clonedTracks'].size).toBe(0);
1142
1279
  });
1143
1280
  });
1281
+
1282
+ describe('Firefox unpublish workaround', () => {
1283
+ const mockSenderParams = (
1284
+ transceiver: RTCRtpTransceiver,
1285
+ encodings: RTCRtpEncodingParameters[],
1286
+ ) => {
1287
+ const getParametersSpy = vi
1288
+ .spyOn(transceiver.sender, 'getParameters')
1289
+ .mockReturnValue(fromPartial({ codecs: [], encodings }));
1290
+ const setParametersSpy = vi
1291
+ .spyOn(transceiver.sender, 'setParameters')
1292
+ .mockResolvedValue();
1293
+ return { getParametersSpy, setParametersSpy };
1294
+ };
1295
+
1296
+ afterEach(() => {
1297
+ vi.mocked(isFirefox).mockReturnValue(false);
1298
+ });
1299
+
1300
+ it('on Firefox, stopTracks deactivates video sender encodings before stopping the track', async () => {
1301
+ vi.mocked(isFirefox).mockReturnValue(true);
1302
+
1303
+ const transceiver = new RTCRtpTransceiver();
1304
+ const track = new MediaStreamTrack();
1305
+ vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(track);
1306
+ const trackStopSpy = vi.spyOn(track, 'stop');
1307
+ const { setParametersSpy } = mockSenderParams(transceiver, [
1308
+ { rid: 'q', active: true },
1309
+ { rid: 'h', active: true },
1310
+ { rid: 'f', active: true },
1311
+ ]);
1312
+
1313
+ publisher['transceiverCache'].add({
1314
+ publishOption: publisher['publishOptions'][0],
1315
+ transceiver,
1316
+ options: {},
1317
+ });
1318
+
1319
+ await publisher.stopTracks(TrackType.VIDEO);
1320
+
1321
+ expect(setParametersSpy).toHaveBeenCalledTimes(1);
1322
+ expect(setParametersSpy.mock.calls[0][0].encodings).toEqual([
1323
+ { rid: 'q', active: false },
1324
+ { rid: 'h', active: false },
1325
+ { rid: 'f', active: false },
1326
+ ]);
1327
+ // setParameters({active: false}) must run before track.stop() so the
1328
+ // encoder is paused before the local track ends
1329
+ expect(setParametersSpy.mock.invocationCallOrder[0]).toBeLessThan(
1330
+ trackStopSpy.mock.invocationCallOrder[0],
1331
+ );
1332
+ expect(trackStopSpy).toHaveBeenCalled();
1333
+ });
1334
+
1335
+ it('on Firefox, stopTracks clears audio senders via replaceTrack(null), not setParameters', async () => {
1336
+ vi.mocked(isFirefox).mockReturnValue(true);
1337
+
1338
+ const audioTransceiver = new RTCRtpTransceiver();
1339
+ const audioTrack = new MediaStreamTrack();
1340
+ vi.spyOn(audioTransceiver.sender, 'track', 'get').mockReturnValue(
1341
+ audioTrack,
1342
+ );
1343
+ const trackStopSpy = vi.spyOn(audioTrack, 'stop');
1344
+ const replaceTrackSpy = vi
1345
+ .spyOn(audioTransceiver.sender, 'replaceTrack')
1346
+ .mockResolvedValue();
1347
+ const setParametersSpy = vi.spyOn(
1348
+ audioTransceiver.sender,
1349
+ 'setParameters',
1350
+ );
1351
+
1352
+ publisher['transceiverCache'].add({
1353
+ // @ts-expect-error incomplete data
1354
+ publishOption: { trackType: TrackType.AUDIO, id: 99 },
1355
+ transceiver: audioTransceiver,
1356
+ options: {},
1357
+ });
1358
+
1359
+ await publisher.stopTracks(TrackType.AUDIO);
1360
+
1361
+ // setParameters({encodings:[...active:false]}) does NOT stop the
1362
+ // Opus encoder on Firefox; replaceTrack(null) is the only reliable
1363
+ // wire silencer for audio.
1364
+ expect(setParametersSpy).not.toHaveBeenCalled();
1365
+ expect(replaceTrackSpy).toHaveBeenCalledWith(null);
1366
+ expect(replaceTrackSpy.mock.invocationCallOrder[0]).toBeLessThan(
1367
+ trackStopSpy.mock.invocationCallOrder[0],
1368
+ );
1369
+ expect(trackStopSpy).toHaveBeenCalled();
1370
+ });
1371
+
1372
+ it('on Firefox, stopTracks leaves senders for other track types alone', async () => {
1373
+ vi.mocked(isFirefox).mockReturnValue(true);
1374
+
1375
+ const videoTransceiver = new RTCRtpTransceiver();
1376
+ vi.spyOn(videoTransceiver.sender, 'track', 'get').mockReturnValue(
1377
+ new MediaStreamTrack(),
1378
+ );
1379
+ const { setParametersSpy: videoSetParams } = mockSenderParams(
1380
+ videoTransceiver,
1381
+ [{ rid: 'q', active: true }],
1382
+ );
1383
+
1384
+ const audioTransceiver = new RTCRtpTransceiver();
1385
+ vi.spyOn(audioTransceiver.sender, 'track', 'get').mockReturnValue(
1386
+ new MediaStreamTrack(),
1387
+ );
1388
+ const audioReplaceTrack = vi
1389
+ .spyOn(audioTransceiver.sender, 'replaceTrack')
1390
+ .mockResolvedValue();
1391
+
1392
+ publisher['transceiverCache'].add({
1393
+ publishOption: publisher['publishOptions'][0],
1394
+ transceiver: videoTransceiver,
1395
+ options: {},
1396
+ });
1397
+ publisher['transceiverCache'].add({
1398
+ // @ts-expect-error incomplete data
1399
+ publishOption: { trackType: TrackType.AUDIO, id: 99 },
1400
+ transceiver: audioTransceiver,
1401
+ options: {},
1402
+ });
1403
+
1404
+ await publisher.stopTracks(TrackType.VIDEO);
1405
+
1406
+ expect(videoSetParams).toHaveBeenCalledTimes(1);
1407
+ expect(audioReplaceTrack).not.toHaveBeenCalled();
1408
+ });
1409
+
1410
+ it('on non-Firefox, stopTracks does not call setParameters or replaceTrack and still stops the track', async () => {
1411
+ // default: isFirefox() === false
1412
+ const transceiver = new RTCRtpTransceiver();
1413
+ const track = new MediaStreamTrack();
1414
+ vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(track);
1415
+ const trackStopSpy = vi.spyOn(track, 'stop');
1416
+ const { setParametersSpy } = mockSenderParams(transceiver, [
1417
+ { rid: 'q', active: true },
1418
+ ]);
1419
+ const replaceTrackSpy = vi.spyOn(transceiver.sender, 'replaceTrack');
1420
+
1421
+ publisher['transceiverCache'].add({
1422
+ publishOption: publisher['publishOptions'][0],
1423
+ transceiver,
1424
+ options: {},
1425
+ });
1426
+
1427
+ await publisher.stopTracks(TrackType.VIDEO);
1428
+
1429
+ expect(setParametersSpy).not.toHaveBeenCalled();
1430
+ expect(replaceTrackSpy).not.toHaveBeenCalled();
1431
+ expect(trackStopSpy).toHaveBeenCalled();
1432
+ });
1433
+
1434
+ it('on Firefox, stopAllTracks deactivates video encodings and clears audio sender tracks', async () => {
1435
+ vi.mocked(isFirefox).mockReturnValue(true);
1436
+
1437
+ const videoTransceiver = new RTCRtpTransceiver();
1438
+ vi.spyOn(videoTransceiver.sender, 'track', 'get').mockReturnValue(
1439
+ new MediaStreamTrack(),
1440
+ );
1441
+ const { setParametersSpy: videoSetParams } = mockSenderParams(
1442
+ videoTransceiver,
1443
+ [{ rid: 'q', active: true }],
1444
+ );
1445
+
1446
+ const audioTransceiver = new RTCRtpTransceiver();
1447
+ vi.spyOn(audioTransceiver.sender, 'track', 'get').mockReturnValue(
1448
+ new MediaStreamTrack(),
1449
+ );
1450
+ const audioReplaceTrack = vi
1451
+ .spyOn(audioTransceiver.sender, 'replaceTrack')
1452
+ .mockResolvedValue();
1453
+
1454
+ publisher['transceiverCache'].add({
1455
+ publishOption: publisher['publishOptions'][0],
1456
+ transceiver: videoTransceiver,
1457
+ options: {},
1458
+ });
1459
+ publisher['transceiverCache'].add({
1460
+ // @ts-expect-error incomplete data
1461
+ publishOption: { trackType: TrackType.AUDIO, id: 99 },
1462
+ transceiver: audioTransceiver,
1463
+ options: {},
1464
+ });
1465
+
1466
+ await publisher.stopAllTracks();
1467
+
1468
+ // each track type uses the lever that actually works for it on Firefox
1469
+ expect(videoSetParams).toHaveBeenCalledTimes(1);
1470
+ expect(audioReplaceTrack).toHaveBeenCalledWith(null);
1471
+ });
1472
+
1473
+ it('on Firefox, re-publishing a video track on an existing transceiver re-activates encodings', async () => {
1474
+ vi.mocked(isFirefox).mockReturnValue(true);
1475
+
1476
+ const transceiver = new RTCRtpTransceiver();
1477
+ const initialTrack = new MediaStreamTrack();
1478
+ vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(
1479
+ initialTrack,
1480
+ );
1481
+ const { setParametersSpy } = mockSenderParams(transceiver, [
1482
+ { rid: 'q', active: true },
1483
+ { rid: 'h', active: true },
1484
+ { rid: 'f', active: true },
1485
+ ]);
1486
+
1487
+ publisher['transceiverCache'].add({
1488
+ publishOption: publisher['publishOptions'][0],
1489
+ transceiver,
1490
+ options: {},
1491
+ });
1492
+
1493
+ // stopping seeds the bundle's videoSender from the current encoder
1494
+ // state and flips encodings to active=false
1495
+ await publisher.stopTracks(TrackType.VIDEO);
1496
+ expect(setParametersSpy).toHaveBeenCalledTimes(1);
1497
+ expect(setParametersSpy.mock.calls[0][0].encodings).toEqual([
1498
+ { rid: 'q', active: false },
1499
+ { rid: 'h', active: false },
1500
+ { rid: 'f', active: false },
1501
+ ]);
1502
+
1503
+ // re-publishing reads the cached snapshot and restores active=true
1504
+ const newTrack = new MediaStreamTrack();
1505
+ const clone = new MediaStreamTrack();
1506
+ vi.spyOn(newTrack, 'clone').mockReturnValue(clone);
1507
+
1508
+ await publisher.publish(newTrack, TrackType.VIDEO);
1509
+
1510
+ expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
1511
+ expect(setParametersSpy).toHaveBeenCalledTimes(2);
1512
+ expect(setParametersSpy.mock.calls[1][0].encodings).toEqual([
1513
+ { rid: 'q', active: true },
1514
+ { rid: 'h', active: true },
1515
+ { rid: 'f', active: true },
1516
+ ]);
1517
+ });
1518
+
1519
+ it('on Firefox, restores each video publishOption independently across multiple codecs', async () => {
1520
+ vi.mocked(isFirefox).mockReturnValue(true);
1521
+
1522
+ publisher['publishOptions'] = [
1523
+ // @ts-expect-error incomplete data
1524
+ { trackType: TrackType.VIDEO, id: 10, codec: { name: 'vp8' } },
1525
+ // @ts-expect-error incomplete data
1526
+ { trackType: TrackType.VIDEO, id: 11, codec: { name: 'vp9' } },
1527
+ ];
1528
+
1529
+ const vp8Transceiver = new RTCRtpTransceiver();
1530
+ vi.spyOn(vp8Transceiver.sender, 'track', 'get').mockReturnValue(
1531
+ new MediaStreamTrack(),
1532
+ );
1533
+ const { setParametersSpy: vp8Spy } = mockSenderParams(vp8Transceiver, [
1534
+ { rid: 'q', active: true },
1535
+ ]);
1536
+
1537
+ const vp9Transceiver = new RTCRtpTransceiver();
1538
+ vi.spyOn(vp9Transceiver.sender, 'track', 'get').mockReturnValue(
1539
+ new MediaStreamTrack(),
1540
+ );
1541
+ const { setParametersSpy: vp9Spy } = mockSenderParams(vp9Transceiver, [
1542
+ { rid: 'q', active: true },
1543
+ ]);
1544
+
1545
+ publisher['transceiverCache'].add({
1546
+ publishOption: publisher['publishOptions'][0],
1547
+ transceiver: vp8Transceiver,
1548
+ options: {},
1549
+ });
1550
+ publisher['transceiverCache'].add({
1551
+ publishOption: publisher['publishOptions'][1],
1552
+ transceiver: vp9Transceiver,
1553
+ options: {},
1554
+ });
1555
+
1556
+ await publisher.stopTracks(TrackType.VIDEO);
1557
+ expect(vp8Spy).toHaveBeenCalledTimes(1);
1558
+ expect(vp9Spy).toHaveBeenCalledTimes(1);
1559
+
1560
+ const vp8Bundle = publisher['transceiverCache'].get(
1561
+ publisher['publishOptions'][0],
1562
+ );
1563
+ const vp9Bundle = publisher['transceiverCache'].get(
1564
+ publisher['publishOptions'][1],
1565
+ );
1566
+ expect(vp8Bundle?.videoSender).toMatchObject({ publishOptionId: 10 });
1567
+ expect(vp9Bundle?.videoSender).toMatchObject({ publishOptionId: 11 });
1568
+
1569
+ const track = new MediaStreamTrack();
1570
+ const clone = new MediaStreamTrack();
1571
+ vi.spyOn(track, 'clone').mockReturnValue(clone);
1572
+
1573
+ await publisher.publish(track, TrackType.VIDEO);
1574
+
1575
+ expect(vp8Transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
1576
+ expect(vp9Transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
1577
+ expect(vp8Spy).toHaveBeenCalledTimes(2);
1578
+ expect(vp9Spy).toHaveBeenCalledTimes(2);
1579
+ expect(vp8Spy.mock.calls[1][0].encodings).toEqual([
1580
+ { rid: 'q', active: true },
1581
+ ]);
1582
+ expect(vp9Spy.mock.calls[1][0].encodings).toEqual([
1583
+ { rid: 'q', active: true },
1584
+ ]);
1585
+ });
1586
+
1587
+ it('on Firefox, the video path is a no-op when the sender has no encodings', async () => {
1588
+ vi.mocked(isFirefox).mockReturnValue(true);
1589
+
1590
+ const transceiver = new RTCRtpTransceiver();
1591
+ vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(
1592
+ new MediaStreamTrack(),
1593
+ );
1594
+ // default mock getParameters returns {}, no encodings field
1595
+ const setParametersSpy = vi.spyOn(transceiver.sender, 'setParameters');
1596
+
1597
+ publisher['transceiverCache'].add({
1598
+ publishOption: publisher['publishOptions'][0],
1599
+ transceiver,
1600
+ options: {},
1601
+ });
1602
+
1603
+ await expect(
1604
+ publisher.stopTracks(TrackType.VIDEO),
1605
+ ).resolves.toBeUndefined();
1606
+ expect(setParametersSpy).not.toHaveBeenCalled();
1607
+ });
1608
+
1609
+ it('on Firefox, defers changePublishQuality while not publishing and applies on next publish', async () => {
1610
+ vi.mocked(isFirefox).mockReturnValue(true);
1611
+
1612
+ // transceiver exists but has no track attached: isPublishing → false
1613
+ const transceiver = new RTCRtpTransceiver();
1614
+ const { setParametersSpy } = mockSenderParams(transceiver, [
1615
+ { rid: 'q', active: false },
1616
+ ]);
1617
+
1618
+ const publishOption = publisher['publishOptions'][0];
1619
+ publisher['transceiverCache'].add({
1620
+ publishOption,
1621
+ transceiver,
1622
+ options: {},
1623
+ });
1624
+
1625
+ // SFU sends a changePublishQuality while we are not publishing.
1626
+ // On Firefox this should be cached but not applied.
1627
+ dispatcher.dispatch(
1628
+ SfuEvent.create({
1629
+ eventPayload: {
1630
+ oneofKind: 'changePublishQuality',
1631
+ changePublishQuality: {
1632
+ audioSenders: [],
1633
+ videoSenders: [
1634
+ {
1635
+ publishOptionId: publishOption.id,
1636
+ trackType: TrackType.VIDEO,
1637
+ layers: [
1638
+ {
1639
+ name: 'q',
1640
+ active: true,
1641
+ maxBitrate: 1_000_000,
1642
+ scaleResolutionDownBy: 1,
1643
+ maxFramerate: 30,
1644
+ scalabilityMode: 'L1T3',
1645
+ },
1646
+ ],
1647
+ },
1648
+ ],
1649
+ },
1650
+ },
1651
+ }) as DispatchableMessage<'changePublishQuality'>,
1652
+ 'test',
1653
+ );
1654
+
1655
+ // cache populated immediately on the matching bundle, no setParameters
1656
+ // call yet
1657
+ expect(
1658
+ publisher['transceiverCache'].get(publishOption)?.videoSender,
1659
+ ).toMatchObject({
1660
+ publishOptionId: publishOption.id,
1661
+ trackType: TrackType.VIDEO,
1662
+ layers: [{ name: 'q', active: true, maxBitrate: 1_000_000 }],
1663
+ });
1664
+ expect(setParametersSpy).not.toHaveBeenCalled();
1665
+
1666
+ // Now publish: updateTransceiver should pull from cache and apply
1667
+ const track = new MediaStreamTrack();
1668
+ const clone = new MediaStreamTrack();
1669
+ vi.spyOn(track, 'clone').mockReturnValue(clone);
1670
+
1671
+ await publisher.publish(track, TrackType.VIDEO);
1672
+
1673
+ expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
1674
+ expect(setParametersSpy).toHaveBeenCalledTimes(1);
1675
+ expect(setParametersSpy.mock.calls[0][0].encodings[0]).toMatchObject({
1676
+ rid: 'q',
1677
+ active: true,
1678
+ maxBitrate: 1_000_000,
1679
+ maxFramerate: 30,
1680
+ scaleResolutionDownBy: 1,
1681
+ });
1682
+ });
1683
+
1684
+ it('on Firefox, serializes stopTracks against changePublishQuality so an inbound event cannot reactivate the encoder mid-stop', async () => {
1685
+ vi.mocked(isFirefox).mockReturnValue(true);
1686
+
1687
+ const transceiver = new RTCRtpTransceiver();
1688
+ const track = new MediaStreamTrack();
1689
+ vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(track);
1690
+ // make track.stop() actually flip readyState, matching real browser
1691
+ // semantics - isPublishing relies on it
1692
+ const trackStopSpy = vi.spyOn(track, 'stop').mockImplementation(() => {
1693
+ // @ts-expect-error readonly field
1694
+ track.readyState = 'ended';
1695
+ });
1696
+
1697
+ const { setParametersSpy } = mockSenderParams(transceiver, [
1698
+ { rid: 'q', active: true },
1699
+ ]);
1700
+ // hold setParameters open so we can race a quality event during the
1701
+ // disable phase
1702
+ const { promise: setParamsPromise, resolve: resolveSetParams } =
1703
+ promiseWithResolvers<void>();
1704
+ setParametersSpy.mockReturnValue(setParamsPromise);
1705
+
1706
+ const publishOption = publisher['publishOptions'][0];
1707
+ publisher['transceiverCache'].add({
1708
+ publishOption,
1709
+ transceiver,
1710
+ options: {},
1711
+ });
1712
+
1713
+ // kick off stopTracks but don't await it yet
1714
+ const stopPromise = publisher.stopTracks(TrackType.VIDEO);
1715
+
1716
+ // give the loop a microtask to start the await on setParameters
1717
+ await Promise.resolve();
1718
+ await Promise.resolve();
1719
+
1720
+ // mid-stop: SFU dispatches a quality event
1721
+ dispatcher.dispatch(
1722
+ SfuEvent.create({
1723
+ eventPayload: {
1724
+ oneofKind: 'changePublishQuality',
1725
+ changePublishQuality: {
1726
+ audioSenders: [],
1727
+ videoSenders: [
1728
+ {
1729
+ publishOptionId: publishOption.id,
1730
+ trackType: TrackType.VIDEO,
1731
+ layers: [
1732
+ {
1733
+ name: 'q',
1734
+ active: true,
1735
+ maxBitrate: 1_000_000,
1736
+ scaleResolutionDownBy: 1,
1737
+ maxFramerate: 30,
1738
+ scalabilityMode: 'L1T3',
1739
+ },
1740
+ ],
1741
+ },
1742
+ ],
1743
+ },
1744
+ },
1745
+ }) as DispatchableMessage<'changePublishQuality'>,
1746
+ 'test',
1747
+ );
1748
+
1749
+ // event handler is blocked behind stopTracks' lock - setParameters
1750
+ // has only been called once (from disableAllEncodings) and the track
1751
+ // has not been stopped yet
1752
+ expect(setParametersSpy).toHaveBeenCalledTimes(1);
1753
+ expect(trackStopSpy).not.toHaveBeenCalled();
1754
+
1755
+ // release setParameters; stopTracks finishes, lock released, the
1756
+ // queued quality event handler then runs
1757
+ resolveSetParams();
1758
+ await stopPromise;
1759
+ await new Promise<void>((r) => setTimeout(r, 0));
1760
+
1761
+ // track was stopped, and the queued quality event was deferred:
1762
+ // setParameters was NOT called a second time (track ended → not publishing)
1763
+ expect(trackStopSpy).toHaveBeenCalled();
1764
+ expect(setParametersSpy).toHaveBeenCalledTimes(1);
1765
+
1766
+ // but the SFU's intent is cached on the bundle for the next publish
1767
+ expect(
1768
+ publisher['transceiverCache'].get(publishOption)?.videoSender,
1769
+ ).toMatchObject({
1770
+ publishOptionId: publishOption.id,
1771
+ layers: [{ name: 'q', active: true, maxBitrate: 1_000_000 }],
1772
+ });
1773
+ });
1774
+
1775
+ it('on Firefox, helper is a no-op once the publisher is disposed', async () => {
1776
+ vi.mocked(isFirefox).mockReturnValue(true);
1777
+
1778
+ const transceiver = new RTCRtpTransceiver();
1779
+ const track = new MediaStreamTrack();
1780
+ vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(track);
1781
+ const trackStopSpy = vi.spyOn(track, 'stop');
1782
+ const { setParametersSpy } = mockSenderParams(transceiver, [
1783
+ { rid: 'q', active: true },
1784
+ ]);
1785
+
1786
+ publisher['transceiverCache'].add({
1787
+ publishOption: publisher['publishOptions'][0],
1788
+ transceiver,
1789
+ options: {},
1790
+ });
1791
+
1792
+ // simulates the state after super.dispose() has run inside dispose()
1793
+ publisher['isDisposed'] = true;
1794
+
1795
+ await publisher.stopTracks(TrackType.VIDEO);
1796
+
1797
+ // setParameters is skipped because the PC is being torn down
1798
+ expect(setParametersSpy).not.toHaveBeenCalled();
1799
+ // track.stop() still runs so local resources are released
1800
+ expect(trackStopSpy).toHaveBeenCalled();
1801
+ });
1802
+ });
1144
1803
  });