@unboundcx/video-sdk-client 2.0.0 → 2.0.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.
- package/VideoMeetingClient.js +1651 -1488
- package/managers/ConnectionHealthMonitor.js +278 -0
- package/managers/ConnectionManager.js +17 -4
- package/managers/MediasoupManager.js +1061 -859
- package/managers/QualityMonitor.js +845 -0
- package/managers/RemoteMediaManager.js +17 -15
- package/managers/StatsCollector.js +35 -4
- package/package.json +1 -1
package/VideoMeetingClient.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { EventEmitter } from
|
|
2
|
-
import { Logger } from
|
|
3
|
-
import { ConnectionManager } from
|
|
4
|
-
import { MediasoupManager } from
|
|
5
|
-
import { LocalMediaManager } from
|
|
6
|
-
import { RemoteMediaManager } from
|
|
7
|
-
import {
|
|
1
|
+
import { EventEmitter } from "./utils/EventEmitter.js";
|
|
2
|
+
import { Logger } from "./utils/Logger.js";
|
|
3
|
+
import { ConnectionManager } from "./managers/ConnectionManager.js";
|
|
4
|
+
import { MediasoupManager } from "./managers/MediasoupManager.js";
|
|
5
|
+
import { LocalMediaManager } from "./managers/LocalMediaManager.js";
|
|
6
|
+
import { RemoteMediaManager } from "./managers/RemoteMediaManager.js";
|
|
7
|
+
import { QualityMonitor } from "./managers/QualityMonitor.js";
|
|
8
|
+
import { ConnectionHealthMonitor } from "./managers/ConnectionHealthMonitor.js";
|
|
9
|
+
import { StateError, RoomError } from "./utils/errors.js";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Main SDK client for video meeting functionality
|
|
@@ -24,1485 +26,1646 @@ import { StateError, RoomError } from './utils/errors.js';
|
|
|
24
26
|
* });
|
|
25
27
|
*/
|
|
26
28
|
export class VideoMeetingClient extends EventEmitter {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
29
|
+
/**
|
|
30
|
+
* @param {Object} options
|
|
31
|
+
* @param {string} [options.serverUrl] - WebSocket server URL (optional if using joinFromApiResponse)
|
|
32
|
+
* @param {boolean} [options.debug=false] - Enable debug logging
|
|
33
|
+
* @param {Object} [options.sdk] - UnboundSDK instance, injected by
|
|
34
|
+
* `sdk.video.createMeetingClient()`. Required for transparent reassignment
|
|
35
|
+
* recovery (calls `sdk.video.joinRoom()`) and for `endSession()` to invoke
|
|
36
|
+
* `sdk.video.endSession()`. Without it, consumers must handle both paths
|
|
37
|
+
* themselves.
|
|
38
|
+
*/
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
super();
|
|
41
|
+
|
|
42
|
+
this.logger = new Logger("SDK:VideoMeetingClient", options.debug);
|
|
43
|
+
this.state = "disconnected"; // disconnected, connecting, connected, waiting-room, in-meeting
|
|
44
|
+
this.currentRoomId = null;
|
|
45
|
+
this.joinData = null; // Store video.join() response data
|
|
46
|
+
this.debug = options.debug;
|
|
47
|
+
this.isGuest = false;
|
|
48
|
+
this.inWaitingRoom = false;
|
|
49
|
+
this.sdk = options.sdk || null;
|
|
50
|
+
|
|
51
|
+
// Reassignment recovery state. Attempt window is 60s; cap at 3 attempts.
|
|
52
|
+
// Backoff schedule: 500ms, 2s, 5s. Counter resets once a connection has
|
|
53
|
+
// stayed up for >60s (see _onReassignmentStableConnect).
|
|
54
|
+
this._reassignmentAttempts = []; // timestamps (Date.now()) of recent attempts
|
|
55
|
+
this._reassignmentInFlight = false;
|
|
56
|
+
this._reassignmentStableTimer = null;
|
|
57
|
+
this._lastJoinArgs = null; // args used by consumer to call sdk.video.joinRoom()
|
|
58
|
+
|
|
59
|
+
// Managers will be initialized when we have connection info
|
|
60
|
+
this.connection = null;
|
|
61
|
+
this.mediasoup = null;
|
|
62
|
+
this.localMedia = null;
|
|
63
|
+
this.remoteMedia = null;
|
|
64
|
+
|
|
65
|
+
// If serverUrl provided, initialize managers now (old behavior)
|
|
66
|
+
if (options.serverUrl) {
|
|
67
|
+
this._initializeManagers(options.serverUrl);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.logger.info("VideoMeetingClient initialized", { hasSdk: !!this.sdk });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Initialize managers with server URL
|
|
75
|
+
* @private
|
|
76
|
+
* @param {string} serverUrl
|
|
77
|
+
* @param {string} [namespace] - Socket.IO namespace path, e.g. `/video`
|
|
78
|
+
*/
|
|
79
|
+
_initializeManagers(serverUrl, namespace = null) {
|
|
80
|
+
this.connection = new ConnectionManager({
|
|
81
|
+
serverUrl,
|
|
82
|
+
namespace,
|
|
83
|
+
debug: this.debug,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.mediasoup = new MediasoupManager({
|
|
87
|
+
connection: this.connection,
|
|
88
|
+
debug: this.debug,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.localMedia = new LocalMediaManager({
|
|
92
|
+
mediasoup: this.mediasoup,
|
|
93
|
+
debug: this.debug,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
this.remoteMedia = new RemoteMediaManager({
|
|
97
|
+
mediasoup: this.mediasoup,
|
|
98
|
+
connection: this.connection,
|
|
99
|
+
videoClient: this, // Pass reference to access joinData
|
|
100
|
+
debug: this.debug,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Adaptive quality: subscribes to StatsCollector samples, runs
|
|
104
|
+
// state machines, silently caps simulcast layers under pressure,
|
|
105
|
+
// and emits user-visible events (`quality` SDK event) for the
|
|
106
|
+
// host app to render toasts / tile badges. See QualityMonitor.js.
|
|
107
|
+
this.qualityMonitor = new QualityMonitor({
|
|
108
|
+
statsCollector: this.mediasoup.statsCollector,
|
|
109
|
+
mediasoupManager: this.mediasoup,
|
|
110
|
+
onQualityEvent: (evt) => this.emit("quality", evt),
|
|
111
|
+
// Solo gate: until we actually have a remote consumer flowing,
|
|
112
|
+
// no one is receiving our media and the encoder / BWE numbers
|
|
113
|
+
// don't reflect a real user-impacting problem. We count
|
|
114
|
+
// mediasoup consumers (people whose media we're receiving) as
|
|
115
|
+
// proxy for "a real meeting is in progress" — this excludes
|
|
116
|
+
// waiting-room participants and pre-join lobby state, which
|
|
117
|
+
// can otherwise appear in the participants list. Returning 0
|
|
118
|
+
// here makes the monitor skip evaluation entirely.
|
|
119
|
+
getRemotePeerCount: () => {
|
|
120
|
+
try {
|
|
121
|
+
return this.mediasoup?.consumers?.size || 0;
|
|
122
|
+
} catch {
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
this.qualityMonitor.start();
|
|
128
|
+
|
|
129
|
+
// Connection health: unified `connection` event combining socket events,
|
|
130
|
+
// mediasoup transport state, and a heartbeat that catches silent failures
|
|
131
|
+
// on bad networks where the OS hasn't admitted the problem yet. The host
|
|
132
|
+
// app subscribes via `client.on('connection', ...)` to render a
|
|
133
|
+
// "Reconnecting…" banner. See ConnectionHealthMonitor.js.
|
|
134
|
+
this.connectionHealth = new ConnectionHealthMonitor({
|
|
135
|
+
connectionManager: this.connection,
|
|
136
|
+
mediasoupManager: this.mediasoup,
|
|
137
|
+
statsCollector: this.mediasoup.statsCollector,
|
|
138
|
+
onConnectionEvent: (evt) => this.emit("connection", evt),
|
|
139
|
+
debug: this.debug,
|
|
140
|
+
});
|
|
141
|
+
this.connectionHealth.start();
|
|
142
|
+
|
|
143
|
+
// Proxy manager events to SDK events
|
|
144
|
+
this._setupEventProxies();
|
|
145
|
+
|
|
146
|
+
// When mediasoup detects its transports are stale after a long disconnect,
|
|
147
|
+
// re-run the room.join flow to get fresh transport options + producer list
|
|
148
|
+
// from the server. This keeps media working across network blips >30s.
|
|
149
|
+
this.mediasoup.on("transports:recreated-needed", () => {
|
|
150
|
+
this._rejoinRoomForTransportRecreation().catch((err) => {
|
|
151
|
+
this.logger.error("Rejoin for transport recreation failed", err);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Setup event proxies from managers to SDK
|
|
158
|
+
* @private
|
|
159
|
+
*/
|
|
160
|
+
_setupEventProxies() {
|
|
161
|
+
// Connection events
|
|
162
|
+
this.connection.on("connected", () => {
|
|
163
|
+
this._setState("connected");
|
|
164
|
+
this.emit("connected");
|
|
165
|
+
|
|
166
|
+
// On Socket.IO reconnect (not initial connect), check whether the
|
|
167
|
+
// WebRTC transports died during the disconnect window. If the state
|
|
168
|
+
// has been bad for >10s, mediasoup will emit transports:recreated-needed.
|
|
169
|
+
if (
|
|
170
|
+
this.mediasoup &&
|
|
171
|
+
(this.mediasoup.sendTransport || this.mediasoup.recvTransport)
|
|
172
|
+
) {
|
|
173
|
+
this.mediasoup.recreateStaleTransports().catch((err) => {
|
|
174
|
+
this.logger.error("recreateStaleTransports threw", err);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
this.connection.on("disconnected", (data) => {
|
|
180
|
+
this._setState("disconnected");
|
|
181
|
+
this.emit("disconnected", data);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
this.connection.on("error", (error) => {
|
|
185
|
+
this.emit("error", error);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Server signaled the assigned pod is stale — re-fetch joinRoom and reconnect.
|
|
189
|
+
this.connection.on("reassignmentRequired", ({ code }) => {
|
|
190
|
+
this.logger.warn("Reassignment required", { code });
|
|
191
|
+
this._handleReassignment(code).catch((err) => {
|
|
192
|
+
this.logger.error("Reassignment handler threw", err);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Local media events
|
|
197
|
+
this.localMedia.on("stream:added", (data) => {
|
|
198
|
+
this.emit("local-stream:added", data);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
this.localMedia.on("stream:removed", (data) => {
|
|
202
|
+
this.emit("local-stream:removed", data);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
this.localMedia.on("device:changed", (data) => {
|
|
206
|
+
this.emit("device:changed", data);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Remote media events
|
|
210
|
+
this.remoteMedia.on("participant:added", (data) => {
|
|
211
|
+
this.emit("participant:joined", data);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
this.remoteMedia.on("participant:removed", (data) => {
|
|
215
|
+
this.emit("participant:left", data);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
this.remoteMedia.on("participant:updated", (data) => {
|
|
219
|
+
this.emit("participant:updated", data);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
this.remoteMedia.on("stream:added", (data) => {
|
|
223
|
+
this.emit("stream:added", data);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
this.remoteMedia.on("stream:removed", (data) => {
|
|
227
|
+
this.emit("stream:removed", data);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Stream consuming events (for retry logic and error handling)
|
|
231
|
+
this.remoteMedia.on("stream:consuming", (data) => {
|
|
232
|
+
this.emit("stream:consuming", data);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
this.remoteMedia.on("stream:consume-failed", (data) => {
|
|
236
|
+
this.emit("stream:consume-failed", data);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Volume control events
|
|
240
|
+
this.remoteMedia.on("participant:volume-changed", (data) => {
|
|
241
|
+
this.emit("participant:volume-changed", data);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Setup room/participant event listeners
|
|
247
|
+
* @private
|
|
248
|
+
*/
|
|
249
|
+
_setupRoomEventListeners() {
|
|
250
|
+
// Room events
|
|
251
|
+
this.connection.onServerEvent("room.closed", (data) => {
|
|
252
|
+
this.logger.info("Room closed", data);
|
|
253
|
+
this._setState("disconnected");
|
|
254
|
+
this.emit("room:closed", data);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
this.connection.onServerEvent("room.update", (data) => {
|
|
258
|
+
this.logger.info("Room updated", data);
|
|
259
|
+
this.emit("room:updated", data);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Participant events
|
|
263
|
+
this.connection.onServerEvent("participant.all", (data) => {
|
|
264
|
+
this.logger.info("All participants", data);
|
|
265
|
+
this.emit("participants:list", data);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
this.connection.onServerEvent("participant.leave", (data) => {
|
|
269
|
+
this.logger.info("Participant left", data);
|
|
270
|
+
this.emit("participant:left", data);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
this.connection.onServerEvent("participant.remove", (data) => {
|
|
274
|
+
this.logger.info("Participant removed", data);
|
|
275
|
+
this.emit("participant:removed", data);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
this.connection.onServerEvent("participant.update", (data) => {
|
|
279
|
+
this.logger.info("Participant updated", data);
|
|
280
|
+
this.emit("participant:updated", data);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Server-side detected a peer's WebRTC transport closed (their network
|
|
284
|
+
// died). Surface as `participant:connection` so the host UI can render a
|
|
285
|
+
// "Reconnecting…" overlay on that peer's tile. Recovery is implicit —
|
|
286
|
+
// when the peer re-joins, normal participant.update + new producer
|
|
287
|
+
// events will arrive and the host clears the overlay.
|
|
288
|
+
this.connection.onServerEvent("participant.connection", (data) => {
|
|
289
|
+
this.logger.info("Participant connection state", data);
|
|
290
|
+
this.emit("participant:connection", data);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Producer close event (for quality adaptation / reconnect)
|
|
294
|
+
this.connection.onServerEvent(
|
|
295
|
+
"participant.producer.close",
|
|
296
|
+
async (data) => {
|
|
297
|
+
this.logger.info("Producer close requested", data);
|
|
298
|
+
await this._handleProducerCloseRequest(data);
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// Waiting room events
|
|
303
|
+
this.connection.onServerEvent("room.waitingRoom.admit", (data) => {
|
|
304
|
+
this.logger.info("Admitted from waiting room", data);
|
|
305
|
+
// The host approved this user — flip the inWaitingRoom flag and
|
|
306
|
+
// notify consumers. Do NOT transition state to "in-meeting" here:
|
|
307
|
+
// approval is not the same as joining. The actual SFU join (with
|
|
308
|
+
// mediasoup transports) happens when the user explicitly calls
|
|
309
|
+
// joinMeeting(). Previously this handler auto-set state, which
|
|
310
|
+
// permanently broke the public joinMeeting() afterward (its
|
|
311
|
+
// precondition is `state === "waiting-room"`) and left the user
|
|
312
|
+
// in a half-state with no transports.
|
|
313
|
+
this.inWaitingRoom = false;
|
|
314
|
+
this.emit("waitingRoom:admitted", data);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Keepalive events
|
|
318
|
+
this.connection.onServerEvent("keepalive.send", (data) => {
|
|
319
|
+
this.emit("keepalive:received", data);
|
|
320
|
+
this.connection.emit("keepalive.ack", data);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
this.connection.onServerEvent("keepalive.alert", (data) => {
|
|
324
|
+
this.emit("keepalive:alert", data);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Connect to the server
|
|
330
|
+
* @param {Object|string} auth - Authentication token or auth object
|
|
331
|
+
* @returns {Promise<void>}
|
|
332
|
+
*/
|
|
333
|
+
async connect(auth = {}) {
|
|
334
|
+
if (this.state !== "disconnected") {
|
|
335
|
+
throw new StateError(
|
|
336
|
+
"Cannot connect: already connected or connecting",
|
|
337
|
+
this.state,
|
|
338
|
+
"disconnected",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
this.logger.info("Connecting to server...");
|
|
343
|
+
this._setState("connecting");
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
// Normalize auth to object format
|
|
347
|
+
const authObj = typeof auth === "string" ? { token: auth } : auth;
|
|
348
|
+
|
|
349
|
+
// Connect to server
|
|
350
|
+
await this.connection.connect(authObj);
|
|
351
|
+
|
|
352
|
+
// Load mediasoup device
|
|
353
|
+
await this.mediasoup.loadDevice();
|
|
354
|
+
|
|
355
|
+
this.logger.info("Connected successfully");
|
|
356
|
+
this._setState("connected");
|
|
357
|
+
} catch (error) {
|
|
358
|
+
this.logger.error("Connection failed:", error);
|
|
359
|
+
this._setState("disconnected");
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Disconnect from server
|
|
366
|
+
* @returns {Promise<void>}
|
|
367
|
+
*/
|
|
368
|
+
/**
|
|
369
|
+
* Host calls this when the user clicks "Keep camera on" in the
|
|
370
|
+
* auto-disable grace toast. Cancels the pending disable and resets
|
|
371
|
+
* the sustained-critical timer so it does not immediately re-fire.
|
|
372
|
+
*/
|
|
373
|
+
cancelAutoDisableCamera() {
|
|
374
|
+
if (this.qualityMonitor) this.qualityMonitor.cancelAutoDisableCamera();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Pause/resume a single remote peer's video. Stops local rendering AND
|
|
379
|
+
* asks the SFU to stop forwarding bytes — real downlink bandwidth
|
|
380
|
+
* savings. Screenshare is unaffected.
|
|
381
|
+
* @param {string} peerParticipantId
|
|
382
|
+
* @param {boolean} paused
|
|
383
|
+
* @returns {boolean} true if a matching consumer was found
|
|
384
|
+
*/
|
|
385
|
+
setPeerVideoPaused(peerParticipantId, paused) {
|
|
386
|
+
if (!this.mediasoup) return false;
|
|
387
|
+
return this.mediasoup.setPeerVideoPaused(peerParticipantId, paused);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Pause/resume every remote peer's video. Used by the "pause all
|
|
392
|
+
* incoming video" control. Returns the count of consumers affected.
|
|
393
|
+
* @param {boolean} paused
|
|
394
|
+
*/
|
|
395
|
+
setAllRemoteVideoPaused(paused) {
|
|
396
|
+
if (!this.mediasoup) return 0;
|
|
397
|
+
return this.mediasoup.setAllRemoteVideoPaused(paused);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Returns a structured snapshot of the QualityMonitor — current
|
|
402
|
+
* state, recent samples (last 10/direction), recent emitted events
|
|
403
|
+
* (last 25). Designed for the in-app debug modal so a tester can
|
|
404
|
+
* reproduce conditions without dropping to DevTools. Returns null
|
|
405
|
+
* if the monitor isn't initialized.
|
|
406
|
+
*/
|
|
407
|
+
getQualityDebugSnapshot() {
|
|
408
|
+
const quality = this.qualityMonitor
|
|
409
|
+
? this.qualityMonitor.getDebugSnapshot()
|
|
410
|
+
: null;
|
|
411
|
+
if (!quality) return null;
|
|
412
|
+
// Fold the connection-health monitor's snapshot into the same
|
|
413
|
+
// payload so the debug modal can show both in one place — saves
|
|
414
|
+
// wiring a second getter through every consumer and makes it
|
|
415
|
+
// obvious whether stale SDK code is running (missing keys here
|
|
416
|
+
// = older bundle).
|
|
417
|
+
quality.connectionHealth = this.connectionHealth
|
|
418
|
+
? this.connectionHealth.getSnapshot()
|
|
419
|
+
: null;
|
|
420
|
+
return quality;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Current connection health snapshot (state, silence, transport state,
|
|
425
|
+
* thresholds). Used by the debug modal and by the host app to render
|
|
426
|
+
* the "Reconnecting…" banner.
|
|
427
|
+
*/
|
|
428
|
+
getConnectionSnapshot() {
|
|
429
|
+
return this.connectionHealth ? this.connectionHealth.getSnapshot() : null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async disconnect() {
|
|
433
|
+
this.logger.info("Disconnecting...");
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
// Leave room if in one
|
|
437
|
+
if (this.state === "in-room") {
|
|
438
|
+
await this.leaveRoom();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Stop quality monitor before tearing down stats sources.
|
|
442
|
+
if (this.qualityMonitor) this.qualityMonitor.stop();
|
|
443
|
+
if (this.connectionHealth) this.connectionHealth.stop();
|
|
444
|
+
|
|
445
|
+
// Clean up managers
|
|
446
|
+
await this.localMedia.cleanup();
|
|
447
|
+
await this.remoteMedia.cleanup();
|
|
448
|
+
await this.mediasoup.cleanup();
|
|
449
|
+
|
|
450
|
+
// Disconnect socket
|
|
451
|
+
await this.connection.disconnect();
|
|
452
|
+
|
|
453
|
+
this._setState("disconnected");
|
|
454
|
+
this.logger.info("Disconnected successfully");
|
|
455
|
+
} catch (error) {
|
|
456
|
+
this.logger.error("Disconnect error:", error);
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Join meeting directly from API response (api.video.joinRoom)
|
|
463
|
+
* This method extracts server info, connects, and determines if user needs to wait
|
|
464
|
+
*
|
|
465
|
+
* @param {Object} joinResponse - Response from api.video.joinRoom(room, password, email)
|
|
466
|
+
* @param {Object} options - Additional options
|
|
467
|
+
* @param {boolean} options.enterWaitingRoom - For guests, enter waiting room (default: auto-detect)
|
|
468
|
+
* @returns {Promise<Object>} Join data
|
|
469
|
+
*
|
|
470
|
+
* @example
|
|
471
|
+
* const joinResponse = await api.video.joinRoom('room-123', 'password', 'user@example.com');
|
|
472
|
+
* await client.joinFromApiResponse(joinResponse);
|
|
473
|
+
*
|
|
474
|
+
* // For guests in waiting room:
|
|
475
|
+
* client.on('waitingRoom:entered', () => showWaitingRoomUI());
|
|
476
|
+
* client.on('waitingRoom:admitted', () => showMeetingUI());
|
|
477
|
+
*/
|
|
478
|
+
async joinFromApiResponse(joinResponse, options = {}) {
|
|
479
|
+
this.logger.info("Joining from API response");
|
|
480
|
+
|
|
481
|
+
// Store args used to obtain this response. If a reassignment is required
|
|
482
|
+
// later, we replay these against sdk.video.joinRoom() to get a fresh
|
|
483
|
+
// assignment without the consumer having to wire anything up.
|
|
484
|
+
if (options.joinRoomArgs !== undefined) {
|
|
485
|
+
this._lastJoinArgs = options.joinRoomArgs;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
const { videoRoom, participant } = joinResponse;
|
|
490
|
+
|
|
491
|
+
// Support both the new shape (connectionInfo.socket) and the legacy
|
|
492
|
+
// shape (top-level server + authorization) during the rollout.
|
|
493
|
+
// Legacy path is removed once every app1-api replica returns the new
|
|
494
|
+
// shape in staging/prod.
|
|
495
|
+
const connectionInfo = joinResponse.connectionInfo;
|
|
496
|
+
const socketInfo = connectionInfo?.socket;
|
|
497
|
+
const legacyServer = joinResponse.server;
|
|
498
|
+
|
|
499
|
+
let socketUrl;
|
|
500
|
+
let socketNamespace;
|
|
501
|
+
let authData;
|
|
502
|
+
let authorization;
|
|
503
|
+
|
|
504
|
+
if (socketInfo?.url) {
|
|
505
|
+
socketUrl = socketInfo.url;
|
|
506
|
+
socketNamespace = socketInfo.namespace || "/video";
|
|
507
|
+
authData = {
|
|
508
|
+
...socketInfo.auth,
|
|
509
|
+
roomId: videoRoom.id,
|
|
510
|
+
participantId: participant.id,
|
|
511
|
+
};
|
|
512
|
+
authorization = connectionInfo.authorization || null;
|
|
513
|
+
} else if (legacyServer?.url && legacyServer?.socketPort) {
|
|
514
|
+
// Legacy per-pod-ingress path. Remove once centralized signaling
|
|
515
|
+
// is fully deployed.
|
|
516
|
+
socketUrl = `https://${legacyServer.url}:${legacyServer.socketPort}`;
|
|
517
|
+
socketNamespace = null;
|
|
518
|
+
authorization = joinResponse.authorization;
|
|
519
|
+
authData = {
|
|
520
|
+
accountNamespace: authorization?.namespace,
|
|
521
|
+
roomId: videoRoom.id,
|
|
522
|
+
participantId: participant.id,
|
|
523
|
+
};
|
|
524
|
+
} else {
|
|
525
|
+
throw new Error(
|
|
526
|
+
"Invalid join response: missing connectionInfo.socket and legacy server info",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Store join data for later use (including reassignment replay)
|
|
531
|
+
this.joinData = {
|
|
532
|
+
videoRoom,
|
|
533
|
+
participant,
|
|
534
|
+
connectionInfo: connectionInfo || null,
|
|
535
|
+
server: legacyServer || null,
|
|
536
|
+
authorization,
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// Detect if this is a guest (no host privileges)
|
|
540
|
+
this.isGuest = !participant.isHost && !participant.isModerator;
|
|
541
|
+
|
|
542
|
+
this.logger.info("Connecting to video server:", {
|
|
543
|
+
socketUrl,
|
|
544
|
+
socketNamespace,
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// Initialize managers if not already done
|
|
548
|
+
if (!this.connection) {
|
|
549
|
+
this._initializeManagers(socketUrl, socketNamespace);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
this._setState("connecting");
|
|
553
|
+
|
|
554
|
+
this.logger.info("Connecting with auth (cookie-based):", authData);
|
|
555
|
+
await this.connection.connect(authData);
|
|
556
|
+
|
|
557
|
+
this._setState("connected");
|
|
558
|
+
|
|
559
|
+
// Connection succeeded. If it stays up >60s, reset the reassignment
|
|
560
|
+
// attempt counter so the next reassignment cycle starts fresh.
|
|
561
|
+
this._armReassignmentStableTimer();
|
|
562
|
+
|
|
563
|
+
// Setup remote media listeners and room event listeners
|
|
564
|
+
this.logger.info("Setting up event listeners");
|
|
565
|
+
this.remoteMedia._setupServerListeners();
|
|
566
|
+
this._setupRoomEventListeners();
|
|
567
|
+
|
|
568
|
+
// ALWAYS emit room.waitingRoom first to initialize room on video server
|
|
569
|
+
// The server requires this event to set up the room before any joins
|
|
570
|
+
this.logger.info(
|
|
571
|
+
"Emitting room.waitingRoom to initialize room on server",
|
|
572
|
+
);
|
|
573
|
+
this.connection.emit("room.waitingRoom", {});
|
|
574
|
+
|
|
575
|
+
// Enter waiting-room state for everyone (hosts and guests)
|
|
576
|
+
// This allows users to set up their devices while room initializes
|
|
577
|
+
this.inWaitingRoom = true;
|
|
578
|
+
this._setState("waiting-room");
|
|
579
|
+
|
|
580
|
+
this.logger.info("Entering waiting room - waiting for room to be ready");
|
|
581
|
+
|
|
582
|
+
this.emit("waitingRoom:entered", {
|
|
583
|
+
roomId: videoRoom.id,
|
|
584
|
+
participant,
|
|
585
|
+
isHost: participant.isHost,
|
|
586
|
+
canJoinImmediately: !this.isGuest, // Hosts can join once ready, guests need admission
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Listen for media.routerCapabilities to know when room is ready
|
|
590
|
+
this.connection.onServerEvent("media.routerCapabilities", (data) => {
|
|
591
|
+
this.logger.info("Room is ready - received media.routerCapabilities");
|
|
592
|
+
this.emit("waitingRoom:ready", {
|
|
593
|
+
roomId: videoRoom.id,
|
|
594
|
+
participant,
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// Return - user must explicitly call joinMeeting() when ready
|
|
599
|
+
// For hosts: after they click "Join Meeting" button (once room is ready)
|
|
600
|
+
// For guests: after host admits them (room.waitingRoom.admit event)
|
|
601
|
+
return {
|
|
602
|
+
...this.joinData,
|
|
603
|
+
inWaitingRoom: true,
|
|
604
|
+
};
|
|
605
|
+
} catch (error) {
|
|
606
|
+
this.logger.error("Failed to join from API response:", error);
|
|
607
|
+
this._setState("disconnected");
|
|
608
|
+
throw new RoomError(`Failed to join meeting: ${error.message}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Public method to join meeting from waiting room
|
|
614
|
+
* Call this after user clicks "Join Meeting" button
|
|
615
|
+
*/
|
|
616
|
+
async joinMeeting() {
|
|
617
|
+
if (this.state !== "waiting-room") {
|
|
618
|
+
throw new StateError("Must be in waiting room to join meeting");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
await this._joinMeeting();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Internal method to join meeting (after connection established)
|
|
626
|
+
* @private
|
|
627
|
+
*/
|
|
628
|
+
async _joinMeeting() {
|
|
629
|
+
this.logger.info("Joining meeting:", this.joinData.videoRoom.friendlyName);
|
|
630
|
+
this._setState("joining");
|
|
631
|
+
|
|
632
|
+
// Setup transport listener BEFORE emitting room.join (but handler will wait for device)
|
|
633
|
+
this._setupMediaEventListeners();
|
|
634
|
+
|
|
635
|
+
// Send room.join event to video server (emit, not request - no callback)
|
|
636
|
+
this.logger.info("Emitting room.join event");
|
|
637
|
+
this.connection.emit("room.join", {});
|
|
638
|
+
|
|
639
|
+
// Load mediasoup device (will wait for media.routerCapabilities event)
|
|
640
|
+
this.logger.info("Waiting for media.routerCapabilities...");
|
|
641
|
+
await this.mediasoup.loadDevice();
|
|
642
|
+
this.logger.info("Device loaded successfully");
|
|
643
|
+
|
|
644
|
+
// Wait for media.transports event (listener will create transports now that device is loaded)
|
|
645
|
+
this.logger.info("Waiting for media.transports...");
|
|
646
|
+
await this._waitForTransports();
|
|
647
|
+
|
|
648
|
+
// Process any producers that arrived before transports were ready
|
|
649
|
+
this.logger.info("Processing pending producers...");
|
|
650
|
+
await this.remoteMedia.processPendingProducers();
|
|
651
|
+
|
|
652
|
+
this.currentRoomId = this.joinData.videoRoom.id;
|
|
653
|
+
this._setState("in-meeting");
|
|
654
|
+
|
|
655
|
+
this.logger.info("Successfully joined meeting");
|
|
656
|
+
this.emit("meeting:joined", {
|
|
657
|
+
roomId: this.joinData.videoRoom.id,
|
|
658
|
+
joinData: this.joinData,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Join a meeting room (legacy method - requires manual connect first)
|
|
664
|
+
* @param {string} roomId - Room ID to join
|
|
665
|
+
* @param {Object} options - Join options
|
|
666
|
+
* @returns {Promise<Object>} Room data
|
|
667
|
+
*/
|
|
668
|
+
async joinRoom(roomId, options = {}) {
|
|
669
|
+
if (this.state !== "connected") {
|
|
670
|
+
throw new StateError(
|
|
671
|
+
"Cannot join room: not connected to server",
|
|
672
|
+
this.state,
|
|
673
|
+
"connected",
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
this.logger.info("Joining room:", roomId);
|
|
678
|
+
this._setState("joining");
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
// Send join request to server
|
|
682
|
+
const response = await this.connection.request("room.join", {
|
|
683
|
+
roomId,
|
|
684
|
+
...options,
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
this.currentRoomId = roomId;
|
|
688
|
+
this._setState("in-room");
|
|
689
|
+
|
|
690
|
+
this.logger.info("Joined room successfully");
|
|
691
|
+
this.emit("room:joined", { roomId, data: response });
|
|
692
|
+
|
|
693
|
+
return response;
|
|
694
|
+
} catch (error) {
|
|
695
|
+
this.logger.error("Failed to join room:", error);
|
|
696
|
+
this._setState("connected");
|
|
697
|
+
throw new RoomError(`Failed to join room: ${error.message}`, roomId);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Leave the current room
|
|
703
|
+
* @returns {Promise<void>}
|
|
704
|
+
*/
|
|
705
|
+
async leaveRoom() {
|
|
706
|
+
if (this.state !== "in-room") {
|
|
707
|
+
throw new StateError(
|
|
708
|
+
"Cannot leave room: not in a room",
|
|
709
|
+
this.state,
|
|
710
|
+
"in-room",
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
this.logger.info("Leaving room:", this.currentRoomId);
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
// Stop all local media
|
|
718
|
+
await this.localMedia.cleanup();
|
|
719
|
+
|
|
720
|
+
// Server-side teardown is driven by the socket disconnect handler
|
|
721
|
+
// (publishes participant.leave to the pod via NATS) and by the
|
|
722
|
+
// consumer's DELETE /video/:id/leave call. No room.leave socket
|
|
723
|
+
// event exists; do not send one.
|
|
724
|
+
|
|
725
|
+
// Clean up remote media
|
|
726
|
+
await this.remoteMedia.cleanup();
|
|
727
|
+
|
|
728
|
+
const roomId = this.currentRoomId;
|
|
729
|
+
this.currentRoomId = null;
|
|
730
|
+
this._setState("connected");
|
|
731
|
+
|
|
732
|
+
this.logger.info("Left room successfully");
|
|
733
|
+
this.emit("room:left", { roomId });
|
|
734
|
+
} catch (error) {
|
|
735
|
+
this.logger.error("Failed to leave room:", error);
|
|
736
|
+
throw error;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ========== Local Media Methods ==========
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Publish camera stream
|
|
744
|
+
* @param {Object} options - Camera options
|
|
745
|
+
* @param {string} options.deviceId - Camera device ID
|
|
746
|
+
* @param {string} options.resolution - Resolution (480p, 720p, 1080p)
|
|
747
|
+
* @param {number} options.frameRate - Frame rate
|
|
748
|
+
* @returns {Promise<MediaStream>}
|
|
749
|
+
*/
|
|
750
|
+
async publishCamera(options = {}) {
|
|
751
|
+
this._ensureInRoom();
|
|
752
|
+
return await this.localMedia.publishCamera(options);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Publish microphone stream
|
|
757
|
+
* @param {Object} options - Microphone options
|
|
758
|
+
* @param {string} options.deviceId - Microphone device ID
|
|
759
|
+
* @returns {Promise<MediaStream>}
|
|
760
|
+
*/
|
|
761
|
+
async publishMicrophone(options = {}) {
|
|
762
|
+
this._ensureInRoom();
|
|
763
|
+
return await this.localMedia.publishMicrophone(options);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Publish screen share
|
|
768
|
+
* @param {Object} options - Screen share options
|
|
769
|
+
* @param {boolean} options.audio - Include system audio
|
|
770
|
+
* @returns {Promise<MediaStream>}
|
|
771
|
+
*/
|
|
772
|
+
async publishScreenShare(options = {}) {
|
|
773
|
+
this._ensureInRoom();
|
|
774
|
+
return await this.localMedia.publishScreenShare(options);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Stop camera
|
|
779
|
+
* @returns {Promise<void>}
|
|
780
|
+
*/
|
|
781
|
+
async stopCamera() {
|
|
782
|
+
return await this.localMedia.stopCamera();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Stop microphone
|
|
787
|
+
* @returns {Promise<void>}
|
|
788
|
+
*/
|
|
789
|
+
async stopMicrophone() {
|
|
790
|
+
return await this.localMedia.stopMicrophone();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Stop screen share
|
|
795
|
+
* @returns {Promise<void>}
|
|
796
|
+
*/
|
|
797
|
+
async stopScreenShare() {
|
|
798
|
+
return await this.localMedia.stopScreenShare();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Enable screenshare audio (add audio to existing screenshare)
|
|
803
|
+
* @returns {Promise<void>}
|
|
804
|
+
*/
|
|
805
|
+
async enableScreenShareAudio() {
|
|
806
|
+
return await this.localMedia.enableScreenShareAudio();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Disable screenshare audio (remove audio from existing screenshare)
|
|
811
|
+
* @returns {Promise<void>}
|
|
812
|
+
*/
|
|
813
|
+
async disableScreenShareAudio() {
|
|
814
|
+
return await this.localMedia.disableScreenShareAudio();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Mute screenshare audio
|
|
819
|
+
* @returns {Promise<void>}
|
|
820
|
+
*/
|
|
821
|
+
async muteScreenShareAudio() {
|
|
822
|
+
return await this.localMedia.muteScreenShareAudio();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Unmute screenshare audio
|
|
827
|
+
* @returns {Promise<void>}
|
|
828
|
+
*/
|
|
829
|
+
async unmuteScreenShareAudio() {
|
|
830
|
+
return await this.localMedia.unmuteScreenShareAudio();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Toggle screenshare audio mute state
|
|
835
|
+
* @returns {Promise<boolean>} New mute state
|
|
836
|
+
*/
|
|
837
|
+
async toggleScreenShareAudioMute() {
|
|
838
|
+
return await this.localMedia.toggleScreenShareAudioMute();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Change camera device
|
|
843
|
+
* @param {string} deviceId - New camera device ID
|
|
844
|
+
* @returns {Promise<MediaStream>}
|
|
845
|
+
*/
|
|
846
|
+
async changeCamera(deviceId) {
|
|
847
|
+
return await this.localMedia.changeCamera(deviceId);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Change microphone device
|
|
852
|
+
* @param {string} deviceId - New microphone device ID
|
|
853
|
+
* @returns {Promise<MediaStream>}
|
|
854
|
+
*/
|
|
855
|
+
async changeMicrophone(deviceId) {
|
|
856
|
+
return await this.localMedia.changeMicrophone(deviceId);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Update camera background effect
|
|
861
|
+
* @param {Object} options - Background options
|
|
862
|
+
* @param {string} options.type - 'none' | 'blur' | 'image'
|
|
863
|
+
* @param {number} options.blurLevel - Blur level in pixels (default: 8)
|
|
864
|
+
* @param {string} options.imageUrl - Background image URL (for type: 'image')
|
|
865
|
+
* @returns {Promise<MediaStream>}
|
|
866
|
+
*
|
|
867
|
+
* @example
|
|
868
|
+
* // Apply blur
|
|
869
|
+
* await client.updateCameraBackground({ type: 'blur', blurLevel: 8 });
|
|
870
|
+
*
|
|
871
|
+
* // Apply virtual background
|
|
872
|
+
* await client.updateCameraBackground({
|
|
873
|
+
* type: 'image',
|
|
874
|
+
* imageUrl: '/images/backgrounds/office.jpg'
|
|
875
|
+
* });
|
|
876
|
+
*
|
|
877
|
+
* // Remove background effect
|
|
878
|
+
* await client.updateCameraBackground({ type: 'none' });
|
|
879
|
+
*/
|
|
880
|
+
async updateCameraBackground(options) {
|
|
881
|
+
this._ensureInRoom();
|
|
882
|
+
return await this.localMedia.updateCameraBackground(options);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Mute camera
|
|
887
|
+
* @returns {Promise<void>}
|
|
888
|
+
*/
|
|
889
|
+
async muteCamera() {
|
|
890
|
+
return await this.localMedia.muteCamera();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Unmute camera
|
|
895
|
+
* @returns {Promise<void>}
|
|
896
|
+
*/
|
|
897
|
+
async unmuteCamera() {
|
|
898
|
+
return await this.localMedia.unmuteCamera();
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Mute microphone
|
|
903
|
+
* @returns {Promise<void>}
|
|
904
|
+
*/
|
|
905
|
+
async muteMicrophone() {
|
|
906
|
+
return await this.localMedia.muteMicrophone();
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Unmute microphone
|
|
911
|
+
* @returns {Promise<void>}
|
|
912
|
+
*/
|
|
913
|
+
async unmuteMicrophone() {
|
|
914
|
+
return await this.localMedia.unmuteMicrophone();
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ========== Device Management ==========
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Get available media devices
|
|
921
|
+
* @returns {Promise<Object>} Object with cameras, microphones, speakers
|
|
922
|
+
*/
|
|
923
|
+
async getDevices() {
|
|
924
|
+
return await this.localMedia.getDevices();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Get local stream
|
|
929
|
+
* @param {string} type - Stream type (camera, microphone, screenShare)
|
|
930
|
+
* @returns {MediaStream|null}
|
|
931
|
+
*/
|
|
932
|
+
getLocalStream(type) {
|
|
933
|
+
return this.localMedia.getStream(type);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Track a video element for automatic quality adjustment based on size
|
|
938
|
+
* @param {string} participantId - Participant ID
|
|
939
|
+
* @param {HTMLVideoElement} videoElement - Video element to track
|
|
940
|
+
*/
|
|
941
|
+
trackVideoElement(participantId, videoElement) {
|
|
942
|
+
if (!this.remoteMedia) {
|
|
943
|
+
this.logger.warn("RemoteMedia not initialized");
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
this.remoteMedia.trackVideoElement(participantId, videoElement);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Stop tracking a video element
|
|
951
|
+
* @param {string} participantId - Participant ID
|
|
952
|
+
*/
|
|
953
|
+
untrackVideoElement(participantId) {
|
|
954
|
+
if (!this.remoteMedia) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
this.remoteMedia.untrackVideoElement(participantId);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Locally mute a remote participant's stream (only for local user, doesn't affect others)
|
|
962
|
+
* @param {string} participantId - Participant ID
|
|
963
|
+
* @param {string} type - Stream type (audio, screenShareAudio)
|
|
964
|
+
* @returns {boolean} Success
|
|
965
|
+
*/
|
|
966
|
+
localMuteRemoteStream(participantId, type = "audio") {
|
|
967
|
+
if (!this.remoteMedia) {
|
|
968
|
+
this.logger.warn("RemoteMedia not initialized");
|
|
969
|
+
return false;
|
|
970
|
+
}
|
|
971
|
+
return this.remoteMedia.localMuteRemoteStream(participantId, type);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Locally unmute a remote participant's stream
|
|
976
|
+
* @param {string} participantId - Participant ID
|
|
977
|
+
* @param {string} type - Stream type (audio, screenShareAudio)
|
|
978
|
+
* @returns {boolean} Success
|
|
979
|
+
*/
|
|
980
|
+
localUnmuteRemoteStream(participantId, type = "audio") {
|
|
981
|
+
if (!this.remoteMedia) {
|
|
982
|
+
this.logger.warn("RemoteMedia not initialized");
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
return this.remoteMedia.localUnmuteRemoteStream(participantId, type);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Toggle local mute state for a remote participant's stream
|
|
990
|
+
* @param {string} participantId - Participant ID
|
|
991
|
+
* @param {string} type - Stream type (audio, screenShareAudio)
|
|
992
|
+
* @returns {boolean} New mute state
|
|
993
|
+
*/
|
|
994
|
+
toggleLocalMuteRemoteStream(participantId, type = "audio") {
|
|
995
|
+
if (!this.remoteMedia) {
|
|
996
|
+
this.logger.warn("RemoteMedia not initialized");
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
return this.remoteMedia.toggleLocalMuteRemoteStream(participantId, type);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Check if a remote stream is locally muted
|
|
1004
|
+
* @param {string} participantId - Participant ID
|
|
1005
|
+
* @param {string} type - Stream type (audio, screenShareAudio)
|
|
1006
|
+
* @returns {boolean} Mute state
|
|
1007
|
+
*/
|
|
1008
|
+
isRemoteStreamLocallyMuted(participantId, type = "audio") {
|
|
1009
|
+
if (!this.remoteMedia) {
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
return this.remoteMedia.isLocallyMuted(participantId, type);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Set callback for stats updates (for local UI display)
|
|
1017
|
+
* @param {Function} callback - Callback function that receives stats data
|
|
1018
|
+
*/
|
|
1019
|
+
setStatsCallback(callback) {
|
|
1020
|
+
if (this.mediasoup && this.mediasoup.statsCollector) {
|
|
1021
|
+
this.mediasoup.statsCollector.setStatsCallback(callback);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// ========== Remote Participant Methods ==========
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Get participant by ID
|
|
1029
|
+
* @param {string} participantId - Participant ID
|
|
1030
|
+
* @returns {Object|null}
|
|
1031
|
+
*/
|
|
1032
|
+
getParticipant(participantId) {
|
|
1033
|
+
return this.remoteMedia.getParticipant(participantId);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Get all participants
|
|
1038
|
+
* @returns {Array<Object>}
|
|
1039
|
+
*/
|
|
1040
|
+
getAllParticipants() {
|
|
1041
|
+
return this.remoteMedia.getAllParticipants();
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Get remote stream
|
|
1046
|
+
* @param {string} participantId - Participant ID
|
|
1047
|
+
* @param {string} type - Stream type (video, audio, screenShare)
|
|
1048
|
+
* @returns {MediaStream|null}
|
|
1049
|
+
*/
|
|
1050
|
+
getRemoteStream(participantId, type) {
|
|
1051
|
+
return this.remoteMedia.getStream(participantId, type);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Get all streams for a participant
|
|
1056
|
+
* @param {string} participantId - Participant ID
|
|
1057
|
+
* @returns {Object|null}
|
|
1058
|
+
*/
|
|
1059
|
+
getRemoteStreams(participantId) {
|
|
1060
|
+
return this.remoteMedia.getStreams(participantId);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ========== Logging Control ==========
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Set verbose logging for specific categories
|
|
1067
|
+
* @param {string} category - Category name (stats, keepalive, performance) or 'all'
|
|
1068
|
+
* @param {boolean} enabled - Enable or disable this category
|
|
1069
|
+
*/
|
|
1070
|
+
setVerboseLogging(category, enabled) {
|
|
1071
|
+
if (category === "all") {
|
|
1072
|
+
Logger.setVerbose(enabled);
|
|
1073
|
+
} else {
|
|
1074
|
+
Logger.setFilter(category, enabled);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// ========== State Getters ==========
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Get current state
|
|
1082
|
+
* @returns {string}
|
|
1083
|
+
*/
|
|
1084
|
+
getState() {
|
|
1085
|
+
return this.state;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Check if connected to server
|
|
1090
|
+
* @returns {boolean}
|
|
1091
|
+
*/
|
|
1092
|
+
isConnected() {
|
|
1093
|
+
return this.state !== "disconnected" && this.connection.connected;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Check if in a room
|
|
1098
|
+
* @returns {boolean}
|
|
1099
|
+
*/
|
|
1100
|
+
isInRoom() {
|
|
1101
|
+
return this.state === "in-room";
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Get current room ID
|
|
1106
|
+
* @returns {string|null}
|
|
1107
|
+
*/
|
|
1108
|
+
getRoomId() {
|
|
1109
|
+
return this.currentRoomId;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Check if camera is active
|
|
1114
|
+
* @returns {boolean}
|
|
1115
|
+
*/
|
|
1116
|
+
isCameraActive() {
|
|
1117
|
+
return this.localMedia.isCameraActive;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Check if microphone is active
|
|
1122
|
+
* @returns {boolean}
|
|
1123
|
+
*/
|
|
1124
|
+
isMicrophoneActive() {
|
|
1125
|
+
return this.localMedia.isMicrophoneActive;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Check if screen share is active
|
|
1130
|
+
* @returns {boolean}
|
|
1131
|
+
*/
|
|
1132
|
+
isScreenShareActive() {
|
|
1133
|
+
return this.localMedia.isScreenShareActive;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Check if camera is muted
|
|
1138
|
+
* @returns {boolean}
|
|
1139
|
+
*/
|
|
1140
|
+
isCameraMuted() {
|
|
1141
|
+
return this.localMedia.isCameraMuted;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Check if microphone is muted
|
|
1146
|
+
* @returns {boolean}
|
|
1147
|
+
*/
|
|
1148
|
+
isMicrophoneMuted() {
|
|
1149
|
+
return this.localMedia.isMicrophoneMuted;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Get participant count
|
|
1154
|
+
* @returns {number}
|
|
1155
|
+
*/
|
|
1156
|
+
getParticipantCount() {
|
|
1157
|
+
return this.remoteMedia.participantCount;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Get video room info from join data
|
|
1162
|
+
* @returns {Object|null}
|
|
1163
|
+
*/
|
|
1164
|
+
getVideoRoom() {
|
|
1165
|
+
return this.joinData?.videoRoom || null;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Get current participant info from join data
|
|
1170
|
+
* @returns {Object|null}
|
|
1171
|
+
*/
|
|
1172
|
+
getCurrentParticipant() {
|
|
1173
|
+
return this.joinData?.participant || null;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Get server info from join data
|
|
1178
|
+
* @returns {Object|null}
|
|
1179
|
+
*/
|
|
1180
|
+
getServerInfo() {
|
|
1181
|
+
return this.joinData?.server || null;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Get full join data (videoRoom, participant, server, authorization)
|
|
1186
|
+
* @returns {Object|null}
|
|
1187
|
+
*/
|
|
1188
|
+
getJoinData() {
|
|
1189
|
+
return this.joinData;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// ========== Private Helpers ==========
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Set SDK state and emit event
|
|
1196
|
+
* @private
|
|
1197
|
+
*/
|
|
1198
|
+
_setState(newState) {
|
|
1199
|
+
const oldState = this.state;
|
|
1200
|
+
this.state = newState;
|
|
1201
|
+
this.logger.info(`State changed: ${oldState} -> ${newState}`);
|
|
1202
|
+
this.emit("state:changed", { from: oldState, to: newState });
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Ensure we're in a room, throw if not
|
|
1207
|
+
* @private
|
|
1208
|
+
*/
|
|
1209
|
+
_ensureInRoom() {
|
|
1210
|
+
if (this.state !== "in-room" && this.state !== "in-meeting") {
|
|
1211
|
+
throw new StateError(
|
|
1212
|
+
"Not in a room. Call joinRoom() first.",
|
|
1213
|
+
this.state,
|
|
1214
|
+
"in-room or in-meeting",
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Setup listeners for media events from server
|
|
1221
|
+
* @private
|
|
1222
|
+
*/
|
|
1223
|
+
_setupMediaEventListeners() {
|
|
1224
|
+
this.logger.info("Setting up media event listeners");
|
|
1225
|
+
|
|
1226
|
+
// Listen for media.transports event
|
|
1227
|
+
this._transportsPromise = new Promise((resolve) => {
|
|
1228
|
+
this._transportsResolve = resolve;
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
this.connection.onServerEvent("media.transports", async (data) => {
|
|
1232
|
+
this.logger.info("Received media.transports", {
|
|
1233
|
+
hasSend: !!data?.sendTransportOptions,
|
|
1234
|
+
hasRecv: !!data?.recvTransportOptions,
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
const { sendTransportOptions, recvTransportOptions } = data;
|
|
1238
|
+
|
|
1239
|
+
// Store transport data for later if device not loaded yet
|
|
1240
|
+
this._pendingTransportData = data;
|
|
1241
|
+
|
|
1242
|
+
// Wait for device to be loaded before creating transports
|
|
1243
|
+
if (!this.mediasoup.device.loaded) {
|
|
1244
|
+
this.logger.warn(
|
|
1245
|
+
"Device not loaded yet, storing transport data and waiting...",
|
|
1246
|
+
);
|
|
1247
|
+
|
|
1248
|
+
// Poll for device to be loaded (with timeout)
|
|
1249
|
+
let attempts = 0;
|
|
1250
|
+
while (!this.mediasoup.device.loaded && attempts < 50) {
|
|
1251
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1252
|
+
attempts++;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (!this.mediasoup.device.loaded) {
|
|
1256
|
+
this.logger.error("Device still not loaded after 5 seconds");
|
|
1257
|
+
if (this._transportsResolve) {
|
|
1258
|
+
this._transportsResolve();
|
|
1259
|
+
}
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
this.logger.info(
|
|
1263
|
+
"Device now loaded, proceeding with transport creation",
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// Create transports
|
|
1268
|
+
try {
|
|
1269
|
+
if (sendTransportOptions) {
|
|
1270
|
+
this.mediasoup.sendTransport =
|
|
1271
|
+
this.mediasoup.device.createSendTransport(sendTransportOptions);
|
|
1272
|
+
this.mediasoup._setupSendTransportListeners(); // Not async - just sets up event listeners
|
|
1273
|
+
this.logger.info("Send transport created");
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (recvTransportOptions) {
|
|
1277
|
+
this.mediasoup.recvTransport =
|
|
1278
|
+
this.mediasoup.device.createRecvTransport(recvTransportOptions);
|
|
1279
|
+
this.mediasoup._setupRecvTransportListeners(); // Not async - just sets up event listeners
|
|
1280
|
+
this.logger.info("Receive transport created");
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Resolve the promise
|
|
1284
|
+
if (this._transportsResolve) {
|
|
1285
|
+
this._transportsResolve();
|
|
1286
|
+
}
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
this.logger.error("Failed to create transports:", error);
|
|
1289
|
+
if (this._transportsResolve) {
|
|
1290
|
+
this._transportsResolve();
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Wait for media.transports event from server
|
|
1298
|
+
* @private
|
|
1299
|
+
*/
|
|
1300
|
+
async _waitForTransports() {
|
|
1301
|
+
const timeout = setTimeout(() => {
|
|
1302
|
+
throw new Error("Timeout waiting for media.transports");
|
|
1303
|
+
}, 10000);
|
|
1304
|
+
|
|
1305
|
+
await this._transportsPromise;
|
|
1306
|
+
clearTimeout(timeout);
|
|
1307
|
+
this.logger.info("Transports ready");
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Handle server request to close and reconnect producer
|
|
1312
|
+
* This happens during quality adaptation
|
|
1313
|
+
* @private
|
|
1314
|
+
*/
|
|
1315
|
+
async _handleProducerCloseRequest(data) {
|
|
1316
|
+
const { producer } = data;
|
|
1317
|
+
const { type, reconnect } = producer;
|
|
1318
|
+
|
|
1319
|
+
this.logger.info("Handling producer close request:", { type, reconnect });
|
|
1320
|
+
|
|
1321
|
+
if (!type) {
|
|
1322
|
+
this.logger.error("Producer close request missing type");
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Get the current stream for this producer before closing
|
|
1327
|
+
const currentStream =
|
|
1328
|
+
this.localMedia.streams[type === "video" ? "camera" : type];
|
|
1329
|
+
const currentProducer = this.localMedia.producers[type];
|
|
1330
|
+
|
|
1331
|
+
if (!currentProducer) {
|
|
1332
|
+
this.logger.warn("No producer found to close:", type);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Close the producer on mediasoup
|
|
1337
|
+
await this.mediasoup.closeProducer(type);
|
|
1338
|
+
|
|
1339
|
+
// If reconnect flag is true, republish with the same stream
|
|
1340
|
+
if (reconnect && currentStream) {
|
|
1341
|
+
this.logger.info("Reconnecting producer:", type);
|
|
1342
|
+
|
|
1343
|
+
try {
|
|
1344
|
+
// Clone the stream to avoid issues with the old one
|
|
1345
|
+
const clonedStream = currentStream.clone();
|
|
1346
|
+
|
|
1347
|
+
// Republish based on type
|
|
1348
|
+
if (type === "video") {
|
|
1349
|
+
// Stop the old stream tracks
|
|
1350
|
+
currentStream.getTracks().forEach((track) => track.stop());
|
|
1351
|
+
|
|
1352
|
+
// Start camera with the cloned stream
|
|
1353
|
+
await this.localMedia.startCamera({
|
|
1354
|
+
existingStream: clonedStream,
|
|
1355
|
+
// Preserve current background if any
|
|
1356
|
+
background: this.localMedia.currentBackgroundOptions,
|
|
1357
|
+
});
|
|
1358
|
+
} else if (type === "audio") {
|
|
1359
|
+
// Stop the old stream tracks
|
|
1360
|
+
currentStream.getTracks().forEach((track) => track.stop());
|
|
1361
|
+
|
|
1362
|
+
// Start microphone with the cloned stream
|
|
1363
|
+
await this.localMedia.startMicrophone({
|
|
1364
|
+
existingStream: clonedStream,
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
this.logger.info("Producer reconnected successfully:", type);
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
this.logger.error("Failed to reconnect producer:", type, error);
|
|
1371
|
+
// Emit error event so UI can handle it
|
|
1372
|
+
this.emit("producer:reconnect:failed", { type, error });
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Set volume for a specific participant (0.0 to 1.0)
|
|
1379
|
+
* Works seamlessly with both audio elements (< 30 participants) and audio mixer (30+ participants)
|
|
1380
|
+
* @param {string} participantId - Participant ID
|
|
1381
|
+
* @param {number} volume - Volume level (0.0 to 1.0)
|
|
1382
|
+
* @returns {boolean} Success status
|
|
1383
|
+
*/
|
|
1384
|
+
setParticipantVolume(participantId, volume) {
|
|
1385
|
+
return this.remoteMedia.setParticipantVolume(participantId, volume);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* Get volume for a specific participant
|
|
1390
|
+
* @param {string} participantId - Participant ID
|
|
1391
|
+
* @returns {number} Volume level (0.0 to 1.0)
|
|
1392
|
+
*/
|
|
1393
|
+
getParticipantVolume(participantId) {
|
|
1394
|
+
return this.remoteMedia.getParticipantVolume(participantId);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Check if audio mixer is currently enabled
|
|
1399
|
+
* Audio mixer is automatically enabled when participant count >= 30
|
|
1400
|
+
* @returns {boolean}
|
|
1401
|
+
*/
|
|
1402
|
+
isAudioMixerEnabled() {
|
|
1403
|
+
return this.remoteMedia.isAudioMixerEnabled();
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* Retry consuming a failed producer stream
|
|
1408
|
+
* Use this when a stream:consume-failed event is received
|
|
1409
|
+
* @param {string} producerId - Producer ID to retry
|
|
1410
|
+
* @param {string} participantId - Participant ID
|
|
1411
|
+
* @returns {Promise<boolean>} Success status
|
|
1412
|
+
*
|
|
1413
|
+
* @example
|
|
1414
|
+
* client.on('stream:consume-failed', async ({ producerId, participantId }) => {
|
|
1415
|
+
* console.warn('Stream failed to load, retrying...');
|
|
1416
|
+
* await client.retryConsumeStream(producerId, participantId);
|
|
1417
|
+
* });
|
|
1418
|
+
*/
|
|
1419
|
+
async retryConsumeStream(producerId, participantId) {
|
|
1420
|
+
if (!this.remoteMedia) {
|
|
1421
|
+
this.logger.warn("RemoteMedia not initialized");
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
return await this.remoteMedia.retryConsumeProducer(
|
|
1425
|
+
producerId,
|
|
1426
|
+
participantId,
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// ========== Session Lifecycle ==========
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* End the meeting session server-side. Clears the videoAuthToken cookie
|
|
1434
|
+
* and invalidates the token row so a stale cookie can't be replayed.
|
|
1435
|
+
*
|
|
1436
|
+
* Intended to be called on `leave()`, on a page `beforeunload`, or when the
|
|
1437
|
+
* consumer surfaces an "already joined in another tab" UX and wants to
|
|
1438
|
+
* clear the incumbent session before retrying. Safe to call from
|
|
1439
|
+
* `fetch({ keepalive: true })` during unload.
|
|
1440
|
+
*
|
|
1441
|
+
* Delegates to `sdk.video.endSession(roomId)` if an SDK was injected via
|
|
1442
|
+
* constructor options; otherwise this is a no-op (consumer must clear the
|
|
1443
|
+
* cookie through its own plumbing).
|
|
1444
|
+
*
|
|
1445
|
+
* @param {Object} [options]
|
|
1446
|
+
* @param {string} [options.roomId] - Override. Defaults to the current room.
|
|
1447
|
+
* @returns {Promise<void>}
|
|
1448
|
+
*/
|
|
1449
|
+
async endSession(options = {}) {
|
|
1450
|
+
const roomId =
|
|
1451
|
+
options.roomId || this.currentRoomId || this.joinData?.videoRoom?.id;
|
|
1452
|
+
if (!roomId) {
|
|
1453
|
+
this.logger.warn("endSession: no roomId available");
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
if (!this.sdk?.video?.endSession) {
|
|
1457
|
+
this.logger.warn(
|
|
1458
|
+
"endSession: sdk not injected — consumer must clear session cookie manually",
|
|
1459
|
+
);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
try {
|
|
1463
|
+
await this.sdk.video.endSession(roomId);
|
|
1464
|
+
this.logger.info("Session ended", { roomId });
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
this.logger.error("endSession failed", err);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// ========== Long-Disconnect Transport Recreation ==========
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* Re-run the `room.join` flow after mediasoup wiped stale transports.
|
|
1474
|
+
* The server responds with fresh media.routerCapabilities + media.transports;
|
|
1475
|
+
* we reuse the existing loadDevice / transport listener plumbing so producers
|
|
1476
|
+
* and consumers get re-wired.
|
|
1477
|
+
*
|
|
1478
|
+
* Consumer must re-publish local streams (camera/microphone) after this —
|
|
1479
|
+
* producers died with the old transports.
|
|
1480
|
+
* @private
|
|
1481
|
+
*/
|
|
1482
|
+
async _rejoinRoomForTransportRecreation() {
|
|
1483
|
+
if (!this.joinData || !this.connection) {
|
|
1484
|
+
this.logger.warn(
|
|
1485
|
+
"Cannot rejoin for transport recreation: no joinData or connection",
|
|
1486
|
+
);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
this.logger.info(
|
|
1490
|
+
"Re-emitting room.join to recreate transports after long disconnect",
|
|
1491
|
+
);
|
|
1492
|
+
|
|
1493
|
+
// Reset the transports promise — _setupMediaEventListeners() creates it
|
|
1494
|
+
// via a fresh media.transports listener in the join flow.
|
|
1495
|
+
this._setupMediaEventListeners();
|
|
1496
|
+
|
|
1497
|
+
// Also need a fresh routerCapabilities listener since device must be
|
|
1498
|
+
// (re-)loaded. mediasoup.loadDevice() is idempotent only if device
|
|
1499
|
+
// isn't already loaded; after cleanup it should be safe to reload.
|
|
1500
|
+
this.connection.emit("room.join", {});
|
|
1501
|
+
|
|
1502
|
+
try {
|
|
1503
|
+
if (!this.mediasoup.device.loaded) {
|
|
1504
|
+
await this.mediasoup.loadDevice();
|
|
1505
|
+
}
|
|
1506
|
+
await this._waitForTransports();
|
|
1507
|
+
this.emit("transports:recreated");
|
|
1508
|
+
this.logger.info("Transport recreation complete");
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
this.logger.error("Transport recreation failed", err);
|
|
1511
|
+
this.emit("error", {
|
|
1512
|
+
code: "transport_recreation_failed",
|
|
1513
|
+
message: err.message,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// ========== Reassignment Recovery ==========
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Arm the stable-connect timer. If the current connection stays up for >60s,
|
|
1522
|
+
* clear the reassignment attempt counter so the next cycle starts fresh.
|
|
1523
|
+
* @private
|
|
1524
|
+
*/
|
|
1525
|
+
_armReassignmentStableTimer() {
|
|
1526
|
+
if (this._reassignmentStableTimer) {
|
|
1527
|
+
clearTimeout(this._reassignmentStableTimer);
|
|
1528
|
+
}
|
|
1529
|
+
this._reassignmentStableTimer = setTimeout(() => {
|
|
1530
|
+
if (this._reassignmentAttempts.length > 0) {
|
|
1531
|
+
this.logger.info(
|
|
1532
|
+
"Connection stable >60s; resetting reassignment attempt counter",
|
|
1533
|
+
);
|
|
1534
|
+
this._reassignmentAttempts = [];
|
|
1535
|
+
}
|
|
1536
|
+
this._reassignmentStableTimer = null;
|
|
1537
|
+
}, 60_000);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Handle a reassignmentRequired signal from the ConnectionManager.
|
|
1542
|
+
* Caps at 3 attempts in a 60s window with backoff 500ms, 2s, 5s.
|
|
1543
|
+
* @private
|
|
1544
|
+
*/
|
|
1545
|
+
async _handleReassignment(code) {
|
|
1546
|
+
if (this._reassignmentInFlight) {
|
|
1547
|
+
this.logger.warn(
|
|
1548
|
+
"Reassignment already in flight; ignoring duplicate signal",
|
|
1549
|
+
);
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
this._reassignmentInFlight = true;
|
|
1553
|
+
|
|
1554
|
+
try {
|
|
1555
|
+
// Prune attempts older than 60s
|
|
1556
|
+
const now = Date.now();
|
|
1557
|
+
const windowStart = now - 60_000;
|
|
1558
|
+
this._reassignmentAttempts = this._reassignmentAttempts.filter(
|
|
1559
|
+
(ts) => ts > windowStart,
|
|
1560
|
+
);
|
|
1561
|
+
|
|
1562
|
+
const attemptIndex = this._reassignmentAttempts.length;
|
|
1563
|
+
if (attemptIndex >= 3) {
|
|
1564
|
+
this.logger.error(
|
|
1565
|
+
"Reassignment cap reached (3 in 60s); emitting reassignment_exhausted",
|
|
1566
|
+
);
|
|
1567
|
+
this.emit("error", {
|
|
1568
|
+
code: "reassignment_exhausted",
|
|
1569
|
+
message: "Reassignment attempts exhausted",
|
|
1570
|
+
triggeredBy: code,
|
|
1571
|
+
});
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
this._reassignmentAttempts.push(now);
|
|
1576
|
+
|
|
1577
|
+
const backoffs = [500, 2000, 5000];
|
|
1578
|
+
const delay = backoffs[attemptIndex];
|
|
1579
|
+
this.logger.info(
|
|
1580
|
+
`Reassignment attempt ${attemptIndex + 1}/3 in ${delay}ms (code=${code})`,
|
|
1581
|
+
);
|
|
1582
|
+
|
|
1583
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1584
|
+
|
|
1585
|
+
if (!this.sdk?.video?.joinRoom || !this._lastJoinArgs) {
|
|
1586
|
+
this.logger.error("Cannot reassign: sdk or last joinRoom args missing");
|
|
1587
|
+
this.emit("error", {
|
|
1588
|
+
code: "reassignment_exhausted",
|
|
1589
|
+
message: "Reassignment plumbing missing (no sdk or joinRoomArgs)",
|
|
1590
|
+
triggeredBy: code,
|
|
1591
|
+
});
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Teardown current media state. The old pod's mediasoup Router is
|
|
1596
|
+
// gone (or the token was rejected); we re-run the full join flow.
|
|
1597
|
+
await this._teardownForReassignment();
|
|
1598
|
+
|
|
1599
|
+
// Fetch a fresh assignment.
|
|
1600
|
+
const args = Array.isArray(this._lastJoinArgs)
|
|
1601
|
+
? this._lastJoinArgs
|
|
1602
|
+
: [this._lastJoinArgs];
|
|
1603
|
+
const response = await this.sdk.video.joinRoom(...args);
|
|
1604
|
+
|
|
1605
|
+
// Re-run the join flow with the fresh response.
|
|
1606
|
+
await this.joinFromApiResponse(response, {
|
|
1607
|
+
joinRoomArgs: this._lastJoinArgs,
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
this.emit("reassigned", { code, attempt: attemptIndex + 1 });
|
|
1611
|
+
this.logger.info("Reassignment succeeded", { attempt: attemptIndex + 1 });
|
|
1612
|
+
} catch (err) {
|
|
1613
|
+
this.logger.error("Reassignment failed", err);
|
|
1614
|
+
// Don't count fetch/connect failures — they'll trigger another
|
|
1615
|
+
// reassignmentRequired or a terminal error on their own.
|
|
1616
|
+
} finally {
|
|
1617
|
+
this._reassignmentInFlight = false;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Tear down state before reconnecting on reassignment. Media is
|
|
1623
|
+
* unrecoverable (mediasoup Router died with the pod), so we clean up
|
|
1624
|
+
* transports, producers, consumers, local device tracks, and the old
|
|
1625
|
+
* Socket.IO handle. Consumer re-publishes camera/mic after the `reassigned`
|
|
1626
|
+
* event fires — matches the plan's "Media state lost (unavoidable)" stance
|
|
1627
|
+
* for pod death.
|
|
1628
|
+
* @private
|
|
1629
|
+
*/
|
|
1630
|
+
async _teardownForReassignment() {
|
|
1631
|
+
try {
|
|
1632
|
+
if (this.localMedia) {
|
|
1633
|
+
await this.localMedia.cleanup();
|
|
1634
|
+
}
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
this.logger.warn("LocalMedia cleanup threw during reassignment", err);
|
|
1637
|
+
}
|
|
1638
|
+
try {
|
|
1639
|
+
if (this.mediasoup) {
|
|
1640
|
+
await this.mediasoup.cleanup();
|
|
1641
|
+
}
|
|
1642
|
+
} catch (err) {
|
|
1643
|
+
this.logger.warn("Mediasoup cleanup threw during reassignment", err);
|
|
1644
|
+
}
|
|
1645
|
+
try {
|
|
1646
|
+
if (this.remoteMedia) {
|
|
1647
|
+
await this.remoteMedia.cleanup();
|
|
1648
|
+
}
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
this.logger.warn("RemoteMedia cleanup threw during reassignment", err);
|
|
1651
|
+
}
|
|
1652
|
+
try {
|
|
1653
|
+
if (this.connection) {
|
|
1654
|
+
await this.connection.disconnect();
|
|
1655
|
+
}
|
|
1656
|
+
} catch (err) {
|
|
1657
|
+
this.logger.warn("Connection disconnect threw during reassignment", err);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// Null out managers so _initializeManagers runs again with the new URL.
|
|
1661
|
+
this.connection = null;
|
|
1662
|
+
this.mediasoup = null;
|
|
1663
|
+
this.localMedia = null;
|
|
1664
|
+
this.remoteMedia = null;
|
|
1665
|
+
|
|
1666
|
+
if (this._reassignmentStableTimer) {
|
|
1667
|
+
clearTimeout(this._reassignmentStableTimer);
|
|
1668
|
+
this._reassignmentStableTimer = null;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1508
1671
|
}
|