@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.browser.es.js +1086 -594
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1086 -594
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1086 -594
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +42 -3
  9. package/dist/src/coordinator/connection/client.d.ts +1 -1
  10. package/dist/src/coordinator/connection/connection.d.ts +31 -25
  11. package/dist/src/coordinator/connection/types.d.ts +14 -0
  12. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  13. package/dist/src/devices/DeviceManager.d.ts +3 -0
  14. package/dist/src/devices/DeviceManagerState.d.ts +0 -1
  15. package/dist/src/gen/video/sfu/event/events.d.ts +5 -1
  16. package/dist/src/gen/video/sfu/models/models.d.ts +34 -0
  17. package/dist/src/helpers/AudioBindingsWatchdog.d.ts +3 -3
  18. package/dist/src/helpers/BlockedAudioTracker.d.ts +30 -0
  19. package/dist/src/helpers/DynascaleManager.d.ts +8 -86
  20. package/dist/src/helpers/MediaPlaybackWatchdog.d.ts +32 -0
  21. package/dist/src/helpers/TrackSubscriptionManager.d.ts +114 -0
  22. package/dist/src/helpers/ViewportTracker.d.ts +11 -17
  23. package/dist/src/helpers/browsers.d.ts +13 -0
  24. package/dist/src/helpers/concurrency.d.ts +6 -4
  25. package/dist/src/rtc/Publisher.d.ts +17 -0
  26. package/dist/src/rtc/Subscriber.d.ts +1 -0
  27. package/dist/src/rtc/helpers/degradationPreference.d.ts +2 -0
  28. package/dist/src/stats/rtc/types.d.ts +1 -1
  29. package/dist/src/store/rxUtils.d.ts +9 -0
  30. package/dist/src/types.d.ts +18 -0
  31. package/package.json +2 -2
  32. package/src/Call.ts +89 -22
  33. package/src/__tests__/Call.lifecycle.test.ts +67 -0
  34. package/src/coordinator/connection/__tests__/connection.test.ts +482 -0
  35. package/src/coordinator/connection/client.ts +1 -1
  36. package/src/coordinator/connection/connection.ts +149 -96
  37. package/src/coordinator/connection/types.ts +15 -0
  38. package/src/coordinator/connection/utils.ts +15 -0
  39. package/src/devices/DeviceManager.ts +92 -32
  40. package/src/devices/DeviceManagerState.ts +0 -1
  41. package/src/devices/__tests__/DeviceManager.test.ts +283 -0
  42. package/src/devices/__tests__/mocks.ts +2 -0
  43. package/src/gen/video/sfu/event/events.ts +15 -0
  44. package/src/gen/video/sfu/models/models.ts +44 -0
  45. package/src/helpers/AudioBindingsWatchdog.ts +10 -7
  46. package/src/helpers/BlockedAudioTracker.ts +74 -0
  47. package/src/helpers/DynascaleManager.ts +46 -337
  48. package/src/helpers/MediaPlaybackWatchdog.ts +121 -0
  49. package/src/helpers/TrackSubscriptionManager.ts +243 -0
  50. package/src/helpers/ViewportTracker.ts +74 -19
  51. package/src/helpers/__tests__/BlockedAudioTracker.test.ts +114 -0
  52. package/src/helpers/__tests__/DynascaleManager.test.ts +175 -122
  53. package/src/helpers/__tests__/MediaPlaybackWatchdog.test.ts +180 -0
  54. package/src/helpers/__tests__/TrackSubscriptionManager.test.ts +310 -0
  55. package/src/helpers/__tests__/ViewportTracker.test.ts +83 -0
  56. package/src/helpers/__tests__/browsers.test.ts +85 -1
  57. package/src/helpers/browsers.ts +24 -0
  58. package/src/helpers/concurrency.ts +9 -10
  59. package/src/rtc/Publisher.ts +47 -1
  60. package/src/rtc/Subscriber.ts +42 -14
  61. package/src/rtc/__tests__/Publisher.test.ts +122 -10
  62. package/src/rtc/__tests__/Subscriber.test.ts +146 -1
  63. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +13 -2
  64. package/src/rtc/helpers/__tests__/degradationPreference.test.ts +23 -0
  65. package/src/rtc/helpers/degradationPreference.ts +22 -0
  66. package/src/stats/rtc/types.ts +1 -0
  67. package/src/store/__tests__/rxUtils.test.ts +276 -0
  68. package/src/store/rxUtils.ts +19 -0
  69. package/src/types.ts +19 -0
@@ -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 [primaryStream] = e.streams;
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
- e.track.id,
79
- e.track,
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
- e.track.addEventListener('mute', () => {
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
- const trackType = toTrackType(rawTrackType);
97
- if (!trackType) {
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(e.track.id, trackType);
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 ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`,
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
- // @ts-expect-error incomplete data
897
- { trackType: TrackType.VIDEO, id: 0, codec: { name: 'vp8' } },
898
- // @ts-expect-error incomplete data
899
- { trackType: TrackType.VIDEO, id: 1, codec: { name: 'av1' } },
900
- // @ts-expect-error incomplete data
901
- { trackType: TrackType.VIDEO, id: 2, codec: { name: 'vp9' } },
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
- const state = new CallState();
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
+ };
@@ -16,6 +16,7 @@ export type RTCStatsDataType =
16
16
  | RTCSessionDescriptionInit
17
17
  | (RTCIceCandidateInit | RTCIceCandidate) // addIceCandidate
18
18
  | object
19
+ | number
19
20
  | null
20
21
  | undefined;
21
22