@stream-io/video-client 1.13.1 → 1.15.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 (99) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +1704 -1762
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1706 -1780
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1704 -1762
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +61 -30
  9. package/dist/src/StreamSfuClient.d.ts +4 -5
  10. package/dist/src/devices/CameraManager.d.ts +5 -8
  11. package/dist/src/devices/InputMediaDeviceManager.d.ts +5 -5
  12. package/dist/src/devices/MicrophoneManager.d.ts +7 -2
  13. package/dist/src/devices/ScreenShareManager.d.ts +1 -2
  14. package/dist/src/gen/coordinator/index.d.ts +904 -515
  15. package/dist/src/gen/video/sfu/event/events.d.ts +38 -19
  16. package/dist/src/gen/video/sfu/models/models.d.ts +76 -9
  17. package/dist/src/helpers/array.d.ts +7 -0
  18. package/dist/src/permissions/PermissionsContext.d.ts +6 -0
  19. package/dist/src/rtc/BasePeerConnection.d.ts +90 -0
  20. package/dist/src/rtc/Dispatcher.d.ts +0 -1
  21. package/dist/src/rtc/IceTrickleBuffer.d.ts +3 -2
  22. package/dist/src/rtc/Publisher.d.ts +32 -86
  23. package/dist/src/rtc/Subscriber.d.ts +4 -56
  24. package/dist/src/rtc/TransceiverCache.d.ts +55 -0
  25. package/dist/src/rtc/codecs.d.ts +1 -15
  26. package/dist/src/rtc/helpers/sdp.d.ts +8 -0
  27. package/dist/src/rtc/helpers/tracks.d.ts +1 -0
  28. package/dist/src/rtc/index.d.ts +3 -0
  29. package/dist/src/rtc/videoLayers.d.ts +11 -25
  30. package/dist/src/stats/{stateStoreStatsReporter.d.ts → CallStateStatsReporter.d.ts} +5 -1
  31. package/dist/src/stats/SfuStatsReporter.d.ts +4 -2
  32. package/dist/src/stats/index.d.ts +1 -1
  33. package/dist/src/stats/types.d.ts +8 -0
  34. package/dist/src/store/CallState.d.ts +47 -5
  35. package/dist/src/store/rxUtils.d.ts +15 -1
  36. package/dist/src/types.d.ts +26 -22
  37. package/package.json +1 -1
  38. package/src/Call.ts +310 -271
  39. package/src/StreamSfuClient.ts +9 -14
  40. package/src/StreamVideoClient.ts +1 -1
  41. package/src/__tests__/Call.publishing.test.ts +306 -0
  42. package/src/devices/CameraManager.ts +33 -16
  43. package/src/devices/InputMediaDeviceManager.ts +36 -27
  44. package/src/devices/MicrophoneManager.ts +29 -8
  45. package/src/devices/ScreenShareManager.ts +6 -8
  46. package/src/devices/__tests__/CameraManager.test.ts +111 -14
  47. package/src/devices/__tests__/InputMediaDeviceManager.test.ts +4 -4
  48. package/src/devices/__tests__/MicrophoneManager.test.ts +59 -21
  49. package/src/devices/__tests__/ScreenShareManager.test.ts +5 -5
  50. package/src/devices/__tests__/mocks.ts +1 -0
  51. package/src/events/__tests__/internal.test.ts +132 -0
  52. package/src/events/__tests__/mutes.test.ts +0 -3
  53. package/src/events/__tests__/speaker.test.ts +92 -0
  54. package/src/events/participant.ts +3 -4
  55. package/src/gen/coordinator/index.ts +902 -514
  56. package/src/gen/video/sfu/event/events.ts +91 -30
  57. package/src/gen/video/sfu/models/models.ts +105 -13
  58. package/src/helpers/array.ts +14 -0
  59. package/src/permissions/PermissionsContext.ts +22 -0
  60. package/src/permissions/__tests__/PermissionsContext.test.ts +40 -0
  61. package/src/rpc/__tests__/createClient.test.ts +38 -0
  62. package/src/rpc/createClient.ts +11 -5
  63. package/src/rtc/BasePeerConnection.ts +240 -0
  64. package/src/rtc/Dispatcher.ts +0 -9
  65. package/src/rtc/IceTrickleBuffer.ts +24 -4
  66. package/src/rtc/Publisher.ts +210 -528
  67. package/src/rtc/Subscriber.ts +26 -200
  68. package/src/rtc/TransceiverCache.ts +120 -0
  69. package/src/rtc/__tests__/Publisher.test.ts +407 -210
  70. package/src/rtc/__tests__/Subscriber.test.ts +88 -36
  71. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +22 -2
  72. package/src/rtc/__tests__/videoLayers.test.ts +161 -54
  73. package/src/rtc/codecs.ts +1 -131
  74. package/src/rtc/helpers/__tests__/rtcConfiguration.test.ts +34 -0
  75. package/src/rtc/helpers/__tests__/sdp.test.ts +59 -0
  76. package/src/rtc/helpers/sdp.ts +30 -0
  77. package/src/rtc/helpers/tracks.ts +3 -0
  78. package/src/rtc/index.ts +4 -0
  79. package/src/rtc/videoLayers.ts +68 -76
  80. package/src/stats/{stateStoreStatsReporter.ts → CallStateStatsReporter.ts} +58 -27
  81. package/src/stats/SfuStatsReporter.ts +31 -3
  82. package/src/stats/index.ts +1 -1
  83. package/src/stats/types.ts +12 -0
  84. package/src/store/CallState.ts +115 -5
  85. package/src/store/__tests__/CallState.test.ts +101 -0
  86. package/src/store/rxUtils.ts +23 -1
  87. package/src/types.ts +27 -22
  88. package/dist/src/helpers/sdp-munging.d.ts +0 -24
  89. package/dist/src/rtc/bitrateLookup.d.ts +0 -2
  90. package/dist/src/rtc/helpers/iceCandidate.d.ts +0 -2
  91. package/src/helpers/__tests__/hq-audio-sdp.ts +0 -332
  92. package/src/helpers/__tests__/sdp-munging.test.ts +0 -283
  93. package/src/helpers/sdp-munging.ts +0 -265
  94. package/src/rtc/__tests__/bitrateLookup.test.ts +0 -12
  95. package/src/rtc/__tests__/codecs.test.ts +0 -145
  96. package/src/rtc/bitrateLookup.ts +0 -61
  97. package/src/rtc/helpers/iceCandidate.ts +0 -16
  98. /package/dist/src/{compatibility.d.ts → helpers/compatibility.d.ts} +0 -0
  99. /package/src/{compatibility.ts → helpers/compatibility.ts} +0 -0
