@stream-io/video-client 1.49.0 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/index.browser.es.js +1086 -594
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1086 -594
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1086 -594
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +42 -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/DeviceManager.d.ts +3 -0
- package/dist/src/devices/DeviceManagerState.d.ts +0 -1
- 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/Publisher.d.ts +17 -0
- package/dist/src/rtc/Subscriber.d.ts +1 -0
- package/dist/src/rtc/helpers/degradationPreference.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 +89 -22
- 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/DeviceManager.ts +92 -32
- package/src/devices/DeviceManagerState.ts +0 -1
- package/src/devices/__tests__/DeviceManager.test.ts +283 -0
- package/src/devices/__tests__/mocks.ts +2 -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/Publisher.ts +47 -1
- package/src/rtc/Subscriber.ts +42 -14
- package/src/rtc/__tests__/Publisher.test.ts +122 -10
- package/src/rtc/__tests__/Subscriber.test.ts +146 -1
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
- package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
- package/src/rtc/helpers/degradationPreference.ts +22 -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
package/src/rtc/Subscriber.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { BasePeerConnection } from './BasePeerConnection';
|
|
2
2
|
import { BasePeerConnectionOpts } from './types';
|
|
3
3
|
import { NegotiationError } from './NegotiationError';
|
|
4
|
-
import { PeerType } from '../gen/video/sfu/models/models';
|
|
4
|
+
import { PeerType, TrackType } from '../gen/video/sfu/models/models';
|
|
5
5
|
import { SubscriberOffer } from '../gen/video/sfu/event/events';
|
|
6
6
|
import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks';
|
|
7
|
+
import { pushToIfMissing, removeFromIfPresent } from '../helpers/array';
|
|
7
8
|
import { enableStereo, removeCodecsExcept } from './helpers/sdp';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -67,7 +68,8 @@ export class Subscriber extends BasePeerConnection {
|
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
private handleOnTrack = (e: RTCTrackEvent) => {
|
|
70
|
-
const
|
|
71
|
+
const { streams, track } = e;
|
|
72
|
+
const [primaryStream] = streams;
|
|
71
73
|
// example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
|
|
72
74
|
const [trackId, rawTrackType] = primaryStream.id.split(':');
|
|
73
75
|
const participantToUpdate = this.state.participants.find(
|
|
@@ -75,30 +77,35 @@ export class Subscriber extends BasePeerConnection {
|
|
|
75
77
|
);
|
|
76
78
|
this.logger.debug(
|
|
77
79
|
`[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
track.id,
|
|
81
|
+
track,
|
|
80
82
|
);
|
|
81
83
|
|
|
84
|
+
const trackType = toTrackType(rawTrackType);
|
|
85
|
+
if (!trackType) {
|
|
86
|
+
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
82
89
|
const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
|
|
83
|
-
|
|
90
|
+
track.addEventListener('mute', () => {
|
|
84
91
|
this.logger.info(`[onTrack]: Track muted: ${trackDebugInfo}`);
|
|
92
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
85
93
|
});
|
|
86
|
-
|
|
87
|
-
e.track.addEventListener('unmute', () => {
|
|
94
|
+
track.addEventListener('unmute', () => {
|
|
88
95
|
this.logger.info(`[onTrack]: Track unmuted: ${trackDebugInfo}`);
|
|
96
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
89
97
|
});
|
|
90
|
-
|
|
91
|
-
e.track.addEventListener('ended', () => {
|
|
98
|
+
track.addEventListener('ended', () => {
|
|
92
99
|
this.logger.info(`[onTrack]: Track ended: ${trackDebugInfo}`);
|
|
100
|
+
this.setRemoteTrackInterrupted(trackId, trackType, false);
|
|
93
101
|
this.state.removeOrphanedTrack(primaryStream.id);
|
|
94
102
|
});
|
|
95
103
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return this.logger.error(`Unknown track type: ${rawTrackType}`);
|
|
104
|
+
if (track.muted) {
|
|
105
|
+
this.setRemoteTrackInterrupted(trackId, trackType, true);
|
|
99
106
|
}
|
|
100
107
|
|
|
101
|
-
this.trackIdToTrackType.set(
|
|
108
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
102
109
|
|
|
103
110
|
if (!participantToUpdate) {
|
|
104
111
|
this.logger.warn(
|
|
@@ -133,7 +140,7 @@ export class Subscriber extends BasePeerConnection {
|
|
|
133
140
|
// now, dispose the previous stream if it exists
|
|
134
141
|
if (previousStream) {
|
|
135
142
|
this.logger.info(
|
|
136
|
-
`[onTrack]: Cleaning up previous remote ${
|
|
143
|
+
`[onTrack]: Cleaning up previous remote ${track.kind} tracks for userId: ${participantToUpdate.userId}`,
|
|
137
144
|
);
|
|
138
145
|
previousStream.getTracks().forEach((t) => {
|
|
139
146
|
t.stop();
|
|
@@ -142,6 +149,27 @@ export class Subscriber extends BasePeerConnection {
|
|
|
142
149
|
}
|
|
143
150
|
};
|
|
144
151
|
|
|
152
|
+
private setRemoteTrackInterrupted = (
|
|
153
|
+
trackId: string,
|
|
154
|
+
trackType: TrackType,
|
|
155
|
+
interrupted: boolean,
|
|
156
|
+
) => {
|
|
157
|
+
if (trackType !== TrackType.AUDIO) return;
|
|
158
|
+
const target = this.state.participants.find(
|
|
159
|
+
(p) => p.trackLookupPrefix === trackId,
|
|
160
|
+
);
|
|
161
|
+
if (!target) return;
|
|
162
|
+
this.state.updateParticipant(target.sessionId, (p) => {
|
|
163
|
+
const current = p.interruptedTracks ?? [];
|
|
164
|
+
const has = current.includes(trackType);
|
|
165
|
+
if (interrupted === has) return {};
|
|
166
|
+
const next = interrupted
|
|
167
|
+
? pushToIfMissing([...current], trackType)
|
|
168
|
+
: removeFromIfPresent([...current], trackType);
|
|
169
|
+
return { interruptedTracks: next };
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
|
|
145
173
|
private negotiate = async (subscriberOffer: SubscriberOffer) => {
|
|
146
174
|
await this.pc.setRemoteDescription({
|
|
147
175
|
type: 'offer',
|
|
@@ -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,
|
|
@@ -82,6 +84,7 @@ describe('Publisher', () => {
|
|
|
82
84
|
fps: 30,
|
|
83
85
|
maxTemporalLayers: 3,
|
|
84
86
|
maxSpatialLayers: 3,
|
|
87
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
85
88
|
},
|
|
86
89
|
],
|
|
87
90
|
);
|
|
@@ -168,16 +171,19 @@ describe('Publisher', () => {
|
|
|
168
171
|
changePublishQuality: {
|
|
169
172
|
audioSenders: [],
|
|
170
173
|
videoSenders: [
|
|
171
|
-
{
|
|
174
|
+
fromPartial({
|
|
172
175
|
publishOptionId: 1,
|
|
173
176
|
trackType: TrackType.VIDEO,
|
|
174
177
|
layers: [],
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
degradationPreference: DegradationPreference.BALANCED,
|
|
179
|
+
}),
|
|
180
|
+
fromPartial({
|
|
177
181
|
publishOptionId: 2,
|
|
178
182
|
trackType: TrackType.SCREEN_SHARE,
|
|
179
183
|
layers: [],
|
|
180
|
-
|
|
184
|
+
degradationPreference:
|
|
185
|
+
DegradationPreference.MAINTAIN_RESOLUTION,
|
|
186
|
+
}),
|
|
181
187
|
],
|
|
182
188
|
},
|
|
183
189
|
},
|
|
@@ -657,6 +663,7 @@ describe('Publisher', () => {
|
|
|
657
663
|
await publisher['changePublishQuality']({
|
|
658
664
|
publishOptionId: 1,
|
|
659
665
|
trackType: TrackType.VIDEO,
|
|
666
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
660
667
|
layers: [
|
|
661
668
|
{
|
|
662
669
|
name: 'q',
|
|
@@ -733,6 +740,7 @@ describe('Publisher', () => {
|
|
|
733
740
|
await publisher['changePublishQuality']({
|
|
734
741
|
publishOptionId: 1,
|
|
735
742
|
trackType: TrackType.VIDEO,
|
|
743
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
736
744
|
layers: [
|
|
737
745
|
{
|
|
738
746
|
name: 'q',
|
|
@@ -796,6 +804,7 @@ describe('Publisher', () => {
|
|
|
796
804
|
await publisher['changePublishQuality']({
|
|
797
805
|
publishOptionId: 1,
|
|
798
806
|
trackType: TrackType.VIDEO,
|
|
807
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
799
808
|
layers: [
|
|
800
809
|
{
|
|
801
810
|
name: 'q',
|
|
@@ -855,6 +864,7 @@ describe('Publisher', () => {
|
|
|
855
864
|
await publisher['changePublishQuality']({
|
|
856
865
|
publishOptionId: 1,
|
|
857
866
|
trackType: TrackType.VIDEO,
|
|
867
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
858
868
|
layers: [
|
|
859
869
|
{
|
|
860
870
|
name: 'q',
|
|
@@ -879,6 +889,93 @@ describe('Publisher', () => {
|
|
|
879
889
|
},
|
|
880
890
|
]);
|
|
881
891
|
});
|
|
892
|
+
|
|
893
|
+
it('applies degradationPreference from the SFU event', async () => {
|
|
894
|
+
const transceiver = new RTCRtpTransceiver();
|
|
895
|
+
const setParametersSpy = vi
|
|
896
|
+
.spyOn(transceiver.sender, 'setParameters')
|
|
897
|
+
.mockResolvedValue();
|
|
898
|
+
vi.spyOn(transceiver.sender, 'getParameters').mockReturnValue({
|
|
899
|
+
// @ts-expect-error incomplete data
|
|
900
|
+
codecs: [{ mimeType: 'video/VP8' }],
|
|
901
|
+
encodings: [{ rid: 'q', active: true }],
|
|
902
|
+
degradationPreference: 'maintain-framerate',
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
publisher['transceiverCache'].add({
|
|
906
|
+
// @ts-expect-error incomplete data
|
|
907
|
+
publishOption: { trackType: TrackType.VIDEO, id: 1 },
|
|
908
|
+
transceiver,
|
|
909
|
+
options: {},
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
await publisher['changePublishQuality']({
|
|
913
|
+
publishOptionId: 1,
|
|
914
|
+
trackType: TrackType.VIDEO,
|
|
915
|
+
degradationPreference: DegradationPreference.BALANCED,
|
|
916
|
+
layers: [
|
|
917
|
+
{
|
|
918
|
+
name: 'q',
|
|
919
|
+
active: true,
|
|
920
|
+
maxBitrate: 100,
|
|
921
|
+
scaleResolutionDownBy: 1,
|
|
922
|
+
maxFramerate: 30,
|
|
923
|
+
scalabilityMode: '',
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
expect(setParametersSpy).toHaveBeenCalled();
|
|
929
|
+
expect(setParametersSpy.mock.calls[0][0].degradationPreference).toBe(
|
|
930
|
+
'balanced',
|
|
931
|
+
);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it('does not call setParameters when nothing changes and degradationPreference is UNSPECIFIED', async () => {
|
|
935
|
+
const transceiver = new RTCRtpTransceiver();
|
|
936
|
+
const setParametersSpy = vi
|
|
937
|
+
.spyOn(transceiver.sender, 'setParameters')
|
|
938
|
+
.mockResolvedValue();
|
|
939
|
+
vi.spyOn(transceiver.sender, 'getParameters').mockReturnValue({
|
|
940
|
+
// @ts-expect-error incomplete data
|
|
941
|
+
codecs: [{ mimeType: 'video/VP8' }],
|
|
942
|
+
encodings: [
|
|
943
|
+
{
|
|
944
|
+
rid: 'q',
|
|
945
|
+
active: true,
|
|
946
|
+
maxBitrate: 100,
|
|
947
|
+
scaleResolutionDownBy: 1,
|
|
948
|
+
maxFramerate: 30,
|
|
949
|
+
},
|
|
950
|
+
],
|
|
951
|
+
degradationPreference: 'maintain-framerate',
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
publisher['transceiverCache'].add({
|
|
955
|
+
// @ts-expect-error incomplete data
|
|
956
|
+
publishOption: { trackType: TrackType.VIDEO, id: 1 },
|
|
957
|
+
transceiver,
|
|
958
|
+
options: {},
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
await publisher['changePublishQuality']({
|
|
962
|
+
publishOptionId: 1,
|
|
963
|
+
trackType: TrackType.VIDEO,
|
|
964
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
965
|
+
layers: [
|
|
966
|
+
{
|
|
967
|
+
name: 'q',
|
|
968
|
+
active: true,
|
|
969
|
+
maxBitrate: 100,
|
|
970
|
+
scaleResolutionDownBy: 1,
|
|
971
|
+
maxFramerate: 30,
|
|
972
|
+
scalabilityMode: '',
|
|
973
|
+
},
|
|
974
|
+
],
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
expect(setParametersSpy).not.toHaveBeenCalled();
|
|
978
|
+
});
|
|
882
979
|
});
|
|
883
980
|
|
|
884
981
|
describe('changePublishOptions', () => {
|
|
@@ -893,12 +990,27 @@ describe('Publisher', () => {
|
|
|
893
990
|
vi.spyOn(publisher, 'negotiate').mockResolvedValue();
|
|
894
991
|
|
|
895
992
|
publisher['publishOptions'] = [
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
993
|
+
{
|
|
994
|
+
trackType: TrackType.VIDEO,
|
|
995
|
+
id: 0,
|
|
996
|
+
// @ts-expect-error incomplete data
|
|
997
|
+
codec: { name: 'vp8' },
|
|
998
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
trackType: TrackType.VIDEO,
|
|
1002
|
+
id: 1,
|
|
1003
|
+
// @ts-expect-error incomplete data
|
|
1004
|
+
codec: { name: 'av1' },
|
|
1005
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
trackType: TrackType.VIDEO,
|
|
1009
|
+
id: 2,
|
|
1010
|
+
// @ts-expect-error incomplete data
|
|
1011
|
+
codec: { name: 'vp9' },
|
|
1012
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
1013
|
+
},
|
|
902
1014
|
];
|
|
903
1015
|
|
|
904
1016
|
publisher['transceiverCache'].add({
|
|
@@ -28,10 +28,11 @@ vi.mock('../../StreamSfuClient', () => {
|
|
|
28
28
|
describe('Subscriber', () => {
|
|
29
29
|
let sfuClient: StreamSfuClient;
|
|
30
30
|
let subscriber: Subscriber;
|
|
31
|
-
|
|
31
|
+
let state: CallState;
|
|
32
32
|
let dispatcher: Dispatcher;
|
|
33
33
|
|
|
34
34
|
beforeEach(() => {
|
|
35
|
+
state = new CallState();
|
|
35
36
|
dispatcher = new Dispatcher();
|
|
36
37
|
sfuClient = new StreamSfuClient({
|
|
37
38
|
dispatcher,
|
|
@@ -272,6 +273,150 @@ describe('Subscriber', () => {
|
|
|
272
273
|
});
|
|
273
274
|
});
|
|
274
275
|
|
|
276
|
+
describe('interruptedTracks', () => {
|
|
277
|
+
const setup = ({ muted = false }: { muted?: boolean } = {}) => {
|
|
278
|
+
const mediaStream = new MediaStream();
|
|
279
|
+
const track = new MediaStreamTrack();
|
|
280
|
+
// @ts-expect-error - mock
|
|
281
|
+
mediaStream.id = 'lookup:TRACK_TYPE_AUDIO';
|
|
282
|
+
// @ts-expect-error - mock
|
|
283
|
+
track.kind = 'audio';
|
|
284
|
+
Object.defineProperty(track, 'muted', {
|
|
285
|
+
configurable: true,
|
|
286
|
+
get: () => muted,
|
|
287
|
+
});
|
|
288
|
+
// @ts-expect-error - incomplete mock
|
|
289
|
+
state.updateOrAddParticipant('session-id', {
|
|
290
|
+
sessionId: 'session-id',
|
|
291
|
+
trackLookupPrefix: 'lookup',
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
295
|
+
// @ts-expect-error - incomplete mock
|
|
296
|
+
onTrack({ streams: [mediaStream], track });
|
|
297
|
+
|
|
298
|
+
const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
|
|
299
|
+
.calls;
|
|
300
|
+
const handlers: Record<string, () => void> = {};
|
|
301
|
+
for (const [event, handler] of calls) {
|
|
302
|
+
handlers[event] = handler as () => void;
|
|
303
|
+
}
|
|
304
|
+
return { track, handlers };
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const interruptedFor = (sessionId: string) =>
|
|
308
|
+
state.participants.find((p) => p.sessionId === sessionId)
|
|
309
|
+
?.interruptedTracks ?? [];
|
|
310
|
+
|
|
311
|
+
it('adds the track type when the mute handler fires', () => {
|
|
312
|
+
const { handlers } = setup();
|
|
313
|
+
expect(interruptedFor('session-id')).toEqual([]);
|
|
314
|
+
|
|
315
|
+
handlers['mute']();
|
|
316
|
+
|
|
317
|
+
expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('removes the track type when the unmute handler fires', () => {
|
|
321
|
+
const { handlers } = setup();
|
|
322
|
+
handlers['mute']();
|
|
323
|
+
expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
|
|
324
|
+
|
|
325
|
+
handlers['unmute']();
|
|
326
|
+
|
|
327
|
+
expect(interruptedFor('session-id')).toEqual([]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('seeds the track type when the track arrives already muted', () => {
|
|
331
|
+
setup({ muted: true });
|
|
332
|
+
|
|
333
|
+
expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('clears the track type when the track ends', () => {
|
|
337
|
+
const { handlers } = setup();
|
|
338
|
+
handlers['mute']();
|
|
339
|
+
expect(interruptedFor('session-id')).toEqual([TrackType.AUDIO]);
|
|
340
|
+
|
|
341
|
+
handlers['ended']();
|
|
342
|
+
|
|
343
|
+
expect(interruptedFor('session-id')).toEqual([]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('ignores non-audio remote tracks to avoid Dynascale false positives', () => {
|
|
347
|
+
// Remote video track.muted is dominated by viewport-driven
|
|
348
|
+
// SFU unsubscriptions, so we deliberately only track audio
|
|
349
|
+
// interruption on remote participants.
|
|
350
|
+
const mediaStream = new MediaStream();
|
|
351
|
+
const track = new MediaStreamTrack();
|
|
352
|
+
// @ts-expect-error - mock
|
|
353
|
+
mediaStream.id = 'video-lookup:TRACK_TYPE_VIDEO';
|
|
354
|
+
// @ts-expect-error - mock
|
|
355
|
+
track.kind = 'video';
|
|
356
|
+
Object.defineProperty(track, 'muted', {
|
|
357
|
+
configurable: true,
|
|
358
|
+
get: () => true,
|
|
359
|
+
});
|
|
360
|
+
// @ts-expect-error - incomplete mock
|
|
361
|
+
state.updateOrAddParticipant('video-session', {
|
|
362
|
+
sessionId: 'video-session',
|
|
363
|
+
trackLookupPrefix: 'video-lookup',
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
367
|
+
// @ts-expect-error - incomplete mock
|
|
368
|
+
onTrack({ streams: [mediaStream], track });
|
|
369
|
+
|
|
370
|
+
// Seeded muted track is ignored.
|
|
371
|
+
expect(interruptedFor('video-session')).toEqual([]);
|
|
372
|
+
|
|
373
|
+
// Subsequent mute / unmute events are ignored too.
|
|
374
|
+
const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
|
|
375
|
+
.calls;
|
|
376
|
+
const handlers: Record<string, () => void> = {};
|
|
377
|
+
for (const [event, handler] of calls) {
|
|
378
|
+
handlers[event] = handler as () => void;
|
|
379
|
+
}
|
|
380
|
+
handlers['mute']();
|
|
381
|
+
handlers['unmute']();
|
|
382
|
+
expect(interruptedFor('video-session')).toEqual([]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('does not mutate state for orphaned tracks until associated', () => {
|
|
386
|
+
const mediaStream = new MediaStream();
|
|
387
|
+
const track = new MediaStreamTrack();
|
|
388
|
+
// @ts-expect-error - mock
|
|
389
|
+
mediaStream.id = 'orphan:TRACK_TYPE_AUDIO';
|
|
390
|
+
// @ts-expect-error - mock
|
|
391
|
+
track.kind = 'audio';
|
|
392
|
+
|
|
393
|
+
const onTrack = subscriber['handleOnTrack'];
|
|
394
|
+
// @ts-expect-error - incomplete mock
|
|
395
|
+
onTrack({ streams: [mediaStream], track });
|
|
396
|
+
|
|
397
|
+
const calls = (track.addEventListener as ReturnType<typeof vi.fn>).mock
|
|
398
|
+
.calls;
|
|
399
|
+
const handlers: Record<string, () => void> = {};
|
|
400
|
+
for (const [event, handler] of calls) {
|
|
401
|
+
handlers[event] = handler as () => void;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Orphan: handler fires before the participant exists.
|
|
405
|
+
handlers['mute']();
|
|
406
|
+
expect(state.participants).toEqual([]);
|
|
407
|
+
|
|
408
|
+
// Once the participant is registered, the next event lands.
|
|
409
|
+
// @ts-expect-error - incomplete mock
|
|
410
|
+
state.updateOrAddParticipant('orphan-session', {
|
|
411
|
+
sessionId: 'orphan-session',
|
|
412
|
+
trackLookupPrefix: 'orphan',
|
|
413
|
+
});
|
|
414
|
+
handlers['mute']();
|
|
415
|
+
|
|
416
|
+
expect(interruptedFor('orphan-session')).toEqual([TrackType.AUDIO]);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
275
420
|
describe('Negotiation', () => {
|
|
276
421
|
it('negotiates with the SFU', async () => {
|
|
277
422
|
sfuClient.sendAnswer = vi.fn();
|
|
@@ -16,8 +16,8 @@ const RTCPeerConnectionMock = vi.fn((): Partial<RTCPeerConnection> => {
|
|
|
16
16
|
close: vi.fn(),
|
|
17
17
|
connectionState: 'connected',
|
|
18
18
|
signalingState: 'stable',
|
|
19
|
-
getReceivers: vi.fn(),
|
|
20
|
-
getSenders: vi.fn(),
|
|
19
|
+
getReceivers: vi.fn().mockReturnValue([]),
|
|
20
|
+
getSenders: vi.fn().mockReturnValue([]),
|
|
21
21
|
removeTrack: vi.fn(),
|
|
22
22
|
};
|
|
23
23
|
});
|
|
@@ -109,6 +109,16 @@ const AudioContextMock = vi.fn((): Partial<AudioContext> => {
|
|
|
109
109
|
gain: { value: 1 },
|
|
110
110
|
} as unknown as GainNode;
|
|
111
111
|
}),
|
|
112
|
+
// Silent keep-alive node used by DynascaleManager's probe AudioContext.
|
|
113
|
+
createConstantSource: vi.fn(() => {
|
|
114
|
+
return {
|
|
115
|
+
offset: { value: 0 },
|
|
116
|
+
connect: vi.fn((v) => v),
|
|
117
|
+
disconnect: vi.fn(),
|
|
118
|
+
start: vi.fn(),
|
|
119
|
+
stop: vi.fn(),
|
|
120
|
+
} as unknown as ConstantSourceNode;
|
|
121
|
+
}),
|
|
112
122
|
close: vi.fn(async function () {
|
|
113
123
|
this.state = 'closed';
|
|
114
124
|
}),
|
|
@@ -119,6 +129,7 @@ const AudioContextMock = vi.fn((): Partial<AudioContext> => {
|
|
|
119
129
|
this.sinkId = sinkId;
|
|
120
130
|
}),
|
|
121
131
|
addEventListener: vi.fn(),
|
|
132
|
+
removeEventListener: vi.fn(),
|
|
122
133
|
};
|
|
123
134
|
});
|
|
124
135
|
vi.stubGlobal('AudioContext', AudioContextMock);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DegradationPreference } from '../../../gen/video/sfu/models/models';
|
|
3
|
+
import { toRTCDegradationPreference } from '../degradationPreference';
|
|
4
|
+
|
|
5
|
+
describe('toRTCDegradationPreference', () => {
|
|
6
|
+
it.each([
|
|
7
|
+
[DegradationPreference.BALANCED, 'balanced'],
|
|
8
|
+
[DegradationPreference.MAINTAIN_FRAMERATE, 'maintain-framerate'],
|
|
9
|
+
[DegradationPreference.MAINTAIN_RESOLUTION, 'maintain-resolution'],
|
|
10
|
+
[
|
|
11
|
+
DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION,
|
|
12
|
+
'maintain-framerate-and-resolution',
|
|
13
|
+
],
|
|
14
|
+
])('maps %s to "%s"', (preference, expected) => {
|
|
15
|
+
expect(toRTCDegradationPreference(preference)).toBe(expected);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns undefined for UNSPECIFIED', () => {
|
|
19
|
+
expect(
|
|
20
|
+
toRTCDegradationPreference(DegradationPreference.UNSPECIFIED),
|
|
21
|
+
).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { DegradationPreference } from '../../gen/video/sfu/models/models';
|
|
2
|
+
import { ensureExhausted } from '../../helpers/ensureExhausted';
|
|
3
|
+
|
|
4
|
+
export const toRTCDegradationPreference = (
|
|
5
|
+
preference: DegradationPreference,
|
|
6
|
+
): RTCDegradationPreference | undefined => {
|
|
7
|
+
switch (preference) {
|
|
8
|
+
case DegradationPreference.BALANCED:
|
|
9
|
+
return 'balanced';
|
|
10
|
+
case DegradationPreference.MAINTAIN_FRAMERATE:
|
|
11
|
+
return 'maintain-framerate';
|
|
12
|
+
case DegradationPreference.MAINTAIN_RESOLUTION:
|
|
13
|
+
return 'maintain-resolution';
|
|
14
|
+
case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
|
|
15
|
+
// @ts-expect-error not in the typedefs yet
|
|
16
|
+
return 'maintain-framerate-and-resolution';
|
|
17
|
+
case DegradationPreference.UNSPECIFIED:
|
|
18
|
+
return undefined;
|
|
19
|
+
default:
|
|
20
|
+
ensureExhausted(preference, 'Unknown degradation preference');
|
|
21
|
+
}
|
|
22
|
+
};
|