@stream-io/video-client 1.14.0 → 1.15.1

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 (92) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +1533 -1783
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1514 -1783
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1533 -1783
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +43 -28
  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/video/sfu/event/events.d.ts +38 -19
  15. package/dist/src/gen/video/sfu/models/models.d.ts +76 -9
  16. package/dist/src/helpers/array.d.ts +7 -0
  17. package/dist/src/permissions/PermissionsContext.d.ts +6 -0
  18. package/dist/src/rtc/BasePeerConnection.d.ts +90 -0
  19. package/dist/src/rtc/Dispatcher.d.ts +0 -1
  20. package/dist/src/rtc/IceTrickleBuffer.d.ts +3 -2
  21. package/dist/src/rtc/Publisher.d.ts +32 -86
  22. package/dist/src/rtc/Subscriber.d.ts +4 -56
  23. package/dist/src/rtc/TransceiverCache.d.ts +55 -0
  24. package/dist/src/rtc/codecs.d.ts +1 -15
  25. package/dist/src/rtc/helpers/sdp.d.ts +8 -0
  26. package/dist/src/rtc/helpers/tracks.d.ts +1 -0
  27. package/dist/src/rtc/index.d.ts +3 -0
  28. package/dist/src/rtc/videoLayers.d.ts +11 -25
  29. package/dist/src/stats/{stateStoreStatsReporter.d.ts → CallStateStatsReporter.d.ts} +5 -1
  30. package/dist/src/stats/SfuStatsReporter.d.ts +4 -2
  31. package/dist/src/stats/index.d.ts +1 -1
  32. package/dist/src/stats/types.d.ts +8 -0
  33. package/dist/src/types.d.ts +12 -22
  34. package/package.json +1 -1
  35. package/src/Call.ts +254 -268
  36. package/src/StreamSfuClient.ts +9 -14
  37. package/src/StreamVideoClient.ts +1 -1
  38. package/src/__tests__/Call.publishing.test.ts +306 -0
  39. package/src/devices/CameraManager.ts +33 -16
  40. package/src/devices/InputMediaDeviceManager.ts +38 -27
  41. package/src/devices/MicrophoneManager.ts +29 -8
  42. package/src/devices/ScreenShareManager.ts +6 -8
  43. package/src/devices/__tests__/CameraManager.test.ts +111 -14
  44. package/src/devices/__tests__/InputMediaDeviceManager.test.ts +4 -4
  45. package/src/devices/__tests__/MicrophoneManager.test.ts +59 -21
  46. package/src/devices/__tests__/ScreenShareManager.test.ts +5 -5
  47. package/src/devices/__tests__/mocks.ts +1 -0
  48. package/src/events/__tests__/internal.test.ts +132 -0
  49. package/src/events/__tests__/mutes.test.ts +0 -3
  50. package/src/events/__tests__/speaker.test.ts +92 -0
  51. package/src/events/participant.ts +3 -4
  52. package/src/gen/video/sfu/event/events.ts +91 -30
  53. package/src/gen/video/sfu/models/models.ts +105 -13
  54. package/src/helpers/array.ts +14 -0
  55. package/src/permissions/PermissionsContext.ts +22 -0
  56. package/src/permissions/__tests__/PermissionsContext.test.ts +40 -0
  57. package/src/rpc/__tests__/createClient.test.ts +38 -0
  58. package/src/rpc/createClient.ts +11 -5
  59. package/src/rtc/BasePeerConnection.ts +240 -0
  60. package/src/rtc/Dispatcher.ts +0 -9
  61. package/src/rtc/IceTrickleBuffer.ts +24 -4
  62. package/src/rtc/Publisher.ts +210 -528
  63. package/src/rtc/Subscriber.ts +26 -200
  64. package/src/rtc/TransceiverCache.ts +120 -0
  65. package/src/rtc/__tests__/Publisher.test.ts +407 -210
  66. package/src/rtc/__tests__/Subscriber.test.ts +88 -36
  67. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +22 -2
  68. package/src/rtc/__tests__/videoLayers.test.ts +161 -54
  69. package/src/rtc/codecs.ts +1 -131
  70. package/src/rtc/helpers/__tests__/rtcConfiguration.test.ts +34 -0
  71. package/src/rtc/helpers/__tests__/sdp.test.ts +59 -0
  72. package/src/rtc/helpers/sdp.ts +30 -0
  73. package/src/rtc/helpers/tracks.ts +3 -0
  74. package/src/rtc/index.ts +4 -0
  75. package/src/rtc/videoLayers.ts +68 -76
  76. package/src/stats/{stateStoreStatsReporter.ts → CallStateStatsReporter.ts} +58 -27
  77. package/src/stats/SfuStatsReporter.ts +31 -3
  78. package/src/stats/index.ts +1 -1
  79. package/src/stats/types.ts +12 -0
  80. package/src/types.ts +12 -22
  81. package/dist/src/helpers/sdp-munging.d.ts +0 -24
  82. package/dist/src/rtc/bitrateLookup.d.ts +0 -2
  83. package/dist/src/rtc/helpers/iceCandidate.d.ts +0 -2
  84. package/src/helpers/__tests__/hq-audio-sdp.ts +0 -332
  85. package/src/helpers/__tests__/sdp-munging.test.ts +0 -283
  86. package/src/helpers/sdp-munging.ts +0 -265
  87. package/src/rtc/__tests__/bitrateLookup.test.ts +0 -12
  88. package/src/rtc/__tests__/codecs.test.ts +0 -145
  89. package/src/rtc/bitrateLookup.ts +0 -61
  90. package/src/rtc/helpers/iceCandidate.ts +0 -16
  91. /package/dist/src/{compatibility.d.ts → helpers/compatibility.d.ts} +0 -0
  92. /package/src/{compatibility.ts → helpers/compatibility.ts} +0 -0
