@stream-io/video-client 1.50.0 → 1.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/index.browser.es.js +288 -58
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +288 -58
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +288 -58
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +1 -0
- package/dist/src/devices/CameraManager.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +20 -0
- package/dist/src/devices/VirtualDevice.d.ts +59 -0
- package/dist/src/devices/devicePersistence.d.ts +1 -1
- package/dist/src/devices/index.d.ts +1 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
- package/dist/src/rtc/Publisher.d.ts +21 -3
- package/dist/src/rtc/TransceiverCache.d.ts +5 -1
- package/dist/src/rtc/helpers/degradationPreference.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/package.json +2 -2
- package/src/Call.ts +22 -11
- package/src/devices/CameraManager.ts +9 -2
- package/src/devices/DeviceManager.ts +148 -8
- package/src/devices/DeviceManagerState.ts +4 -1
- package/src/devices/VirtualDevice.ts +69 -0
- package/src/devices/__tests__/CameraManager.test.ts +19 -0
- package/src/devices/__tests__/DeviceManager.test.ts +121 -1
- package/src/devices/devicePersistence.ts +2 -1
- package/src/devices/index.ts +1 -0
- package/src/rtc/BasePeerConnection.ts +15 -3
- package/src/rtc/Publisher.ts +140 -41
- package/src/rtc/TransceiverCache.ts +10 -3
- package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
- package/src/rtc/__tests__/Publisher.test.ts +659 -112
- package/src/rtc/__tests__/Subscriber.test.ts +2 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +33 -1
- package/src/rtc/helpers/degradationPreference.ts +18 -0
- package/src/rtc/types.ts +2 -0
|
@@ -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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
});
|