@webex/plugin-meetings 3.0.0-beta.0 → 3.0.0-beta.2

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 (111) hide show
  1. package/dist/common/errors/webex-errors.js +5 -29
  2. package/dist/common/errors/webex-errors.js.map +1 -1
  3. package/dist/constants.js +15 -74
  4. package/dist/constants.js.map +1 -1
  5. package/dist/media/index.js +68 -213
  6. package/dist/media/index.js.map +1 -1
  7. package/dist/media/internal-media-core-wrapper.js +22 -0
  8. package/dist/media/internal-media-core-wrapper.js.map +1 -0
  9. package/dist/media/properties.js +20 -25
  10. package/dist/media/properties.js.map +1 -1
  11. package/dist/media/util.js +0 -27
  12. package/dist/media/util.js.map +1 -1
  13. package/dist/meeting/index.js +742 -500
  14. package/dist/meeting/index.js.map +1 -1
  15. package/dist/meeting/request.js +1 -0
  16. package/dist/meeting/request.js.map +1 -1
  17. package/dist/meeting/util.js +3 -44
  18. package/dist/meeting/util.js.map +1 -1
  19. package/dist/meetings/index.js +64 -5
  20. package/dist/meetings/index.js.map +1 -1
  21. package/dist/meetings/util.js +24 -1
  22. package/dist/meetings/util.js.map +1 -1
  23. package/dist/members/index.js +68 -0
  24. package/dist/members/index.js.map +1 -1
  25. package/dist/multistream/mediaRequestManager.js +132 -0
  26. package/dist/multistream/mediaRequestManager.js.map +1 -0
  27. package/dist/multistream/multistreamMedia.js +116 -0
  28. package/dist/multistream/multistreamMedia.js.map +1 -0
  29. package/dist/multistream/receiveSlot.js +209 -0
  30. package/dist/multistream/receiveSlot.js.map +1 -0
  31. package/dist/multistream/receiveSlotManager.js +195 -0
  32. package/dist/multistream/receiveSlotManager.js.map +1 -0
  33. package/dist/multistream/remoteMedia.js +284 -0
  34. package/dist/multistream/remoteMedia.js.map +1 -0
  35. package/dist/multistream/remoteMediaGroup.js +243 -0
  36. package/dist/multistream/remoteMediaGroup.js.map +1 -0
  37. package/dist/multistream/remoteMediaManager.js +1113 -0
  38. package/dist/multistream/remoteMediaManager.js.map +1 -0
  39. package/dist/reconnection-manager/index.js +109 -130
  40. package/dist/reconnection-manager/index.js.map +1 -1
  41. package/dist/roap/index.js +57 -240
  42. package/dist/roap/index.js.map +1 -1
  43. package/dist/roap/request.js +2 -114
  44. package/dist/roap/request.js.map +1 -1
  45. package/dist/roap/turnDiscovery.js +11 -5
  46. package/dist/roap/turnDiscovery.js.map +1 -1
  47. package/dist/statsAnalyzer/global.js +2 -0
  48. package/dist/statsAnalyzer/global.js.map +1 -1
  49. package/dist/statsAnalyzer/index.js +39 -36
  50. package/dist/statsAnalyzer/index.js.map +1 -1
  51. package/package.json +20 -19
  52. package/src/common/errors/webex-errors.js +0 -18
  53. package/src/constants.ts +139 -180
  54. package/src/media/index.js +60 -194
  55. package/src/media/internal-media-core-wrapper.ts +9 -0
  56. package/src/media/properties.js +19 -25
  57. package/src/media/util.js +0 -22
  58. package/src/meeting/index.js +622 -398
  59. package/src/meeting/request.js +1 -0
  60. package/src/meeting/util.js +3 -46
  61. package/src/meetings/index.js +30 -1
  62. package/src/meetings/util.js +23 -2
  63. package/src/members/index.js +48 -0
  64. package/src/multistream/mediaRequestManager.ts +164 -0
  65. package/src/multistream/multistreamMedia.ts +92 -0
  66. package/src/multistream/receiveSlot.ts +141 -0
  67. package/src/multistream/receiveSlotManager.ts +142 -0
  68. package/src/multistream/remoteMedia.ts +219 -0
  69. package/src/multistream/remoteMediaGroup.ts +224 -0
  70. package/src/multistream/remoteMediaManager.ts +911 -0
  71. package/src/reconnection-manager/index.js +40 -53
  72. package/src/roap/index.js +47 -207
  73. package/src/roap/request.js +1 -72
  74. package/src/roap/turnDiscovery.ts +12 -6
  75. package/src/statsAnalyzer/global.js +2 -0
  76. package/src/statsAnalyzer/index.js +32 -46
  77. package/test/integration/spec/journey.js +1 -1
  78. package/test/unit/spec/media/index.ts +223 -0
  79. package/test/unit/spec/media/properties.ts +73 -82
  80. package/test/unit/spec/meeting/effectsState.js +1 -3
  81. package/test/unit/spec/meeting/index.js +420 -228
  82. package/test/unit/spec/meeting/muteState.js +7 -0
  83. package/test/unit/spec/meeting/utils.js +61 -2
  84. package/test/unit/spec/meetings/index.js +0 -4
  85. package/test/unit/spec/members/index.js +164 -2
  86. package/test/unit/spec/multistream/mediaRequestManager.ts +511 -0
  87. package/test/unit/spec/multistream/receiveSlot.ts +104 -0
  88. package/test/unit/spec/multistream/receiveSlotManager.ts +173 -0
  89. package/test/unit/spec/multistream/remoteMedia.ts +217 -0
  90. package/test/unit/spec/multistream/remoteMediaGroup.ts +396 -0
  91. package/test/unit/spec/multistream/remoteMediaManager.ts +1251 -0
  92. package/test/unit/spec/roap/index.ts +63 -35
  93. package/test/unit/spec/stats-analyzer/index.js +19 -22
  94. package/dist/peer-connection-manager/index.js +0 -794
  95. package/dist/peer-connection-manager/index.js.map +0 -1
  96. package/dist/roap/collection.js +0 -73
  97. package/dist/roap/collection.js.map +0 -1
  98. package/dist/roap/handler.js +0 -337
  99. package/dist/roap/handler.js.map +0 -1
  100. package/dist/roap/state.js +0 -164
  101. package/dist/roap/state.js.map +0 -1
  102. package/dist/roap/util.js +0 -102
  103. package/dist/roap/util.js.map +0 -1
  104. package/src/peer-connection-manager/index.js +0 -723
  105. package/src/roap/collection.js +0 -63
  106. package/src/roap/handler.js +0 -252
  107. package/src/roap/state.js +0 -149
  108. package/src/roap/util.js +0 -93
  109. package/test/unit/spec/peerconnection-manager/index.js +0 -188
  110. package/test/unit/spec/peerconnection-manager/utils.js +0 -48
  111. package/test/unit/spec/roap/util.js +0 -30
