@stream-io/video-client 1.7.4 → 1.8.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.
@@ -1,4 +1,3 @@
1
- import { Call } from '../Call';
2
1
  import {
3
2
  AudioTrackType,
4
3
  DebounceType,
@@ -6,8 +5,9 @@ import {
6
5
  VideoTrackType,
7
6
  VisibilityState,
8
7
  } from '../types';
9
- import { VideoDimension } from '../gen/video/sfu/models/models';
8
+ import { TrackType, VideoDimension } from '../gen/video/sfu/models/models';
10
9
  import {
10
+ BehaviorSubject,
11
11
  combineLatest,
12
12
  distinctUntilChanged,
13
13
  distinctUntilKeyChanged,
@@ -18,7 +18,16 @@ import {
18
18
  import { ViewportTracker } from './ViewportTracker';
19
19
  import { getLogger } from '../logger';
20
20
  import { isFirefox, isSafari } from './browsers';
21
- import { hasScreenShare, hasVideo } from './participantUtils';
21
+ import {
22
+ hasScreenShare,
23
+ hasScreenShareAudio,
24
+ hasVideo,
25
+ } from './participantUtils';
26
+ import type { TrackSubscriptionDetails } from '../gen/video/sfu/signal_rpc/signal';
27
+ import type { CallState } from '../store';
28
+ import type { StreamSfuClient } from '../StreamSfuClient';
29
+ import { SpeakerManager } from '../devices';
30
+ import { getCurrentValue, setCurrentValue } from '../store/rxUtils';
22
31
 
23
32
  const DEFAULT_VIEWPORT_VISIBILITY_STATE: Record<
24
33
  VideoTrackType,
@@ -28,6 +37,20 @@ const DEFAULT_VIEWPORT_VISIBILITY_STATE: Record<
28
37
  screenShareTrack: VisibilityState.UNKNOWN,
29
38
  } as const;
30
39
 
40
+ type VideoTrackSubscriptionOverride =
41
+ | {
42
+ enabled: true;
43
+ dimension: VideoDimension;
44
+ }
45
+ | { enabled: false };
46
+
47
+ const globalOverrideKey = Symbol('globalOverrideKey');
48
+
49
+ interface VideoTrackSubscriptionOverrides {
50
+ [sessionId: string]: VideoTrackSubscriptionOverride | undefined;
51
+ [globalOverrideKey]?: VideoTrackSubscriptionOverride;
52
+ }
53
+
31
54
  /**
32
55
  * A manager class that handles dynascale related tasks like:
33
56
  *
@@ -45,17 +68,151 @@ export class DynascaleManager {
45
68
  readonly viewportTracker = new ViewportTracker();
46
69
 
47
70
  private logger = getLogger(['DynascaleManager']);
48
- private call: Call;
71
+ private callState: CallState;
72
+ private speaker: SpeakerManager;
73
+ private sfuClient: StreamSfuClient | undefined;
74
+ private pendingSubscriptionsUpdate: NodeJS.Timeout | null = null;
75
+
76
+ private videoTrackSubscriptionOverridesSubject =
77
+ new BehaviorSubject<VideoTrackSubscriptionOverrides>({});
78
+
79
+ videoTrackSubscriptionOverrides$ =
80
+ this.videoTrackSubscriptionOverridesSubject.asObservable();
81
+
82
+ incomingVideoSettings$ = this.videoTrackSubscriptionOverrides$.pipe(
83
+ map((overrides) => {
84
+ const { [globalOverrideKey]: globalSettings, ...participants } =
85
+ overrides;
86
+ return {
87
+ enabled: globalSettings?.enabled !== false,
88
+ preferredResolution: globalSettings?.enabled
89
+ ? globalSettings.dimension
90
+ : undefined,
91
+ participants: Object.fromEntries(
92
+ Object.entries(participants).map(
93
+ ([sessionId, participantOverride]) => [
94
+ sessionId,
95
+ {
96
+ enabled: participantOverride?.enabled !== false,
97
+ preferredResolution: participantOverride?.enabled
98
+ ? participantOverride.dimension
99
+ : undefined,
100
+ },
101
+ ],
102
+ ),
103
+ ),
104
+ isParticipantVideoEnabled: (sessionId: string) =>
105
+ overrides[sessionId]?.enabled ??
106
+ overrides[globalOverrideKey]?.enabled ??
107
+ true,
108
+ };
109
+ }),
110
+ shareReplay(1),
111
+ );
49
112
 
50
113
  /**
51
114
  * Creates a new DynascaleManager instance.
52
115
  *
53
116
  * @param call the call to manage.
54
117
  */
55
- constructor(call: Call) {
56
- this.call = call;
118
+ constructor(callState: CallState, speaker: SpeakerManager) {
119
+ this.callState = callState;
120
+ this.speaker = speaker;
121
+ }
122
+
123
+ setSfuClient(sfuClient: StreamSfuClient | undefined) {
124
+ this.sfuClient = sfuClient;
125
+ }
126
+
127
+ get trackSubscriptions() {
128
+ const subscriptions: TrackSubscriptionDetails[] = [];
129
+ for (const p of this.callState.remoteParticipants) {
130
+ // NOTE: audio tracks don't have to be requested explicitly
131
+ // as the SFU will implicitly subscribe us to all of them,
132
+ // once they become available.
133
+ if (p.videoDimension && hasVideo(p)) {
134
+ const override =
135
+ this.videoTrackSubscriptionOverrides[p.sessionId] ??
136
+ this.videoTrackSubscriptionOverrides[globalOverrideKey];
137
+
138
+ if (override?.enabled !== false) {
139
+ subscriptions.push({
140
+ userId: p.userId,
141
+ sessionId: p.sessionId,
142
+ trackType: TrackType.VIDEO,
143
+ dimension: override?.dimension ?? p.videoDimension,
144
+ });
145
+ }
146
+ }
147
+ if (p.screenShareDimension && hasScreenShare(p)) {
148
+ subscriptions.push({
149
+ userId: p.userId,
150
+ sessionId: p.sessionId,
151
+ trackType: TrackType.SCREEN_SHARE,
152
+ dimension: p.screenShareDimension,
153
+ });
154
+ }
155
+ if (hasScreenShareAudio(p)) {
156
+ subscriptions.push({
157
+ userId: p.userId,
158
+ sessionId: p.sessionId,
159
+ trackType: TrackType.SCREEN_SHARE_AUDIO,
160
+ });
161
+ }
162
+ }
163
+ return subscriptions;
57
164
  }
58
165
 
166
+ get videoTrackSubscriptionOverrides() {
167
+ return getCurrentValue(this.videoTrackSubscriptionOverrides$);
168
+ }
169
+
170
+ setVideoTrackSubscriptionOverrides = (
171
+ override: VideoTrackSubscriptionOverride | undefined,
172
+ sessionIds?: string[],
173
+ ) => {
174
+ if (!sessionIds) {
175
+ return setCurrentValue(
176
+ this.videoTrackSubscriptionOverridesSubject,
177
+ override ? { [globalOverrideKey]: override } : {},
178
+ );
179
+ }
180
+
181
+ return setCurrentValue(
182
+ this.videoTrackSubscriptionOverridesSubject,
183
+ (overrides) => ({
184
+ ...overrides,
185
+ ...Object.fromEntries(sessionIds.map((id) => [id, override])),
186
+ }),
187
+ );
188
+ };
189
+
190
+ applyTrackSubscriptions = (
191
+ debounceType: DebounceType = DebounceType.SLOW,
192
+ ) => {
193
+ if (this.pendingSubscriptionsUpdate) {
194
+ clearTimeout(this.pendingSubscriptionsUpdate);
195
+ }
196
+
197
+ const updateSubscriptions = () => {
198
+ this.pendingSubscriptionsUpdate = null;
199
+ this.sfuClient
200
+ ?.updateSubscriptions(this.trackSubscriptions)
201
+ .catch((err: unknown) => {
202
+ this.logger('debug', `Failed to update track subscriptions`, err);
203
+ });
204
+ };
205
+
206
+ if (debounceType) {
207
+ this.pendingSubscriptionsUpdate = setTimeout(
208
+ updateSubscriptions,
209
+ debounceType,
210
+ );
211
+ } else {
212
+ updateSubscriptions();
213
+ }
214
+ };
215
+
59
216
  /**
60
217
  * Will begin tracking the given element for visibility changes within the
61
218
  * configured viewport element (`call.setViewport`).
@@ -71,7 +228,7 @@ export class DynascaleManager {
71
228
  trackType: VideoTrackType,
72
229
  ) => {
73
230
  const cleanup = this.viewportTracker.observe(element, (entry) => {
74
- this.call.state.updateParticipant(sessionId, (participant) => {
231
+ this.callState.updateParticipant(sessionId, (participant) => {
75
232
  const previousVisibilityState =
76
233
  participant.viewportVisibilityState ??
77
234
  DEFAULT_VIEWPORT_VISIBILITY_STATE;
@@ -97,7 +254,7 @@ export class DynascaleManager {
97
254
  // reset visibility state to UNKNOWN upon cleanup
98
255
  // so that the layouts that are not actively observed
99
256
  // can still function normally (runtime layout switching)
100
- this.call.state.updateParticipant(sessionId, (participant) => {
257
+ this.callState.updateParticipant(sessionId, (participant) => {
101
258
  const previousVisibilityState =
102
259
  participant.viewportVisibilityState ??
103
260
  DEFAULT_VIEWPORT_VISIBILITY_STATE;
@@ -142,7 +299,7 @@ export class DynascaleManager {
142
299
  trackType: VideoTrackType,
143
300
  ) => {
144
301
  const boundParticipant =
145
- this.call.state.findParticipantBySessionId(sessionId);
302
+ this.callState.findParticipantBySessionId(sessionId);
146
303
  if (!boundParticipant) return;
147
304
 
148
305
  const requestTrackWithDimensions = (
@@ -157,14 +314,13 @@ export class DynascaleManager {
157
314
  this.logger('debug', `Ignoring 0x0 dimension`, boundParticipant);
158
315
  dimension = undefined;
159
316
  }
160
- this.call.updateSubscriptionsPartial(
161
- trackType,
162
- { [sessionId]: { dimension } },
163
- debounceType,
164
- );
317
+ this.callState.updateParticipantTracks(trackType, {
318
+ [sessionId]: { dimension },
319
+ });
320
+ this.applyTrackSubscriptions(debounceType);
165
321
  };
166
322
 
167
- const participant$ = this.call.state.participants$.pipe(
323
+ const participant$ = this.callState.participants$.pipe(
168
324
  map(
169
325
  (participants) =>
170
326
  participants.find(
@@ -324,10 +480,10 @@ export class DynascaleManager {
324
480
  sessionId: string,
325
481
  trackType: AudioTrackType,
326
482
  ) => {
327
- const participant = this.call.state.findParticipantBySessionId(sessionId);
483
+ const participant = this.callState.findParticipantBySessionId(sessionId);
328
484
  if (!participant || participant.isLocalParticipant) return;
329
485
 
330
- const participant$ = this.call.state.participants$.pipe(
486
+ const participant$ = this.callState.participants$.pipe(
331
487
  map(
332
488
  (participants) =>
333
489
  participants.find(
@@ -364,7 +520,7 @@ export class DynascaleManager {
364
520
  // audio output device shall be set after the audio element is played
365
521
  // otherwise, the browser will not pick it up, and will always
366
522
  // play audio through the system's default device
367
- const { selectedDevice } = this.call.speaker.state;
523
+ const { selectedDevice } = this.speaker.state;
368
524
  if (selectedDevice && 'setSinkId' in audioElement) {
369
525
  audioElement.setSinkId(selectedDevice);
370
526
  }
@@ -374,14 +530,14 @@ export class DynascaleManager {
374
530
 
375
531
  const sinkIdSubscription = !('setSinkId' in audioElement)
376
532
  ? null
377
- : this.call.speaker.state.selectedDevice$.subscribe((deviceId) => {
533
+ : this.speaker.state.selectedDevice$.subscribe((deviceId) => {
378
534
  if (deviceId) {
379
535
  audioElement.setSinkId(deviceId);
380
536
  }
381
537
  });
382
538
 
383
539
  const volumeSubscription = combineLatest([
384
- this.call.speaker.state.volume$,
540
+ this.speaker.state.volume$,
385
541
  participant$.pipe(distinctUntilKeyChanged('audioVolume')),
386
542
  ]).subscribe(([volume, p]) => {
387
543
  audioElement.volume = p.audioVolume ?? volume;
@@ -9,7 +9,7 @@ import { DynascaleManager } from '../DynascaleManager';
9
9
  import { Call } from '../../Call';
10
10
  import { StreamClient } from '../../coordinator/connection/client';
11
11
  import { StreamVideoWriteableStateStore } from '../../store';
12
- import { DebounceType, VisibilityState } from '../../types';
12
+ import { VisibilityState } from '../../types';
13
13
  import { noopComparator } from '../../sorting';
14
14
  import { TrackType } from '../../gen/video/sfu/models/models';
15
15
 
@@ -25,7 +25,7 @@ describe('DynascaleManager', () => {
25
25
  clientStore: new StreamVideoWriteableStateStore(),
26
26
  });
27
27
  call.setSortParticipantsBy(noopComparator());
28
- dynascaleManager = new DynascaleManager(call);
28
+ dynascaleManager = new DynascaleManager(call.state, call.speaker);
29
29
  });
30
30
 
31
31
  afterEach(() => {
@@ -194,7 +194,10 @@ describe('DynascaleManager', () => {
194
194
  });
195
195
 
196
196
  it('video: should update subscription when track becomes available', () => {
197
- const updateSubscription = vi.spyOn(call, 'updateSubscriptionsPartial');
197
+ const updateSubscription = vi.spyOn(
198
+ call.state,
199
+ 'updateParticipantTracks',
200
+ );
198
201
 
199
202
  // @ts-ignore
200
203
  call.state.updateOrAddParticipant('session-id', {
@@ -213,52 +216,45 @@ describe('DynascaleManager', () => {
213
216
  expect(videoElement.muted).toBe(true);
214
217
  expect(videoElement.playsInline).toBe(true);
215
218
 
216
- expect(updateSubscription).toHaveBeenCalledWith(
217
- 'videoTrack',
218
- { 'session-id': { dimension: undefined } },
219
- DebounceType.FAST,
220
- );
219
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
220
+ 'session-id': { dimension: undefined },
221
+ });
221
222
 
222
223
  call.state.updateParticipant('session-id', {
223
224
  publishedTracks: [TrackType.VIDEO],
224
225
  });
225
226
 
226
- expect(updateSubscription).toHaveBeenCalledWith(
227
- 'videoTrack',
228
- {
229
- 'session-id': {
230
- dimension: {
231
- width: videoElement.clientWidth,
232
- height: videoElement.clientHeight,
233
- },
227
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
228
+ 'session-id': {
229
+ dimension: {
230
+ width: videoElement.clientWidth,
231
+ height: videoElement.clientHeight,
234
232
  },
235
233
  },
236
- DebounceType.FAST,
237
- );
234
+ });
238
235
 
239
236
  call.state.updateParticipant('session-id', {
240
237
  publishedTracks: [TrackType.VIDEO],
241
238
  });
242
239
 
243
- expect(updateSubscription).toHaveBeenCalledWith(
244
- 'videoTrack',
245
- { 'session-id': { dimension: undefined } },
246
- DebounceType.FAST,
247
- );
240
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
241
+ 'session-id': { dimension: undefined },
242
+ });
248
243
 
249
244
  cleanup?.();
250
245
 
251
- expect(updateSubscription).toHaveBeenLastCalledWith(
252
- 'videoTrack',
253
- { 'session-id': { dimension: undefined } },
254
- DebounceType.FAST,
255
- );
246
+ expect(updateSubscription).toHaveBeenLastCalledWith('videoTrack', {
247
+ 'session-id': { dimension: undefined },
248
+ });
256
249
  });
257
250
 
258
251
  it('video: should play video when track becomes available', () => {
259
252
  vi.useFakeTimers();
260
253
 
261
- const updateSubscription = vi.spyOn(call, 'updateSubscriptionsPartial');
254
+ const updateSubscription = vi.spyOn(
255
+ call.state,
256
+ 'updateParticipantTracks',
257
+ );
262
258
  const play = vi.spyOn(videoElement, 'play').mockResolvedValue();
263
259
 
264
260
  // @ts-ignore
@@ -282,28 +278,22 @@ describe('DynascaleManager', () => {
282
278
 
283
279
  vi.runAllTimers();
284
280
 
285
- expect(updateSubscription).toHaveBeenCalledWith(
286
- 'videoTrack',
287
- {
288
- 'session-id': {
289
- dimension: {
290
- width: videoElement.clientWidth,
291
- height: videoElement.clientHeight,
292
- },
281
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
282
+ 'session-id': {
283
+ dimension: {
284
+ width: videoElement.clientWidth,
285
+ height: videoElement.clientHeight,
293
286
  },
294
287
  },
295
- DebounceType.FAST,
296
- );
288
+ });
297
289
  expect(play).toHaveBeenCalled();
298
290
  expect(videoElement.srcObject).toBe(mediaStream);
299
291
 
300
292
  cleanup?.();
301
293
 
302
- expect(updateSubscription).toHaveBeenLastCalledWith(
303
- 'videoTrack',
304
- { 'session-id': { dimension: undefined } },
305
- DebounceType.FAST,
306
- );
294
+ expect(updateSubscription).toHaveBeenLastCalledWith('videoTrack', {
295
+ 'session-id': { dimension: undefined },
296
+ });
307
297
  });
308
298
 
309
299
  it('video: should update subscription when element becomes visible', () => {
@@ -318,7 +308,10 @@ describe('DynascaleManager', () => {
318
308
  },
319
309
  });
320
310
 
321
- const updateSubscription = vi.spyOn(call, 'updateSubscriptionsPartial');
311
+ const updateSubscription = vi.spyOn(
312
+ call.state,
313
+ 'updateParticipantTracks',
314
+ );
322
315
 
323
316
  const cleanup = dynascaleManager.bindVideoElement(
324
317
  videoElement,
@@ -326,11 +319,9 @@ describe('DynascaleManager', () => {
326
319
  'videoTrack',
327
320
  );
328
321
 
329
- expect(updateSubscription).toHaveBeenCalledWith(
330
- 'videoTrack',
331
- { 'session-id': { dimension: undefined } },
332
- DebounceType.FAST,
333
- );
322
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
323
+ 'session-id': { dimension: undefined },
324
+ });
334
325
 
335
326
  call.state.updateParticipant('session-id', {
336
327
  viewportVisibilityState: {
@@ -339,18 +330,14 @@ describe('DynascaleManager', () => {
339
330
  },
340
331
  });
341
332
 
342
- expect(updateSubscription).toHaveBeenCalledWith(
343
- 'videoTrack',
344
- {
345
- 'session-id': {
346
- dimension: {
347
- width: videoElement.clientWidth,
348
- height: videoElement.clientHeight,
349
- },
333
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
334
+ 'session-id': {
335
+ dimension: {
336
+ width: videoElement.clientWidth,
337
+ height: videoElement.clientHeight,
350
338
  },
351
339
  },
352
- DebounceType.MEDIUM,
353
- );
340
+ });
354
341
 
355
342
  call.state.updateParticipant('session-id', {
356
343
  viewportVisibilityState: {
@@ -359,11 +346,9 @@ describe('DynascaleManager', () => {
359
346
  },
360
347
  });
361
348
 
362
- expect(updateSubscription).toHaveBeenCalledWith(
363
- 'videoTrack',
364
- { 'session-id': { dimension: undefined } },
365
- DebounceType.MEDIUM,
366
- );
349
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
350
+ 'session-id': { dimension: undefined },
351
+ });
367
352
 
368
353
  call.state.updateParticipant('session-id', {
369
354
  viewportVisibilityState: {
@@ -372,26 +357,20 @@ describe('DynascaleManager', () => {
372
357
  },
373
358
  });
374
359
 
375
- expect(updateSubscription).toHaveBeenCalledWith(
376
- 'videoTrack',
377
- {
378
- 'session-id': {
379
- dimension: {
380
- width: videoElement.clientWidth,
381
- height: videoElement.clientHeight,
382
- },
360
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
361
+ 'session-id': {
362
+ dimension: {
363
+ width: videoElement.clientWidth,
364
+ height: videoElement.clientHeight,
383
365
  },
384
366
  },
385
- DebounceType.MEDIUM,
386
- );
367
+ });
387
368
 
388
369
  cleanup?.();
389
370
 
390
- expect(updateSubscription).toHaveBeenLastCalledWith(
391
- 'videoTrack',
392
- { 'session-id': { dimension: undefined } },
393
- DebounceType.FAST,
394
- );
371
+ expect(updateSubscription).toHaveBeenLastCalledWith('videoTrack', {
372
+ 'session-id': { dimension: undefined },
373
+ });
395
374
  });
396
375
 
397
376
  it('video: should update subscription when element resizes', () => {
@@ -406,7 +385,7 @@ describe('DynascaleManager', () => {
406
385
  },
407
386
  });
408
387
 
409
- let updateSubscription = vi.spyOn(call, 'updateSubscriptionsPartial');
388
+ let updateSubscription = vi.spyOn(call.state, 'updateParticipantTracks');
410
389
 
411
390
  let resizeObserverCallback: ResizeObserverCallback;
412
391
  window.ResizeObserver = class ResizeObserver {
@@ -428,18 +407,14 @@ describe('DynascaleManager', () => {
428
407
  'videoTrack',
429
408
  );
430
409
 
431
- expect(updateSubscription).toHaveBeenCalledWith(
432
- 'videoTrack',
433
- {
434
- 'session-id': {
435
- dimension: {
436
- width: videoElement.clientWidth,
437
- height: videoElement.clientHeight,
438
- },
410
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
411
+ 'session-id': {
412
+ dimension: {
413
+ width: videoElement.clientWidth,
414
+ height: videoElement.clientHeight,
439
415
  },
440
416
  },
441
- DebounceType.FAST,
442
- );
417
+ });
443
418
 
444
419
  // @ts-ignore simulate resize
445
420
  videoElement.clientHeight = 101;
@@ -449,19 +424,15 @@ describe('DynascaleManager', () => {
449
424
  // @ts-ignore simulate resize
450
425
  resizeObserverCallback();
451
426
 
452
- expect(updateSubscription).toHaveBeenCalledWith(
453
- 'videoTrack',
454
- { 'session-id': { dimension: { width: 101, height: 101 } } },
455
- DebounceType.SLOW,
456
- );
427
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
428
+ 'session-id': { dimension: { width: 101, height: 101 } },
429
+ });
457
430
 
458
431
  cleanup?.();
459
432
 
460
- expect(updateSubscription).toHaveBeenLastCalledWith(
461
- 'videoTrack',
462
- { 'session-id': { dimension: undefined } },
463
- DebounceType.FAST,
464
- );
433
+ expect(updateSubscription).toHaveBeenLastCalledWith('videoTrack', {
434
+ 'session-id': { dimension: undefined },
435
+ });
465
436
  });
466
437
 
467
438
  it('video: should unsubscribe when element dimensions are zero', () => {
@@ -476,7 +447,7 @@ describe('DynascaleManager', () => {
476
447
  },
477
448
  });
478
449
 
479
- let updateSubscription = vi.spyOn(call, 'updateSubscriptionsPartial');
450
+ let updateSubscription = vi.spyOn(call.state, 'updateParticipantTracks');
480
451
 
481
452
  // @ts-ignore simulate resize
482
453
  videoElement.clientHeight = 0;
@@ -489,19 +460,15 @@ describe('DynascaleManager', () => {
489
460
  'videoTrack',
490
461
  );
491
462
 
492
- expect(updateSubscription).toHaveBeenCalledWith(
493
- 'videoTrack',
494
- { 'session-id': { dimension: undefined } },
495
- DebounceType.FAST,
496
- );
463
+ expect(updateSubscription).toHaveBeenCalledWith('videoTrack', {
464
+ 'session-id': { dimension: undefined },
465
+ });
497
466
 
498
467
  cleanup?.();
499
468
 
500
- expect(updateSubscription).toHaveBeenLastCalledWith(
501
- 'videoTrack',
502
- { 'session-id': { dimension: undefined } },
503
- DebounceType.FAST,
504
- );
469
+ expect(updateSubscription).toHaveBeenLastCalledWith('videoTrack', {
470
+ 'session-id': { dimension: undefined },
471
+ });
505
472
  });
506
473
  });
507
474
  });
@@ -9,9 +9,11 @@ import type { Patch } from './rxUtils';
9
9
  import * as RxUtils from './rxUtils';
10
10
  import { CallingState } from './CallingState';
11
11
  import {
12
- StreamVideoParticipant,
13
- StreamVideoParticipantPatch,
14
- StreamVideoParticipantPatches,
12
+ type StreamVideoParticipant,
13
+ type StreamVideoParticipantPatch,
14
+ type StreamVideoParticipantPatches,
15
+ type SubscriptionChanges,
16
+ VideoTrackType,
15
17
  VisibilityState,
16
18
  } from '../types';
17
19
  import { CallStatsReport } from '../stats';
@@ -903,6 +905,44 @@ export class CallState {
903
905
  );
904
906
  };
905
907
 
908
+ /**
909
+ * Update track subscription configuration for one or more participants.
910
+ * You have to create a subscription for each participant for all the different kinds of tracks you want to receive.
911
+ * You can only subscribe for tracks after the participant started publishing the given kind of track.
912
+ *
913
+ * @param trackType the kind of subscription to update.
914
+ * @param changes the list of subscription changes to do.
915
+ * @param type the debounce type to use for the update.
916
+ */
917
+ updateParticipantTracks = (
918
+ trackType: VideoTrackType,
919
+ changes: SubscriptionChanges,
920
+ ) => {
921
+ return this.updateParticipants(
922
+ Object.entries(changes).reduce<StreamVideoParticipantPatches>(
923
+ (acc, [sessionId, change]) => {
924
+ if (change.dimension) {
925
+ change.dimension.height = Math.ceil(change.dimension.height);
926
+ change.dimension.width = Math.ceil(change.dimension.width);
927
+ }
928
+ const prop: keyof StreamVideoParticipant | undefined =
929
+ trackType === 'videoTrack'
930
+ ? 'videoDimension'
931
+ : trackType === 'screenShareTrack'
932
+ ? 'screenShareDimension'
933
+ : undefined;
934
+ if (prop) {
935
+ acc[sessionId] = {
936
+ [prop]: change.dimension,
937
+ };
938
+ }
939
+ return acc;
940
+ },
941
+ {},
942
+ ),
943
+ );
944
+ };
945
+
906
946
  /**
907
947
  * Updates the call state with the data received from the server.
908
948
  *