@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
@@ -24,6 +24,7 @@ import { IceTrickleBuffer } from '../IceTrickleBuffer';
24
24
  import { StreamClient } from '../../coordinator/connection/client';
25
25
  import { TransceiverCache } from '../TransceiverCache';
26
26
  import { promiseWithResolvers } from '../../helpers/promise';
27
+ import { isFirefox } from '../../helpers/browsers';
27
28
 
28
29
  vi.mock('../../StreamSfuClient', () => {
29
30
  console.log('MOCKING StreamSfuClient');
@@ -32,6 +33,12 @@ vi.mock('../../StreamSfuClient', () => {
32
33
  };
33
34
  });
34
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
+
35
42
  describe('Publisher', () => {
36
43
  const sessionId = 'session-id-test';
37
44
  let publisher: Publisher;
@@ -90,11 +97,11 @@ describe('Publisher', () => {
90
97
  );
91
98
  });
92
99
 
93
- afterEach(() => {
100
+ afterEach(async () => {
94
101
  vi.useRealTimers();
95
102
  vi.clearAllMocks();
96
103
  vi.resetModules();
97
- publisher.dispose();
104
+ await publisher.dispose();
98
105
  });
99
106
 
100
107
  describe('Publishing', () => {
@@ -660,37 +667,40 @@ describe('Publisher', () => {
660
667
  options: {},
661
668
  });
662
669
 
663
- await publisher['changePublishQuality']({
664
- publishOptionId: 1,
665
- trackType: TrackType.VIDEO,
666
- degradationPreference: DegradationPreference.UNSPECIFIED,
667
- layers: [
668
- {
669
- name: 'q',
670
- active: true,
671
- maxBitrate: 100,
672
- scaleResolutionDownBy: 4,
673
- maxFramerate: 30,
674
- scalabilityMode: '',
675
- },
676
- {
677
- name: 'h',
678
- active: false,
679
- maxBitrate: 150,
680
- scaleResolutionDownBy: 2,
681
- maxFramerate: 30,
682
- scalabilityMode: '',
683
- },
684
- {
685
- name: 'f',
686
- active: true,
687
- maxBitrate: 200,
688
- scaleResolutionDownBy: 1,
689
- maxFramerate: 30,
690
- scalabilityMode: '',
691
- },
692
- ],
693
- });
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
+ );
694
704
 
695
705
  expect(getParametersSpy).toHaveBeenCalled();
696
706
  expect(setParametersSpy).toHaveBeenCalled();
@@ -737,21 +747,24 @@ describe('Publisher', () => {
737
747
  options: {},
738
748
  });
739
749
 
740
- await publisher['changePublishQuality']({
741
- publishOptionId: 1,
742
- trackType: TrackType.VIDEO,
743
- degradationPreference: DegradationPreference.UNSPECIFIED,
744
- layers: [
745
- {
746
- name: 'q',
747
- active: true,
748
- maxBitrate: 100,
749
- scaleResolutionDownBy: 4,
750
- maxFramerate: 30,
751
- scalabilityMode: '',
752
- },
753
- ],
754
- });
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
+ );
755
768
 
756
769
  expect(getParametersSpy).toHaveBeenCalled();
757
770
  expect(setParametersSpy).toHaveBeenCalled();
@@ -801,21 +814,24 @@ describe('Publisher', () => {
801
814
  transceiver,
802
815
  options: {},
803
816
  });
804
- await publisher['changePublishQuality']({
805
- publishOptionId: 1,
806
- trackType: TrackType.VIDEO,
807
- degradationPreference: DegradationPreference.UNSPECIFIED,
808
- layers: [
809
- {
810
- name: 'q',
811
- active: true,
812
- maxBitrate: 50,
813
- scaleResolutionDownBy: 1,
814
- maxFramerate: 30,
815
- scalabilityMode: 'L1T3',
816
- },
817
- ],
818
- });
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
+ );
819
835
 
820
836
  expect(getParametersSpy).toHaveBeenCalled();
821
837
  expect(setParametersSpy).toHaveBeenCalled();
@@ -861,21 +877,24 @@ describe('Publisher', () => {
861
877
  options: {},
862
878
  });
863
879
 
864
- await publisher['changePublishQuality']({
865
- publishOptionId: 1,
866
- trackType: TrackType.VIDEO,
867
- degradationPreference: DegradationPreference.UNSPECIFIED,
868
- layers: [
869
- {
870
- name: 'q',
871
- active: true,
872
- maxBitrate: 50,
873
- scaleResolutionDownBy: 1,
874
- maxFramerate: 30,
875
- scalabilityMode: 'L1T3',
876
- },
877
- ],
878
- });
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
+ );
879
898
 
880
899
  expect(getParametersSpy).toHaveBeenCalled();
881
900
  expect(setParametersSpy).toHaveBeenCalled();
@@ -909,21 +928,24 @@ describe('Publisher', () => {
909
928
  options: {},
910
929
  });
911
930
 
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
- });
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
+ );
927
949
 
928
950
  expect(setParametersSpy).toHaveBeenCalled();
929
951
  expect(setParametersSpy.mock.calls[0][0].degradationPreference).toBe(
@@ -958,21 +980,24 @@ describe('Publisher', () => {
958
980
  options: {},
959
981
  });
960
982
 
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
- });
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
+ );
976
1001
 
977
1002
  expect(setParametersSpy).not.toHaveBeenCalled();
978
1003
  });
@@ -1235,22 +1260,544 @@ describe('Publisher', () => {
1235
1260
  expect(publisher.getTrackType('unknown')).toBeUndefined();
1236
1261
  });
1237
1262
 
1238
- it('stopTracks should stop tracks', () => {
1263
+ it('stopTracks should stop tracks', async () => {
1239
1264
  const track = cache['cache'][0].transceiver.sender.track!;
1240
1265
  vi.spyOn(track, 'stop');
1241
1266
  expect(publisher['clonedTracks'].size).toBe(3);
1242
- publisher.stopTracks(TrackType.VIDEO);
1267
+ await publisher.stopTracks(TrackType.VIDEO);
1243
1268
  expect(track!.stop).toHaveBeenCalled();
1244
1269
  expect(publisher['clonedTracks'].size).toBe(1);
1245
1270
  });
1246
1271
 
1247
- it('stopAllTracks should stop all tracks', () => {
1272
+ it('stopAllTracks should stop all tracks', async () => {
1248
1273
  const track = cache['cache'][0].transceiver.sender.track!;
1249
1274
  vi.spyOn(track, 'stop');
1250
1275
  expect(publisher['clonedTracks'].size).toBe(3);
1251
- publisher.stopAllTracks();
1276
+ await publisher.stopAllTracks();
1252
1277
  expect(track!.stop).toHaveBeenCalled();
1253
1278
  expect(publisher['clonedTracks'].size).toBe(0);
1254
1279
  });
1255
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
+ });
1256
1803
  });