@unboundcx/video-sdk-client 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VideoMeetingClient.js +1644 -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
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Device } from
|
|
2
|
-
import { EventEmitter } from
|
|
3
|
-
import { Logger } from
|
|
4
|
-
import { MediasoupError, StateError } from
|
|
5
|
-
import { StatsCollector } from
|
|
1
|
+
import { Device } from "mediasoup-client";
|
|
2
|
+
import { EventEmitter } from "../utils/EventEmitter.js";
|
|
3
|
+
import { Logger } from "../utils/Logger.js";
|
|
4
|
+
import { MediasoupError, StateError } from "../utils/errors.js";
|
|
5
|
+
import { StatsCollector } from "./StatsCollector.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Manages mediasoup Device and Transports
|
|
@@ -16,858 +16,1060 @@ import { StatsCollector } from './StatsCollector.js';
|
|
|
16
16
|
* - 'consumer:created' - Consumer created
|
|
17
17
|
*/
|
|
18
18
|
export class MediasoupManager extends EventEmitter {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} options
|
|
21
|
+
* @param {ConnectionManager} options.connection - Connection manager instance
|
|
22
|
+
* @param {boolean} options.debug - Enable debug logging
|
|
23
|
+
*/
|
|
24
|
+
constructor(options) {
|
|
25
|
+
super();
|
|
26
|
+
|
|
27
|
+
this.connection = options.connection;
|
|
28
|
+
this.logger = new Logger("SDK:MediasoupManager", options.debug);
|
|
29
|
+
|
|
30
|
+
this.device = new Device();
|
|
31
|
+
this.sendTransport = null;
|
|
32
|
+
this.recvTransport = null;
|
|
33
|
+
this.producers = new Map(); // Map<type, Producer>
|
|
34
|
+
this.consumers = new Map(); // Map<consumerId, Consumer>
|
|
35
|
+
|
|
36
|
+
// Initialize stats collector
|
|
37
|
+
this.statsCollector = new StatsCollector(this.logger);
|
|
38
|
+
this.virtualBackgroundStore = null; // Will be set externally
|
|
39
|
+
|
|
40
|
+
// Timestamps for tracking transport ICE failure duration. Used by
|
|
41
|
+
// recreateStaleTransports() on Socket.IO reconnect to decide whether
|
|
42
|
+
// a network blip was long enough (>10s) to kill WebRTC state, in
|
|
43
|
+
// which case we must recreate transports rather than expect ICE to
|
|
44
|
+
// recover on its own.
|
|
45
|
+
this._sendFailedAt = null;
|
|
46
|
+
this._recvFailedAt = null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Load device with router RTP capabilities
|
|
51
|
+
* This sets up a listener for media.routerCapabilities event from server
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
54
|
+
async loadDevice() {
|
|
55
|
+
if (this.device.loaded) {
|
|
56
|
+
this.logger.warn("Device already loaded");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.logger.info("Waiting for media.routerCapabilities from server...");
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const timeout = setTimeout(() => {
|
|
64
|
+
reject(new MediasoupError("Timeout waiting for router capabilities"));
|
|
65
|
+
}, 10000);
|
|
66
|
+
|
|
67
|
+
// Listen for media.routerCapabilities event from server
|
|
68
|
+
this.connection.onServerEvent(
|
|
69
|
+
"media.routerCapabilities",
|
|
70
|
+
async (data) => {
|
|
71
|
+
try {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
|
|
74
|
+
this.logger.info("Received media.routerCapabilities", {
|
|
75
|
+
hasCapabilities: !!data?.routerRtpCapabilities,
|
|
76
|
+
codecCount: data?.routerRtpCapabilities?.codecs?.length || 0,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const { routerRtpCapabilities } = data;
|
|
80
|
+
|
|
81
|
+
if (!routerRtpCapabilities) {
|
|
82
|
+
throw new MediasoupError(
|
|
83
|
+
"No router capabilities in server response",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Debug: Log router codecs
|
|
88
|
+
this.logger.info(
|
|
89
|
+
"Router Video Codecs:",
|
|
90
|
+
routerRtpCapabilities.codecs
|
|
91
|
+
.filter((c) => c.kind === "video")
|
|
92
|
+
.map(
|
|
93
|
+
(c) =>
|
|
94
|
+
`${c.mimeType} (params: ${JSON.stringify(c.parameters || {})})`,
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Load device
|
|
99
|
+
await this.device.load({ routerRtpCapabilities });
|
|
100
|
+
|
|
101
|
+
this.logger.info("Device loaded successfully", {
|
|
102
|
+
codecs: this.device.rtpCapabilities.codecs.length,
|
|
103
|
+
canProduce: {
|
|
104
|
+
video: this.device.canProduce("video"),
|
|
105
|
+
audio: this.device.canProduce("audio"),
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Debug: Log device codecs
|
|
110
|
+
this.logger.info(
|
|
111
|
+
"Device Video Codecs:",
|
|
112
|
+
this.device.rtpCapabilities.codecs
|
|
113
|
+
.filter((c) => c.kind === "video")
|
|
114
|
+
.map((c) => c.mimeType),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
this.emit("device:loaded", {
|
|
118
|
+
rtpCapabilities: this.device.rtpCapabilities,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
resolve();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
this.logger.error("Failed to load device:", error);
|
|
124
|
+
reject(
|
|
125
|
+
new MediasoupError("Failed to load device", "loadDevice", error),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create send transport for publishing media
|
|
135
|
+
* @returns {Promise<Transport>}
|
|
136
|
+
*/
|
|
137
|
+
async createSendTransport() {
|
|
138
|
+
if (!this.device.loaded) {
|
|
139
|
+
throw new StateError("Device not loaded", "not-loaded", "loaded");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (this.sendTransport) {
|
|
143
|
+
this.logger.warn("Send transport already exists");
|
|
144
|
+
return this.sendTransport;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.logger.info("Creating send transport...");
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// Request transport options from server
|
|
151
|
+
const transportOptions = await this.connection.request(
|
|
152
|
+
"media.createSendTransport",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Create transport
|
|
156
|
+
this.sendTransport = this.device.createSendTransport(transportOptions);
|
|
157
|
+
|
|
158
|
+
// Setup transport event handlers
|
|
159
|
+
this._setupSendTransportListeners(this.sendTransport);
|
|
160
|
+
|
|
161
|
+
this.emit("transport:created", {
|
|
162
|
+
direction: "send",
|
|
163
|
+
id: this.sendTransport.id,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return this.sendTransport;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
this.logger.error("Failed to create send transport:", error);
|
|
169
|
+
throw new MediasoupError(
|
|
170
|
+
"Failed to create send transport",
|
|
171
|
+
"createSendTransport",
|
|
172
|
+
error,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create receive transport for consuming media
|
|
179
|
+
* @returns {Promise<Transport>}
|
|
180
|
+
*/
|
|
181
|
+
async createRecvTransport() {
|
|
182
|
+
if (!this.device.loaded) {
|
|
183
|
+
throw new StateError("Device not loaded", "not-loaded", "loaded");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (this.recvTransport) {
|
|
187
|
+
this.logger.warn("Receive transport already exists");
|
|
188
|
+
return this.recvTransport;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.logger.info("Creating receive transport...");
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
// Request transport options from server
|
|
195
|
+
const transportOptions = await this.connection.request(
|
|
196
|
+
"media.createRecvTransport",
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Create transport
|
|
200
|
+
this.recvTransport = this.device.createRecvTransport(transportOptions);
|
|
201
|
+
|
|
202
|
+
// Setup transport event handlers
|
|
203
|
+
this._setupRecvTransportListeners(this.recvTransport);
|
|
204
|
+
|
|
205
|
+
this.emit("transport:created", {
|
|
206
|
+
direction: "recv",
|
|
207
|
+
id: this.recvTransport.id,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return this.recvTransport;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
this.logger.error("Failed to create receive transport:", error);
|
|
213
|
+
throw new MediasoupError(
|
|
214
|
+
"Failed to create receive transport",
|
|
215
|
+
"createRecvTransport",
|
|
216
|
+
error,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Setup send transport event listeners (matches your server's emit/on pattern)
|
|
223
|
+
* @private
|
|
224
|
+
*/
|
|
225
|
+
_setupSendTransportListeners() {
|
|
226
|
+
const transport = this.sendTransport;
|
|
227
|
+
const socket = this.connection.socket; // Cache socket reference like old system
|
|
228
|
+
|
|
229
|
+
// Connect event - DTLS parameters need to be sent to server
|
|
230
|
+
transport.on("connect", ({ dtlsParameters }, callback, errback) => {
|
|
231
|
+
this.logger.log("Send transport connecting...");
|
|
232
|
+
|
|
233
|
+
// Emit to server
|
|
234
|
+
socket.emit("media.connectSendTransport", {
|
|
235
|
+
transportId: transport.id,
|
|
236
|
+
dtlsParameters,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Listen for success/error responses
|
|
240
|
+
socket.once("media.connectSendTransport.success", () => {
|
|
241
|
+
socket.off("media.connectSendTransport.error");
|
|
242
|
+
this.logger.info("Send transport connected");
|
|
243
|
+
this.emit("transport:connected", {
|
|
244
|
+
direction: "send",
|
|
245
|
+
id: transport.id,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Start stats collection for send transport
|
|
249
|
+
this.statsCollector.startSendStats(
|
|
250
|
+
transport,
|
|
251
|
+
socket,
|
|
252
|
+
this.virtualBackgroundStore,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
callback();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
socket.once("media.connectSendTransport.error", (error) => {
|
|
259
|
+
socket.off("media.connectSendTransport.success");
|
|
260
|
+
this.logger.error("Failed to connect send transport:", error);
|
|
261
|
+
errback(error);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Produce event - New track needs to be registered with server
|
|
266
|
+
transport.on(
|
|
267
|
+
"produce",
|
|
268
|
+
({ kind, rtpParameters, appData }, callback, errback) => {
|
|
269
|
+
this.logger.log("Producing", kind);
|
|
270
|
+
|
|
271
|
+
// Emit produce request to server
|
|
272
|
+
socket.emit("media.produce", {
|
|
273
|
+
transportId: transport.id,
|
|
274
|
+
rtpParameters,
|
|
275
|
+
producerType: appData?.type || kind, // Fixed: use appData.type (matches LocalMediaManager)
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Listen for success/error responses - use socket directly
|
|
279
|
+
socket.once("media.produce.success", ({ producerId }) => {
|
|
280
|
+
socket.off("media.produce.error");
|
|
281
|
+
this.logger.info("Producer created:", producerId);
|
|
282
|
+
callback({ id: producerId });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
socket.once("media.produce.error", (error) => {
|
|
286
|
+
socket.off("media.produce.success");
|
|
287
|
+
this.logger.error("Failed to create producer:", error);
|
|
288
|
+
errback(error);
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// Connection state change
|
|
294
|
+
transport.on("connectionstatechange", (state) => {
|
|
295
|
+
this.logger.info("Send transport state:", state);
|
|
296
|
+
|
|
297
|
+
if (state === "failed" || state === "disconnected") {
|
|
298
|
+
if (!this._sendFailedAt) this._sendFailedAt = Date.now();
|
|
299
|
+
} else if (state === "connected" || state === "completed") {
|
|
300
|
+
this._sendFailedAt = null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Always surface raw transport state so the connection-health monitor
|
|
304
|
+
// can react to disconnected (transient) as well as failed/closed.
|
|
305
|
+
this.emit("transport:state", {
|
|
306
|
+
direction: "send",
|
|
307
|
+
id: transport.id,
|
|
308
|
+
state,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (state === "failed" || state === "closed") {
|
|
312
|
+
this.emit("transport:closed", {
|
|
313
|
+
direction: "send",
|
|
314
|
+
id: transport.id,
|
|
315
|
+
state,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Setup receive transport event listeners (matches your server's emit/on pattern)
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
_setupRecvTransportListeners() {
|
|
326
|
+
const transport = this.recvTransport;
|
|
327
|
+
|
|
328
|
+
// Connect event
|
|
329
|
+
transport.on("connect", ({ dtlsParameters }, callback, errback) => {
|
|
330
|
+
this.logger.log("Receive transport connecting...");
|
|
331
|
+
|
|
332
|
+
// Emit to server
|
|
333
|
+
this.connection.emit("media.connectRecvTransport", {
|
|
334
|
+
transportId: transport.id,
|
|
335
|
+
dtlsParameters,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Listen for success/error responses
|
|
339
|
+
this.connection.socket.once("media.connectRecvTransport.success", () => {
|
|
340
|
+
this.connection.socket.off("media.connectRecvTransport.error");
|
|
341
|
+
this.logger.info("Receive transport connected");
|
|
342
|
+
this.emit("transport:connected", {
|
|
343
|
+
direction: "recv",
|
|
344
|
+
id: transport.id,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Start stats collection for recv transport
|
|
348
|
+
this.statsCollector.startRecvStats(
|
|
349
|
+
transport,
|
|
350
|
+
this.connection.socket,
|
|
351
|
+
this.virtualBackgroundStore,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
callback();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
this.connection.socket.once(
|
|
358
|
+
"media.connectRecvTransport.error",
|
|
359
|
+
(error) => {
|
|
360
|
+
this.connection.socket.off("media.connectRecvTransport.success");
|
|
361
|
+
this.logger.error("Failed to connect receive transport:", error);
|
|
362
|
+
errback(error);
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Connection state change
|
|
368
|
+
transport.on("connectionstatechange", (state) => {
|
|
369
|
+
this.logger.info("Receive transport state:", state);
|
|
370
|
+
|
|
371
|
+
if (state === "failed" || state === "disconnected") {
|
|
372
|
+
if (!this._recvFailedAt) this._recvFailedAt = Date.now();
|
|
373
|
+
} else if (state === "connected" || state === "completed") {
|
|
374
|
+
this._recvFailedAt = null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this.emit("transport:state", {
|
|
378
|
+
direction: "recv",
|
|
379
|
+
id: transport.id,
|
|
380
|
+
state,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (state === "failed" || state === "closed") {
|
|
384
|
+
this.emit("transport:closed", {
|
|
385
|
+
direction: "recv",
|
|
386
|
+
id: transport.id,
|
|
387
|
+
state,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* After Socket.IO reconnects, check whether the underlying WebRTC
|
|
395
|
+
* transports have been in a `failed`/`disconnected` state long enough
|
|
396
|
+
* (>10s) that ICE won't recover on its own. If so, close both transports
|
|
397
|
+
* and signal the consumer to re-run the `room.join` flow (which yields
|
|
398
|
+
* fresh routerCapabilities + transport options + producer list from the
|
|
399
|
+
* server).
|
|
400
|
+
*
|
|
401
|
+
* Without this, a long network blip leaves the client with a live
|
|
402
|
+
* Socket.IO connection but dead WebRTC media — audio and video look
|
|
403
|
+
* broken to the user even though signaling is fine.
|
|
404
|
+
*
|
|
405
|
+
* @returns {boolean} true if transports were recreated
|
|
406
|
+
*/
|
|
407
|
+
async recreateStaleTransports() {
|
|
408
|
+
const STALE_THRESHOLD_MS = 10_000;
|
|
409
|
+
const now = Date.now();
|
|
410
|
+
|
|
411
|
+
const sendStale =
|
|
412
|
+
this._sendFailedAt && now - this._sendFailedAt > STALE_THRESHOLD_MS;
|
|
413
|
+
const recvStale =
|
|
414
|
+
this._recvFailedAt && now - this._recvFailedAt > STALE_THRESHOLD_MS;
|
|
415
|
+
|
|
416
|
+
if (!sendStale && !recvStale) {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
this.logger.warn("Transports stale after reconnect — recreating", {
|
|
421
|
+
sendFailedAt: this._sendFailedAt,
|
|
422
|
+
recvFailedAt: this._recvFailedAt,
|
|
423
|
+
now,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Stop stats; close producers, consumers, transports.
|
|
427
|
+
this.statsCollector.stopAll();
|
|
428
|
+
|
|
429
|
+
for (const [type, producer] of this.producers) {
|
|
430
|
+
try {
|
|
431
|
+
producer.close();
|
|
432
|
+
} catch (err) {
|
|
433
|
+
this.logger.error("closing producer", type, err);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
this.producers.clear();
|
|
437
|
+
|
|
438
|
+
for (const [id, consumer] of this.consumers) {
|
|
439
|
+
try {
|
|
440
|
+
consumer.close();
|
|
441
|
+
} catch (err) {
|
|
442
|
+
this.logger.error("closing consumer", id, err);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
this.consumers.clear();
|
|
446
|
+
|
|
447
|
+
if (this.sendTransport) {
|
|
448
|
+
try {
|
|
449
|
+
this.sendTransport.close();
|
|
450
|
+
} catch (err) {
|
|
451
|
+
this.logger.error("closing sendTransport", err);
|
|
452
|
+
}
|
|
453
|
+
this.sendTransport = null;
|
|
454
|
+
}
|
|
455
|
+
if (this.recvTransport) {
|
|
456
|
+
try {
|
|
457
|
+
this.recvTransport.close();
|
|
458
|
+
} catch (err) {
|
|
459
|
+
this.logger.error("closing recvTransport", err);
|
|
460
|
+
}
|
|
461
|
+
this.recvTransport = null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
this._sendFailedAt = null;
|
|
465
|
+
this._recvFailedAt = null;
|
|
466
|
+
|
|
467
|
+
// Consumer (VideoMeetingClient) reacts to this and drives the
|
|
468
|
+
// re-join — it owns the room.join emission + the _transportsPromise
|
|
469
|
+
// wiring.
|
|
470
|
+
this.emit("transports:recreated-needed");
|
|
471
|
+
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Produce media (send to server)
|
|
477
|
+
* @param {MediaStreamTrack} track - Media track to produce
|
|
478
|
+
* @param {Object} options - Producer options
|
|
479
|
+
* @returns {Promise<Producer>}
|
|
480
|
+
*/
|
|
481
|
+
async produce(track, options = {}) {
|
|
482
|
+
if (!this.sendTransport) {
|
|
483
|
+
await this.createSendTransport();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
this.logger.info("Producing track:", track.kind, track.id);
|
|
487
|
+
this.logger.info("Produce options received:", {
|
|
488
|
+
simulcast: options.simulcast,
|
|
489
|
+
hasSimulcastOption: "simulcast" in options,
|
|
490
|
+
willEnableSimulcast:
|
|
491
|
+
track.kind === "video" && options.simulcast !== false,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
// Match old working system exactly - minimal options
|
|
496
|
+
const produceOptions = {
|
|
497
|
+
track,
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// Only add appData if provided (match old system pattern)
|
|
501
|
+
if (options.appData) {
|
|
502
|
+
produceOptions.appData = options.appData;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Add simulcast encodings for video tracks (enabled by default, can be disabled)
|
|
506
|
+
if (track.kind === "video" && options.simulcast !== false) {
|
|
507
|
+
// Get track settings to determine base resolution
|
|
508
|
+
const settings = track.getSettings();
|
|
509
|
+
const baseWidth = settings.width || 1920;
|
|
510
|
+
const baseHeight = settings.height || 1080;
|
|
511
|
+
|
|
512
|
+
// Check if user has set a max resolution preference
|
|
513
|
+
const maxResolution = options.maxResolution || "1080p"; // Default to 1080p
|
|
514
|
+
|
|
515
|
+
// Calculate target resolution based on user preference
|
|
516
|
+
let targetWidth = baseWidth;
|
|
517
|
+
let targetHeight = baseHeight;
|
|
518
|
+
let targetBitrate = 3500000;
|
|
519
|
+
|
|
520
|
+
if (maxResolution === "720p" && baseWidth > 1280) {
|
|
521
|
+
// User prefers 720p max - scale down from 1080p
|
|
522
|
+
targetWidth = 1280;
|
|
523
|
+
targetHeight = 720;
|
|
524
|
+
targetBitrate = 2000000;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Adaptive simulcast configuration based on target resolution
|
|
528
|
+
if (baseWidth >= 1280) {
|
|
529
|
+
// High resolution camera (720p+) - Use 3-layer simulcast
|
|
530
|
+
produceOptions.encodings = [
|
|
531
|
+
{
|
|
532
|
+
rid: "l",
|
|
533
|
+
maxBitrate: 200000,
|
|
534
|
+
scaleResolutionDownBy: baseWidth / (targetWidth / 4),
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
rid: "m",
|
|
538
|
+
maxBitrate: 700000,
|
|
539
|
+
scaleResolutionDownBy: baseWidth / (targetWidth / 2),
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
rid: "h",
|
|
543
|
+
maxBitrate: targetBitrate,
|
|
544
|
+
scaleResolutionDownBy: baseWidth / targetWidth,
|
|
545
|
+
},
|
|
546
|
+
];
|
|
547
|
+
this.logger.info(
|
|
548
|
+
"VIDEO_QUALITY :: 3-layer simulcast for high-res camera",
|
|
549
|
+
{
|
|
550
|
+
baseResolution: `${baseWidth}×${baseHeight}`,
|
|
551
|
+
maxResolution: maxResolution,
|
|
552
|
+
targetResolution: `${targetWidth}×${targetHeight}`,
|
|
553
|
+
layers: [
|
|
554
|
+
`l: ${Math.round(targetWidth / 4)}×${Math.round(targetHeight / 4)} @ 200kbps`,
|
|
555
|
+
`m: ${Math.round(targetWidth / 2)}×${Math.round(targetHeight / 2)} @ 700kbps`,
|
|
556
|
+
`h: ${targetWidth}×${targetHeight} @ ${(targetBitrate / 1000000).toFixed(1)}Mbps`,
|
|
557
|
+
],
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
} else if (baseWidth >= 640) {
|
|
561
|
+
// Medium resolution camera (640×360 to 1280×720) - Use 2-layer
|
|
562
|
+
// Don't scale down too much on already low-res cameras
|
|
563
|
+
produceOptions.encodings = [
|
|
564
|
+
{ rid: "l", maxBitrate: 200000, scaleResolutionDownBy: 2.0 },
|
|
565
|
+
{ rid: "h", maxBitrate: 1200000, scaleResolutionDownBy: 1.0 },
|
|
566
|
+
];
|
|
567
|
+
this.logger.info(
|
|
568
|
+
"VIDEO_QUALITY :: 2-layer simulcast for medium-res camera",
|
|
569
|
+
{
|
|
570
|
+
baseResolution: `${baseWidth}×${baseHeight}`,
|
|
571
|
+
layers: [
|
|
572
|
+
`l: ${Math.round(baseWidth / 2)}×${Math.round(baseHeight / 2)} @ 200kbps`,
|
|
573
|
+
`h: ${baseWidth}×${baseHeight} @ 1.2Mbps`,
|
|
574
|
+
],
|
|
575
|
+
},
|
|
576
|
+
);
|
|
577
|
+
} else {
|
|
578
|
+
// Very low resolution camera (<640) - Single layer, higher bitrate
|
|
579
|
+
produceOptions.encodings = [
|
|
580
|
+
{ rid: "h", maxBitrate: 800000, scaleResolutionDownBy: 1.0 },
|
|
581
|
+
];
|
|
582
|
+
this.logger.info("VIDEO_QUALITY :: Single layer for low-res camera", {
|
|
583
|
+
baseResolution: `${baseWidth}×${baseHeight}`,
|
|
584
|
+
bitrate: "800kbps",
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
this.logger.info("Calling transport.produce with options:", {
|
|
590
|
+
trackKind: track.kind,
|
|
591
|
+
trackId: track.id,
|
|
592
|
+
hasEncodings: !!produceOptions.encodings,
|
|
593
|
+
encodingsCount: produceOptions.encodings?.length,
|
|
594
|
+
encodings: produceOptions.encodings,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Log track settings before producing
|
|
598
|
+
if (track.kind === "video") {
|
|
599
|
+
const settings = track.getSettings();
|
|
600
|
+
this.logger.info("Video track settings before produce:", {
|
|
601
|
+
width: settings.width,
|
|
602
|
+
height: settings.height,
|
|
603
|
+
frameRate: settings.frameRate,
|
|
604
|
+
facingMode: settings.facingMode,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
this.logger.info(
|
|
609
|
+
"About to call sendTransport.produce() - this will trigger the produce event",
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
const producer = await this.sendTransport.produce(produceOptions);
|
|
613
|
+
this.logger.info(
|
|
614
|
+
"sendTransport.produce() succeeded - producer created:",
|
|
615
|
+
producer.id,
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
// Store producer
|
|
619
|
+
const type = options.appData?.type || track.kind;
|
|
620
|
+
this.producers.set(type, producer);
|
|
621
|
+
|
|
622
|
+
this.emit("producer:created", { producer, type });
|
|
623
|
+
|
|
624
|
+
// Handle producer events
|
|
625
|
+
producer.on("transportclose", () => {
|
|
626
|
+
this.logger.warn("Producer transport closed:", producer.id);
|
|
627
|
+
this.producers.delete(type);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
producer.on("trackended", () => {
|
|
631
|
+
this.logger.warn("Producer track ended:", producer.id);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
return producer;
|
|
635
|
+
} catch (error) {
|
|
636
|
+
this.logger.error("Failed to produce:", error);
|
|
637
|
+
throw new MediasoupError("Failed to produce media", "produce", error);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Consume media (receive from server)
|
|
643
|
+
* @param {string} producerId - Producer ID to consume
|
|
644
|
+
* @param {string} participantId - Participant ID
|
|
645
|
+
* @param {Object} options - Consume options
|
|
646
|
+
* @param {number} options.retryAttempt - Current retry attempt (for internal use)
|
|
647
|
+
* @returns {Promise<Consumer>}
|
|
648
|
+
*/
|
|
649
|
+
async consume(producerId, participantId, options = {}) {
|
|
650
|
+
if (!this.recvTransport) {
|
|
651
|
+
await this.createRecvTransport();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const retryAttempt = options.retryAttempt || 0;
|
|
655
|
+
const maxRetries = 3;
|
|
656
|
+
|
|
657
|
+
this.logger.info(
|
|
658
|
+
"Consuming producer:",
|
|
659
|
+
producerId,
|
|
660
|
+
retryAttempt > 0 ? `(attempt ${retryAttempt + 1}/${maxRetries + 1})` : "",
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
// Wait for media.consumer.created event from server
|
|
665
|
+
const consumerParams = await new Promise((resolve, reject) => {
|
|
666
|
+
const timeout = setTimeout(() => {
|
|
667
|
+
this.connection.socket.off("media.consumer.created", responseHandler);
|
|
668
|
+
reject(new Error("Timeout waiting for media.consumer.created"));
|
|
669
|
+
}, 5000);
|
|
670
|
+
|
|
671
|
+
const responseHandler = (data) => {
|
|
672
|
+
// Check if this response is for our producer request
|
|
673
|
+
if (data.producer?.id === producerId) {
|
|
674
|
+
clearTimeout(timeout);
|
|
675
|
+
this.connection.socket.off(
|
|
676
|
+
"media.consumer.created",
|
|
677
|
+
responseHandler,
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
// Extract consumer params needed by mediasoup
|
|
681
|
+
const params = {
|
|
682
|
+
id: data.consumer.id,
|
|
683
|
+
kind: data.consumer.kind,
|
|
684
|
+
rtpParameters: data.consumer.rtpParameters,
|
|
685
|
+
producerId: producerId,
|
|
686
|
+
};
|
|
687
|
+
resolve(params);
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
this.connection.socket.on("media.consumer.created", responseHandler);
|
|
692
|
+
|
|
693
|
+
// Emit request to server with expected structure
|
|
694
|
+
this.connection.socket.emit("media.consumer.create", {
|
|
695
|
+
producer: { id: producerId },
|
|
696
|
+
consumer: { rtpCapabilities: this.device.rtpCapabilities },
|
|
697
|
+
participant: { id: participantId },
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// Create consumer - this is where duplicate msid errors occur
|
|
702
|
+
const consumer = await this.recvTransport.consume(consumerParams);
|
|
703
|
+
|
|
704
|
+
// Tag for later lookup by QualityMonitor / setPeerPreferredLayer.
|
|
705
|
+
// mediasoup-client's appData is a plain object on the consumer.
|
|
706
|
+
consumer.appData = { ...(consumer.appData || {}), participantId };
|
|
707
|
+
|
|
708
|
+
// Store consumer
|
|
709
|
+
this.consumers.set(consumer.id, consumer);
|
|
710
|
+
|
|
711
|
+
// Register track for stats collection
|
|
712
|
+
if (consumer.track) {
|
|
713
|
+
this.statsCollector.registerTrack(
|
|
714
|
+
consumer.track.id,
|
|
715
|
+
participantId,
|
|
716
|
+
consumer.kind,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
this.emit("consumer:created", { consumer, producerId, participantId });
|
|
721
|
+
|
|
722
|
+
// Handle consumer events
|
|
723
|
+
consumer.on("transportclose", () => {
|
|
724
|
+
this.logger.warn("Consumer transport closed:", consumer.id);
|
|
725
|
+
this.consumers.delete(consumer.id);
|
|
726
|
+
|
|
727
|
+
// Unregister track from stats
|
|
728
|
+
if (consumer.track) {
|
|
729
|
+
this.statsCollector.unregisterTrack(consumer.track.id);
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
consumer.on("trackended", () => {
|
|
734
|
+
this.logger.warn("Consumer track ended:", consumer.id);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// Consumer starts unpaused on server (paused: false)
|
|
738
|
+
// No need to resume
|
|
739
|
+
|
|
740
|
+
return consumer;
|
|
741
|
+
} catch (error) {
|
|
742
|
+
// Check if this is a duplicate msid error or other SDP-related error
|
|
743
|
+
const isDuplicateMsidError =
|
|
744
|
+
error.message?.includes("Duplicate a=msid") ||
|
|
745
|
+
error.message?.includes("setRemoteDescription") ||
|
|
746
|
+
error.name === "OperationError";
|
|
747
|
+
|
|
748
|
+
// Retry logic for duplicate msid errors
|
|
749
|
+
if (isDuplicateMsidError && retryAttempt < maxRetries) {
|
|
750
|
+
const backoffDelay = Math.min(1000 * Math.pow(2, retryAttempt), 5000); // Exponential backoff: 1s, 2s, 4s
|
|
751
|
+
this.logger.warn(
|
|
752
|
+
`MediasoupManager :: Consume Failed :: Duplicate msid or SDP error detected, retrying in ${backoffDelay}ms (attempt ${retryAttempt + 1}/${maxRetries})`,
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
// Wait before retrying
|
|
756
|
+
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
|
|
757
|
+
|
|
758
|
+
// Retry with incremented attempt counter
|
|
759
|
+
return this.consume(producerId, participantId, {
|
|
760
|
+
retryAttempt: retryAttempt + 1,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
this.logger.error("Failed to consume:", error);
|
|
765
|
+
throw new MediasoupError("Failed to consume media", "consume", error);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Close a producer
|
|
771
|
+
* @param {string} type - Producer type (video, audio, screenshare)
|
|
772
|
+
*/
|
|
773
|
+
async closeProducer(type) {
|
|
774
|
+
const producer = this.producers.get(type);
|
|
775
|
+
if (!producer) {
|
|
776
|
+
this.logger.warn("Producer not found:", type);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
this.logger.info("Closing producer:", type);
|
|
781
|
+
|
|
782
|
+
try {
|
|
783
|
+
producer.close();
|
|
784
|
+
this.producers.delete(type);
|
|
785
|
+
|
|
786
|
+
// Notify server with correct event name and format
|
|
787
|
+
await this.connection.request("media.produce.close", {
|
|
788
|
+
producer: {
|
|
789
|
+
id: producer.id,
|
|
790
|
+
producerType: type,
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
} catch (error) {
|
|
794
|
+
this.logger.error("Failed to close producer:", error);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Replace track in an existing producer
|
|
800
|
+
* @param {string} type - Producer type
|
|
801
|
+
* @param {MediaStreamTrack} newTrack - New track to replace with
|
|
802
|
+
*/
|
|
803
|
+
async replaceTrack(type, newTrack) {
|
|
804
|
+
const producer = this.producers.get(type);
|
|
805
|
+
if (!producer) {
|
|
806
|
+
this.logger.warn("Producer not found:", type);
|
|
807
|
+
throw new Error(`Producer not found: ${type}`);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
this.logger.info("Replacing track for producer:", type);
|
|
811
|
+
|
|
812
|
+
try {
|
|
813
|
+
await producer.replaceTrack({ track: newTrack });
|
|
814
|
+
this.logger.info("Track replaced successfully for producer:", type);
|
|
815
|
+
} catch (error) {
|
|
816
|
+
this.logger.error("Failed to replace track:", error);
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Pause a producer (mute)
|
|
823
|
+
* @param {string} type - Producer type
|
|
824
|
+
*/
|
|
825
|
+
async pauseProducer(type) {
|
|
826
|
+
const producer = this.producers.get(type);
|
|
827
|
+
if (!producer) {
|
|
828
|
+
this.logger.warn("Producer not found:", type);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
this.logger.info("Pausing producer:", type);
|
|
833
|
+
|
|
834
|
+
try {
|
|
835
|
+
await producer.pause();
|
|
836
|
+
|
|
837
|
+
// Notify server
|
|
838
|
+
await this.connection.request("media.producer.pause", {
|
|
839
|
+
producer: {
|
|
840
|
+
id: producer.id,
|
|
841
|
+
producerType: type,
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
this.logger.info("Producer paused:", type);
|
|
846
|
+
} catch (error) {
|
|
847
|
+
this.logger.error("Failed to pause producer:", error);
|
|
848
|
+
throw error;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Resume a producer (unmute)
|
|
854
|
+
* @param {string} type - Producer type
|
|
855
|
+
*/
|
|
856
|
+
async resumeProducer(type) {
|
|
857
|
+
const producer = this.producers.get(type);
|
|
858
|
+
if (!producer) {
|
|
859
|
+
this.logger.warn("Producer not found:", type);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
this.logger.info("Resuming producer:", type);
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
await producer.resume();
|
|
867
|
+
|
|
868
|
+
// Notify server
|
|
869
|
+
await this.connection.request("media.producer.resume", {
|
|
870
|
+
producer: {
|
|
871
|
+
id: producer.id,
|
|
872
|
+
producerType: type,
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
this.logger.info("Producer resumed:", type);
|
|
877
|
+
} catch (error) {
|
|
878
|
+
this.logger.error("Failed to resume producer:", error);
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Close a consumer
|
|
885
|
+
* @param {string} consumerId - Consumer ID
|
|
886
|
+
*/
|
|
887
|
+
async closeConsumer(consumerId) {
|
|
888
|
+
const consumer = this.consumers.get(consumerId);
|
|
889
|
+
if (!consumer) {
|
|
890
|
+
this.logger.warn("Consumer not found:", consumerId);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
this.logger.info("Closing consumer:", consumerId);
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
consumer.close();
|
|
898
|
+
this.consumers.delete(consumerId);
|
|
899
|
+
} catch (error) {
|
|
900
|
+
this.logger.error("Failed to close consumer:", error);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Clean up all resources
|
|
906
|
+
*/
|
|
907
|
+
async cleanup() {
|
|
908
|
+
this.logger.info("Cleaning up mediasoup resources...");
|
|
909
|
+
|
|
910
|
+
// Stop stats collection
|
|
911
|
+
this.statsCollector.stopAll();
|
|
912
|
+
|
|
913
|
+
// Close all producers
|
|
914
|
+
for (const [type, producer] of this.producers) {
|
|
915
|
+
try {
|
|
916
|
+
producer.close();
|
|
917
|
+
} catch (error) {
|
|
918
|
+
this.logger.error("Error closing producer:", type, error);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
this.producers.clear();
|
|
922
|
+
|
|
923
|
+
// Close all consumers
|
|
924
|
+
for (const [id, consumer] of this.consumers) {
|
|
925
|
+
try {
|
|
926
|
+
consumer.close();
|
|
927
|
+
} catch (error) {
|
|
928
|
+
this.logger.error("Error closing consumer:", id, error);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
this.consumers.clear();
|
|
932
|
+
|
|
933
|
+
// Close transports
|
|
934
|
+
if (this.sendTransport) {
|
|
935
|
+
this.sendTransport.close();
|
|
936
|
+
this.sendTransport = null;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (this.recvTransport) {
|
|
940
|
+
this.recvTransport.close();
|
|
941
|
+
this.recvTransport = null;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
this.logger.info("Cleanup complete");
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Set virtual background store getter for stats collection
|
|
949
|
+
* @param {Function} getter - Function that returns virtual background state
|
|
950
|
+
*/
|
|
951
|
+
setVirtualBackgroundStore(getter) {
|
|
952
|
+
this.virtualBackgroundStore = getter;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Get producer by type
|
|
957
|
+
* @param {string} type - Producer type
|
|
958
|
+
* @returns {Producer|null}
|
|
959
|
+
*/
|
|
960
|
+
getProducer(type) {
|
|
961
|
+
return this.producers.get(type) || null;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Get the local video (camera) producer. Used by QualityMonitor to
|
|
966
|
+
* apply simulcast layer caps via the producer's RTCRtpSender.
|
|
967
|
+
* @returns {Producer|null}
|
|
968
|
+
*/
|
|
969
|
+
getVideoProducer() {
|
|
970
|
+
return this.producers.get("video") || this.producers.get("camera") || null;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Ask the SFU to forward a lower simulcast layer of the given peer's
|
|
975
|
+
* video producer to us. spatialLayer 0=low, 1=mid, 2=high.
|
|
976
|
+
*
|
|
977
|
+
* mediasoup's consumer.setPreferredLayers is server-side only; the
|
|
978
|
+
* client signals the request and the SFU applies it. We send via the
|
|
979
|
+
* existing `media.consumer.setPreferredLayers` event handled by the
|
|
980
|
+
* pod. Returns true if the event was sent.
|
|
981
|
+
* @param {string} peerParticipantId
|
|
982
|
+
* @param {number} spatialLayer
|
|
983
|
+
*/
|
|
984
|
+
setPeerPreferredLayer(peerParticipantId, spatialLayer) {
|
|
985
|
+
if (!this.connection?.socket) return false;
|
|
986
|
+
// Find every consumer whose producer belongs to this peer. We may
|
|
987
|
+
// have a video + a screenshare consumer for the same peer; we cap
|
|
988
|
+
// only kind='video' to avoid clobbering screen share quality.
|
|
989
|
+
let sent = false;
|
|
990
|
+
for (const consumer of this.consumers.values()) {
|
|
991
|
+
if (consumer.kind !== "video") continue;
|
|
992
|
+
if (consumer.appData?.participantId !== peerParticipantId) continue;
|
|
993
|
+
this.connection.socket.emit("media.consumer.setPreferredLayers", {
|
|
994
|
+
consumer: { id: consumer.id },
|
|
995
|
+
preferredLayers: { spatialLayer, temporalLayer: 2 },
|
|
996
|
+
});
|
|
997
|
+
sent = true;
|
|
998
|
+
}
|
|
999
|
+
return sent;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Pause/resume the local consumer AND ask the SFU to stop/start
|
|
1004
|
+
* forwarding bytes for this peer's video. Local pause alone would
|
|
1005
|
+
* stop rendering but the SFU keeps shipping bytes — server-side
|
|
1006
|
+
* pause is what actually frees downlink bandwidth. Scoped to
|
|
1007
|
+
* kind='video' so screenshares aren't affected.
|
|
1008
|
+
*
|
|
1009
|
+
* @param {string} peerParticipantId
|
|
1010
|
+
* @param {boolean} paused — true to pause, false to resume
|
|
1011
|
+
* @returns {boolean} whether at least one consumer was matched
|
|
1012
|
+
*/
|
|
1013
|
+
setPeerVideoPaused(peerParticipantId, paused) {
|
|
1014
|
+
if (!this.connection?.socket) return false;
|
|
1015
|
+
const event = paused ? "media.consumer.pause" : "media.consumer.resume";
|
|
1016
|
+
let matched = false;
|
|
1017
|
+
for (const consumer of this.consumers.values()) {
|
|
1018
|
+
if (consumer.kind !== "video") continue;
|
|
1019
|
+
if (consumer.appData?.participantId !== peerParticipantId) continue;
|
|
1020
|
+
try {
|
|
1021
|
+
if (paused) consumer.pause();
|
|
1022
|
+
else consumer.resume();
|
|
1023
|
+
} catch (_e) {}
|
|
1024
|
+
this.connection.socket.emit(event, { consumer: { id: consumer.id } });
|
|
1025
|
+
matched = true;
|
|
1026
|
+
}
|
|
1027
|
+
return matched;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Apply pause/resume to every remote video consumer at once. Used by
|
|
1032
|
+
* the "pause all incoming video" control. Returns the count of
|
|
1033
|
+
* consumers affected.
|
|
1034
|
+
* @param {boolean} paused
|
|
1035
|
+
*/
|
|
1036
|
+
setAllRemoteVideoPaused(paused) {
|
|
1037
|
+
if (!this.connection?.socket) return 0;
|
|
1038
|
+
const event = paused ? "media.consumer.pause" : "media.consumer.resume";
|
|
1039
|
+
let count = 0;
|
|
1040
|
+
for (const consumer of this.consumers.values()) {
|
|
1041
|
+
if (consumer.kind !== "video") continue;
|
|
1042
|
+
try {
|
|
1043
|
+
if (paused) consumer.pause();
|
|
1044
|
+
else consumer.resume();
|
|
1045
|
+
} catch (_e) {}
|
|
1046
|
+
this.connection.socket.emit(event, { consumer: { id: consumer.id } });
|
|
1047
|
+
count++;
|
|
1048
|
+
}
|
|
1049
|
+
return count;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Check if device is loaded
|
|
1054
|
+
* @returns {boolean}
|
|
1055
|
+
*/
|
|
1056
|
+
get isDeviceLoaded() {
|
|
1057
|
+
return this.device.loaded;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Check if device can produce video
|
|
1062
|
+
* @returns {boolean}
|
|
1063
|
+
*/
|
|
1064
|
+
get canProduceVideo() {
|
|
1065
|
+
return this.device.loaded && this.device.canProduce("video");
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Check if device can produce audio
|
|
1070
|
+
* @returns {boolean}
|
|
1071
|
+
*/
|
|
1072
|
+
get canProduceAudio() {
|
|
1073
|
+
return this.device.loaded && this.device.canProduce("audio");
|
|
1074
|
+
}
|
|
873
1075
|
}
|