@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.
- package/CHANGELOG.md +22 -0
- package/dist/index.browser.es.js +1404 -682
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1404 -682
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1404 -682
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +43 -3
- package/dist/src/coordinator/connection/client.d.ts +1 -1
- package/dist/src/coordinator/connection/connection.d.ts +31 -25
- package/dist/src/coordinator/connection/types.d.ts +14 -0
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/devices/CameraManager.d.ts +1 -0
- package/dist/src/devices/DeviceManager.d.ts +23 -0
- package/dist/src/devices/DeviceManagerState.d.ts +0 -1
- 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/gen/video/sfu/event/events.d.ts +5 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
- package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
- package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
- package/dist/src/helpers/DynascaleManager.d.ts +8 -86
- package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
- package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
- package/dist/src/helpers/ViewportTracker.d.ts +11 -17
- package/dist/src/helpers/browsers.d.ts +13 -0
- package/dist/src/helpers/concurrency.d.ts +6 -4
- package/dist/src/rtc/BasePeerConnection.d.ts +7 -2
- package/dist/src/rtc/Publisher.d.ts +38 -3
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/TransceiverCache.d.ts +5 -1
- package/dist/src/rtc/helpers/degradationPreference.d.ts +3 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/stats/rtc/types.d.ts +1 -1
- package/dist/src/store/rxUtils.d.ts +9 -0
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/Call.ts +111 -33
- package/src/__tests__/Call.lifecycle.test.ts +67 -0
- package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
- package/src/coordinator/connection/client.ts +1 -1
- package/src/coordinator/connection/connection.ts +149 -96
- package/src/coordinator/connection/types.ts +15 -0
- package/src/coordinator/connection/utils.ts +15 -0
- package/src/devices/CameraManager.ts +9 -2
- package/src/devices/DeviceManager.ts +239 -39
- package/src/devices/DeviceManagerState.ts +4 -2
- package/src/devices/VirtualDevice.ts +69 -0
- package/src/devices/__tests__/CameraManager.test.ts +19 -0
- package/src/devices/__tests__/DeviceManager.test.ts +404 -1
- package/src/devices/__tests__/mocks.ts +2 -0
- package/src/devices/devicePersistence.ts +2 -1
- package/src/devices/index.ts +1 -0
- package/src/gen/video/sfu/event/events.ts +15 -0
- package/src/gen/video/sfu/models/models.ts +44 -0
- package/src/helpers/AudioBindingsWatchdog.ts +10 -7
- package/src/helpers/BlockedAudioTracker.ts +74 -0
- package/src/helpers/DynascaleManager.ts +46 -337
- package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
- package/src/helpers/TrackSubscriptionManager.ts +243 -0
- package/src/helpers/ViewportTracker.ts +74 -19
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
- package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
- package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
- package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
- package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
- package/src/helpers/__tests__/browsers.test.ts +85 -1
- package/src/helpers/browsers.ts +24 -0
- package/src/helpers/concurrency.ts +9 -10
- package/src/rtc/BasePeerConnection.ts +15 -3
- package/src/rtc/Publisher.ts +185 -40
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/TransceiverCache.ts +10 -3
- package/src/rtc/__tests__/Call.reconnect.test.ts +121 -2
- package/src/rtc/__tests__/Publisher.test.ts +747 -88
- package/src/rtc/__tests__/Subscriber.test.ts +148 -3
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +55 -0
- package/src/rtc/helpers/degradationPreference.ts +40 -0
- package/src/rtc/types.ts +2 -0
- package/src/stats/rtc/types.ts +1 -0
- package/src/store/__tests__/rxUtils.test.ts +276 -0
- package/src/store/rxUtils.ts +19 -0
- 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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
});
|