@@ -14,6 +14,7 @@ import { getVideoStream } from '../devices';
14
14
  import { TrackType } from '../../gen/video/sfu/models/models';
15
15
  import { CameraManager } from '../CameraManager';
16
16
  import { of } from 'rxjs';
17
+ import { PermissionsContext } from '../../permissions';
17
18
 
18
19
  vi.mock('../devices.ts', () => {
19
20
  console.log('MOCKING devices API');
@@ -36,7 +37,7 @@ vi.mock('../../Call.ts', () => {
36
37
  };
37
38
  });
38
39
 
39
- vi.mock('../../compatibility.ts', () => {
40
+ vi.mock('../../helpers/compatibility.ts', () => {
40
41
  console.log('MOCKING mobile device');
41
42
  return {
42
43
  isMobile: () => true,
@@ -52,16 +53,16 @@ vi.mock('../../helpers/platforms', () => {
52
53
 
53
54
  describe('CameraManager', () => {
54
55
  let manager: CameraManager;
56
+ let call: Call;
55
57
 
56
58
  beforeEach(() => {
57
- manager = new CameraManager(
58
- new Call({
59
- id: '',
60
- type: '',
61
- streamClient: new StreamClient('abc123'),
62
- clientStore: new StreamVideoWriteableStateStore(),
63
- }),
64
- );
59
+ call = new Call({
60
+ id: '',
61
+ type: '',
62
+ streamClient: new StreamClient('abc123'),
63
+ clientStore: new StreamVideoWriteableStateStore(),
64
+ });
65
+ manager = new CameraManager(call);
65
66
  });
66
67
 
67
68
  it('list devices', () => {
@@ -94,8 +95,9 @@ describe('CameraManager', () => {
94
95
 
95
96
  await manager.enable();
96
97
 
97
- expect(manager['call'].publishVideoStream).toHaveBeenCalledWith(
98
+ expect(manager['call'].publish).toHaveBeenCalledWith(
98
99
  manager.state.mediaStream,
100
+ TrackType.VIDEO,
99
101
  );
100
102
  });
101
103
 
@@ -105,10 +107,7 @@ describe('CameraManager', () => {
105
107
 
106
108
  await manager.disable();
107
109
 
108
- expect(manager['call'].stopPublish).toHaveBeenCalledWith(
109
- TrackType.VIDEO,
110
- true,
111
- );
110
+ expect(manager['call'].stopPublish).toHaveBeenCalledWith(TrackType.VIDEO);
112
111
  });
113
112
 
114
113
  it('flip', async () => {
@@ -205,6 +204,104 @@ describe('CameraManager', () => {
205
204
  expect(getVideoStream).toHaveBeenCalledOnce();
206
205
  });
207
206
 
207
+ describe('Video Settings', () => {
208
+ beforeEach(() => {
209
+ // @ts-expect-error - read only property
210
+ call.permissionsContext = new PermissionsContext();
211
+ call.permissionsContext.hasPermission = vi.fn().mockReturnValue(true);
212
+ });
213
+
214
+ it('should enable the camera when set on the dashboard', async () => {
215
+ vi.spyOn(manager, 'enable');
216
+ await manager.apply(
217
+ // @ts-expect-error - partial settings
218
+ {
219
+ target_resolution: { width: 640, height: 480 },
220
+ camera_facing: 'front',
221
+ camera_default_on: true,
222
+ },
223
+ true,
224
+ );
225
+
226
+ expect(manager.state.direction).toBe('front');
227
+ expect(manager.state.status).toBe('enabled');
228
+ expect(manager['targetResolution']).toEqual({ width: 640, height: 480 });
229
+ expect(manager.enable).toHaveBeenCalled();
230
+ });
231
+
232
+ it('should not enable the camera when set on the dashboard', async () => {
233
+ vi.spyOn(manager, 'enable');
234
+ await manager.apply(
235
+ // @ts-expect-error - partial settings
236
+ {
237
+ target_resolution: { width: 640, height: 480 },
238
+ camera_facing: 'front',
239
+ camera_default_on: false,
240
+ },
241
+ true,
242
+ );
243
+
244
+ expect(manager.state.direction).toBe('front');
245
+ expect(manager.state.status).toBe(undefined);
246
+ expect(manager['targetResolution']).toEqual({ width: 640, height: 480 });
247
+ expect(manager.enable).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it('should not turn on the camera when publish is false', async () => {
251
+ vi.spyOn(manager, 'enable');
252
+ await manager.apply(
253
+ // @ts-expect-error - partial settings
254
+ {
255
+ target_resolution: { width: 640, height: 480 },
256
+ camera_facing: 'front',
257
+ camera_default_on: true,
258
+ },
259
+ false,
260
+ );
261
+
262
+ expect(manager.state.direction).toBe('front');
263
+ expect(manager.state.status).toBe(undefined);
264
+ expect(manager['targetResolution']).toEqual({ width: 640, height: 480 });
265
+ expect(manager.enable).not.toHaveBeenCalled();
266
+ });
267
+
268
+ it('should not enable the camera when the user does not have permission', async () => {
269
+ call.permissionsContext.hasPermission = vi.fn().mockReturnValue(false);
270
+ vi.spyOn(manager, 'enable');
271
+ await manager.apply(
272
+ // @ts-expect-error - partial settings
273
+ {
274
+ target_resolution: { width: 640, height: 480 },
275
+ camera_facing: 'front',
276
+ camera_default_on: true,
277
+ },
278
+ true,
279
+ );
280
+
281
+ expect(manager.state.direction).toBe(undefined);
282
+ expect(manager.state.status).toBe(undefined);
283
+ expect(manager['targetResolution']).toEqual({ width: 1280, height: 720 });
284
+ expect(manager.enable).not.toHaveBeenCalled();
285
+ });
286
+
287
+ it('should publish the stream when the camera is already enabled', async () => {
288
+ await manager.enable();
289
+ // @ts-expect-error - private api
290
+ vi.spyOn(manager, 'publishStream');
291
+ await manager.apply(
292
+ // @ts-expect-error - partial settings
293
+ {
294
+ target_resolution: { width: 640, height: 480 },
295
+ camera_facing: 'front',
296
+ camera_default_on: true,
297
+ },
298
+ true,
299
+ );
300
+
301
+ expect(manager['publishStream']).toHaveBeenCalled();
302
+ });
303
+ });
304
+
208
305
  afterEach(() => {
209
306
  vi.clearAllMocks();
210
307
  vi.resetModules();
@@ -4,11 +4,11 @@ import { CallingState, StreamVideoWriteableStateStore } from '../../store';
4
4
 
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
6
  import {
7
- MockTrack,
8
7
  emitDeviceIds,
9
8
  mockBrowserPermission,
10
9
  mockCall,
11
10
  mockDeviceIds$,
11
+ MockTrack,
12
12
  mockVideoDevices,
13
13
  mockVideoStream,
14
14
  } from './mocks';
@@ -123,7 +123,7 @@ describe('InputMediaDeviceManager.test', () => {
123
123
 
124
124
  await manager.disable();
125
125
 
126
- expect(manager.stopPublishStream).toHaveBeenCalledWith(true);
126
+ expect(manager.stopPublishStream).toHaveBeenCalled();
127
127
  });
128
128
 
129
129
  it('disable device with forceStop', async () => {
@@ -134,7 +134,7 @@ describe('InputMediaDeviceManager.test', () => {
134
134
 
135
135
  await manager.disable(true);
136
136
 
137
- expect(manager.stopPublishStream).toHaveBeenCalledWith(true);
137
+ expect(manager.stopPublishStream).toHaveBeenCalled();
138
138
  expect(manager.state.mediaStream).toBeUndefined();
139
139
  expect(manager.state.status).toBe('disabled');
140
140
  });
@@ -179,7 +179,7 @@ describe('InputMediaDeviceManager.test', () => {
179
179
  const deviceId = mockVideoDevices[1].deviceId;
180
180
  await manager.select(deviceId);
181
181
 
182
- expect(manager.stopPublishStream).toHaveBeenCalledWith(true);
182
+ expect(manager.stopPublishStream).toHaveBeenCalled();
183
183
  expect(manager.getStream).toHaveBeenCalledWith({
184
184
  deviceId: { exact: deviceId },
185
185
  });
@@ -23,6 +23,7 @@ import {
23
23
  createSoundDetector,
24
24
  SoundStateChangeHandler,
25
25
  } from '../../helpers/sound-detector';
26
+ import { PermissionsContext } from '../../permissions';
26
27
 
27
28
  vi.mock('../devices.ts', () => {
28
29
  console.log('MOCKING devices API');
@@ -60,7 +61,7 @@ class NoiseCancellationStub implements INoiseCancellation {
60
61
  enable = () => this.listeners['change']?.forEach((l) => l(true));
61
62
  disable = () => this.listeners['change']?.forEach((l) => l(false));
62
63
  dispose = () => Promise.resolve(undefined);
63
- toFilter = () => async (ms: MediaStream) => ms;
64
+ toFilter = () => (ms: MediaStream) => ({ output: ms });
64
65
  on = (event, callback) => {
65
66
  (this.listeners[event] ??= []).push(callback);
66
67
  return () => {};
@@ -70,17 +71,16 @@ class NoiseCancellationStub implements INoiseCancellation {
70
71
 
71
72
  describe('MicrophoneManager', () => {
72
73
  let manager: MicrophoneManager;
74
+ let call: Call;
73
75
 
74
76
  beforeEach(() => {
75
- manager = new MicrophoneManager(
76
- new Call({
77
- id: '',
78
- type: '',
79
- streamClient: new StreamClient('abc123'),
80
- clientStore: new StreamVideoWriteableStateStore(),
81
- }),
82
- 'disable-tracks',
83
- );
77
+ call = new Call({
78
+ id: '',
79
+ type: '',
80
+ streamClient: new StreamClient('abc123'),
81
+ clientStore: new StreamVideoWriteableStateStore(),
82
+ });
83
+ manager = new MicrophoneManager(call, 'disable-tracks');
84
84
  });
85
85
  it('list devices', () => {
86
86
  const spy = vi.fn();
@@ -110,8 +110,9 @@ describe('MicrophoneManager', () => {
110
110
 
111
111
  await manager.enable();
112
112
 
113
- expect(manager['call'].publishAudioStream).toHaveBeenCalledWith(
113
+ expect(manager['call'].publish).toHaveBeenCalledWith(
114
114
  manager.state.mediaStream,
115
+ TrackType.AUDIO,
115
116
  );
116
117
  });
117
118
 
@@ -121,10 +122,7 @@ describe('MicrophoneManager', () => {
121
122
 
122
123
  await manager.disable();
123
124
 
124
- expect(manager['call'].stopPublish).toHaveBeenCalledWith(
125
- TrackType.AUDIO,
126
- false,
127
- );
125
+ expect(manager['call'].stopPublish).toHaveBeenCalledWith(TrackType.AUDIO);
128
126
  });
129
127
 
130
128
  it('disable-enable mic should set track.enabled', async () => {
@@ -244,7 +242,6 @@ describe('MicrophoneManager', () => {
244
242
 
245
243
  describe('Noise Cancellation', () => {
246
244
  it('should register filter if all preconditions are met', async () => {
247
- const call = manager['call'];
248
245
  call.state.setCallingState(CallingState.IDLE);
249
246
  const registerFilter = vi.spyOn(manager, 'registerFilter');
250
247
  const noiseCancellation = new NoiseCancellationStub();
@@ -259,12 +256,10 @@ describe('MicrophoneManager', () => {
259
256
  const noiseCancellation = new NoiseCancellationStub();
260
257
  await manager.enableNoiseCancellation(noiseCancellation);
261
258
  await manager.disableNoiseCancellation();
262
- const call = manager['call'];
263
259
  expect(call.notifyNoiseCancellationStopped).toBeCalled();
264
260
  });
265
261
 
266
262
  it('should throw when own capabilities are missing', async () => {
267
- const call = manager['call'];
268
263
  call.state.setOwnCapabilities([]);
269
264
 
270
265
  await expect(() =>
@@ -273,7 +268,6 @@ describe('MicrophoneManager', () => {
273
268
  });
274
269
 
275
270
  it('should throw when noise cancellation is disabled in call settings', async () => {
276
- const call = manager['call'];
277
271
  call.state.setOwnCapabilities([OwnCapability.ENABLE_NOISE_CANCELLATION]);
278
272
  call.state.updateFromCallResponse({
279
273
  // @ts-expect-error partial data
@@ -289,7 +283,6 @@ describe('MicrophoneManager', () => {
289
283
  });
290
284
 
291
285
  it('should automatically enable noise noise suppression after joining a call', async () => {
292
- const call = manager['call'];
293
286
  call.state.setCallingState(CallingState.IDLE); // reset state
294
287
  call.state.updateFromCallResponse({
295
288
  settings: {
@@ -320,7 +313,6 @@ describe('MicrophoneManager', () => {
320
313
  });
321
314
 
322
315
  it('should automatically disable noise suppression after leaving the call', async () => {
323
- const call = manager['call'];
324
316
  const noiseCancellation = new NoiseCancellationStub();
325
317
  const noiseSuppressionDisable = vi.spyOn(noiseCancellation, 'disable');
326
318
  await manager.enableNoiseCancellation(noiseCancellation);
@@ -339,6 +331,52 @@ describe('MicrophoneManager', () => {
339
331
  });
340
332
  });
341
333
 
334
+ describe('Audio Settings', () => {
335
+ beforeEach(() => {
336
+ // @ts-expect-error - read only property
337
+ call.permissionsContext = new PermissionsContext();
338
+ call.permissionsContext.hasPermission = vi.fn().mockReturnValue(true);
339
+ });
340
+
341
+ it('should turn the mic on when set on dashboard', async () => {
342
+ const enable = vi.spyOn(manager, 'enable');
343
+ // @ts-expect-error - partial data
344
+ await manager.apply({ mic_default_on: true }, true);
345
+ expect(enable).toHaveBeenCalled();
346
+ });
347
+
348
+ it('should not turn the mic on when set on dashboard', async () => {
349
+ const enable = vi.spyOn(manager, 'enable');
350
+ // @ts-expect-error - partial data
351
+ await manager.apply({ mic_default_on: false }, true);
352
+ expect(enable).not.toHaveBeenCalled();
353
+ });
354
+
355
+ it('should not turn on the mic when publish is false', async () => {
356
+ const enable = vi.spyOn(manager, 'enable');
357
+ // @ts-expect-error - partial data
358
+ await manager.apply({ mic_default_on: true }, false);
359
+ expect(enable).not.toHaveBeenCalled();
360
+ });
361
+
362
+ it('should not turn on the mic when permission is missing', async () => {
363
+ call.permissionsContext.hasPermission = vi.fn().mockReturnValue(false);
364
+ const enable = vi.spyOn(manager, 'enable');
365
+ // @ts-expect-error - partial data
366
+ await manager.apply({ mic_default_on: true }, true);
367
+ expect(enable).not.toHaveBeenCalled();
368
+ });
369
+
370
+ it('should publish the audio stream when mic is turned on before settings are applied', async () => {
371
+ await manager.enable();
372
+ // @ts-expect-error - private api
373
+ vi.spyOn(manager, 'publishStream');
374
+ // @ts-expect-error - partial data
375
+ await manager.apply({ mic_default_on: true }, true);
376
+ expect(manager['publishStream']).toHaveBeenCalled();
377
+ });
378
+ });
379
+
342
380
  afterEach(() => {
343
381
  vi.clearAllMocks();
344
382
  vi.resetModules();
@@ -49,8 +49,8 @@ describe('ScreenShareManager', () => {
49
49
  expect(RxUtils.getCurrentValue(devices)).toEqual([]);
50
50
  });
51
51
 
52
- it('select device', () => {
53
- expect(manager.select('any-device-id')).rejects.toThrowError();
52
+ it('select device', async () => {
53
+ await expect(manager.select('any-device-id')).rejects.toThrowError();
54
54
  });
55
55
 
56
56
  it('get stream', async () => {
@@ -113,8 +113,9 @@ describe('ScreenShareManager', () => {
113
113
  const call = manager['call'];
114
114
  call.state.setCallingState(CallingState.JOINED);
115
115
  await manager.enable();
116
- expect(call.publishScreenShareStream).toHaveBeenCalledWith(
116
+ expect(call.publish).toHaveBeenCalledWith(
117
117
  manager.state.mediaStream,
118
+ TrackType.SCREEN_SHARE,
118
119
  );
119
120
  });
120
121
 
@@ -125,10 +126,9 @@ describe('ScreenShareManager', () => {
125
126
 
126
127
  await manager.disable();
127
128
  expect(manager.state.status).toEqual('disabled');
128
- expect(call.stopPublish).toHaveBeenCalledWith(TrackType.SCREEN_SHARE, true);
129
129
  expect(call.stopPublish).toHaveBeenCalledWith(
130
+ TrackType.SCREEN_SHARE,
130
131
  TrackType.SCREEN_SHARE_AUDIO,
131
- true,
132
132
  );
133
133
  });
134
134
  });
@@ -96,6 +96,7 @@ export const mockCall = (): Partial<Call> => {
96
96
  publishVideoStream: vi.fn(),
97
97
  publishAudioStream: vi.fn(),
98
98
  publishScreenShareStream: vi.fn(),
99
+ publish: vi.fn(),
99
100
  stopPublish: vi.fn(),
100
101
  notifyNoiseCancellationStarting: vi.fn().mockResolvedValue(undefined),
101
102
  notifyNoiseCancellationStopped: vi.fn().mockResolvedValue(undefined),
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { Call } from '../../Call';
3
+ import { Dispatcher } from '../../rtc';
4
+ import { CallState } from '../../store';
5
+ import {
6
+ watchConnectionQualityChanged,
7
+ watchLiveEnded,
8
+ watchParticipantCountChanged,
9
+ watchPinsUpdated,
10
+ } from '../internal';
11
+ import {
12
+ ConnectionQuality,
13
+ ErrorCode,
14
+ } from '../../gen/video/sfu/models/models';
15
+
16
+ describe('internal events', () => {
17
+ it('handles connectionQualityChanged', () => {
18
+ const state = new CallState();
19
+ const dispatcher = new Dispatcher();
20
+ state.setParticipants([
21
+ // @ts-expect-error
22
+ { sessionId: 'session-1', connectionQuality: ConnectionQuality.POOR },
23
+ ]);
24
+
25
+ watchConnectionQualityChanged(dispatcher, state);
26
+
27
+ dispatcher.dispatch({
28
+ eventPayload: {
29
+ oneofKind: 'connectionQualityChanged',
30
+ // @ts-expect-error
31
+ connectionQualityChanged: {
32
+ connectionQualityUpdates: [
33
+ {
34
+ sessionId: 'session-1',
35
+ connectionQuality: ConnectionQuality.EXCELLENT,
36
+ },
37
+ ],
38
+ },
39
+ },
40
+ });
41
+ expect(state.participants).toEqual([
42
+ {
43
+ sessionId: 'session-1',
44
+ connectionQuality: ConnectionQuality.EXCELLENT,
45
+ },
46
+ ]);
47
+ });
48
+
49
+ it('handles healthCheckResponse', () => {
50
+ const state = new CallState();
51
+ const dispatcher = new Dispatcher();
52
+ state.setParticipantCount(0);
53
+ state.setAnonymousParticipantCount(0);
54
+
55
+ watchParticipantCountChanged(dispatcher, state);
56
+
57
+ dispatcher.dispatch({
58
+ eventPayload: {
59
+ oneofKind: 'healthCheckResponse',
60
+ // @ts-expect-error
61
+ healthCheckResponse: {
62
+ participantCount: {
63
+ total: 5,
64
+ anonymous: 2,
65
+ },
66
+ },
67
+ },
68
+ });
69
+ expect(state.participantCount).toBe(5);
70
+ expect(state.anonymousParticipantCount).toBe(2);
71
+ });
72
+
73
+ it('handles liveEnded', () => {
74
+ const dispatcher = new Dispatcher();
75
+ const call = {
76
+ permissionsContext: { hasPermission: () => false },
77
+ leave: vi.fn().mockResolvedValue(undefined),
78
+ logger: vi.fn(),
79
+ } as unknown as Call;
80
+
81
+ watchLiveEnded(dispatcher, call);
82
+
83
+ dispatcher.dispatch({
84
+ eventPayload: {
85
+ oneofKind: 'error',
86
+ // @ts-expect-error
87
+ error: { code: ErrorCode.LIVE_ENDED },
88
+ },
89
+ });
90
+ expect(call.leave).toHaveBeenCalled();
91
+ });
92
+
93
+ it('handles liveEnded when user has permission to stay in backstage', () => {
94
+ const dispatcher = new Dispatcher();
95
+ const call = {
96
+ permissionsContext: { hasPermission: () => true },
97
+ leave: vi.fn().mockResolvedValue(undefined),
98
+ logger: vi.fn(),
99
+ } as unknown as Call;
100
+
101
+ watchLiveEnded(dispatcher, call);
102
+
103
+ dispatcher.dispatch({
104
+ eventPayload: {
105
+ oneofKind: 'error',
106
+ // @ts-expect-error
107
+ error: { code: ErrorCode.LIVE_ENDED },
108
+ },
109
+ });
110
+ expect(call.leave).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('handles pinUpdated', () => {
114
+ const state = new CallState();
115
+ state.setParticipants([
116
+ // @ts-expect-error
117
+ { userId: 'u1', sessionId: 'session-1', pin: { isLocalPin: false } },
118
+ // @ts-expect-error
119
+ { userId: 'u2', sessionId: 'session-2', pin: { isLocalPin: false } },
120
+ ]);
121
+ const update = watchPinsUpdated(state);
122
+ update({ pins: [{ userId: 'u1', sessionId: 'session-1' }] });
123
+ expect(state.participants).toEqual([
124
+ {
125
+ userId: 'u1',
126
+ sessionId: 'session-1',
127
+ pin: { isLocalPin: false, pinnedAt: expect.any(Number) },
128
+ },
129
+ { userId: 'u2', sessionId: 'session-2', pin: undefined },
130
+ ]);
131
+ });
132
+ });
@@ -20,12 +20,9 @@ describe('mutes', () => {
20
20
  id: 'test',
21
21
  streamClient: new StreamClient('api-key'),
22
22
  });
23
- // disable all event handlers
24
- call['dispatcher'].offAll();
25
23
 
26
24
  // @ts-expect-error partial data
27
25
  call.publisher = vi.fn();
28
- // @ts-expect-error partial data
29
26
  call.publisher.isPublishing = vi.fn().mockReturnValue(true);
30
27
 
31
28
  vi.spyOn(call.camera, 'disable').mockResolvedValue(undefined);
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CallState } from '../../store';
3
+ import { noopComparator } from '../../sorting';
4
+ import {
5
+ watchAudioLevelChanged,
6
+ watchDominantSpeakerChanged,
7
+ } from '../speaker';
8
+ import { Dispatcher } from '../../rtc';
9
+
10
+ describe('speaker events', () => {
11
+ it('should watch dominant speaker changed', () => {
12
+ const state = new CallState();
13
+ state.setSortParticipantsBy(noopComparator());
14
+ state.setParticipants([
15
+ // @ts-expect-error
16
+ { userId: 'user-1', sessionId: 'session-1', isDominantSpeaker: false },
17
+ // @ts-expect-error
18
+ { userId: 'user-2', sessionId: 'session-2', isDominantSpeaker: true },
19
+ ]);
20
+ const dispatcher = new Dispatcher();
21
+
22
+ watchDominantSpeakerChanged(dispatcher, state);
23
+
24
+ dispatcher.dispatch({
25
+ eventPayload: {
26
+ oneofKind: 'dominantSpeakerChanged',
27
+ // @ts-expect-error
28
+ dominantSpeakerChanged: {
29
+ userId: 'user-1',
30
+ sessionId: 'session-1',
31
+ },
32
+ },
33
+ });
34
+
35
+ expect(state.participants).toEqual([
36
+ { userId: 'user-1', sessionId: 'session-1', isDominantSpeaker: true },
37
+ { userId: 'user-2', sessionId: 'session-2', isDominantSpeaker: false },
38
+ ]);
39
+ });
40
+
41
+ it('watchAudioLevelChanged', () => {
42
+ const state = new CallState();
43
+ state.setSortParticipantsBy(noopComparator());
44
+ state.setParticipants([
45
+ // @ts-expect-error
46
+ {
47
+ userId: 'user-1',
48
+ sessionId: 'session-1',
49
+ audioLevel: 0,
50
+ isSpeaking: false,
51
+ },
52
+ // @ts-expect-error
53
+ {
54
+ userId: 'user-2',
55
+ sessionId: 'session-2',
56
+ audioLevel: 0,
57
+ isSpeaking: false,
58
+ },
59
+ ]);
60
+ const dispatcher = new Dispatcher();
61
+
62
+ watchAudioLevelChanged(dispatcher, state);
63
+
64
+ dispatcher.dispatch({
65
+ eventPayload: {
66
+ oneofKind: 'audioLevelChanged',
67
+ // @ts-expect-error
68
+ audioLevelChanged: {
69
+ audioLevels: [
70
+ { sessionId: 'session-1', level: 0.5, isSpeaking: true },
71
+ { sessionId: 'session-2', level: 0.5, isSpeaking: true },
72
+ ],
73
+ },
74
+ },
75
+ });
76
+
77
+ expect(state.participants).toEqual([
78
+ {
79
+ userId: 'user-1',
80
+ sessionId: 'session-1',
81
+ audioLevel: 0.5,
82
+ isSpeaking: true,
83
+ },
84
+ {
85
+ userId: 'user-2',
86
+ sessionId: 'session-2',
87
+ audioLevel: 0.5,
88
+ isSpeaking: true,
89
+ },
90
+ ]);
91
+ });
92
+ });
@@ -12,7 +12,8 @@ import {
12
12
  VisibilityState,
13
13
  } from '../types';
14
14
  import { CallState } from '../store';
15
- import { trackTypeToParticipantStreamKey } from '../rtc/helpers/tracks';
15
+ import { trackTypeToParticipantStreamKey } from '../rtc';
16
+ import { pushToIfMissing } from '../helpers/array';
16
17
 
17
18
  /**
18
19
  * An event responder which handles the `participantJoined` event.
@@ -88,7 +89,7 @@ export const watchTrackPublished = (state: CallState) => {
88
89
  state.updateOrAddParticipant(sessionId, participant);
89
90
  } else {
90
91
  state.updateParticipant(sessionId, (p) => ({
91
- publishedTracks: [...p.publishedTracks, type].filter(unique),
92
+ publishedTracks: pushToIfMissing([...p.publishedTracks], type),
92
93
  }));
93
94
  }
94
95
  };
@@ -114,8 +115,6 @@ export const watchTrackUnpublished = (state: CallState) => {
114
115
  };
115
116
  };
116
117
 
117
- const unique = <T>(v: T, i: number, arr: T[]) => arr.indexOf(v) === i;
118
-
119
118
  /**
120
119
  * Reconciles orphaned tracks (if any) for the given participant.
121
120
  *