@@ -5,10 +5,16 @@ import { Publisher } from '../Publisher';
5
5
  import { CallState } from '../../store';
6
6
  import { StreamSfuClient } from '../../StreamSfuClient';
7
7
  import { DispatchableMessage, Dispatcher } from '../Dispatcher';
8
- import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
8
+ import {
9
+ PeerType,
10
+ PublishOption,
11
+ TrackInfo,
12
+ TrackType,
13
+ } from '../../gen/video/sfu/models/models';
9
14
  import { SfuEvent } from '../../gen/video/sfu/event/events';
10
15
  import { IceTrickleBuffer } from '../IceTrickleBuffer';
11
16
  import { StreamClient } from '../../coordinator/connection/client';
17
+ import { TransceiverCache } from '../TransceiverCache';
12
18
 
13
19
  vi.mock('../../StreamSfuClient', () => {
14
20
  console.log('MOCKING StreamSfuClient');
@@ -17,22 +23,6 @@ vi.mock('../../StreamSfuClient', () => {
17
23
  };
18
24
  });
19
25
 
20
- vi.mock('../codecs', async () => {
21
- const codecs = await vi.importActual('../codecs');
22
- return {
23
- getPreferredCodecs: vi.fn((): RTCRtpCodecCapability[] => [
24
- {
25
- channels: 1,
26
- clockRate: 48000,
27
- mimeType: 'video/h264',
28
- sdpFmtpLine: 'profile-level-id=42e01f',
29
- },
30
- ]),
31
- getOptimalVideoCodec: codecs.getOptimalVideoCodec,
32
- isSvcCodec: codecs.isSvcCodec,
33
- };
34
- });
35
-
36
26
  describe('Publisher', () => {
37
27
  const sessionId = 'session-id-test';
38
28
  let publisher: Publisher;
@@ -69,154 +59,124 @@ describe('Publisher', () => {
69
59
  sfuClient,
70
60
  dispatcher,
71
61
  state,
72
- isDtxEnabled: true,
73
- isRedEnabled: true,
74
62
  logTag: 'test',
63
+ publishOptions: [
64
+ {
65
+ id: 1,
66
+ trackType: TrackType.VIDEO,
67
+ bitrate: 1000,
68
+ // @ts-expect-error - incomplete data
69
+ codec: { name: 'vp9' },
70
+ fps: 30,
71
+ maxTemporalLayers: 3,
72
+ maxSpatialLayers: 3,
73
+ },
74
+ ],
75
75
  });
76
76
  });
77
77
 
78
78
  afterEach(() => {
79
79
  vi.clearAllMocks();
80
80
  vi.resetModules();
81
- dispatcher.offAll();
81
+ publisher.dispose();
82
82
  });
83
83
 
84
- it('can publish, re-publish and un-publish a stream', async () => {
85
- const mediaStream = new MediaStream();
86
- const track = new MediaStreamTrack();
87
- mediaStream.addTrack(track);
88
-
89
- state.setParticipants([
90
- // @ts-ignore
91
- {
92
- isLocalParticipant: true,
93
- userId: 'test-user-id',
94
- sessionId: sessionId,
95
- publishedTracks: [],
96
- },
97
- ]);
84
+ describe('Publishing', () => {
85
+ it('should throw when publishing ended tracks', async () => {
86
+ const track = new MediaStreamTrack();
87
+ // @ts-ignore readonly field
88
+ track.readyState = 'ended';
89
+ await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toThrow();
90
+ });
98
91
 
99
- vi.spyOn(track, 'getSettings').mockReturnValue({
100
- width: 640,
101
- height: 480,
102
- deviceId: 'test-device-id',
92
+ it('should throw when attempting to publish a track that has no publish options', async () => {
93
+ const track = new MediaStreamTrack();
94
+ await expect(publisher.publish(track, TrackType.AUDIO)).rejects.toThrow();
103
95
  });
104
96
 
105
- const transceiver = new RTCRtpTransceiver();
106
- vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(track);
107
- vi.spyOn(publisher['pc'], 'addTransceiver').mockReturnValue(transceiver);
108
- vi.spyOn(publisher['pc'], 'getTransceivers').mockReturnValue([transceiver]);
109
-
110
- sfuClient.updateMuteState = vi.fn();
111
-
112
- // initial publish
113
- await publisher.publishStream(mediaStream, track, TrackType.VIDEO);
114
-
115
- expect(state.localParticipant?.publishedTracks).toContain(TrackType.VIDEO);
116
- expect(state.localParticipant?.videoStream).toEqual(mediaStream);
117
- expect(transceiver.setCodecPreferences).toHaveBeenCalled();
118
- expect(sfuClient.updateMuteState).toHaveBeenCalledWith(
119
- TrackType.VIDEO,
120
- false,
121
- );
122
- expect(track.addEventListener).toHaveBeenCalledWith(
123
- 'ended',
124
- expect.any(Function),
125
- );
126
-
127
- // re-publish a new track
128
- const newMediaStream = new MediaStream();
129
- const newTrack = new MediaStreamTrack();
130
- newMediaStream.addTrack(newTrack);
131
-
132
- vi.spyOn(newTrack, 'getSettings').mockReturnValue({
133
- width: 1280,
134
- height: 720,
135
- deviceId: 'test-device-id-2',
97
+ it('should add a transceiver for new tracks', async () => {
98
+ const track = new MediaStreamTrack();
99
+ const clone = new MediaStreamTrack();
100
+ vi.spyOn(track, 'clone').mockReturnValue(clone);
101
+
102
+ await publisher.publish(track, TrackType.VIDEO);
103
+
104
+ expect(track.clone).toHaveBeenCalled();
105
+ expect(publisher['pc'].addTransceiver).toHaveBeenCalledWith(clone, {
106
+ direction: 'sendonly',
107
+ sendEncodings: [
108
+ {
109
+ rid: 'q',
110
+ active: true,
111
+ maxBitrate: 1000,
112
+ height: 720,
113
+ width: 1280,
114
+ maxFramerate: 30,
115
+ scalabilityMode: 'L3T3_KEY',
116
+ },
117
+ ],
118
+ });
136
119
  });
137
120
 
138
- await publisher.publishStream(newMediaStream, newTrack, TrackType.VIDEO);
139
- vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(newTrack);
140
-
141
- expect(track.stop).toHaveBeenCalled();
142
- expect(newTrack.addEventListener).not.toHaveBeenCalledWith(
143
- 'ended',
144
- expect.any(Function),
145
- );
146
- expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(newTrack);
147
-
148
- // stop publishing
149
- await publisher.unpublishStream(TrackType.VIDEO, true);
150
- expect(newTrack.stop).toHaveBeenCalled();
151
- expect(state.localParticipant?.publishedTracks).not.toContain(
152
- TrackType.VIDEO,
153
- );
154
- });
121
+ it('should update an existing transceiver for a new track', async () => {
122
+ const track = new MediaStreamTrack();
123
+ const clone = new MediaStreamTrack();
124
+ vi.spyOn(track, 'clone').mockReturnValue(clone);
155
125
 
156
- it('can publish and un-publish with just enabling and disabling tracks', async () => {
157
- const mediaStream = new MediaStream();
158
- const track = new MediaStreamTrack();
159
- mediaStream.addTrack(track);
126
+ const transceiver = new RTCRtpTransceiver();
127
+ publisher['transceiverCache'].add(
128
+ publisher['publishOptions'][0],
129
+ transceiver,
130
+ );
160
131
 
161
- state.setParticipants([
162
- // @ts-ignore
163
- {
164
- isLocalParticipant: true,
165
- userId: 'test-user-id',
166
- sessionId: sessionId,
167
- publishedTracks: [],
168
- },
169
- ]);
132
+ await publisher.publish(track, TrackType.VIDEO);
170
133
 
171
- vi.spyOn(track, 'getSettings').mockReturnValue({
172
- width: 640,
173
- height: 480,
174
- deviceId: 'test-device-id',
134
+ expect(track.clone).toHaveBeenCalled();
135
+ expect(publisher['pc'].addTransceiver).not.toHaveBeenCalled();
136
+ expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
137
+ });
138
+ });
139
+
140
+ describe('Event Handling', () => {
141
+ it('handles changePublishQuality events', () => {
142
+ publisher['changePublishQuality'] = vi.fn();
143
+ dispatcher.dispatch(
144
+ SfuEvent.create({
145
+ eventPayload: {
146
+ oneofKind: 'changePublishQuality',
147
+ changePublishQuality: {
148
+ audioSenders: [],
149
+ videoSenders: [
150
+ {
151
+ publishOptionId: 1,
152
+ trackType: TrackType.VIDEO,
153
+ layers: [],
154
+ },
155
+ {
156
+ publishOptionId: 2,
157
+ trackType: TrackType.SCREEN_SHARE,
158
+ layers: [],
159
+ },
160
+ ],
161
+ },
162
+ },
163
+ }) as DispatchableMessage<'changePublishQuality'>,
164
+ );
165
+ expect(publisher['changePublishQuality']).toHaveBeenCalled();
175
166
  });
176
167
 
177
- const transceiver = new RTCRtpTransceiver();
178
- vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(track);
179
- vi.spyOn(publisher['pc'], 'addTransceiver').mockReturnValue(transceiver);
180
- vi.spyOn(publisher['pc'], 'getTransceivers').mockReturnValue([transceiver]);
181
-
182
- sfuClient.updateMuteState = vi.fn();
183
-
184
- // initial publish
185
- await publisher.publishStream(mediaStream, track, TrackType.VIDEO);
186
-
187
- expect(state.localParticipant?.publishedTracks).toContain(TrackType.VIDEO);
188
- expect(track.enabled).toBe(true);
189
- expect(state.localParticipant?.videoStream).toEqual(mediaStream);
190
- expect(transceiver.setCodecPreferences).toHaveBeenCalled();
191
- expect(sfuClient.updateMuteState).toHaveBeenCalledWith(
192
- TrackType.VIDEO,
193
- false,
194
- );
195
-
196
- expect(track.addEventListener).toHaveBeenCalledWith(
197
- 'ended',
198
- expect.any(Function),
199
- );
200
-
201
- // stop publishing
202
- await publisher.unpublishStream(TrackType.VIDEO, false);
203
- expect(track.stop).not.toHaveBeenCalled();
204
- expect(track.enabled).toBe(false);
205
- expect(state.localParticipant?.publishedTracks).not.toContain(
206
- TrackType.VIDEO,
207
- );
208
- expect(state.localParticipant?.videoStream).toBeUndefined();
209
-
210
- const addEventListenerSpy = vi.spyOn(track, 'addEventListener');
211
- const removeEventListenerSpy = vi.spyOn(track, 'removeEventListener');
212
-
213
- // start publish again
214
- await publisher.publishStream(mediaStream, track, TrackType.VIDEO);
215
-
216
- expect(track.enabled).toBe(true);
217
- // republishing the same stream should use the previously registered event handlers
218
- expect(removeEventListenerSpy).not.toHaveBeenCalled();
219
- expect(addEventListenerSpy).not.toHaveBeenCalled();
168
+ it('handles changePublishOptions events', () => {
169
+ publisher['syncPublishOptions'] = vi.fn();
170
+ dispatcher.dispatch(
171
+ SfuEvent.create({
172
+ eventPayload: {
173
+ oneofKind: 'changePublishOptions',
174
+ changePublishOptions: { publishOptions: [], reason: 'test' },
175
+ },
176
+ }) as DispatchableMessage<'changePublishOptions'>,
177
+ );
178
+ expect(publisher['syncPublishOptions']).toHaveBeenCalled();
179
+ });
220
180
  });
221
181
 
222
182
  describe('Publisher ICE Restart', () => {
@@ -304,34 +264,42 @@ describe('Publisher', () => {
304
264
  });
305
265
 
306
266
  // inject the transceiver
307
- publisher['transceiverCache'].set(TrackType.VIDEO, transceiver);
267
+ publisher['transceiverCache'].add(
268
+ // @ts-expect-error incomplete data
269
+ { trackType: TrackType.VIDEO, id: 1 },
270
+ transceiver,
271
+ );
308
272
 
309
- await publisher['changePublishQuality']([
310
- {
311
- name: 'q',
312
- active: true,
313
- maxBitrate: 100,
314
- scaleResolutionDownBy: 4,
315
- maxFramerate: 30,
316
- scalabilityMode: '',
317
- },
318
- {
319
- name: 'h',
320
- active: false,
321
- maxBitrate: 150,
322
- scaleResolutionDownBy: 2,
323
- maxFramerate: 30,
324
- scalabilityMode: '',
325
- },
326
- {
327
- name: 'f',
328
- active: true,
329
- maxBitrate: 200,
330
- scaleResolutionDownBy: 1,
331
- maxFramerate: 30,
332
- scalabilityMode: '',
333
- },
334
- ]);
273
+ await publisher['changePublishQuality']({
274
+ publishOptionId: 1,
275
+ trackType: TrackType.VIDEO,
276
+ layers: [
277
+ {
278
+ name: 'q',
279
+ active: true,
280
+ maxBitrate: 100,
281
+ scaleResolutionDownBy: 4,
282
+ maxFramerate: 30,
283
+ scalabilityMode: '',
284
+ },
285
+ {
286
+ name: 'h',
287
+ active: false,
288
+ maxBitrate: 150,
289
+ scaleResolutionDownBy: 2,
290
+ maxFramerate: 30,
291
+ scalabilityMode: '',
292
+ },
293
+ {
294
+ name: 'f',
295
+ active: true,
296
+ maxBitrate: 200,
297
+ scaleResolutionDownBy: 1,
298
+ maxFramerate: 30,
299
+ scalabilityMode: '',
300
+ },
301
+ ],
302
+ });
335
303
 
336
304
  expect(getParametersSpy).toHaveBeenCalled();
337
305
  expect(setParametersSpy).toHaveBeenCalled();
@@ -346,9 +314,6 @@ describe('Publisher', () => {
346
314
  {
347
315
  rid: 'h',
348
316
  active: false,
349
- maxBitrate: 150,
350
- scaleResolutionDownBy: 2,
351
- maxFramerate: 30,
352
317
  },
353
318
  {
354
319
  rid: 'f',
@@ -374,18 +339,26 @@ describe('Publisher', () => {
374
339
  });
375
340
 
376
341
  // inject the transceiver
377
- publisher['transceiverCache'].set(TrackType.VIDEO, transceiver);
342
+ publisher['transceiverCache'].add(
343
+ // @ts-expect-error incomplete data
344
+ { trackType: TrackType.VIDEO, id: 1 },
345
+ transceiver,
346
+ );
378
347
 
379
- await publisher['changePublishQuality']([
380
- {
381
- name: 'q',
382
- active: true,
383
- maxBitrate: 100,
384
- scaleResolutionDownBy: 4,
385
- maxFramerate: 30,
386
- scalabilityMode: '',
387
- },
388
- ]);
348
+ await publisher['changePublishQuality']({
349
+ publishOptionId: 1,
350
+ trackType: TrackType.VIDEO,
351
+ layers: [
352
+ {
353
+ name: 'q',
354
+ active: true,
355
+ maxBitrate: 100,
356
+ scaleResolutionDownBy: 4,
357
+ maxFramerate: 30,
358
+ scalabilityMode: '',
359
+ },
360
+ ],
361
+ });
389
362
 
390
363
  expect(getParametersSpy).toHaveBeenCalled();
391
364
  expect(setParametersSpy).toHaveBeenCalled();
@@ -429,18 +402,25 @@ describe('Publisher', () => {
429
402
  });
430
403
 
431
404
  // inject the transceiver
432
- publisher['transceiverCache'].set(TrackType.VIDEO, transceiver);
433
-
434
- await publisher['changePublishQuality']([
435
- {
436
- name: 'q',
437
- active: true,
438
- maxBitrate: 50,
439
- scaleResolutionDownBy: 1,
440
- maxFramerate: 30,
441
- scalabilityMode: 'L1T3',
442
- },
443
- ]);
405
+ publisher['transceiverCache'].add(
406
+ // @ts-expect-error incomplete data
407
+ { trackType: TrackType.VIDEO, id: 1 },
408
+ transceiver,
409
+ );
410
+ await publisher['changePublishQuality']({
411
+ publishOptionId: 1,
412
+ trackType: TrackType.VIDEO,
413
+ layers: [
414
+ {
415
+ name: 'q',
416
+ active: true,
417
+ maxBitrate: 50,
418
+ scaleResolutionDownBy: 1,
419
+ maxFramerate: 30,
420
+ scalabilityMode: 'L1T3',
421
+ },
422
+ ],
423
+ });
444
424
 
445
425
  expect(getParametersSpy).toHaveBeenCalled();
446
426
  expect(setParametersSpy).toHaveBeenCalled();
@@ -479,18 +459,26 @@ describe('Publisher', () => {
479
459
  });
480
460
 
481
461
  // inject the transceiver
482
- publisher['transceiverCache'].set(TrackType.VIDEO, transceiver);
462
+ publisher['transceiverCache'].add(
463
+ // @ts-expect-error incomplete data
464
+ { trackType: TrackType.VIDEO, id: 1 },
465
+ transceiver,
466
+ );
483
467
 
484
- await publisher['changePublishQuality']([
485
- {
486
- name: 'q',
487
- active: true,
488
- maxBitrate: 50,
489
- scaleResolutionDownBy: 1,
490
- maxFramerate: 30,
491
- scalabilityMode: 'L1T3',
492
- },
493
- ]);
468
+ await publisher['changePublishQuality']({
469
+ publishOptionId: 1,
470
+ trackType: TrackType.VIDEO,
471
+ layers: [
472
+ {
473
+ name: 'q',
474
+ active: true,
475
+ maxBitrate: 50,
476
+ scaleResolutionDownBy: 1,
477
+ maxFramerate: 30,
478
+ scalabilityMode: 'L1T3',
479
+ },
480
+ ],
481
+ });
494
482
 
495
483
  expect(getParametersSpy).toHaveBeenCalled();
496
484
  expect(setParametersSpy).toHaveBeenCalled();
@@ -505,4 +493,213 @@ describe('Publisher', () => {
505
493
  ]);
506
494
  });
507
495
  });
496
+
497
+ describe('changePublishOptions', () => {
498
+ it('adds missing transceivers', async () => {
499
+ const transceiver = new RTCRtpTransceiver();
500
+ const track = new MediaStreamTrack();
501
+ vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(track);
502
+ vi.spyOn(track, 'clone').mockReturnValue(track);
503
+ // @ts-expect-error private method
504
+ vi.spyOn(publisher, 'addTransceiver');
505
+
506
+ publisher['publishOptions'] = [
507
+ // @ts-expect-error incomplete data
508
+ { trackType: TrackType.VIDEO, id: 0, codec: { name: 'vp8' } },
509
+ // @ts-expect-error incomplete data
510
+ { trackType: TrackType.VIDEO, id: 1, codec: { name: 'av1' } },
511
+ // @ts-expect-error incomplete data
512
+ { trackType: TrackType.VIDEO, id: 2, codec: { name: 'vp9' } },
513
+ ];
514
+
515
+ publisher['transceiverCache'].add(
516
+ publisher['publishOptions'][0],
517
+ transceiver,
518
+ );
519
+
520
+ vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
521
+
522
+ // enable av1 and vp9
523
+ await publisher['syncPublishOptions']();
524
+
525
+ expect(publisher['transceiverCache'].items().length).toBe(3);
526
+ expect(publisher['addTransceiver']).toHaveBeenCalledTimes(2);
527
+ expect(publisher['addTransceiver']).toHaveBeenCalledWith(
528
+ track,
529
+ expect.objectContaining({
530
+ trackType: TrackType.VIDEO,
531
+ id: 1,
532
+ codec: { name: 'av1' },
533
+ }),
534
+ );
535
+ expect(publisher['addTransceiver']).toHaveBeenCalledWith(
536
+ track,
537
+ expect.objectContaining({
538
+ trackType: TrackType.VIDEO,
539
+ id: 2,
540
+ codec: { name: 'vp9' },
541
+ }),
542
+ );
543
+ });
544
+
545
+ it('disables extra transceivers', async () => {
546
+ const publishOptions: PublishOption[] = [
547
+ // @ts-expect-error incomplete data
548
+ { trackType: TrackType.VIDEO, id: 0, codec: { name: 'vp8' } },
549
+ // @ts-expect-error incomplete data
550
+ { trackType: TrackType.VIDEO, id: 1, codec: { name: 'av1' } },
551
+ // @ts-expect-error incomplete data
552
+ { trackType: TrackType.VIDEO, id: 2, codec: { name: 'vp9' } },
553
+ ];
554
+
555
+ const track = new MediaStreamTrack();
556
+ const transceiver = new RTCRtpTransceiver();
557
+ // @ts-ignore test setup
558
+ transceiver.sender.track = track;
559
+
560
+ publisher['transceiverCache'].add(publishOptions[0], transceiver);
561
+ publisher['transceiverCache'].add(publishOptions[1], transceiver);
562
+ publisher['transceiverCache'].add(publishOptions[2], transceiver);
563
+
564
+ vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
565
+ // disable av1
566
+ publisher['publishOptions'] = publishOptions.filter(
567
+ (o) => o.codec?.name !== 'av1',
568
+ );
569
+
570
+ await publisher['syncPublishOptions']();
571
+
572
+ expect(publisher['transceiverCache'].items().length).toBe(3);
573
+ expect(track.stop).toHaveBeenCalledOnce();
574
+ expect(transceiver.sender.replaceTrack).toHaveBeenCalledOnce();
575
+ expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(null);
576
+ });
577
+ });
578
+
579
+ describe('negotiation and track management', () => {
580
+ let cache: TransceiverCache;
581
+
582
+ beforeEach(() => {
583
+ cache = publisher['transceiverCache'];
584
+ const transceiver = new RTCRtpTransceiver();
585
+ const track = new MediaStreamTrack();
586
+ vi.spyOn(track, 'enabled', 'get').mockReturnValue(true);
587
+ vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(track);
588
+
589
+ const inactiveTransceiver = new RTCRtpTransceiver();
590
+ const inactiveTrack = new MediaStreamTrack();
591
+ vi.spyOn(inactiveTrack, 'enabled', 'get').mockReturnValue(false);
592
+ vi.spyOn(inactiveTransceiver.sender, 'track', 'get').mockReturnValue(
593
+ inactiveTrack,
594
+ );
595
+ vi.spyOn(inactiveTrack, 'readyState', 'get').mockReturnValue('ended');
596
+
597
+ // @ts-expect-error incomplete data
598
+ cache.add({ trackType: TrackType.VIDEO, id: 1 }, transceiver);
599
+ // @ts-expect-error incomplete data
600
+ cache.add({ trackType: TrackType.VIDEO, id: 2 }, inactiveTransceiver);
601
+ });
602
+
603
+ it('negotiate should set up the local and remote descriptions', async () => {
604
+ const spyOffer: RTCSessionDescriptionInit = {
605
+ sdp: 'offer-sdp',
606
+ type: 'offer',
607
+ };
608
+ const createOfferSpy = vi
609
+ .spyOn(publisher['pc'], 'createOffer')
610
+ // @ts-expect-error TS picks up the wrong overload
611
+ .mockResolvedValue(spyOffer);
612
+
613
+ const setLocalDescriptionSpy = vi
614
+ .spyOn(publisher['pc'], 'setLocalDescription')
615
+ .mockResolvedValue();
616
+
617
+ const setRemoteDescriptionSpy = vi
618
+ .spyOn(publisher['pc'], 'setRemoteDescription')
619
+ .mockResolvedValue();
620
+
621
+ const addIceCandidateSpy = vi
622
+ .spyOn(publisher['pc'], 'addIceCandidate')
623
+ .mockResolvedValue();
624
+
625
+ sfuClient.setPublisher = vi.fn().mockResolvedValue({
626
+ response: {
627
+ sdp: 'answer-sdp',
628
+ },
629
+ });
630
+
631
+ // @ts-expect-error incomplete data
632
+ const trackInfosMock: TrackInfo[] = [{ trackId: '123' }];
633
+ vi.spyOn(publisher, 'getAnnouncedTracks').mockReturnValue(trackInfosMock);
634
+
635
+ sfuClient['iceTrickleBuffer'].push({
636
+ peerType: PeerType.PUBLISHER_UNSPECIFIED,
637
+ iceCandidate: '{ "ufrag": "test", "candidate": "test" }',
638
+ });
639
+
640
+ await publisher['negotiate']();
641
+
642
+ expect(sfuClient.setPublisher).toHaveBeenCalledWith({
643
+ sdp: 'offer-sdp',
644
+ tracks: trackInfosMock,
645
+ });
646
+ expect(createOfferSpy).toHaveBeenCalled();
647
+ expect(setLocalDescriptionSpy).toHaveBeenCalledWith(spyOffer);
648
+ expect(setRemoteDescriptionSpy).toHaveBeenCalledWith({
649
+ sdp: 'answer-sdp',
650
+ type: 'answer',
651
+ });
652
+ expect(addIceCandidateSpy).toHaveBeenCalledWith({
653
+ ufrag: 'test',
654
+ candidate: 'test',
655
+ });
656
+ });
657
+
658
+ it('onNegotiationNeeded delegates to negotiate', () => {
659
+ publisher['negotiate'] = vi.fn().mockResolvedValue(void 0);
660
+ publisher['onNegotiationNeeded']();
661
+ expect(publisher['negotiate']).toHaveBeenCalled();
662
+ });
663
+
664
+ it('getPublishedTracks returns the published tracks', () => {
665
+ const tracks = publisher.getPublishedTracks();
666
+ expect(tracks).toHaveLength(1);
667
+ expect(tracks[0].readyState).toBe('live');
668
+ });
669
+
670
+ it('getAnnouncedTracks should return all tracks', () => {
671
+ const trackInfos = publisher.getAnnouncedTracks('');
672
+ expect(trackInfos).toHaveLength(2);
673
+ expect(trackInfos[0].muted).toBe(false);
674
+ expect(trackInfos[0].mid).toBe('0');
675
+ expect(trackInfos[1].muted).toBe(true);
676
+ expect(trackInfos[1].mid).toBe('1');
677
+ });
678
+
679
+ it('getAnnouncedTracksForReconnect should return only the active tracks', () => {
680
+ const trackInfos = publisher.getAnnouncedTracksForReconnect();
681
+ expect(trackInfos).toHaveLength(1);
682
+ expect(trackInfos[0].muted).toBe(false);
683
+ expect(trackInfos[0].mid).toBe('0');
684
+ });
685
+
686
+ it('isPublishing should return true if there are active tracks', () => {
687
+ expect(publisher.isPublishing(TrackType.VIDEO)).toBe(true);
688
+ expect(publisher.isPublishing(TrackType.SCREEN_SHARE_AUDIO)).toBe(false);
689
+ });
690
+
691
+ it('getTrackType should return the track type', () => {
692
+ expect(
693
+ publisher.getTrackType(cache['cache'][0].transceiver.sender.track!.id),
694
+ ).toBe(TrackType.VIDEO);
695
+ expect(publisher.getTrackType('unknown')).toBeUndefined();
696
+ });
697
+
698
+ it('stopTracks should stop tracks', () => {
699
+ const track = cache['cache'][0].transceiver.sender.track;
700
+ vi.spyOn(track, 'stop');
701
+ publisher.stopTracks(TrackType.VIDEO);
702
+ expect(track!.stop).toHaveBeenCalled();
703
+ });
704
+ });
508
705
  });