@@ -0,0 +1,911 @@
1
+ import {cloneDeep, remove} from 'lodash';
2
+ import {EventMap} from 'typed-emitter';
3
+ import {MediaConnection as MC} from '@webex/internal-media-core';
4
+
5
+ import LoggerProxy from '../common/logs/logger-proxy';
6
+ import EventsScope from '../common/events/events-scope';
7
+
8
+ import {RemoteMedia, RemoteVideoResolution} from './remoteMedia';
9
+ import {ReceiveSlot, CSI} from './receiveSlot';
10
+ import {ReceiveSlotManager} from './receiveSlotManager';
11
+ import {RemoteMediaGroup} from './remoteMediaGroup';
12
+ import {MediaRequestManager} from './mediaRequestManager';
13
+
14
+ export type PaneSize = RemoteVideoResolution;
15
+ export type LayoutId = string;
16
+ export type PaneId = string;
17
+ export type PaneGroupId = string;
18
+
19
+ export interface ActiveSpeakerVideoPaneGroup {
20
+ id: PaneGroupId;
21
+ numPanes: number; // maximum number of panes in the group (actual number may be lower, if there are not enough participants in the meeting)
22
+ size: PaneSize; // preferred size for all panes in the group
23
+ priority: number; // 0-255 (255 = highest priority), each group must have a different priority from all other groups
24
+ }
25
+
26
+ export interface MemberVideoPane {
27
+ id: PaneId;
28
+ size: PaneSize;
29
+ csi?: CSI;
30
+ }
31
+
32
+ export interface VideoLayout {
33
+ screenShareVideo: {
34
+ size: PaneSize | null; // null if you don't want to receive any screen share video
35
+ };
36
+ activeSpeakerVideoPaneGroups?: ActiveSpeakerVideoPaneGroup[]; // list of active speaker video pane groups
37
+ memberVideoPanes?: MemberVideoPane[]; // list of video panes for specific members, CSI values can be changed later via setVideoPaneCsi()
38
+ }
39
+
40
+ export interface Configuration {
41
+ audio: {
42
+ numOfActiveSpeakerStreams: number; // number of audio streams we want to receive
43
+ };
44
+ video: {
45
+ preferLiveVideo: boolean; // applies to all pane groups with active speaker policy
46
+ initialLayoutId: LayoutId;
47
+
48
+ layouts: {[key: LayoutId]: VideoLayout}; // a map of all available layouts, a layout can be set via setLayout() method
49
+ };
50
+ screenShare: {
51
+ audio: boolean; // whether we ever want to receive screen share audio at all
52
+ video: boolean; // whether we ever want to receive screen share video at all
53
+ };
54
+ }
55
+
56
+ /* Predefined layouts: */
57
+
58
+ // An "all equal" grid, with size up to 3 x 3 = 9:
59
+ const AllEqualLayout: VideoLayout = {
60
+ screenShareVideo: {size: null},
61
+ activeSpeakerVideoPaneGroups: [
62
+ {
63
+ id: 'main',
64
+ numPanes: 9,
65
+ size: 'best',
66
+ priority: 255,
67
+ },
68
+ ],
69
+ };
70
+
71
+ // A layout with just a single remote active speaker video pane:
72
+ const SingleLayout: VideoLayout = {
73
+ screenShareVideo: {size: null},
74
+ activeSpeakerVideoPaneGroups: [
75
+ {
76
+ id: 'main',
77
+ numPanes: 1,
78
+ size: 'best',
79
+ priority: 255,
80
+ },
81
+ ],
82
+ };
83
+
84
+ // A layout with 1 big pane for the highest priority active speaker and 5 small panes for other active speakers:
85
+ const OnePlusFiveLayout: VideoLayout = {
86
+ screenShareVideo: {size: null},
87
+ activeSpeakerVideoPaneGroups: [
88
+ {
89
+ id: 'mainBigOne',
90
+ numPanes: 1,
91
+ size: 'large',
92
+ priority: 255,
93
+ },
94
+ {
95
+ id: 'secondarySetOfSmallPanes',
96
+ numPanes: 5,
97
+ size: 'very small',
98
+ priority: 254,
99
+ },
100
+ ],
101
+ };
102
+
103
+ // A layout with 2 big panes for 2 main active speakers and a strip of 6 small panes for other active speakers:
104
+ const TwoMainPlusSixSmallLayout: VideoLayout = {
105
+ screenShareVideo: {size: null},
106
+ activeSpeakerVideoPaneGroups: [
107
+ {
108
+ id: 'mainGroupWith2BigPanes',
109
+ numPanes: 2,
110
+ size: 'large',
111
+ priority: 255,
112
+ },
113
+ {
114
+ id: 'secondaryGroupOfSmallPanes',
115
+ numPanes: 6,
116
+ size: 'small',
117
+ priority: 254,
118
+ },
119
+ ],
120
+ };
121
+
122
+ // A strip of 8 small video panes (thumbnails) displayed at the top of a remote screenshare:
123
+ const RemoteScreenShareWithSmallThumbnailsLayout: VideoLayout = {
124
+ screenShareVideo: {size: 'best'},
125
+ activeSpeakerVideoPaneGroups: [
126
+ {
127
+ id: 'thumbnails',
128
+ numPanes: 8,
129
+ size: 'thumbnail',
130
+ priority: 255,
131
+ },
132
+ ],
133
+ };
134
+
135
+ // A staged layout with 4 pre-selected meeting participants in the main 2x2 grid and 6 small panes for other active speakers at the top:
136
+ const Stage2x2With6ThumbnailsLayout: VideoLayout = {
137
+ screenShareVideo: {size: null},
138
+ activeSpeakerVideoPaneGroups: [
139
+ {
140
+ id: 'thumbnails',
141
+ numPanes: 6,
142
+ size: 'thumbnail',
143
+ priority: 255,
144
+ },
145
+ ],
146
+ memberVideoPanes: [
147
+ {id: 'stage-1', size: 'medium', csi: undefined},
148
+ {id: 'stage-2', size: 'medium', csi: undefined},
149
+ {id: 'stage-3', size: 'medium', csi: undefined},
150
+ {id: 'stage-4', size: 'medium', csi: undefined},
151
+ ],
152
+ };
153
+
154
+ /**
155
+ * Default configuration:
156
+ * - uses 3 audio streams
157
+ * - prefers active speakers with live video (e.g. are not audio only or video muted) over active speakers without live video
158
+ * - has a few layouts defined, including 1 that contains remote screen share (ScreenShareView)
159
+ */
160
+ export const DefaultConfiguration: Configuration = {
161
+ audio: {
162
+ numOfActiveSpeakerStreams: 3,
163
+ },
164
+ video: {
165
+ preferLiveVideo: true,
166
+ initialLayoutId: 'AllEqual',
167
+
168
+ layouts: {
169
+ AllEqual: AllEqualLayout,
170
+ OnePlusFive: OnePlusFiveLayout,
171
+ Single: SingleLayout,
172
+ Stage: Stage2x2With6ThumbnailsLayout,
173
+ ScreenShareView: RemoteScreenShareWithSmallThumbnailsLayout,
174
+ },
175
+ },
176
+ screenShare: {
177
+ audio: true,
178
+ video: true,
179
+ },
180
+ };
181
+
182
+ export enum Event {
183
+ // events for audio streams
184
+ AudioCreated = 'AudioCreated',
185
+ ScreenShareAudioCreated = 'ScreenShareCreated',
186
+
187
+ // events for video streams
188
+ VideoLayoutChanged = 'VideoLayoutChanged',
189
+ }
190
+
191
+ export interface VideoLayoutChangedEventData {
192
+ layoutId: LayoutId;
193
+ activeSpeakerVideoPanes: {
194
+ [key: PaneGroupId]: RemoteMediaGroup;
195
+ };
196
+ memberVideoPanes: {[key: PaneId]: RemoteMedia};
197
+ screenShareVideo?: RemoteMedia;
198
+ }
199
+ export interface Events extends EventMap {
200
+ // audio
201
+ [Event.AudioCreated]: (audio: RemoteMediaGroup) => void;
202
+ [Event.ScreenShareAudioCreated]: (screenShareAudio: RemoteMedia) => void;
203
+
204
+ // video
205
+ [Event.VideoLayoutChanged]: (data: VideoLayoutChangedEventData) => void;
206
+ }
207
+
208
+ /**
209
+ * A helper class that manages all remote audio/video streams in order to achieve a predefined set of layouts.
210
+ * It also creates a fixed number of audio streams and these don't change during the meeting.
211
+ *
212
+ * Things that RemoteMediaManager does:
213
+ * - owns the receive slots (creates them when needed, and re-uses them when switching layouts)
214
+ * - constructs appropriate RemoteMedia and RemoteMediaGroup objects and sends appropriate mediaRequests
215
+ */
216
+ export class RemoteMediaManager extends EventsScope {
217
+ private config: Configuration;
218
+
219
+ private started: boolean;
220
+
221
+ private receiveSlotManager: ReceiveSlotManager;
222
+
223
+ private mediaRequestManagers: {
224
+ audio: MediaRequestManager;
225
+ video: MediaRequestManager;
226
+ };
227
+
228
+ private currentLayout?: VideoLayout;
229
+
230
+ private slots: {
231
+ audio: ReceiveSlot[];
232
+ screenShareAudio?: ReceiveSlot;
233
+ screenShareVideo?: ReceiveSlot;
234
+ video: {
235
+ unused: ReceiveSlot[];
236
+ activeSpeaker: ReceiveSlot[];
237
+ receiverSelected: ReceiveSlot[];
238
+ };
239
+ };
240
+
241
+ private media: {
242
+ audio?: RemoteMediaGroup;
243
+ video: {
244
+ activeSpeakerGroups: {
245
+ [key: PaneGroupId]: RemoteMediaGroup;
246
+ };
247
+ memberPanes: {[key: PaneId]: RemoteMedia};
248
+ };
249
+ };
250
+
251
+ private receiveSlotAllocations: {
252
+ activeSpeaker: {[key: PaneGroupId]: {slots: ReceiveSlot[]}};
253
+ receiverSelected: {[key: PaneId]: ReceiveSlot};
254
+ };
255
+
256
+ private currentLayoutId?: LayoutId;
257
+
258
+ /**
259
+ * Constructor
260
+ *
261
+ * @param {ReceiveSlotManager} receiveSlotManager
262
+ * @param {{audio: MediaRequestManager, video: mediaRequestManagers}} mediaRequestManagers
263
+ * @param {Configuration} config Configuration describing what video layouts to use during the meeting
264
+ */
265
+ constructor(
266
+ receiveSlotManager: ReceiveSlotManager,
267
+ mediaRequestManagers: {
268
+ audio: MediaRequestManager;
269
+ video: MediaRequestManager;
270
+ },
271
+ config: Configuration = DefaultConfiguration
272
+ ) {
273
+ super();
274
+ this.started = false;
275
+ this.config = config;
276
+ this.receiveSlotManager = receiveSlotManager;
277
+ this.mediaRequestManagers = mediaRequestManagers;
278
+ this.media = {
279
+ audio: undefined,
280
+ video: {
281
+ activeSpeakerGroups: {},
282
+ memberPanes: {},
283
+ },
284
+ };
285
+
286
+ this.checkConfigValidity();
287
+
288
+ this.slots = {
289
+ audio: [],
290
+ screenShareAudio: undefined,
291
+ screenShareVideo: undefined,
292
+ video: {
293
+ unused: [],
294
+ activeSpeaker: [],
295
+ receiverSelected: [],
296
+ },
297
+ };
298
+
299
+ this.receiveSlotAllocations = {activeSpeaker: {}, receiverSelected: {}};
300
+
301
+ LoggerProxy.logger.log(
302
+ `RemoteMediaManager#constructor --> RemoteMediaManager created with config: ${JSON.stringify(
303
+ this.config
304
+ )}`
305
+ );
306
+ }
307
+
308
+ /**
309
+ * Checks if configuration is valid, throws an error if it's not
310
+ */
311
+ private checkConfigValidity() {
312
+ if (!(this.config.video.initialLayoutId in this.config.video.layouts)) {
313
+ throw new Error(
314
+ `invalid config: initialLayoutId "${this.config.video.initialLayoutId}" doesn't match any of the layouts`
315
+ );
316
+ }
317
+
318
+ // check if each layout is valid
319
+ Object.values(this.config.video.layouts).forEach((layout) => {
320
+ const groupIds = {};
321
+ const paneIds = {};
322
+ const groupPriorites = {};
323
+
324
+ layout.activeSpeakerVideoPaneGroups?.forEach((group) => {
325
+ if (groupIds[group.id]) {
326
+ throw new Error(
327
+ `invalid config: duplicate active speaker video pane group id: ${group.id}`
328
+ );
329
+ }
330
+ groupIds[group.id] = true;
331
+
332
+ if (groupPriorites[group.priority]) {
333
+ throw new Error(
334
+ `invalid config: multiple active speaker video pane groups have same priority: ${group.priority}`
335
+ );
336
+ }
337
+ groupPriorites[group.priority] = true;
338
+ });
339
+
340
+ layout.memberVideoPanes?.forEach((pane) => {
341
+ if (paneIds[pane.id]) {
342
+ throw new Error(`invalid config: duplicate member video pane id: ${pane.id}`);
343
+ }
344
+ paneIds[pane.id] = true;
345
+ });
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Starts the RemoteMediaManager.
351
+ *
352
+ * @returns {Promise}
353
+ */
354
+ public async start() {
355
+ if (this.started) {
356
+ throw new Error('start() failure: already started');
357
+ }
358
+ this.started = true;
359
+
360
+ await this.createAudioMedia();
361
+
362
+ // todo: create screen share audio remote media (SPARK-377812)
363
+ // todo: create screen share video receive slot (SPARK-377812)
364
+
365
+ await this.preallocateVideoReceiveSlots();
366
+
367
+ await this.setLayout(this.config.video.initialLayoutId);
368
+ }
369
+
370
+ /**
371
+ * Releases all the used resources (like allocated receive slots). This function needs
372
+ * to be called when we leave the meeting, etc.
373
+ */
374
+ public stop() {
375
+ // invalidate all remoteMedia objects
376
+ this.invalidateCurrentRemoteMedia({audio: true, video: true, commit: true});
377
+
378
+ // release all audio receive slots
379
+ this.slots.audio.forEach((slot) => this.receiveSlotManager.releaseSlot(slot));
380
+ this.slots.audio.length = 0;
381
+
382
+ // todo: screenshare slots... (SPARK-377812)
383
+
384
+ this.receiveSlotAllocations = {activeSpeaker: {}, receiverSelected: {}};
385
+
386
+ this.slots.video.unused.push(...this.slots.video.activeSpeaker);
387
+ this.slots.video.activeSpeaker.length = 0;
388
+
389
+ this.slots.video.unused.push(...this.slots.video.receiverSelected);
390
+ this.slots.video.receiverSelected.length = 0;
391
+
392
+ this.releaseUnusedVideoSlots();
393
+
394
+ this.currentLayout = undefined;
395
+ this.currentLayoutId = undefined;
396
+ this.started = false;
397
+ }
398
+
399
+ /**
400
+ * Returns the total number of main video panes required for a given layout
401
+ *
402
+ * @param {VideoLayout} layout
403
+ * @returns {number}
404
+ */
405
+ private getRequiredNumVideoSlotsForLayout(layout?: VideoLayout) {
406
+ if (!layout) {
407
+ return 0;
408
+ }
409
+
410
+ const activeSpeakerCount =
411
+ layout.activeSpeakerVideoPaneGroups?.reduce(
412
+ (sum, paneGroup) => sum + paneGroup.numPanes,
413
+ 0
414
+ ) || 0;
415
+
416
+ const receiverSelectedCount = layout.memberVideoPanes?.length || 0;
417
+
418
+ return activeSpeakerCount + receiverSelectedCount;
419
+ }
420
+
421
+ /**
422
+ * Allocates the maximum number of panes that any of the configured layouts will require.
423
+ * We do this at the beginning, because it's more efficient (much faster) then allocating receive slots
424
+ * later, after the SDP exchange was done.
425
+ */
426
+ private async preallocateVideoReceiveSlots() {
427
+ const maxNumVideoPanesRequired = Object.values(this.config.video.layouts).reduce(
428
+ (maxValue, layout) => Math.max(maxValue, this.getRequiredNumVideoSlotsForLayout(layout)),
429
+ 0
430
+ );
431
+
432
+ while (this.slots.video.unused.length < maxNumVideoPanesRequired) {
433
+ // eslint-disable-next-line no-await-in-loop
434
+ this.slots.video.unused.push(
435
+ await this.receiveSlotManager.allocateSlot(MC.MediaType.VideoMain)
436
+ );
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Changes the layout (triggers Event.VideoLayoutChanged)
442
+ *
443
+ * @param {LayoutId} layoutId new layout id
444
+ * @returns {Promise}
445
+ */
446
+ public async setLayout(layoutId: LayoutId) {
447
+ if (!(layoutId in this.config.video.layouts)) {
448
+ throw new Error(
449
+ `invalid layoutId: "${layoutId}" doesn't match any of the configured layouts`
450
+ );
451
+ }
452
+ if (!this.started) {
453
+ throw new Error('setLayout() called before start()');
454
+ }
455
+ this.currentLayoutId = layoutId;
456
+ this.currentLayout = cloneDeep(this.config.video.layouts[this.currentLayoutId]);
457
+
458
+ await this.updateVideoReceiveSlots();
459
+ this.updateVideoRemoteMediaObjects();
460
+ this.emitVideoLayoutChangedEvent();
461
+ }
462
+
463
+ /**
464
+ * Returns the currently selected layout id
465
+ *
466
+ * @returns {LayoutId}
467
+ */
468
+ public getLayoutId(): LayoutId | undefined {
469
+ return this.currentLayoutId;
470
+ }
471
+
472
+ /**
473
+ * Creates the audio slots
474
+ */
475
+ private async createAudioMedia() {
476
+ // create the audio receive slots
477
+ for (let i = 0; i < this.config.audio.numOfActiveSpeakerStreams; i += 1) {
478
+ // eslint-disable-next-line no-await-in-loop
479
+ const slot = await this.receiveSlotManager.allocateSlot(MC.MediaType.AudioMain);
480
+
481
+ this.slots.audio.push(slot);
482
+ }
483
+
484
+ // create a remote media group
485
+ this.media.audio = new RemoteMediaGroup(
486
+ this.mediaRequestManagers.audio,
487
+ this.slots.audio,
488
+ 255,
489
+ true
490
+ );
491
+
492
+ this.emit(
493
+ {file: 'multistream/remoteMediaManager', function: 'createAudioMedia'},
494
+ Event.AudioCreated,
495
+ this.media.audio
496
+ );
497
+ }
498
+
499
+ /**
500
+ * Goes over all receiver-selected slots and keeps only the ones that are required by a given layout,
501
+ * the rest are all moved to the "unused" list
502
+ */
503
+ private trimReceiverSelectedSlots() {
504
+ const requiredCsis = {};
505
+
506
+ // fill requiredCsis with all the CSIs that the given layout requires
507
+ this.currentLayout?.memberVideoPanes?.forEach((memberVideoPane) => {
508
+ if (memberVideoPane.csi !== undefined) {
509
+ requiredCsis[memberVideoPane.csi] = true;
510
+ }
511
+ });
512
+
513
+ const isCsiNeededByCurrentLayout = (csi?: CSI): boolean => {
514
+ if (csi === undefined) {
515
+ return false;
516
+ }
517
+
518
+ return !!requiredCsis[csi];
519
+ };
520
+
521
+ // keep receiverSelected slots that match our new requiredCsis, move the rest of receiverSelected slots to unused
522
+ const notNeededReceiverSelectedSlots = remove(
523
+ this.slots.video.receiverSelected,
524
+ (slot) => isCsiNeededByCurrentLayout(slot.csi) === false
525
+ );
526
+
527
+ this.slots.video.unused.push(...notNeededReceiverSelectedSlots);
528
+ }
529
+
530
+ /**
531
+ * Releases all the "unused" video slots.
532
+ */
533
+ private releaseUnusedVideoSlots() {
534
+ this.slots.video.unused.forEach((slot) => this.receiveSlotManager.releaseSlot(slot));
535
+ this.slots.video.unused.length = 0;
536
+ }
537
+
538
+ /**
539
+ * Allocates receive slots to all video panes in the current selected layout
540
+ */
541
+ private allocateSlotsToVideoPaneGroups() {
542
+ this.receiveSlotAllocations = {activeSpeaker: {}, receiverSelected: {}};
543
+
544
+ this.currentLayout?.activeSpeakerVideoPaneGroups?.forEach((group) => {
545
+ this.receiveSlotAllocations.activeSpeaker[group.id] = {slots: []};
546
+
547
+ for (let paneIndex = 0; paneIndex < group.numPanes; paneIndex += 1) {
548
+ // allocate a slot from the "unused" list
549
+ const freeSlot = this.slots.video.unused.pop();
550
+
551
+ if (freeSlot) {
552
+ this.slots.video.activeSpeaker.push(freeSlot);
553
+ this.receiveSlotAllocations.activeSpeaker[group.id].slots.push(freeSlot);
554
+ }
555
+ }
556
+ });
557
+
558
+ this.currentLayout?.memberVideoPanes?.forEach((memberPane) => {
559
+ // check if there is existing slot for this csi
560
+ const existingSlot = this.slots.video.receiverSelected.find(
561
+ (slot) => slot.csi === memberPane.csi
562
+ );
563
+
564
+ const isExistingSlotAlreadyAllocated = Object.values(
565
+ this.receiveSlotAllocations.receiverSelected
566
+ ).includes(existingSlot);
567
+
568
+ if (memberPane.csi !== undefined && existingSlot && !isExistingSlotAlreadyAllocated) {
569
+ // found it, so use it
570
+ this.receiveSlotAllocations.receiverSelected[memberPane.id] = existingSlot;
571
+ } else {
572
+ // allocate a slot from the "unused" list
573
+ const freeSlot = this.slots.video.unused.pop();
574
+
575
+ if (freeSlot) {
576
+ this.slots.video.receiverSelected.push(freeSlot);
577
+ this.receiveSlotAllocations.receiverSelected[memberPane.id] = freeSlot;
578
+ }
579
+ }
580
+ });
581
+ }
582
+
583
+ /**
584
+ * Makes sure we have the right number of receive slots created for the current layout
585
+ * and allocates them to the right video panes / pane groups
586
+ *
587
+ * @returns {Promise}
588
+ */
589
+ private async updateVideoReceiveSlots() {
590
+ const requiredNumSlots = this.getRequiredNumVideoSlotsForLayout(this.currentLayout);
591
+ const totalNumSlots =
592
+ this.slots.video.unused.length +
593
+ this.slots.video.activeSpeaker.length +
594
+ this.slots.video.receiverSelected.length;
595
+
596
+ // ensure we have enough total slots for current layout
597
+ if (totalNumSlots < requiredNumSlots) {
598
+ let numSlotsToCreate = requiredNumSlots - totalNumSlots;
599
+
600
+ while (numSlotsToCreate > 0) {
601
+ // eslint-disable-next-line no-await-in-loop
602
+ this.slots.video.unused.push(
603
+ await this.receiveSlotManager.allocateSlot(MC.MediaType.VideoMain)
604
+ );
605
+ numSlotsToCreate -= 1;
606
+ }
607
+ }
608
+
609
+ // move all no longer needed receiver-selected slots to "unused"
610
+ this.trimReceiverSelectedSlots();
611
+
612
+ // move all active speaker slots to "unused"
613
+ this.slots.video.unused.push(...this.slots.video.activeSpeaker);
614
+ this.slots.video.activeSpeaker.length = 0;
615
+
616
+ // allocate the slots to the right panes / pane groups
617
+ this.allocateSlotsToVideoPaneGroups();
618
+
619
+ LoggerProxy.logger.log(
620
+ `RemoteMediaManager#updateVideoReceiveSlots --> receive slots updated: unused=${this.slots.video.unused.length}, activeSpeaker=${this.slots.video.activeSpeaker.length}, receiverSelected=${this.slots.video.receiverSelected.length}`
621
+ );
622
+
623
+ // If this is the initial layout, there may be some "unused" slots left because of the preallocation
624
+ // done in this.preallocateVideoReceiveSlots(), so release them now
625
+ this.releaseUnusedVideoSlots();
626
+ }
627
+
628
+ /**
629
+ * Creates new RemoteMedia and RemoteMediaGroup objects for the current layout
630
+ * and sends the media requests for all of them.
631
+ */
632
+ private updateVideoRemoteMediaObjects() {
633
+ // invalidate all the previous remote media objects and cancel their media requests
634
+ this.invalidateCurrentRemoteMedia({audio: false, video: true, commit: false});
635
+
636
+ // create new remoteMediaGroup objects
637
+ this.media.video.activeSpeakerGroups = {};
638
+ this.media.video.memberPanes = {};
639
+
640
+ for (const [groupId, group] of Object.entries(this.receiveSlotAllocations.activeSpeaker)) {
641
+ const paneGroupInCurrentLayout = this.currentLayout?.activeSpeakerVideoPaneGroups?.find(
642
+ (groupInLayout) => groupInLayout.id === groupId
643
+ );
644
+
645
+ if (paneGroupInCurrentLayout) {
646
+ const mediaGroup = new RemoteMediaGroup(
647
+ this.mediaRequestManagers.video,
648
+ group.slots,
649
+ paneGroupInCurrentLayout.priority,
650
+ false,
651
+ {
652
+ preferLiveVideo: this.config.video.preferLiveVideo,
653
+ resolution: paneGroupInCurrentLayout.size,
654
+ }
655
+ );
656
+
657
+ this.media.video.activeSpeakerGroups[groupId] = mediaGroup;
658
+ } else {
659
+ // this should never happen, because this.receiveSlotAllocations are created based on current layout configuration
660
+ LoggerProxy.logger.warn(
661
+ `a group id ${groupId} from this.receiveSlotAllocations.activeSpeaker cannot be found in the current layout configuration`
662
+ );
663
+ }
664
+ }
665
+
666
+ // create new remoteMedia objects
667
+ for (const [paneId, slot] of Object.entries(this.receiveSlotAllocations.receiverSelected)) {
668
+ const paneInCurrentLayout = this.currentLayout?.memberVideoPanes?.find(
669
+ (paneInLayout) => paneInLayout.id === paneId
670
+ );
671
+
672
+ if (paneInCurrentLayout) {
673
+ const remoteMedia = new RemoteMedia(slot, this.mediaRequestManagers.video, {
674
+ resolution: paneInCurrentLayout.size,
675
+ });
676
+
677
+ if (paneInCurrentLayout.csi) {
678
+ remoteMedia.sendMediaRequest(paneInCurrentLayout.csi, false);
679
+ }
680
+
681
+ this.media.video.memberPanes[paneId] = remoteMedia;
682
+ } else {
683
+ // this should never happen, because this.receiveSlotAllocations are created based on current layout configuration
684
+ LoggerProxy.logger.warn(
685
+ `a pane id ${paneId} from this.receiveSlotAllocations.receiverSelected cannot be found in the current layout configuration`
686
+ );
687
+ }
688
+ }
689
+ // todo: screenshare (SPARK-377812)
690
+
691
+ this.mediaRequestManagers.video.commit();
692
+ }
693
+
694
+ /**
695
+ * Invalidates all remote media objects belonging to currently selected layout
696
+ */
697
+ private invalidateCurrentRemoteMedia(options: {audio: boolean; video: boolean; commit: boolean}) {
698
+ const {audio, video, commit} = options;
699
+
700
+ if (audio && this.media.audio) {
701
+ this.media.audio.stop(commit);
702
+ }
703
+ if (video) {
704
+ Object.values(this.media.video.activeSpeakerGroups).forEach((remoteMediaGroup) => {
705
+ remoteMediaGroup.stop(false);
706
+ });
707
+ Object.values(this.media.video.memberPanes).forEach((remoteMedia) => {
708
+ remoteMedia.stop(false);
709
+ });
710
+ if (commit) {
711
+ this.mediaRequestManagers.video.commit();
712
+ }
713
+ }
714
+ }
715
+
716
+ /** emits Event.VideoLayoutChanged */
717
+ private emitVideoLayoutChangedEvent() {
718
+ // todo: at this point the receive slots might still be showing a participant from previous layout, we should
719
+ // wait for our media requests to be fullfilled, but there is no API for that right now (we could wait for source updates
720
+ // but in some cases they might never come, or would need to always make sure to use a new set of receiver slots)
721
+ // for now it's fine to have it like this, we will re-evaluate if it needs improving after more testing
722
+
723
+ this.emit(
724
+ {
725
+ file: 'multistream/remoteMediaManager',
726
+ function: 'emitVideoLayoutChangedEvent',
727
+ },
728
+ Event.VideoLayoutChanged,
729
+ {
730
+ layoutId: this.currentLayoutId,
731
+ activeSpeakerVideoPanes: this.media.video.activeSpeakerGroups,
732
+ memberVideoPanes: this.media.video.memberPanes,
733
+ screenShareVideo: undefined, // todo: screen share (SPARK-377812)
734
+ }
735
+ );
736
+ }
737
+
738
+ /**
739
+ * Sets a new CSI on a given remote media object
740
+ *
741
+ * @param {RemoteMedia} remoteMedia remote Media object to modify
742
+ * @param {CSI} csi new CSI value, can be null if we want to stop receiving media
743
+ */
744
+ public setRemoteVideoCsi(remoteMedia: RemoteMedia, csi: CSI | null) {
745
+ if (!Object.values(this.media.video.memberPanes).includes(remoteMedia)) {
746
+ throw new Error('remoteMedia not found');
747
+ }
748
+
749
+ if (csi) {
750
+ remoteMedia.sendMediaRequest(csi, true);
751
+ } else {
752
+ remoteMedia.cancelMediaRequest(true);
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Adds a new member video pane to the currently selected layout.
758
+ *
759
+ * Changes to the layout are lost after a layout change.
760
+ *
761
+ * @param {MemberVideoPane} newPane
762
+ * @returns {Promise<RemoteMedia>}
763
+ */
764
+ public async addMemberVideoPane(newPane: MemberVideoPane): Promise<RemoteMedia> {
765
+ if (!this.currentLayout) {
766
+ throw new Error('There is no current layout selected, call start() first');
767
+ }
768
+
769
+ if (!this.currentLayout?.memberVideoPanes) {
770
+ this.currentLayout.memberVideoPanes = [];
771
+ }
772
+
773
+ if (newPane.id in this.currentLayout.memberVideoPanes) {
774
+ throw new Error(
775
+ `duplicate pane id ${newPane.id} - this pane already exists in current layout's memberVideoPanes`
776
+ );
777
+ }
778
+
779
+ this.currentLayout.memberVideoPanes.push(newPane);
780
+
781
+ const receiveSlot = await this.receiveSlotManager.allocateSlot(MC.MediaType.VideoMain);
782
+
783
+ this.slots.video.receiverSelected.push(receiveSlot);
784
+
785
+ const remoteMedia = new RemoteMedia(receiveSlot, this.mediaRequestManagers.video, {
786
+ resolution: newPane.size,
787
+ });
788
+
789
+ if (newPane.csi) {
790
+ remoteMedia.sendMediaRequest(newPane.csi, true);
791
+ }
792
+
793
+ this.media.video.memberPanes[newPane.id] = remoteMedia;
794
+
795
+ return remoteMedia;
796
+ }
797
+
798
+ /**
799
+ * Removes a member video pane from the currently selected layout.
800
+ *
801
+ * Changes to the layout are lost after a layout change.
802
+ *
803
+ * @param {PaneId} paneId pane id of the pane to remove
804
+ * @returns {Promise<void>}
805
+ */
806
+ public removeMemberVideoPane(paneId: PaneId): Promise<void> {
807
+ if (!this.currentLayout) {
808
+ return Promise.reject(new Error('There is no current layout selected, call start() first'));
809
+ }
810
+
811
+ if (!this.currentLayout.memberVideoPanes?.find((pane) => pane.id === paneId)) {
812
+ // pane id doesn't exist, so nothing to do
813
+ LoggerProxy.logger.log(
814
+ `RemoteMediaManager#removeMemberVideoPane --> removeMemberVideoPane() called for a non-existent paneId: ${paneId} (pane not found in currentLayout.memberVideoPanes)`
815
+ );
816
+
817
+ return Promise.resolve();
818
+ }
819
+
820
+ if (!this.media.video.memberPanes[paneId]) {
821
+ // pane id doesn't exist, so nothing to do
822
+ LoggerProxy.logger.log(
823
+ `RemoteMediaManager#removeMemberVideoPane --> removeMemberVideoPane() called for a non-existent paneId: ${paneId} (pane not found in this.media.video.memberPanes)`
824
+ );
825
+
826
+ return Promise.resolve();
827
+ }
828
+
829
+ const remoteMedia = this.media.video.memberPanes[paneId];
830
+
831
+ const receiveSlot = remoteMedia.getUnderlyingReceiveSlot();
832
+
833
+ if (receiveSlot) {
834
+ this.receiveSlotManager.releaseSlot(receiveSlot);
835
+
836
+ const index = this.slots.video.receiverSelected.indexOf(receiveSlot);
837
+
838
+ if (index >= 0) {
839
+ this.slots.video.receiverSelected.splice(index, 1);
840
+ }
841
+ }
842
+ remoteMedia.stop();
843
+
844
+ delete this.media.video.memberPanes[paneId];
845
+ delete this.currentLayout.memberVideoPanes?.[paneId];
846
+
847
+ return Promise.resolve();
848
+ }
849
+
850
+ /**
851
+ * Pins an active speaker remote media object to the given CSI value. From that moment
852
+ * onwards the remote media will only play audio/video from that specific CSI until
853
+ * unpinActiveSpeakerVideoPane() is called or current layout is changed.
854
+ *
855
+ * @param {RemoteMedia} remoteMedia remote media object reference
856
+ * @param {CSI} csi CSI value to pin to, if undefined, then current CSI value is used
857
+ */
858
+ public pinActiveSpeakerVideoPane(remoteMedia: RemoteMedia, csi?: CSI): void {
859
+ const remoteMediaGroup = Object.values(this.media.video.activeSpeakerGroups).find((group) =>
860
+ group.includes(remoteMedia, 'unpinned')
861
+ );
862
+
863
+ if (!remoteMediaGroup) {
864
+ throw new Error(
865
+ 'remoteMedia not found among the unpinned remote media from any active speaker group'
866
+ );
867
+ }
868
+
869
+ remoteMediaGroup.pin(remoteMedia, csi);
870
+ }
871
+
872
+ /**
873
+ * Unpins a remote media object from the fixed CSI value it was pinned to.
874
+ *
875
+ * @param {RemoteMedia} remoteMedia remote media object reference
876
+ */
877
+ public unpinActiveSpeakerVideoPane(remoteMedia: RemoteMedia) {
878
+ const remoteMediaGroup = Object.values(this.media.video.activeSpeakerGroups).find((group) =>
879
+ group.includes(remoteMedia, 'pinned')
880
+ );
881
+
882
+ if (!remoteMediaGroup) {
883
+ throw new Error(
884
+ 'remoteMedia not found among the pinned remote media from any active speaker group'
885
+ );
886
+ }
887
+
888
+ remoteMediaGroup.unpin(remoteMedia);
889
+ }
890
+
891
+ /**
892
+ * Returns true if a given remote media object belongs to an active speaker group and has been pinned.
893
+ * Throws an error if the remote media object doesn't belong to any active speaker remote media group.
894
+ *
895
+ * @param {RemoteMedia} remoteMedia remote media object
896
+ * @returns {boolean}
897
+ */
898
+ public isPinned(remoteMedia: RemoteMedia) {
899
+ const remoteMediaGroup = Object.values(this.media.video.activeSpeakerGroups).find((group) =>
900
+ group.includes(remoteMedia)
901
+ );
902
+
903
+ if (!remoteMediaGroup) {
904
+ throw new Error(
905
+ 'remoteMedia not found among any remote media (pinned or unpinned) from any active speaker group'
906
+ );
907
+ }
908
+
909
+ return remoteMediaGroup.isPinned(remoteMedia);
910
+ }
911
+ }